@@ -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+ / ^ e x p o r t \s * (?: \{ | \* | d e f a u l t \b ) / ,
744+ // the four import forms, and none of `import lists are checked elsewhere,`
745+ / ^ i m p o r t \s * (?: \{ | \* | t y p e \s | [ ' " ] | [ A - Z a - z _ $ ] [ \w $ ] * \s * (?: , | f r o m \b ) ) / ,
746+ // a binding: `const x =`, `let { a } =`, `export var n:` — never `let us …`
747+ / ^ (?: e x p o r t \s + ) ? (?: c o n s t | l e t | v a r ) \s + (?: [ { [ ] | [ A - Z a - z _ $ ] [ \w $ ] * \s * [: = ] ) / ,
748+ // `function f(`, `async function* g<`, `export default function h(`
749+ / ^ (?: e x p o r t \s + ) ? (?: d e f a u l t \s + ) ? (?: a s y n c \s + ) ? f u n c t i o n \s * \* ? \s * [ A - Z a - z _ $ ] [ \w $ ] * \s * [ ( < ] / ,
750+ // `class C {` / `export abstract class C extends D` — never `class hierarchies …`
751+ / ^ (?: e x p o r t \s + ) ? (?: d e f a u l t \s + ) ? (?: a b s t r a c t \s + ) ? c l a s s \s + [ A - Z a - z _ $ ] [ \w $ ] * \s * (?: e x t e n d s \b | i m p l e m e n t s \b | \{ | < ) / ,
752+ // the TypeScript declaration shapes
753+ / ^ (?: e x p o r t \s + ) ? (?: d e c l a r e \s + ) ? i n t e r f a c e \s + [ A - Z a - z _ $ ] [ \w $ ] * \s * (?: e x t e n d s \b | \{ | < ) / ,
754+ / ^ (?: e x p o r t \s + ) ? (?: d e c l a r e \s + ) ? (?: c o n s t \s + ) ? e n u m \s + [ A - Z a - z _ $ ] [ \w $ ] * \s * \{ / ,
755+ / ^ (?: e x p o r t \s + ) ? t y p e \s + [ A - Z a - z _ $ ] [ \w $ ] * \s * [ = < ] / ,
756+ // CommonJS, which `scripts/` still writes
757+ / ^ (?: m o d u l e \. e x p o r t s | e x p o r t s \. [ A - Z a - 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 ) => / \b m j s \b / . test ( glob ) && / \b j s \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+
643889export 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