From e91ef39f5a90bb0de62b2b09cff70cbeefc62418 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:38:20 +0800 Subject: [PATCH] fix: preserve lint migration contracts --- .../2026-06-11-quality-gates.i18n.yaml | 4 +- .../process/2026-06-11-quality-gates.md | 2 +- .../process/2026-06-11-quality-gates.zh.md | 2 +- .../2026-07-22-fast-local-git-hooks.i18n.yaml | 4 +- .../2026-07-22-fast-local-git-hooks.md | 6 +- .../2026-07-22-fast-local-git-hooks.zh.md | 6 +- .../2026-07-29-oxlint-linter.i18n.yaml | 4 +- .../process/2026-07-29-oxlint-linter.md | 18 +- .../process/2026-07-29-oxlint-linter.zh.md | 18 +- .oxlintrc.json | 216 ++++-------------- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- eslint.format.config.mjs | 51 +++++ lefthook.yml | 7 + package.json | 4 +- packages/fs/fs-policy/src/index.ts | 4 +- pnpm-lock.yaml | 105 +++++++++ scripts/lint-rule-parity.spec.ts | 15 +- scripts/oxlint-contract.spec.ts | 119 ++++++++++ scripts/run-gates.spec.ts | 5 +- scripts/run-gates.ts | 17 +- 22 files changed, 401 insertions(+), 214 deletions(-) create mode 100644 eslint.format.config.mjs create mode 100644 scripts/oxlint-contract.spec.ts diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index 162c76b969..3c41db871b 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-quality-gates.md -2026-06-11-quality-gates.md: 320ef1ac6a6a47cd5bf9c458f0c918ed3cccf594 -2026-06-11-quality-gates.zh.md: bf28b09e027eebdcf18aecde2e76e782667d72e6 +2026-06-11-quality-gates.md: 60db7ba5cfa8184c0fcce764aa027f32a9b721ab +2026-06-11-quality-gates.zh.md: a5ac7cd831255d479f7a1d75586785e546877fba diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 320ef1ac6a..60db7ba5cf 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -19,7 +19,7 @@ Every mechanically checkable AGENTS.md promise gets a command that exits non-zer - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. -- lefthook pre-commit applies staged Oxlint fixes, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. +- lefthook pre-commit applies formatting-only ESLint fixes before Oxlint validation and native fixes, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index bf28b09e02..a5ac7cd831 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -19,7 +19,7 @@ Status: implemented - jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 - knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 -- lefthook pre-commit 对暂存文件应用 Oxlint 修复、拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 +- lefthook pre-commit 先应用仅用于格式化的 ESLint 修复,再执行 Oxlint 验证和原生修复,拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index 4e77fcc028..e4cd3fd34f 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md -2026-07-22-fast-local-git-hooks.md: cfb8e5f6bbab5ba6b2243b310c1b2ba12039df68 -2026-07-22-fast-local-git-hooks.zh.md: b78a82371124d1503f95bd3287241b68aaa3bb17 +2026-07-22-fast-local-git-hooks.md: 838024c4293372b1430d357774feb06cd9742b9b +2026-07-22-fast-local-git-hooks.zh.md: 460acf5270c075a808c6a4dc42635a808c7cd192 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index cfb8e5f6bb..838024c429 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -12,7 +12,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave ## Decision -[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: [Oxlint](2026-07-29-oxlint-linter.md) fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. +[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a formatting-only ESLint config fixes and re-stages changed JavaScript and TypeScript, [Oxlint](2026-07-29-oxlint-linter.md) validates those files and applies native safe fixes, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. @@ -27,10 +27,10 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202 - **Keep the full pre-push suite and optimize its scheduler** — preserves the earliest exhaustive signal but still repeats agent-selected evidence and CI, while unrelated failures continue blocking publication. - **Remove pre-push entirely** — makes pushes cheapest but loses the fast cross-file guarantee that TypeScript provides after several commits. - **Keep typecheck in pre-commit** — catches type errors earlier but charges every intermediate commit instead of one push; staged lint already covers the commit-local syntax and style boundary. -- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the existing auto-fix workflow; Lefthook's `stage_fixed` owns re-staging so the command does not duplicate `git add`. +- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the auto-fix workflow; the formatting-only pass and Lefthook's `stage_fixed` preserve it without making ESLint a repository correctness runner or duplicating `git add`. ## Consequences -Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. +Normal commits take the staged formatter-and-lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index b78a823711..460acf5270 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -12,7 +12,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查, ## 决策 -[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:[Oxlint](2026-07-29-oxlint-linter.md) 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 +[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:仅用于格式化的 ESLint 配置修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,[Oxlint](2026-07-29-oxlint-linter.md) 验证这些文件并应用原生安全修复,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 @@ -27,10 +27,10 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小 - **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。 - **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。 - **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。 -- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留现有的自动修复工作流;Lefthook 的 `stage_fixed` 负责重新暂存,因此命令无需重复执行 `git add`。 +- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留自动修复工作流;仅用于格式化的流程和 Lefthook 的 `stage_fixed` 会保留该工作流,而不会让 ESLint 成为仓库正确性检查运行器,也无需重复执行 `git add`。 ## 结果 -普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 +普通提交的关键路径是暂存文件格式化与 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml index de6568776b..59cc5d88bc 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-29-oxlint-linter.md -2026-07-29-oxlint-linter.md: 6a6b76d3d53ecdd065c9c43c0f60644ba473672c -2026-07-29-oxlint-linter.zh.md: f534d6914ca1ba6bb9292d9f102e329d6adbc2c5 +2026-07-29-oxlint-linter.md: c1876b09dbdddecef30ab718005b949f21e3e93c +2026-07-29-oxlint-linter.zh.md: bfb60cc7f5d95b460d36a2041196c7fa922cc439 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md index 6a6b76d3d5..c1876b09db 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md @@ -12,21 +12,25 @@ A faster runner cannot justify losing rules. The migration must preserve the str ## Decision -The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the lint configuration, and `oxlint` is the only lint command used by package scripts, the gate scheduler, CI, and lefthook. The direct `eslint` and `typescript-eslint` development dependencies and `eslint.config.mjs` are absent. +The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative repository lint configuration. The `lint` package script, gate scheduler, and CI invoke Oxlint for repository-wide and type-aware validation. The `lint:fix` script and lefthook first invoke the formatting-only [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs), then run Oxlint. The direct `eslint` and `@typescript-eslint/parser` development dependencies exist only for this parser-without-project formatting pass; that config contains no correctness or type-aware rules. -`options.typeAware` enables `oxlint-tsgolint`. The configuration explicitly carries the migrated strict-type-checked rules and repository overrides instead of enabling broad Oxlint categories whose contents may change. `typescript/no-unnecessary-condition` remains enabled from Oxlint's nursery set because it was an enforced repository rule before migration. Oxlint discovers each file's TypeScript project; the existing package projects and separate host/client aggregates remain the source of type context. +`options.typeAware` enables `oxlint-tsgolint`. Its backend always performs per-file TypeScript-project discovery; Oxlint's `--tsconfig` override affects import resolution but is ignored by type-aware linting, so this repository does not set it. The configuration explicitly carries the migrated strict-type-checked rules and repository overrides instead of enabling broad Oxlint categories whose contents may change. `typescript/no-unnecessary-condition` remains enabled from Oxlint's nursery set because it was an enforced repository rule before migration. -Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. These packages retain their ESLint peer dependency transitively, but the repository neither configures nor invokes the ESLint runner. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`. +Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. The compatibility layer reports `@stylistic` violations but does not execute their fixers, so the formatting-only ESLint pass owns only the corresponding auto-fixes. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`. -CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` optionally bounds Oxlint's native worker count in the gate scheduler for shared or benchmark runners; ordinary local runs use Oxlint's default. Pre-commit applies safe Oxlint fixes to staged JavaScript and TypeScript files and re-stages them through lefthook. +CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` optionally passes the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable in the gate scheduler; ordinary local runs use both defaults. Pre-commit applies the formatting-only ESLint fixes, runs Oxlint validation and native safe fixes, and re-stages the result through lefthook. ## Verification -The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A committed fingerprint test normalizes severities and rule-name translations, then deep-compares every active rule and option against the exact deleted ESLint configuration blob: source is 88-to-88, examples are 87-to-87, and tests are 83-to-83, with no missing, extra, or changed pairs. The repository lint command exercises type-aware rules and both JavaScript compatibility plugins. Focused gate-scheduler execution covers the explicit thread-bound path, while typecheck confirms that migration-driven source edits preserve the TypeScript programs. +The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A committed fingerprint test normalizes severities and rule-name translations, then deep-compares every active rule and option against the exact deleted ESLint configuration blob: source is 88-to-88, examples are 87-to-87, and tests are 83-to-83, with no missing, extra, or changed pairs. Evaluating `typescript-eslint@8.61.0` also confirms that `strictTypeChecked` did not enable `@typescript-eslint/no-empty-function`; the deleted tests-only `off` entry was inert. + +An executable contract test injects `typescript/no-floating-promises` violations into host package source and tests, client package source and tests, scripts, examples, and website code, then requires all seven diagnostics from one Oxlint invocation. It also drives one deliberately misformatted staged file through the ESLint formatter and Oxlint validator and asserts the final bytes after one pass. Gate-scheduler tests pin both worker controls, the repository lint command exercises both JavaScript compatibility plugins, and typecheck confirms that migration-driven source edits preserve the TypeScript programs. ## Alternatives considered -**Run Oxlint before a reduced ESLint fallback.** This is the recommended incremental path when required rules are unsupported, but every enforced repository rule is available through Oxlint's native rules, nursery rule, or JavaScript-plugin compatibility layer. Keeping both runners would preserve the slower program setup and two configurations without adding a check. +**Run both linters repository-wide.** Every correctness rule is available through Oxlint's native rules, nursery rule, or JavaScript-plugin compatibility layer. A repository-wide ESLint fallback would preserve the slower project-service setup and two correctness configurations without adding a check; the retained ESLint pass is deliberately limited to project-free staged formatting. + +**Rely on compatibility-layer fixes.** The layer reports the established `@stylistic` rules but does not apply their fixes under either Oxlint fix mode. Keeping the narrow staged formatter preserves the contributor contract without broadening ESLint back into a repository linter. **Drop @stylistic or SonarJS rules that are not native.** This would remove dependencies but weaken the mechanical quality contract. The compatibility layer preserves those rules until native replacements can be evaluated as a separate decision. @@ -38,4 +42,4 @@ Local migration measurements reduced a clean type-aware lint run from about 61 s Type-aware diagnostics now come from the TypeScript Go analyzer bundled through `oxlint-tsgolint`, so edge-case inference can differ from typescript-eslint even when `tsc` accepts the same program. Lint and typecheck remain separate required evidence. -The JavaScript-plugin compatibility API is an additional boundary to maintain, and its peer graph still includes ESLint packages. The executable lint path, configuration ownership, cache policy, worker control, and inline directives are nevertheless Oxlint-only. +The JavaScript-plugin compatibility API and staged formatter are additional boundaries to maintain. Commits pay one project-free ESLint startup before Oxlint, and the root development graph retains ESLint plus the TypeScript parser. Repository-wide validation, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned. diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md index f534d6914c..bfb60cc7f5 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md @@ -12,21 +12,25 @@ Status: implemented ## 决策 -根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是 lint 配置;包(package)脚本、门禁调度器、CI 和 lefthook 使用的唯一 lint 命令均为 `oxlint`。直接的 `eslint` 和 `typescript-eslint` 开发依赖以及 `eslint.config.mjs` 均不存在。 +根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库 lint 配置的权威来源。`lint` 包(package)脚本、门禁调度器和 CI 使用 Oxlint 进行全仓库及类型感知验证。`lint:fix` 脚本和 lefthook 先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs),再运行 Oxlint。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;该配置不包含正确性规则或类型感知规则。 -`options.typeAware` 启用 `oxlint-tsgolint`。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。Oxlint 会发现每个文件所属的 TypeScript 项目;现有包项目及相互独立的 host/client 聚合项目仍是类型上下文的真源。 +`options.typeAware` 启用 `oxlint-tsgolint`。其后端始终按文件发现 TypeScript 项目;Oxlint 的 `--tsconfig` 覆盖项会影响导入解析,但类型感知 lint 会忽略它,因此本仓库不设置该选项。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。 -Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。这些包的 ESLint 对等依赖(peer dependency)仍会作为传递依赖保留,但仓库既不配置也不调用 ESLint 运行器。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。 +Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。兼容层会报告 `@stylistic` 违规,但不会执行其修复器,因此仅用于格式化的 ESLint 流程只负责相应的自动修复。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。 -CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 可以在门禁调度器中限制 Oxlint 的原生工作线程数,供共享运行器或基准测试运行器使用;普通本地运行使用 Oxlint 默认值。Pre-commit 对暂存的 JavaScript 和 TypeScript 文件应用安全的 Oxlint 修复,并由 lefthook 重新暂存这些文件。 +CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 可以在门禁调度器中将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 应用仅用于格式化的 ESLint 修复,运行 Oxlint 验证和原生安全修复,并通过 lefthook 重新暂存结果。 ## 验证 -解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。一项已提交的指纹测试会对严重级别和规则名映射进行归一化,再以已删除 ESLint 配置的精确 blob 为基准,对每一项启用的规则及其选项进行深度比较:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项,不存在缺失、多余或发生变化的配对。仓库 lint 命令会运行类型感知规则和两个 JavaScript 兼容插件。针对门禁调度器的聚焦执行覆盖显式线程上限路径,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 +解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。一项已提交的指纹测试会对严重级别和规则名映射进行归一化,再以已删除 ESLint 配置的精确 blob 为基准,对每一项启用的规则及其选项进行深度比较:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项,不存在缺失、多余或发生变化的配对。对 `typescript-eslint@8.61.0` 的评估还确认,`strictTypeChecked` 并未启用 `@typescript-eslint/no-empty-function`;已删除、仅用于测试的 `off` 条目不起作用。 + +一项可执行契约测试会在 host 包源码和测试、client 包源码和测试、脚本、示例以及网站代码中注入 `typescript/no-floating-promises` 违规,随后要求 Oxlint 单次调用报告全部七条诊断。它还让一个刻意格式错误的暂存文件依次通过 ESLint 格式化器和 Oxlint 验证器,并断言单次流程后的最终字节。门禁调度器测试锁定两项工作线程控制,仓库 lint 命令运行两个 JavaScript 兼容插件,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 ## 考虑过的替代方案 -**先运行 Oxlint,再使用精简的 ESLint 回退。** 当必要规则尚未得到支持时,这是推荐的渐进迁移路径;但仓库强制执行的所有规则均可通过 Oxlint 原生规则、nursery 规则或 JavaScript 插件兼容层获得。保留两个运行器会继续承担较慢的程序初始化和两套配置,却不会增加任何检查。 +**在全仓库范围内同时运行两个 linter。** 所有正确性规则均可通过 Oxlint 原生规则、nursery 规则或 JavaScript 插件兼容层获得。在全仓库范围启用 ESLint 回退会保留较慢的项目服务初始化和两套正确性配置,却不会增加任何检查;保留的 ESLint 流程被刻意限制为不加载项目的暂存文件格式化。 + +**依赖兼容层修复。** 兼容层会报告既有的 `@stylistic` 规则,但在 Oxlint 的两种修复模式下都不会应用这些规则的修复。保留窄范围的暂存文件格式化器,可以在不将 ESLint 扩张回仓库 linter 的情况下维持贡献者契约。 **移除尚无原生实现的 @stylistic 或 SonarJS 规则。** 这会移除依赖,但也会削弱机械质量契约。兼容层会保留这些规则,直到能够通过单独决策评估原生替代规则。 @@ -38,4 +42,4 @@ CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 可以在门禁 类型感知诊断现在来自通过 `oxlint-tsgolint` 捆绑的 TypeScript Go 分析器,因此即使 `tsc` 接受同一程序,边界场景下的类型推断也可能与 typescript-eslint 不同。lint 与类型检查仍是两项相互独立的必要证据。 -JavaScript 插件兼容 API 是需要维护的额外边界,其对等依赖图中仍包含 ESLint 包。不过,可执行 lint 路径、配置所有权、缓存政策、工作线程控制和内联指令已全部只使用 Oxlint。 +JavaScript 插件兼容 API 和暂存文件格式化器是需要维护的额外边界。每次提交在 Oxlint 之前需要启动一次不加载项目的 ESLint,根目录开发依赖图仍保留 ESLint 和 TypeScript 解析器。全仓库验证、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。 diff --git a/.oxlintrc.json b/.oxlintrc.json index 4746d47046..8e2e5a3195 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,52 +14,36 @@ "**/lib/**", "**/node_modules/**", "**/.sessions/**", - ".claude/**", + ".claude/**", // Harness-local state belongs to other checkouts, not this checkout's sources. "**/.doc-typecheck-*/**", "**/.node-next-types-*/**", + "**/.oxlint-contract-*/**", // Scratch files created by the executable lint-contract tests. "website/.generated/**", - "vendor/**", - "native/**", + "vendor/**", // Vendored source keeps upstream style and idioms. + "native/**", // The imported landlock-run subtree has its own gates; see native/README.md. "**/*.js", "**/*.mjs", - "*.config.ts", - "apps/*/*.config.ts", - "**/tsdown.config.ts", - "packages/client/tsdown.client.ts" + "**/*.config.ts", // Tool and app configs are outside the repository TypeScript programs. + "packages/client/tsdown.client.ts" // Shared client build preset, also outside a TypeScript program. ], "overrides": [ { + // Shared strict type-aware rules. Source/test differences stay in the short overrides below. "files": [ "packages/*/*/src/**/*.{ts,tsx}", + "packages/*/*/tests/**/*.{ts,tsx}", "apps/*/src/**/*.{ts,tsx}", + "apps/*/tests/**/*.{ts,tsx}", "examples/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx}", "website/**/*.{ts,tsx}" ], "rules": { - "constructor-super": "off", - "getter-return": "off", - "no-class-assign": "off", - "no-const-assign": "off", - "no-dupe-class-members": "off", - "no-dupe-keys": "off", - "no-func-assign": "off", - "no-import-assign": "off", - "no-new-native-nonconstructor": "off", - "no-obj-calls": "off", - "no-redeclare": "off", - "no-setter-return": "off", - "no-this-before-super": "off", - "no-undef": "off", - "no-unreachable": "off", - "no-unsafe-negation": "off", "no-var": "error", - "no-with": "off", "prefer-const": "error", "prefer-rest-params": "error", "prefer-spread": "error", "no-array-constructor": "error", - "no-implied-eval": "off", "no-unused-expressions": "error", "no-unused-vars": [ "error", @@ -70,9 +54,6 @@ } ], "no-useless-constructor": "error", - "no-throw-literal": "off", - "prefer-promise-reject-errors": "off", - "require-await": "off", "typescript/await-thenable": "error", "typescript/ban-ts-comment": [ "error", @@ -87,33 +68,27 @@ "typescript/no-duplicate-enum-values": "error", "typescript/no-duplicate-type-constituents": "error", "typescript/no-dynamic-delete": "error", - "typescript/no-empty-object-type": "off", - "typescript/no-explicit-any": "error", + "typescript/no-empty-object-type": "off", // Merge-extensible maps intentionally use empty object types. + "typescript/no-explicit-any": "error", // Every intentional any needs a narrow suppression with rationale. "typescript/no-extra-non-null-assertion": "error", "typescript/no-extraneous-class": "error", + // Lost promises in the agent loop are the repository's highest-value linted bug class. "typescript/no-floating-promises": "error", "typescript/no-for-in-array": "error", "typescript/no-implied-eval": "error", - "typescript/no-invalid-void-type": "off", + "typescript/no-invalid-void-type": "off", // Event signatures intentionally use void in source. "typescript/no-meaningless-void-operator": "error", "typescript/no-misused-new": "error", "typescript/no-misused-promises": "error", "typescript/no-misused-spread": "error", "typescript/no-mixed-enums": "error", - "typescript/no-namespace": "off", + "typescript/no-namespace": "off", // Cordis Config namespaces are the repository idiom. "typescript/no-non-null-asserted-nullish-coalescing": "error", "typescript/no-non-null-asserted-optional-chain": "error", - "typescript/no-non-null-assertion": "error", "typescript/no-redundant-type-constituents": "error", "typescript/no-require-imports": "error", "typescript/no-this-alias": "error", "typescript/no-unnecessary-boolean-literal-compare": "error", - "typescript/no-unnecessary-condition": [ - "error", - { - "allowConstantLoopConditions": true - } - ], "typescript/no-unnecessary-template-expression": "error", "typescript/no-unnecessary-type-arguments": "error", "typescript/no-unnecessary-type-assertion": "error", @@ -131,7 +106,6 @@ "typescript/no-unsafe-unary-minus": "error", "typescript/no-useless-default-assignment": "error", "typescript/no-wrapper-object-types": "error", - "typescript/only-throw-error": "error", "typescript/prefer-as-const": "error", "typescript/prefer-literal-enum-member": "error", "typescript/prefer-namespace-keyword": "error", @@ -139,7 +113,6 @@ "typescript/prefer-reduce-type-parameter": "error", "typescript/prefer-return-this-type": "error", "typescript/related-getter-setter-pairs": "error", - "typescript/require-await": "error", "typescript/restrict-plus-operands": [ "error", { @@ -150,13 +123,6 @@ "allowRegExp": false } ], - "typescript/restrict-template-expressions": [ - "error", - { - "allowNumber": true, - "allowBoolean": true - } - ], "typescript/return-await": [ "error", "error-handling-correctness-only" @@ -165,7 +131,37 @@ "typescript/unbound-method": "error", "typescript/unified-signatures": "error", "typescript/use-unknown-in-catch-callback-variable": "error", - "no-void": "off", + "no-void": "off" // void foo() marks deliberate fire-and-forget arrow listeners. + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "packages/*/*/src/**/*.{ts,tsx}", + "apps/*/src/**/*.{ts,tsx}", + "examples/**/*.{ts,tsx}", + "scripts/**/*.{ts,tsx}", + "website/**/*.{ts,tsx}" + ], + "rules": { + "typescript/no-non-null-assertion": "error", + "typescript/no-unnecessary-condition": [ + "error", + { + "allowConstantLoopConditions": true + } + ], + "typescript/only-throw-error": "error", + "typescript/require-await": "error", + "typescript/restrict-template-expressions": [ + "error", + { + "allowNumber": true, + "allowBoolean": true + } + ], "typescript/switch-exhaustiveness-check": [ "error", { @@ -182,7 +178,7 @@ "examples/**/*.ts" ], "rules": { - "typescript/require-await": "off" + "typescript/require-await": "off" // Demo callbacks conform to async interfaces without awaiting. }, "plugins": [ "typescript" @@ -196,124 +192,12 @@ "scripts/**/*.spec.{ts,tsx}" ], "rules": { - "constructor-super": "off", - "getter-return": "off", - "no-class-assign": "off", - "no-const-assign": "off", - "no-dupe-class-members": "off", - "no-dupe-keys": "off", - "no-func-assign": "off", - "no-import-assign": "off", - "no-new-native-nonconstructor": "off", - "no-obj-calls": "off", - "no-redeclare": "off", - "no-setter-return": "off", - "no-this-before-super": "off", - "no-undef": "off", - "no-unreachable": "off", - "no-unsafe-negation": "off", - "no-var": "error", - "no-with": "off", - "prefer-const": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "no-array-constructor": "error", - "no-implied-eval": "off", - "no-unused-expressions": "error", - "no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } - ], - "no-useless-constructor": "error", - "no-throw-literal": "off", - "prefer-promise-reject-errors": "off", - "require-await": "off", - "typescript/await-thenable": "error", - "typescript/ban-ts-comment": [ - "error", - { - "minimumDescriptionLength": 10 - } - ], - "typescript/no-array-delete": "error", - "typescript/no-base-to-string": "error", - "typescript/no-confusing-void-expression": "error", - "typescript/no-deprecated": "error", - "typescript/no-duplicate-enum-values": "error", - "typescript/no-duplicate-type-constituents": "error", - "typescript/no-dynamic-delete": "error", - "typescript/no-empty-object-type": "off", - "typescript/no-explicit-any": "error", - "typescript/no-extra-non-null-assertion": "error", - "typescript/no-extraneous-class": "error", - "typescript/no-floating-promises": "error", - "typescript/no-for-in-array": "error", - "typescript/no-implied-eval": "error", "typescript/no-invalid-void-type": "error", - "typescript/no-meaningless-void-operator": "error", - "typescript/no-misused-new": "error", - "typescript/no-misused-promises": "error", - "typescript/no-misused-spread": "error", - "typescript/no-mixed-enums": "error", - "typescript/no-namespace": "off", - "typescript/no-non-null-asserted-nullish-coalescing": "error", - "typescript/no-non-null-asserted-optional-chain": "error", - "typescript/no-non-null-assertion": "off", - "typescript/no-redundant-type-constituents": "error", - "typescript/no-require-imports": "error", - "typescript/no-this-alias": "error", - "typescript/no-unnecessary-boolean-literal-compare": "error", + "typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence. "typescript/no-unnecessary-condition": "off", - "typescript/no-unnecessary-template-expression": "error", - "typescript/no-unnecessary-type-arguments": "error", - "typescript/no-unnecessary-type-assertion": "error", - "typescript/no-unnecessary-type-constraint": "error", - "typescript/no-unnecessary-type-conversion": "error", - "typescript/no-unnecessary-type-parameters": "error", - "typescript/no-unsafe-argument": "error", - "typescript/no-unsafe-assignment": "error", - "typescript/no-unsafe-call": "error", - "typescript/no-unsafe-declaration-merging": "error", - "typescript/no-unsafe-enum-comparison": "error", - "typescript/no-unsafe-function-type": "error", - "typescript/no-unsafe-member-access": "error", - "typescript/no-unsafe-return": "error", - "typescript/no-unsafe-unary-minus": "error", - "typescript/no-useless-default-assignment": "error", - "typescript/no-wrapper-object-types": "error", - "typescript/only-throw-error": "off", - "typescript/prefer-as-const": "error", - "typescript/prefer-literal-enum-member": "error", - "typescript/prefer-namespace-keyword": "error", - "typescript/prefer-promise-reject-errors": "error", - "typescript/prefer-reduce-type-parameter": "error", - "typescript/prefer-return-this-type": "error", - "typescript/related-getter-setter-pairs": "error", - "typescript/require-await": "off", - "typescript/restrict-plus-operands": [ - "error", - { - "allowAny": false, - "allowBoolean": false, - "allowNullish": false, - "allowNumberAndString": false, - "allowRegExp": false - } - ], - "typescript/restrict-template-expressions": "off", - "typescript/return-await": [ - "error", - "error-handling-correctness-only" - ], - "typescript/triple-slash-reference": "error", - "typescript/unbound-method": "error", - "typescript/unified-signatures": "error", - "typescript/use-unknown-in-catch-callback-variable": "error", - "no-empty-function": "off" + "typescript/only-throw-error": "off", // Tests deliberately exercise non-Error throws. + "typescript/require-await": "off", // Mock execute() implementations must retain async signatures. + "typescript/restrict-template-expressions": "off" }, "plugins": [ "typescript" diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 9cb2a67802..9f05d48c79 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: cbe7735806f9e898057ad025237c2a70e79c7eb1 -development.zh.md: ed598b4de8b47df957dc34306f212adbc27bb400 +development.md: adb295d840e66aee10067a39f787b250d50172d0 +development.zh.md: 92f2b0490776cd16985eccd7da91225b0c03bd57 diff --git a/docs/development.md b/docs/development.md index cbe7735806..adb295d840 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` runs staged-file Oxlint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. diff --git a/docs/development.zh.md b/docs/development.zh.md index ed598b4de8..92f2b04907 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 运行对暂存文件的 Oxlint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 diff --git a/eslint.format.config.mjs b/eslint.format.config.mjs new file mode 100644 index 0000000000..1ead934d82 --- /dev/null +++ b/eslint.format.config.mjs @@ -0,0 +1,51 @@ +import stylistic from '@stylistic/eslint-plugin' +import parser from '@typescript-eslint/parser' + +// Oxlint's JavaScript-plugin compatibility layer reports these rules but does +// not execute their fixers. Keep this config formatting-only: Oxlint remains +// the authoritative repository linter after this pass applies safe fixes. +export default [ + { + ignores: [ + '**/lib/**', + '**/node_modules/**', + '**/.sessions/**', + '.claude/**', + '**/.doc-typecheck-*/**', + '**/.node-next-types-*/**', + 'website/.generated/**', + 'vendor/**', + 'native/**', + '**/*.js', + '**/*.mjs', + '**/*.config.ts', + 'packages/client/tsdown.client.ts', + ], + }, + { + files: ['**/*.{ts,tsx,mts,cts}'], + languageOptions: { + parser, + parserOptions: { + sourceType: 'module', + }, + }, + plugins: { + '@stylistic': stylistic, + }, + rules: { + '@stylistic/indent': ['error', 2], + '@stylistic/semi': ['error', 'never'], + '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + '@stylistic/comma-dangle': ['error', 'always-multiline'], + '@stylistic/eol-last': ['error', 'always'], + '@stylistic/no-trailing-spaces': 'error', + '@stylistic/object-curly-spacing': ['error', 'always'], + '@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }], + '@stylistic/member-delimiter-style': ['error', { + multiline: { delimiter: 'none' }, + singleline: { delimiter: 'semi', requireLast: false }, + }], + }, + }, +] diff --git a/lefthook.yml b/lefthook.yml index 40fb32f7b7..79b2ccd6e7 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,6 +4,13 @@ pre-commit: jobs: + - name: format (staged) + glob: '*.{ts,tsx,mts,cts,mjs}' + exclude: + - 'vendor/*/src/**' + run: node_modules/.bin/eslint --config eslint.format.config.mjs --fix --no-warn-ignored {staged_files} + stage_fixed: true + - name: lint (staged) glob: '*.{ts,tsx,mts,cts,mjs}' exclude: diff --git a/package.json b/package.json index cf9353eb5e..a5d545bf7f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", "lint": "oxlint .", - "lint:fix": "oxlint . --fix", + "lint:fix": "eslint --config eslint.format.config.mjs --fix . && oxlint . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -117,8 +117,10 @@ "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", + "@typescript-eslint/parser": "8.61.0", "@vitest/coverage-v8": "^4.1.8", "@yarnpkg/cli-dist": "4.17.1", + "eslint": "10.5.0", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", "fast-check": "^4.8.0", diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index 11efe60edb..3765a7e512 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -32,7 +32,9 @@ class ObservedStateGate { * the write/edit prior-observation policy. */ private owner(actor: object | undefined): object | undefined { - // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- tsc requires the structural view before property access. + // tsgolint treats object as assignable to weak FsPolicyExec, while tsc still requires the structural cast for property access. + // See the analyzer-divergence consequence in .agents/notes/implemented/process/2026-07-29-oxlint-linter.md. + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type. return (actor as FsPolicyExec | undefined)?.agent?.session } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4b7ab619..f8c7fe6f33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,12 +38,18 @@ importers: '@types/node': specifier: ^22.20.0 version: 22.20.0 + '@typescript-eslint/parser': + specifier: 8.61.0 + version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) '@yarnpkg/cli-dist': specifier: 4.17.1 version: 4.17.1 + eslint: + specifier: 10.5.0 + version: 10.5.0(jiti@2.7.0) eslint-plugin-sonarjs: specifier: ^4.1.0 version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) @@ -8228,10 +8234,53 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -13152,8 +13201,64 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + eslint: 10.5.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.3': {} '@upsetjs/venn.js@2.0.0': diff --git a/scripts/lint-rule-parity.spec.ts b/scripts/lint-rule-parity.spec.ts index 1ce615edef..b8d6e1b31e 100644 --- a/scripts/lint-rule-parity.spec.ts +++ b/scripts/lint-rule-parity.spec.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' import { describe, expect, it } from 'vitest' type Rules = Record @@ -12,22 +13,22 @@ interface Profile { } // Captured from eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2 -// after mapping @typescript-eslint/* to typescript/* and the five extension +// after mapping @typescript-eslint/* to typescript/* and the four extension // rules to their Oxlint core equivalents. const profiles = { source: { count: 88, - indexes: [0, 3, 4], + indexes: [0, 1, 4, 5], sha256: 'da1dfd77cb6eb66be93d8d3820f9b9b68b7aa391c24680f8851c0910298f9e3b', }, example: { count: 87, - indexes: [0, 1, 3, 4], + indexes: [0, 1, 2, 4, 5], sha256: '6a2606053bc1ec1de3b02611de88ea51d201dac13a1f193e4934d33c08b95f08', }, test: { count: 83, - indexes: [2, 3, 4], + indexes: [0, 3, 4, 5], sha256: '7995e14926a36c40bd65c474637735222a95fb030395681685f03060e50a7b78', }, } as const satisfies Record @@ -75,7 +76,11 @@ function mergedRules(config: unknown, indexes: readonly number[]): Rules { describe('Oxlint migration rule parity', () => { const path = fileURLToPath(new URL('../.oxlintrc.json', import.meta.url)) - const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + const result = parseConfigFileTextToJson(path, readFileSync(path, 'utf8')) + if (result.error !== undefined) { + throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n')) + } + const parsed: unknown = result.config it.each(Object.entries(profiles))('matches the ESLint %s profile pairwise', (_name, profile) => { const rules = mergedRules(parsed, profile.indexes) diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts new file mode 100644 index 0000000000..e904a86b38 --- /dev/null +++ b/scripts/oxlint-contract.spec.ts @@ -0,0 +1,119 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url)) +const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url)) + +function runStagedFormatter(paths: readonly string[]) { + return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + }) +} + +function runOxlint(args: readonly string[]) { + return spawnSync(process.execPath, [oxlintCli, ...args], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + }) +} + +function normalizedOutput(result: ReturnType): string { + return `${result.stdout}${result.stderr}`.replaceAll('\\', '/') +} + +async function writeContractConfig(suffix: string): Promise { + const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`) + await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] })) + return path +} + +describe('Oxlint executable contract', () => { + it('runs type-aware rules for every owned TypeScript file class', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const probes = [ + ['host package source', 'packages/fs/fs-policy/src', 'host-source.ts'], + ['host package test', 'packages/fs/fs-policy/tests', 'host-test.spec.ts'], + ['client package source', 'packages/client/ui-primitives/src', 'client-source.ts'], + ['client package test', 'packages/client/ui-trajectory/tests', 'client-test.spec.ts'], + ['client aggregate script', 'scripts', 'client-bundle-purity.spec.ts'], + ['example', 'examples', 'example.ts'], + ['website', 'website', 'website.ts'], + ] as const + const directories: string[] = [] + const source = `function probePromise(): Promise { + return Promise.resolve() +} + +probePromise() +` + + try { + const paths: Array = [] + for (const [label, parent, filename] of probes) { + const directory = join(repositoryRoot, parent, `.oxlint-contract-${suffix}`) + directories.push(directory) + await mkdir(directory, { recursive: true }) + const path = join(directory, filename) + await writeFile(path, source) + paths.push([label, relative(repositoryRoot, path)]) + } + + const result = runOxlint([ + '--config', + relative(repositoryRoot, configPath), + '--format', + 'unix', + ...paths.map(([, path]) => path), + ]) + const output = normalizedOutput(result) + + expect(result.error).toBeUndefined() + expect(result.status, output).toBe(1) + for (const [label, path] of paths) { + expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`) + } + expect(output.match(/typescript\(no-floating-promises\)/g)).toHaveLength(probes.length) + } finally { + await Promise.all([ + ...directories.map(directory => rm(directory, { recursive: true, force: true })), + rm(configPath, { force: true }), + ]) + } + }, 20_000) + + it('applies staged stylistic fixes before Oxlint validation', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`) + const path = join(directory, 'fix.ts') + + try { + await mkdir(directory, { recursive: true }) + await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n') + + const relativePath = relative(repositoryRoot, path) + const formatResult = runStagedFormatter([relativePath]) + const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath]) + + expect(formatResult.error).toBeUndefined() + expect(formatResult.status, normalizedOutput(formatResult)).toBe(0) + expect(lintResult.error).toBeUndefined() + expect(lintResult.status, normalizedOutput(lintResult)).toBe(0) + await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n') + } finally { + await Promise.all([ + rm(directory, { recursive: true, force: true }), + rm(configPath, { force: true }), + ]) + } + }, 20_000) +}) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index b9e3736181..edd2b6f9d1 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -121,7 +121,7 @@ describe('Oxlint gate', () => { }) }) - it('passes the configured native thread bound to Oxlint', () => { + it('passes the configured worker bound to both Oxlint backends', () => { const subject = withEnv('DSH_OXLINT_THREADS', '4', () => withPnpmEntrypoint(() => gatesForMode('ci-lint')[0])) @@ -130,10 +130,11 @@ describe('Oxlint gate', () => { displayCommand: 'pnpm exec oxlint . --threads=4', command: process.execPath, args: ['/private/pnpm.cjs', 'exec', 'oxlint', '.', '--threads=4'], + env: { GOMAXPROCS: '4' }, }) }) - it('rejects a non-positive or non-integer native thread bound', () => { + it('rejects a non-positive or non-integer worker bound', () => { expect(() => withEnv('DSH_OXLINT_THREADS', 'auto', () => withPnpmEntrypoint(() => gatesForMode('ci-lint')))) .toThrow('DSH_OXLINT_THREADS must be a positive integer') diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3d32ca2e12..229c7a8b1f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -376,22 +376,25 @@ function ciWindowsObservationalGates(): Gate[] { ] } -function lintGate(oxlintTargets: readonly string[] = ['.']): Gate { - const threadArgs = oxlintThreadArgs() - if (threadArgs.length > 0) { - return pnpmExec('lint', ['oxlint', ...oxlintTargets, ...threadArgs], { label: 'lint' }) +function lintGate(): Gate { + const threadBound = oxlintThreadBound() + if (threadBound !== undefined) { + return pnpmExec('lint', ['oxlint', '.', `--threads=${threadBound}`], { + label: 'lint', + env: { GOMAXPROCS: threadBound }, + }) } return pnpmScript('lint', 'lint') } -function oxlintThreadArgs(): string[] { +function oxlintThreadBound(): string | undefined { const raw = process.env.DSH_OXLINT_THREADS - if (raw === undefined || raw === '') return [] + if (raw === undefined || raw === '') return undefined const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`run-gates: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`) } - return [`--threads=${raw}`] + return raw } function coverageGate(): Gate {