@@ -44,6 +44,14 @@ import {
4444 auditPageExpressionEnvelopes ,
4545 renderBareExpressionFindings ,
4646} from '@objectstack/lint' ;
47+ // The one answer this tree has to "comment, literal, or code". It is a plain
48+ // `.mjs`, but `scripts/js-comment-mask.d.mts` beside it is a hand-written
49+ // declaration mirror (governed by `check:declaration-mirrors`), so this import
50+ // is typed and needs no suppression -- a `@ts-expect-error` here would be an
51+ // UNUSED directive. That `.d.mts` is what gives `maskComments` its type, so it
52+ // is an input to this package's typecheck verdict as well as to this scan.
53+ // Same spelling `packages/cli`'s contract tests use.
54+ import { maskComments } from '../../../scripts/js-comment-mask.mjs' ;
4755import { CloudConnectionSettingsPage } from './cloud-connection-ui.js' ;
4856import { MarketplaceInstalledPage } from './marketplace-ui.js' ;
4957
@@ -92,9 +100,47 @@ function tsFilesUnder(dir: string, out: string[] = []): string[] {
92100 return out ;
93101}
94102
95- /** Strip comments so a `: Page =` inside prose is not read as a declaration. */
96- function stripComments ( source : string ) : string {
97- return source . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, '' ) . replace ( / ^ [ \t ] * \/ \/ .* $ / gm, '' ) ;
103+ /**
104+ * Every `export const X: Page = …` this source text declares, read from CODE.
105+ *
106+ * ## Why the shared mask and not a private comment-stripper
107+ *
108+ * Masking is not a detail of this scan, it is the scan's population rule: text
109+ * this function mistakes for a comment is a page this gate never audits, and
110+ * the gate still reports GREEN — a green line over a page nobody read, which is
111+ * the exact defect class this file exists to prevent, re-entering through the
112+ * detector instead of through the authoring.
113+ *
114+ * What used to stand here was two regexes, block pass first
115+ * (`/\*[\s\S]*?\*\/` lazily, then `^[ \t]*\/\/.*$`) — the same pair #9367
116+ * retired from six gates and #10453 found surviving in two `packages/cli`
117+ * tests. Its failure is the silent one: a block-comment OPENER that is not a
118+ * comment at all — inside a string literal, or inside a line comment — opens a
119+ * phantom comment that runs to the next real `\*\/` and deletes every line
120+ * between, declarations included.
121+ *
122+ * This package is where that stopped being hypothetical. Measured on this tree
123+ * at the time of the conversion, `src/cloud-connection-ui.ts` — the file
124+ * declaring `CloudConnectionSettingsPage` — carries one: the line comment
125+ * `// … /api/v1/cloud-connection/* routes this plugin mounts.` opens a phantom
126+ * that the NEXT docblock's terminator closes, and the retired regex deletes 122
127+ * bytes of live page literal in between, `type: 'cloud-connection:panel'`
128+ * included. `src/marketplace-proxy-plugin.ts` carries two more spans, 277 bytes.
129+ * This gate stayed green only because that opener sits BELOW the
130+ * `export const … : Page =` the scan anchors on — a page declared thirty lines
131+ * further down that file would simply have vanished from the population, and
132+ * every audit below would have reported green over it.
133+ *
134+ * `maskComments` blanks comment spans and leaves string, template and regex
135+ * literals intact, so offsets and line numbers both survive and a `: Page =`
136+ * inside prose still cannot be read as a declaration.
137+ *
138+ * Split out from the walk so the pin below drives the REAL scan over a fixture
139+ * rather than over whatever this package happens to contain today.
140+ */
141+ function pageDeclarationsIn ( source : string ) : string [ ] {
142+ return [ ...maskComments ( source ) . matchAll ( / e x p o r t \s + c o n s t \s + ( \w + ) \s * : \s * P a g e \s * = / g) ]
143+ . map ( match => match [ 1 ] ! ) ;
98144}
99145
100146/**
@@ -108,9 +154,8 @@ function stripComments(source: string): string {
108154function declaredPageExports ( ) : { name : string ; file : string } [ ] {
109155 const out : { name : string ; file : string } [ ] = [ ] ;
110156 for ( const file of tsFilesUnder ( HERE ) ) {
111- const source = stripComments ( readFileSync ( file , 'utf8' ) ) ;
112- for ( const match of source . matchAll ( / e x p o r t \s + c o n s t \s + ( \w + ) \s * : \s * P a g e \s * = / g) ) {
113- out . push ( { name : match [ 1 ] ! , file : file . slice ( HERE . length + 1 ) } ) ;
157+ for ( const name of pageDeclarationsIn ( readFileSync ( file , 'utf8' ) ) ) {
158+ out . push ( { name, file : file . slice ( HERE . length + 1 ) } ) ;
114159 }
115160 }
116161 return out . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
@@ -246,3 +291,89 @@ describe('downgrade control — a shipped page, bare predicate injected', () =>
246291 expect ( renderBareExpressionFindings ( pristine . findings ) ) . toBe ( '' ) ;
247292 } ) ;
248293} ) ;
294+
295+ // ───────────────────────────────────────────────────────────────────────────
296+ // Pin — a phantom comment cannot delete a page from the population
297+ // ───────────────────────────────────────────────────────────────────────────
298+
299+ /**
300+ * The conversion above, pinned by the shape it was made for.
301+ *
302+ * Both fixtures carry a block-comment OPENER that is not a comment — one in a
303+ * line comment (the shape `src/cloud-connection-ui.ts` ships today), one in a
304+ * string literal — followed by a real terminator further down, with a `Page`
305+ * declaration in between. The retired two-regex stripper honours the opener,
306+ * runs lazily to that terminator, and the declaration between them is gone; the
307+ * scan then reports a population one page short and every audit below is green
308+ * over a page it never read.
309+ *
310+ * Measured at the conversion, on this exact text: the retired regex found only
311+ * the trailing page in each fixture, `maskComments` finds both. Reverting
312+ * `pageDeclarationsIn` to that stripper reds these two cases and nothing else in
313+ * this file — the scan is the only thing they exercise.
314+ *
315+ * The `openerIsNotAComment` precondition is here so the pin cannot go quietly
316+ * vacuous: strip the opener out of a fixture while editing and both strippers
317+ * agree again, leaving two tests that pass without asserting anything.
318+ */
319+ const PHANTOM_IN_LINE_COMMENT = [
320+ "import type { Page } from '@objectstack/spec/ui';" ,
321+ '' ,
322+ '// The console panel talks to the same-origin /api/v1/cloud-connection/*' ,
323+ '// routes this plugin mounts.' ,
324+ '' ,
325+ 'export const PhantomPage: Page = {' ,
326+ " name: 'phantom_page'," ,
327+ " regions: [{ name: 'main', width: 'full', components: [] }]," ,
328+ '};' ,
329+ '' ,
330+ '/** Setup-nav contribution — the terminator that closes the phantom. */' ,
331+ "export const LaterPage: Page = { name: 'later_page', regions: [] };" ,
332+ ] . join ( '\n' ) ;
333+
334+ const PHANTOM_IN_STRING_LITERAL = [
335+ "import type { Page } from '@objectstack/spec/ui';" ,
336+ '' ,
337+ "const PROXY_GLOB = '/api/v1/marketplace/*';" ,
338+ '' ,
339+ 'export const LiteralPhantomPage: Page = {' ,
340+ " name: 'literal_phantom_page'," ,
341+ ' regions: [],' ,
342+ '};' ,
343+ '' ,
344+ '/** A docblock whose terminator closes the phantom opened in the string. */' ,
345+ "export const LiteralLaterPage: Page = { name: 'literal_later', regions: [] };" ,
346+ ] . join ( '\n' ) ;
347+
348+ /** The fixture still carries the shape: an opener above, a terminator below. */
349+ function openerIsNotAComment ( fixture : string , declaration : string ) : void {
350+ const opener = fixture . indexOf ( '/' + '*' ) ;
351+ const declaredAt = fixture . indexOf ( declaration ) ;
352+ const terminator = fixture . indexOf ( '*' + '/' , opener ) ;
353+ expect ( opener , 'fixture lost its block-comment opener' ) . toBeGreaterThan ( - 1 ) ;
354+ expect ( declaredAt , 'fixture lost its page declaration' ) . toBeGreaterThan ( opener ) ;
355+ expect ( terminator , 'fixture lost the terminator that closes the phantom' )
356+ . toBeGreaterThan ( declaredAt ) ;
357+ }
358+
359+ describe ( 'population scan reads comments, not comment-shaped text' , ( ) => {
360+ it ( 'keeps a page straddled by an opener inside a LINE COMMENT' , ( ) => {
361+ openerIsNotAComment ( PHANTOM_IN_LINE_COMMENT , 'export const PhantomPage' ) ;
362+ expect ( pageDeclarationsIn ( PHANTOM_IN_LINE_COMMENT ) ) . toEqual ( [ 'PhantomPage' , 'LaterPage' ] ) ;
363+ } ) ;
364+
365+ it ( 'keeps a page straddled by an opener inside a STRING LITERAL' , ( ) => {
366+ openerIsNotAComment ( PHANTOM_IN_STRING_LITERAL , 'export const LiteralPhantomPage' ) ;
367+ expect ( pageDeclarationsIn ( PHANTOM_IN_STRING_LITERAL ) )
368+ . toEqual ( [ 'LiteralPhantomPage' , 'LiteralLaterPage' ] ) ;
369+ } ) ;
370+
371+ it ( 'still refuses a `: Page =` written inside genuine prose' , ( ) => {
372+ const prose = [
373+ '/** Authors write `export const X: Page = {}` in docblocks like this. */' ,
374+ '// and in line comments: export const YPage: Page = {}' ,
375+ "export const RealPage: Page = { name: 'real', regions: [] };" ,
376+ ] . join ( '\n' ) ;
377+ expect ( pageDeclarationsIn ( prose ) ) . toEqual ( [ 'RealPage' ] ) ;
378+ } ) ;
379+ } ) ;
0 commit comments