Skip to content

eslint: catch a statement swallowed by an unterminated block comment - #10429

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9758-unterminated-block-comment-checker
Aug 20, 2026
Merged

eslint: catch a statement swallowed by an unterminated block comment#10429
os-zhuang merged 1 commit into
mainfrom
claude/issue-9758-unterminated-block-comment-checker

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9758

A block comment that is never closed is not a syntax error. The next terminator in the file closes it — normally the docblock of the following declaration — so the file parses, every gate stays green, and a statement quietly stops existing while the header above it goes on describing it. #9640 was one instance: export { maskComments }; in scripts/pm/dispatch-gates.mjs had been comment text.

#9758 is a disposition card: it recorded the class rather than building for it, and asked whether the base rate earns a checker. This PR answers yes, on cost, and adds one rule — comment-swallow/no-code-inside-block-comment — to eslint.config.mjs, the file that already carries three inline plugins. Nothing else changes: no new file, no new script, no workflow step, no required context, no check family, no dispatch lead.

Answering the card's question, measured

The base rate is now 0, not 1 in 4,595. Re-derived on origin/main at b9a5c46 over 4,679 files — with @typescript-eslint/parser's comment nodes rather than the repo masker — the class has zero instances. #9640 was the singleton and its PR repaired it. premise_still_valid for the card's own measurement: the count it took is no longer the count.

A zero base rate is a real argument against a new gate family — a script, a workflow step, a required context, its own self-test and watch hints, and one more line of reader attention in every dispatch derivation. It is not an argument against this, because the marginal cost here is:

new CI steps0
new required contexts0
new check:* families0 (dispatch-gates.mjs still discovers 117)
new dispatch leads0
files newly entering the lint set0
pnpm lint wall time, whole tree56s

verify-stand-in/no-asserted-driver-argument is the in-file precedent for a guard that starts at a zero baseline: the state worth having is that the count stays 0, and holding a 0 is what a rule is for. This one likewise ships with no baseline and no ignores beyond the build directories.

The false-positive surface, which is the whole risk

