Files
msd-core/docs/adr/452-eslint-lint-harness.md
Tom Boucher ed1c20061b docs(#456): add testing standards, ADRs 452/456/457, and CONTEXT.md entries (#458)
- docs/adr/452-eslint-lint-harness.md (Accepted): adopt ESLint flat config
  with typescript-eslint, eslint-plugin-n, eslint-plugin-no-only-tests, and
  local AST-rule plugin; retire homegrown scripts/lint-*.cjs regex checkers;
  three test-rigor rules ship at warn, promoted to error after #453 cleanup
- docs/adr/456-test-rigor-architecture.md (Accepted): deterministic-over-racing
  via injectable clock seam + node:test mock.timers; antagonistic tier with
  fast-check + Stryker at 80% threshold; typed-surface mandate; delete-bad-tests
  policy with no-permanent-quarantine
- docs/adr/457-generated-cjs-single-source.md (Proposed): future direction to
  collapse ~59 hand-written bin/lib/*.cjs to TS-generated single source;
  eliminates tsconfig.lint.json stopgap; marked Proposed / not yet executed
- TESTING-STANDARDS.md: orients to existing docs; codifies six test-rigor
  contracts; adds new policies (no-timing-assertion, clock-seam, property-based,
  mutation-score, delete-bad-tests); pairs each with exact ESLint rule names;
  markdownlint-clean (MD040 fences, MD056 table columns)
- CONTEXT.md: adds six RULESET.TESTS.* predicates (no-timing-assertion,
  clock-seam, property-based-testing, mutation-score, delete-bad-tests,
  eslint-harness) and five glossary terms (clock seam, deterministic scheduler,
  property-based test, mutation testing/score, ESLint harness)
- docs/adr/README.md: adds index rows for ADRs 452, 456, 457

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:33:36 -04:00

87 lines
7.3 KiB
Markdown

# ADR 452: Adopt standard ESLint flat-config lint harness
- **Status:** Accepted
- **Date:** 2026-05-28
This codebase adopts ESLint flat config (eslint ≥ 9) with `typescript-eslint`, `eslint-plugin-n`, `eslint-plugin-no-only-tests`, and a local AST-rule plugin as the canonical lint harness, replacing the homegrown regex-based `scripts/lint-*.cjs` scripts. The ESLint harness becomes the single enforcement point for import-graph, Node API, and test-rigor rules. The three test-rigor rules (`local/no-source-grep`, `local/no-magic-sleep-in-tests`, `local/no-elapsed-assertion`) initially ship at `warn`; a follow-up issue (tracked at #453) promotes them to `error` after the cleanup phases merge.
## Context
### Existing homegrown regex harness
`scripts/lint-no-source-grep.cjs`, `scripts/lint-no-magic-sleep.cjs`, and related scripts implement test-rigor guards as regular-expression line scanners over raw source text. This approach has several structural weaknesses:
- **False positives** — regex on raw text fires on string literals, comments, and doc blocks that are not code paths.
- **No AST context** — the regex scanners cannot distinguish a banned call inside a helper wrapper from a banned call inside a live test assertion.
- **No incremental mode** — the scripts always scan the entire tree; they have no ESLint-style `--cache`, `--fix`, or `--changed` modes.
- **No editor integration** — IDEs speak LSP/ESLint, not project-local shell scripts; contributors see violations only in CI.
- **Maintenance cost** — each new rule requires a new bespoke script with its own exit-code wiring.
### Type-aware linting stopgap
`tsconfig.lint.json` was introduced to give `tsc --noEmit` access to the hand-written `.cjs` files alongside the generated ones. It is explicitly described in the codebase as a stopgap pending a proper type-aware ESLint setup.
### Generated vs hand-written split
Approximately 59 hand-written and 13 generated `.cjs` files currently coexist in `get-shit-done/bin/lib/`. The hand-written files are not checked by `typescript-eslint` type-aware rules because `tsconfig.lint.json` is not wired into an ESLint project. ADR 457 (`457-generated-cjs-single-source.md`) proposes collapsing this split; the present ADR is a prerequisite: the ESLint harness must exist before the collapse can surface type errors.
## Decision
1. **Adopt ESLint flat config** (`eslint.config.mjs` at repo root) with:
- `typescript-eslint` (type-aware rules enabled via `tsconfig.lint.json` project reference)
- `eslint-plugin-n` for Node API and `require()` graph enforcement
- `eslint-plugin-no-only-tests` (`no-only-tests/no-only-tests`) to prevent `test.only` / `describe.only` leaking into CI
- A local plugin at `scripts/eslint-rules/` that exposes the AST-rewrite of the three homegrown test-rigor rules:
- `local/no-source-grep` — bans `readFileSync` on source files + `.includes()/.match()/.startsWith()` on the bound variable; also bans `assert.match/doesNotMatch` on `.stdout/.stderr` without JSON round-trip
- `local/no-magic-sleep-in-tests` — bans `setTimeout`/`sleep`/`delay` calls inside `test()` / `it()` / `describe()` bodies
- `local/no-elapsed-assertion` — bans `assert` on elapsed-time values (e.g., `Date.now() - start > N`, `process.hrtime`, `performance.now()` comparisons in assertions)
2. **Phase in at `warn`**. All three `local/*` rules ship at severity `warn` from the initial merge. The CI lint gate (`npm run lint`) does not fail on warnings; it does emit them as annotation. A dedicated follow-up (tracked at #453) flips all three to `error` after the cleanup phases that eliminate existing violations have merged.
3. **Retire homegrown scripts**. `scripts/lint-no-source-grep.cjs` and all sibling regex-scanner scripts are deleted in the same PR that introduces the ESLint config. The CI step that called them is replaced by a single `npm run lint` invocation.
4. **`no-restricted-syntax` bans** (in `eslint.config.mjs`, severity `error` from day one):
- `CallExpression[callee.name='setTimeout']` inside `Program > ExpressionStatement` (top-level sleeps — catches a different shape than `local/no-magic-sleep-in-tests`)
- `MemberExpression[property.name='only'][object.name=/^(test|it|describe)$/]` as a belt-and-suspenders backstop alongside `eslint-plugin-no-only-tests`
5. **`eslint-plugin-n`** enforces:
- `n/no-missing-require` — catches import-graph drift for hand-written CJS files
- `n/no-unsupported-features/es-syntax` against `engines.node` (`>=22.0.0`)
6. **Editor integration**. Commit the recommended `.vscode/extensions.json` entry for `dbaeumer.vscode-eslint` and an `.editorconfig` fallback so contributors see inline violations without running CI.
## Consequences
### For contributors
- ESLint runs in CI (`npm run lint`) alongside the test suite. A clean lint is required before a PR is mergeable.
- The three test-rigor rules fire as warnings initially; they become errors after #453 merges. Violations added after the initial cleanup phase will block CI.
- Existing `// allow-test-rule: <reason>` comments in `.cjs` files translate to ESLint `// eslint-disable-next-line local/no-source-grep -- <reason>` comments. The old exemption syntax is no longer recognized.
- `test.only` / `describe.only` committed to any non-scratch file fail CI immediately (`error` from day one).
### For the test-rigor audit
- Violations of `local/no-source-grep`, `local/no-magic-sleep-in-tests`, and `local/no-elapsed-assertion` are now surfaced in the IDE and in CI annotations before a PR is opened, removing the current pattern of discovering violations only in PR review.
- The `no-elapsed-assertion` rule enforces the clock-seam pattern codified in ADR 456 (`456-test-rigor-architecture.md`): tests that assert on elapsed time must use the injectable clock seam instead of wall-clock assertions.
### For ADR 457
- The ESLint harness with `typescript-eslint` type-aware rules is a prerequisite for collapsing the hand-written/generated `.cjs` split. Once `tsconfig.lint.json` is wired into ESLint's project references, `typescript-eslint` will surface type drift between the hand-written CJS surface and the TS source.
## Rejected Alternatives
**(a) Keep the homegrown regex harness.** Rejected. False positives, no AST context, no editor integration, and per-rule maintenance cost all compound over time. The homegrown scripts solved the immediate gap but are not a sustainable lint surface.
**(b) Legacy `.eslintrc` format.** Rejected. ESLint 9 deprecated `.eslintrc`; flat config is the supported path for new plugins and type-aware rules. Starting on a deprecated format incurs migration debt immediately.
**(c) Per-file `ts-check` only (no ESLint).** Rejected. `@ts-check` in `.cjs` files gives type feedback inside the file but does not enforce import-graph, test-rigor, or no-only-tests rules. It is a supplementary aid, not a lint harness.
## References
- Tracking issue: [#452](https://github.com/open-gsd/get-shit-done-redux/issues/452)
- Follow-up (warn → error): [#453](https://github.com/open-gsd/get-shit-done-redux/issues/453)
- Test-rigor architecture: `456-test-rigor-architecture.md`
- Generated CJS collapse (future): `457-generated-cjs-single-source.md`
- Homegrown scripts retired: `scripts/lint-no-source-grep.cjs`, `scripts/lint-no-magic-sleep.cjs`
- Stopgap: `tsconfig.lint.json`