Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
7.3 KiB
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--changedmodes. - 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 msd-core/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
-
Adopt ESLint flat config (
eslint.config.mjsat repo root) with:typescript-eslint(type-aware rules enabled viatsconfig.lint.jsonproject reference)eslint-plugin-nfor Node API andrequire()graph enforcementeslint-plugin-no-only-tests(no-only-tests/no-only-tests) to preventtest.only/describe.onlyleaking 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— bansreadFileSyncon source files +.includes()/.match()/.startsWith()on the bound variable; also bansassert.match/doesNotMatchon.stdout/.stderrwithout JSON round-triplocal/no-magic-sleep-in-tests— banssetTimeout/sleep/delaycalls insidetest()/it()/describe()bodieslocal/no-elapsed-assertion— bansasserton elapsed-time values (e.g.,Date.now() - start > N,process.hrtime,performance.now()comparisons in assertions)
-
Phase in at
warn. All threelocal/*rules ship at severitywarnfrom 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 toerrorafter the cleanup phases that eliminate existing violations have merged. -
Retire homegrown scripts.
scripts/lint-no-source-grep.cjsand 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 singlenpm run lintinvocation. -
no-restricted-syntaxbans (ineslint.config.mjs, severityerrorfrom day one):CallExpression[callee.name='setTimeout']insideProgram > ExpressionStatement(top-level sleeps — catches a different shape thanlocal/no-magic-sleep-in-tests)MemberExpression[property.name='only'][object.name=/^(test|it|describe)$/]as a belt-and-suspenders backstop alongsideeslint-plugin-no-only-tests
-
eslint-plugin-nenforces:n/no-missing-require— catches import-graph drift for hand-written CJS filesn/no-unsupported-features/es-syntaxagainstengines.node(>=22.0.0)
-
Editor integration. Commit the recommended
.vscode/extensions.jsonentry fordbaeumer.vscode-eslintand an.editorconfigfallback 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.cjsfiles translate to ESLint// eslint-disable-next-line local/no-source-grep -- <reason>comments. The old exemption syntax is no longer recognized. test.only/describe.onlycommitted to any non-scratch file fail CI immediately (errorfrom day one).
For the test-rigor audit
- Violations of
local/no-source-grep,local/no-magic-sleep-in-tests, andlocal/no-elapsed-assertionare 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-assertionrule 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-eslinttype-aware rules is a prerequisite for collapsing the hand-written/generated.cjssplit. Oncetsconfig.lint.jsonis wired into ESLint's project references,typescript-eslintwill 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.