fix(doc-gates): review findings — scan every merge block, any quote style, .tsx too; self-check the scan

ds-review-bot round: the backstop stopped at a file's first declare-module
block while the projection it guards reads them all; the textual prefilter
matched only single quotes; .tsx sources escaped the glob. All three are the
silent-vanish class the scan exists to prevent. contextMergeFiles now yields
one entry per block (quote-agnostic prefilter, ts+tsx patterns), and a third
partition direction requires everything rendered to be scan-visible, so a
future scan regression is a hard error. Spec fixture made projection-
consistent; the partially superseded 2026-07-28 regions note is rewritten to
the current mechanism and cross-linked both ways.
This commit is contained in:
Tianyi Cui
2026-08-09 10:29:13 +08:00
parent 0fb062d85e
commit c9205901ec
9 files changed
+123 -34

No files matched your search

+30 -18
View File
@@ -9,41 +9,53 @@ import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
/**
* Parse every file matching `pattern` (repo-relative, sorted, `/`-normalized)
* that textually contains a cordis module merge, yielding each file's
* module-merge body. Files without a merge are skipped.
* @param scanRoot - Repository root the pattern is resolved against.
* @param pattern - Glob selecting the TypeScript files to scan.
* @returns One entry per file with a cordis module merge, in path order.
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
* that textually contains a cordis module merge, yielding one entry per merge
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
* Files without a merge are skipped.
* @param scanRoot - Repository root the patterns are resolved against.
* @param patterns - Glob(s) selecting the TypeScript files to scan.
* @returns One entry per cordis module block, in path then source order.
*/
export function contextMergeFiles(
scanRoot: string,
pattern: string,
patterns: string | readonly string[],
): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
for (const rel of globSync(pattern, { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const rels = [...new Set(globSync(patterns as string | string[], { cwd: scanRoot }).map(s => s.split(sep).join('/')))].sort()
for (const rel of rels) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes("declare module 'cordis'") && !text.includes("declare module './context.ts'")) continue
if (!MERGE_HEAD.test(text)) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
out.push({ rel, sf, text, body })
for (const body of cordisModuleBodies(sf)) out.push({ rel, sf, text, body })
}
return out
}
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
* (harness packages) or `declare module './context.ts'` (vendor core), or
* null when the file has neither. */
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
* packages) or `declare module './context.ts'` (vendor core), in source order. */
export function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
const bodies: ts.ModuleBlock[] = []
for (const stmt of sf.statements) {
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
}
return null
return bodies
}
/** The FIRST cordis module-merge body in `sf`, or null without one — for the
* vendor core-API renderer whose input files carry exactly one merge; the
* exhaustiveness scan uses {@link cordisModuleBodies} to read them all. */
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
return cordisModuleBodies(sf)[0] ?? null
}
/**
+66 -2
View File
@@ -61,12 +61,33 @@ describe('walkPartitionProblems', () => {
it('rejects an event exemption whose event the projection renders', () => {
const { input, maps } = baseline()
const rendered = { ...input, renderedEventNames: new Set(['llm/request', 'theme/change']) }
expect(walkPartitionProblems(rendered, maps)).toEqual([
// A projection that renders theme/change necessarily renders the theme
// scope too; the fixture models that and maps the scope so the only
// violation is the stale exemption.
const rendered = {
...input,
renderedScopes: new Set(['llm', 'theme']),
renderedEventNames: new Set(['llm/request', 'theme/change']),
}
const mapped = { ...maps, eventScopePage: { llm: 'llm-streaming.md', theme: 'client-modules.md' } }
expect(walkPartitionProblems(rendered, mapped)).toEqual([
expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
])
})
it('rejects rendered surface the independent scan cannot see, naming the scan as the defect', () => {
const { input, maps } = baseline()
const blind = {
...input,
declaredKeys: new Map([['theme', 'packages/client/ui-theme/src/client/index.ts']]),
declaredEvents: new Map([['theme/change', 'packages/client/ui-theme/src/client/index.ts']]),
}
expect(walkPartitionProblems(blind, maps)).toEqual([
expect.stringContaining('ctx.llm is rendered by the projection but the independent scan finds no Context merge declaring it'),
expect.stringContaining("event 'llm/request' is rendered by the projection but the independent scan finds no Events merge declaring it"),
])
})
it('rejects an event exemption no Events merge declares', () => {
const { input, maps } = baseline()
const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
@@ -123,6 +144,49 @@ describe('cordis-walk scan reach', () => {
expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
})
it('yields every merge block of a multi-block file, double-quoted heads, and .tsx sources', () => {
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
roots.push(root)
const dir = join(root, 'packages/client/ui-x/src')
mkdirSync(dir, { recursive: true })
// The Typert analyzer reads every cordis module block in a file; the
// backstop must not stop at the first one, skip the double-quoted legal
// form, or ignore .tsx sources.
writeFileSync(join(dir, 'split.ts'), [
"declare module 'cordis' {",
' interface Context {',
' first: FirstService',
' }',
'}',
'declare module "cordis" {',
' interface Events {',
" 'second/changed'(): void",
' }',
'}',
'export {}',
'',
].join('\n'))
writeFileSync(join(dir, 'view.tsx'), [
"declare module 'cordis' {",
' interface Context {',
' fromTsx: TsxService',
' }',
'}',
'export {}',
'',
].join('\n'))
const merges = contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])
expect(merges.map(m => m.rel)).toEqual([
'packages/client/ui-x/src/split.ts',
'packages/client/ui-x/src/split.ts',
'packages/client/ui-x/src/view.tsx',
])
const keys = merges.flatMap(m => [...contextKeyMap(m.body, m.sf).keys()])
const events = merges.flatMap(m => eventNameList(m.body, m.sf))
expect(keys).toEqual(['first', 'fromTsx'])
expect(events).toEqual(['second/changed'])
})
it('reads string-literal and identifier member names from an Events merge', () => {
const sf = ts.createSourceFile('x.ts', [
"declare module 'cordis' {",
+15 -2
View File
@@ -573,7 +573,9 @@ export interface WalkPartitionMaps {
* partition maps, fail-closed in both directions for services AND events: a
* rendered key/scope must be mapped to a page, a mapped key/scope must still
* render, and — the backstop — a DECLARED key/event the projection cannot see
* must carry a named walk exemption (a rendered one must not). Pure so the
* must carry a named walk exemption (a rendered one must not). A third
* direction guards the scan itself: everything rendered must also be declared
* to the scan, so a scan blind spot cannot decay silently. Pure so the
* acceptance paths are provable without running the projection.
* @param input - rendered surface plus the declared-key/event scans.
* @param maps - the curated page maps and walk exemptions.
@@ -622,6 +624,17 @@ export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkParti
for (const name of Object.keys(maps.eventWalkExemptions)) {
if (!input.declaredEvents.has(name)) problems.push(`EVENT_WALK_EXEMPTIONS names '${name}' but no Events merge declares it; remove the stale exemption.`)
}
// Self-check the scan itself: everything the projection renders is declared
// in a Context/Events merge the scan must also reach, so a rendered key or
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
// block walk) — a partial blind spot that exemption staleness alone would
// never surface.
for (const key of input.renderedKeys.keys()) {
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
}
for (const name of input.renderedEventNames) {
if (!input.declaredEvents.has(name)) problems.push(`event '${name}' is rendered by the projection but the independent scan finds no Events merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
}
return problems
}
@@ -642,7 +655,7 @@ export function computeOutputs(): [string, string][] {
const declaredKeys = new Map<string, string>()
const declaredEvents = new Map<string, string>()
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/**/*.ts')) {
for (const { rel, sf, body } of contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])) {
for (const key of contextKeyMap(body, sf).keys()) {
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
}