Skip to content

Commit a03ac28

Browse files
os-zhuangclaude
andauthored
eslint: rule for a statement swallowed by an unterminated block comment (#9758) (#10429)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46cfa5b commit a03ac28

1 file changed

Lines changed: 272 additions & 0 deletions

File tree

eslint.config.mjs

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,252 @@ const verifyStandInPlugin = {
640640
},
641641
};
642642

643+
// ---------------------------------------------------------------------------
644+
// issue #9758 — a statement swallowed by an unterminated block comment.
645+
// ---------------------------------------------------------------------------
646+
//
647+
// A block comment that is never closed is NOT a syntax error. The next comment
648+
// terminator in the file closes it — normally the docblock of the following
649+
// declaration — so the file parses, every gate stays green, and the only
650+
// symptom is a statement that quietly stopped existing. #9640 is one instance:
651+
// `export { maskComments };` in `scripts/pm/dispatch-gates.mjs` had been comment
652+
// TEXT, while that module's own header went on claiming the re-export.
653+
//
654+
// The class is invisible to review and trivial to parse: the swallowed line
655+
// looks like code (textually it IS code) and the terminator looks like it
656+
// belongs to the docblock below. ESLint already visits every file with a real
657+
// parser and hands a rule the comment nodes for free, so the checker is the
658+
// predicate below and nothing else.
659+
//
660+
// ## Why a rule here and not a `check:*` family — #9758's question, measured
661+
//
662+
// #9758 recorded the class at 1 instance in 4,595 files and asked whether that
663+
// base rate earns a checker at all. Re-derived on this tree — 4,679 files, a
664+
// real parser rather than the repo masker — the count is 0: #9640 was the
665+
// singleton and its PR repaired it.
666+
//
667+
// A zero base rate is a real argument against a new gate FAMILY: a script, a
668+
// workflow step, a required context, its own self-test and watch hints, and one
669+
// more line of reader attention in every dispatch derivation, all to hold a
670+
// class nobody trips. It is not an argument against THIS, which is one rule in
671+
// the file that already carries three inline plugins, under a `pnpm lint` that
672+
// already parses all 4,679 files. Zero new CI steps, zero new required
673+
// contexts, zero new check families, zero new dispatch leads.
674+
// `verify-stand-in/no-asserted-driver-argument` below is the precedent for a
675+
// guard that starts at a zero baseline: the state worth having is that the
676+
// count stays 0, and holding a 0 is what a rule is for.
677+
//
678+
// ## The false-positive surface, which is the whole risk — measured
679+
//
680+
// Commented-out code is legitimate and common, so a predicate that flags every
681+
// code-shaped line inside a block comment is unshippable. Two facts bound it,
682+
// both measured over this tree's 21,007 multi-line block comments:
683+
//
684+
// • all 240,529 of their interior source lines carry the `*` prose marker —
685+
// not most, all. So exempting marker lines costs no recall on real prose,
686+
// and it is what makes the predicate quiet: 540 interior lines ARE
687+
// code-shaped (`@example` blocks — `* import { createHonoApp } from …`,
688+
// `* export default app;`) and every one of them is a marker line. Without
689+
// the exemption those 540 are false positives; with it they are invisible.
690+
// • the predicate matches statement SHAPES, not statement keywords. `let`,
691+
// `class`, `type`, `import` and `export` are ordinary English words, and a
692+
// keyword test flags prose that opens with one. `let us assume…` does not
693+
// match `let <ident> [:=]`; `class hierarchies are…` does not match
694+
// `class <ident> (extends|implements|{|<)`; `import lists are…` does not
695+
// match an import form. Those three are in the pinned cases below.
696+
//
697+
// Against those two, plus the block-comment OPENER signature (comments do not
698+
// nest, so a `/*` at the head of a line inside a comment span is the structural
699+
// signature of "never closed"), a sweep over 261,536 candidate lines in 4,679
700+
// files reports 0. So the rule ships with no baseline and no ignores beyond the
701+
// build directories — there is nothing to grandfather.
702+
//
703+
// ## What it cannot see, stated rather than implied
704+
//
705+
// ESLint never sees a file that does not parse, so this rule's domain is
706+
// exactly the SILENT half of the class. When the swallowed span happens to
707+
// leave behind text that is not valid JavaScript — a glob literal such as
708+
// `packages/` + `**` + `/*.ts` carries a comment terminator and closes a
709+
// phantom span mid-token — the parser rejects the file loudly and no checker is
710+
// needed. That split is also why the predicate is allowed to be narrow: the
711+
// loud half is already covered, by the compiler.
712+
//
713+
// The other half it cannot see is a swallowed line that is neither
714+
// statement-shaped nor a comment opener — a bare call, a JSX fragment, an
715+
// object continuation. Widening to "any line inside a block comment without a
716+
// `*` marker" would catch those and measures 0 on this tree too, but it is a
717+
// STYLE claim wearing a defect's clothes: it would reject a perfectly ordinary
718+
// marker-less `/* TODO: … */` block, and this repo lints with
719+
// `--no-inline-config`, so there would be no per-site escape from it. The
720+
// escape that does exist is the house style itself — prefix the line with `*`.
721+
722+
/** The rule's own id, exported for the same reason as the two ids above. */
723+
export const COMMENT_SWALLOW_RULE_ID = 'comment-swallow/no-code-inside-block-comment';
724+
725+
export const COMMENT_SWALLOW_MESSAGE =
726+
'This line is inside a block comment, and it is shaped like code. That is the signature of a ' +
727+
'block comment that was never closed: an unterminated opener is not a syntax error — the next ' +
728+
'comment terminator in the file closes it, usually the docblock of the following declaration — ' +
729+
'so the file parses, every gate stays green, and a statement quietly stops existing while the ' +
730+
'header above it goes on describing it (#9640: `export { maskComments };` was comment text for ' +
731+
'as long as nobody looked). Add the missing terminator to the comment above. If the line really ' +
732+
'is prose or a deliberately commented-out example, prefix it with the `*` marker every one of ' +
733+
"this tree's 240,529 block-comment lines already carries — that is the exemption, and it is the " +
734+
'house style rather than an opt-out. See issues #9640 and #9758.';
735+
736+
/**
737+
* Statement SHAPES, not statement keywords — see the false-positive section
738+
* above. Each pattern is anchored on the punctuation that makes the line code
739+
* rather than a sentence that happens to open with a reserved word.
740+
*/
741+
export const COMMENT_SWALLOW_PATTERNS = [
742+
// `export { a };` / `export * from './x';` / `export default f;`
743+
/^export\s*(?:\{|\*|default\b)/,
744+
// the four import forms, and none of `import lists are checked elsewhere,`
745+
/^import\s*(?:\{|\*|type\s|['"]|[A-Za-z_$][\w$]*\s*(?:,|from\b))/,
746+
// a binding: `const x =`, `let { a } =`, `export var n:` — never `let us …`
747+
/^(?:export\s+)?(?:const|let|var)\s+(?:[{[]|[A-Za-z_$][\w$]*\s*[:=])/,
748+
// `function f(`, `async function* g<`, `export default function h(`
749+
/^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*[A-Za-z_$][\w$]*\s*[(<]/,
750+
// `class C {` / `export abstract class C extends D` — never `class hierarchies …`
751+
/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+[A-Za-z_$][\w$]*\s*(?:extends\b|implements\b|\{|<)/,
752+
// the TypeScript declaration shapes
753+
/^(?:export\s+)?(?:declare\s+)?interface\s+[A-Za-z_$][\w$]*\s*(?:extends\b|\{|<)/,
754+
/^(?:export\s+)?(?:declare\s+)?(?:const\s+)?enum\s+[A-Za-z_$][\w$]*\s*\{/,
755+
/^(?:export\s+)?type\s+[A-Za-z_$][\w$]*\s*[=<]/,
756+
// CommonJS, which `scripts/` still writes
757+
/^(?:module\.exports|exports\.[A-Za-z_$][\w$]*)\s*=/,
758+
// a block-comment OPENER. Comments do not nest, so one that begins a line
759+
// inside a comment span is the structural signature of "never closed" — and
760+
// in the #9640 shape it is the docblock of the declaration below, which is
761+
// exactly the thing that made the defect unreadable.
762+
/^\/\*/,
763+
];
764+
765+
/**
766+
* True when this source line, taken on its own, reads as code rather than
767+
* prose. The caller supplies the fact that the line lies inside a block-comment
768+
* span; this half decides nothing about spans and everything about shape.
769+
*/
770+
export function looksLikeSwallowedCode(sourceLine) {
771+
const text = sourceLine.trim();
772+
if (!text || text.startsWith('*')) return false;
773+
return COMMENT_SWALLOW_PATTERNS.some((pattern) => pattern.test(text));
774+
}
775+
776+
/**
777+
* The extension list is load-bearing and DELIBERATELY wider than the
778+
* `verify-stand-in` block's: it carries `js`/`jsx`/`mjs`/`cjs`. The only
779+
* instance this class has ever had on this tree was `scripts/pm/
780+
* dispatch-gates.mjs`, a `.mjs` file, and a block copied from the
781+
* `{ts,tsx,mts,cts}` sibling below would have covered every file except the
782+
* one kind that has actually carried the defect. `assertCommentSwallow` pins
783+
* it, so the narrowing cannot happen quietly.
784+
*/
785+
export const COMMENT_SWALLOW_FILES = ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'];
786+
787+
/**
788+
* The predicate's contract, pinned as cases rather than trusted.
789+
*
790+
* A rule with a ZERO baseline is green three ways — the tree is clean, the
791+
* predicate was edited into matching nothing, or the config block stopped
792+
* matching files — and `pnpm lint` reports the same nothing in all three. That
793+
* is the dead-pin shape the three sibling guards in this file each pay a gate
794+
* script to avoid. This one does not need a gate script: the predicate is a
795+
* pure function of one line, so its cases can run where the config loads, on
796+
* every `pnpm lint` in CI and locally, at a cost of ~20 regex tests. The span
797+
* half needs no pinning — that is the parser's answer, not ours.
798+
*/
799+
export const COMMENT_SWALLOW_CASES = [
800+
// FIRES — the class.
801+
['export { maskComments };', true], // the #9640 statement itself
802+
['const RE = /a*' + '/;', true],
803+
[' const limit = 10;', true], // indented, inside a function body
804+
['module.exports = { a: 1 };', true],
805+
['export type Id = string;', true],
806+
['export function selfTest() {', true],
807+
['import { readFileSync } from "node:fs";', true],
808+
['/** the docblock whose terminator closed the phantom span */', true],
809+
// SILENT — prose, and marked-up example code.
810+
[' * import { createHonoApp } from "@objectstack/hono";', false],
811+
[' * export default app;', false],
812+
['let us assume the value is null, and', false],
813+
['class hierarchies are not the point here;', false],
814+
['import lists are checked elsewhere,', false],
815+
['export the module however you like;', false],
816+
['type checking happens later', false],
817+
[' */', false],
818+
['', false],
819+
];
820+
821+
function assertCommentSwallow() {
822+
const wrong = COMMENT_SWALLOW_CASES.filter(([line, expected]) => looksLikeSwallowedCode(line) !== expected);
823+
if (wrong.length > 0) {
824+
throw new Error(
825+
`${COMMENT_SWALLOW_RULE_ID}: the detector no longer matches its pinned cases — ` +
826+
`${wrong.length} of ${COMMENT_SWALLOW_CASES.length} disagree, starting with ` +
827+
`${JSON.stringify(wrong[0][0])} (expected ${wrong[0][1] ? 'a report' : 'silence'}). ` +
828+
'A rule with a zero baseline cannot be trusted to be quiet for the right reason, so this ' +
829+
'runs where the config loads. Fix COMMENT_SWALLOW_PATTERNS, or amend the case if the ' +
830+
'contract really changed — never delete it to get lint green. See issue #9758.'
831+
);
832+
}
833+
if (!COMMENT_SWALLOW_FILES.some((glob) => /\bmjs\b/.test(glob) && /\bjs\b/.test(glob))) {
834+
throw new Error(
835+
`${COMMENT_SWALLOW_RULE_ID}: COMMENT_SWALLOW_FILES stopped covering plain JavaScript. The ` +
836+
'one instance this class has ever had on this tree was a `.mjs` file under `scripts/`, so a ' +
837+
'TypeScript-only scope is a guard that cannot see the only place the defect has occurred. ' +
838+
'See issue #9758.'
839+
);
840+
}
841+
}
842+
843+
assertCommentSwallow();
844+
845+
const commentSwallowPlugin = {
846+
rules: {
847+
'no-code-inside-block-comment': {
848+
meta: {
849+
type: 'problem',
850+
docs: {
851+
description:
852+
'Ban a code-shaped line inside a block-comment span — the signature of a block ' +
853+
'comment that was never closed.',
854+
},
855+
schema: [],
856+
messages: { swallowed: COMMENT_SWALLOW_MESSAGE },
857+
},
858+
create(context) {
859+
return {
860+
Program() {
861+
const { sourceCode } = context;
862+
const lines = sourceCode.lines;
863+
for (const comment of sourceCode.getAllComments()) {
864+
if (comment.type !== 'Block') continue;
865+
const { start, end } = comment.loc;
866+
if (end.line === start.line) continue;
867+
// From the line AFTER the opener through the terminator's own
868+
// line. The opener's line is excluded because whatever precedes a
869+
// `/*` on it is live code; the terminator's line is INCLUDED
870+
// because a span can close mid-line, on a line that is otherwise
871+
// a statement — a regex or a glob literal a few lines down
872+
// carries a terminator and ends the phantom span right there.
873+
for (let line = start.line + 1; line <= end.line; line++) {
874+
const text = lines[line - 1];
875+
if (text === undefined || !looksLikeSwallowedCode(text)) continue;
876+
context.report({
877+
loc: { start: { line, column: 0 }, end: { line, column: text.length } },
878+
messageId: 'swallowed',
879+
});
880+
}
881+
}
882+
},
883+
};
884+
},
885+
},
886+
},
887+
};
888+
643889
export default [
644890
{
645891
files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
@@ -897,4 +1143,30 @@ export default [
8971143
plugins: { 'verify-stand-in': verifyStandInPlugin },
8981144
rules: { 'verify-stand-in/no-asserted-driver-argument': 'error' },
8991145
},
1146+
// issue #9758 — a statement swallowed by an unterminated block comment. The
1147+
// rationale, the re-derived base rate and the false-positive bound are all on
1148+
// `COMMENT_SWALLOW_MESSAGE` above.
1149+
//
1150+
// ⚠️ `COMMENT_SWALLOW_FILES` is wider than the `verify-stand-in` block's
1151+
// pattern directly above: it carries `js`/`jsx`/`mjs`/`cjs`, because the one
1152+
// instance this class has ever had was a `.mjs` file under `scripts/`.
1153+
// Copying the sibling's `{ts,tsx,mts,cts}` scope here would produce a guard
1154+
// that covers 4,326 package files and not the one kind of file the defect has
1155+
// actually occurred in. `assertCommentSwallow` refuses that narrowing.
1156+
//
1157+
// No baseline and no `ignores` beyond the build directories, for the same
1158+
// reason `verify-stand-in` has none: the tree measures 0 today (261,536
1159+
// candidate lines, 4,679 files), so there is nothing to grandfather and every
1160+
// future report is a new defect. Adding an entry later would mean the state
1161+
// stopped being locked.
1162+
{
1163+
files: COMMENT_SWALLOW_FILES,
1164+
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.turbo/**'],
1165+
languageOptions: {
1166+
parser: tsParser,
1167+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
1168+
},
1169+
plugins: { 'comment-swallow': commentSwallowPlugin },
1170+
rules: { 'comment-swallow/no-code-inside-block-comment': 'error' },
1171+
},
9001172
];

0 commit comments

Comments
 (0)