Commented-out code is legitimate and common, so a predicate that flags every code-shaped line inside a block comment is unshippable. Two facts bound it, both measured over this tree's 21,007 multi-line block comments:

  • All 240,529 of their interior source lines carry the * prose marker. Not most — all. So exempting marker lines costs no recall on real prose, and it is what makes the predicate quiet: 540 interior lines are code-shaped (@example blocks: * import { createHonoApp } from …, * export default app;) and every one of them is a marker line. Without the exemption those 540 red; with it they are invisible. This is the criterion narrowing on evidence, and it is the whole difference between 540 and 0.
  • The predicate matches statement SHAPES, not keywords.let, class, type, import and export are ordinary English words. let us assume… does not match let <ident> [:=]; class hierarchies are… does not match class <ident> (extends|implements|{|<); import lists are… matches no import form. All three are pinned cases.

Against those, plus the block-comment opener signature (comments do not nest, so a /* heading a line inside a comment span is the structural signature of "never closed"), the sweep reports:

4,679 files 21,007 multi-line block comments 261,536 candidate lines 0 hits

The card's own looser criteria, for comparison, measured on the same corpus: /* anywhere inside a block comment → 321 hits, all prose (* reads live \examples/**` for the nameless cases`). Statement-keyword-at-any-indent with no marker exemption → the 540 above. Both are the unshippable versions.

Positive control — it fires on the real #9640, reconstructed in the tree

The defect was reintroduced by deleting the terminator of the docblock above export { maskComments }; in scripts/pm/dispatch-gates.mjs, confirmed on disk by anchored counts (original 3-line form 0 occurrences, mutated form 1), then linted:

scripts/pm/dispatch-gates.mjs
528:1 error This line is inside a block comment, and it is shaped like code… comment-swallow/no-code-inside-block-comment
530:1 error … comment-swallow/no-code-inside-block-comment
✖ 2 problems (2 errors, 0 warnings)

Both signatures: line 528 is the swallowed statement, line 530 is the /** of the docblock below whose terminator closed the phantom span. With that defect in the tree:

node --check scripts/pm/dispatch-gates.mjs EXIT=0 (it still parses — the class is silent)
import(...) -> 'maskComments' in module false (the export is genuinely gone)

Restored byte-identically afterwards — git hash-object matches git rev-parse HEAD:<path> — and both legs re-run green.

Ablation — the rule can go red, and so can its self-check

A rule with a zero baseline is green three ways: the tree is clean, the predicate was edited into matching nothing, or the config block stopped matching files. pnpm lint reports the same nothing in all three. The three sibling guards in this file each pay a gate script to tell those apart; this one does not need one, because the predicate is a pure function of a single line, so its cases run where the config loads — on every pnpm lint, in CI and locally, for ~20 regex tests.

Both arms were mutated and confirmed on disk by anchored before/after counts (target text 1 → 0, injected text 0 → 1), then restored to a blob sha matching HEAD:

mutationresult
break one pattern (^export^exportXX)throws at config load: "the detector no longer matches its pinned cases — 1 of 17 disagree, starting with "export { maskComments };" (expected a report)"; pnpm lint exits 2
narrow COMMENT_SWALLOW_FILES to {ts,tsx,mts,cts}throws: "COMMENT_SWALLOW_FILES stopped covering plain JavaScript"

That second arm guards a real trap. The whole-tree block directly above this one is scoped **/*.{ts,tsx,mts,cts}, and the only instance this class has ever had was a .mjs file under scripts/. A block copied from the sibling would cover 4,326 package files and not the one kind of file the defect has actually occurred in.

Limits, stated rather than implied

  • ESLint never sees a file that does not parse, so this rule's domain is exactly the silent half of the class. When the swallowed span leaves behind text that is not valid JavaScript — a glob literal like packages/ + ** + /*.ts carries a terminator and closes a phantom span mid-token — the parser already rejects the file loudly. That split is why the predicate is allowed to be narrow: the loud half belongs to the compiler.
  • It does not see a swallowed line that is neither statement-shaped nor a comment opener (a bare call, a JSX fragment, an object continuation). Widening to "any line inside a block comment without a * marker" catches those and also measures 0 on this tree — but it is a style claim wearing a defect's clothes, it would reject an ordinary marker-less /* TODO: … */, and this repo lints with --no-inline-config, so there would be no per-site escape. The escape that does exist is the house style: prefix the line with *.
  • check:pm-dispatch-gates already pins the one statement [finding] dispatch-gates' export { maskComments } is swallowed by an unterminated block comment — the docblock claims a re-export the module does not have #9640 repaired, and it does go red on the reconstruction above (2 of 421 cases). That covers one statement in one file; this covers 4,679.

Gates

Union re-derived at final commit 0964f06f3 and run on that tree:

node scripts/pm/dispatch-gates.mjs → 1 path (eslint.config.mjs); 0 of 117 families named
pnpm lint (whole tree, in os-verify-lock) VERDICT command-exit 0 · held 56s · waited 98s
pnpm check:nul-bytes OK 6,122 text files, no raw ASCII control bytes
pnpm check:slot-lookup OK ratchet holds, 107 unswept sites, none new
pnpm check:query-options-erasure OK ratchet holds, 67 unswept non-test sites, none new
pnpm check:verify-stand-in OK 2 checks guarded, 10 call sites, 0 asserted arguments
node scripts/pm/check-dispatch-gates.mjs ✓ 421 cases pass

The last four are run because they import from eslint.config.mjs and this change adds a throw path to its module load. The sweeps and the control harness were run outside os-verify-lock and are declared: pure source-text scanners that neither build nor read dist/. pnpm lint was run inside it.

dispatch-gates.mjs names no check family for eslint.config.mjs, which is the same fact as the cost table above: this rule rides a gate that already exists.

Clause ②: not reachable — the change touches no packages/spec/src/** path and alters no contract accept/reject behaviour or public surface.

Changeset: none. eslint.config.mjs publishes nothing, so this carries skip-changeset.

Filed, not fixed here

#10427scanSource (scripts/js-comment-mask.mjs) desyncs on a nested template literal inside ${...}, and 16 files diverge from a real parser's comment ranges: 15 in the direction that module's own header calls the worse one (real comments read as live code — 9,405 bytes in view.zod.ts alone), 2 in the blinding direction (~85 lines of live code blanked out of check-governed-prose.mjs). Found because #9758's sweep uses scanSource as its instrument; its 13 apparent hits on current main are all phantoms from that desync, which is part of why this rule reads the parser's comment nodes instead. Out of scope here and untouched by this PR.


Generated by Claude Code

@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 20, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 20, 2026 18:46
@os-zhuang
os-zhuang enabled auto-merge August 20, 2026 18:46
@os-zhuang
os-zhuang added this pull request to the merge queueAug 20, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 20, 2026
@os-zhuang
os-zhuang added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit a03ac28Aug 20, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9758-unterminated-block-comment-checker branch August 20, 2026 21:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude