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 changed files with 123 additions and 34 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.md
2026-08-09-cordis-event-walk-backstop.md: 59aeb06149925dc93839df64145555f27257d7be
2026-08-09-cordis-event-walk-backstop.zh.md: 4549e9d382e5ff17ddff30eafad8d6c7e66d61ed
2026-08-09-cordis-event-walk-backstop.md: 1d833858f9471c98f54b3793edd12e4710b8c0fc
2026-08-09-cordis-event-walk-backstop.zh.md: 0cb0f3bd738b6cee703cee71f1c24741554df87a

View File

@@ -6,7 +6,7 @@ English | [中文](2026-08-09-cordis-event-walk-backstop.zh.md)
## Problem
`gen-cordis-catalog` renders every service and event the Typert host-face projection discovers, and fail-closed page maps (`SERVICE_PAGE`, `EVENT_SCOPE_PAGE`) guarantee each discovered key or scope lands on exactly one `docs/subsystems/` page. Discovery itself was only backstopped for services: an independent AST scan read every `declare module 'cordis'` Context merge and demanded each declared key be rendered or carry a named `SERVICE_WALK_EXEMPTIONS` reason.
`gen-cordis-catalog` renders every service and event the Typert host-face projection discovers, and fail-closed page maps (`SERVICE_PAGE`, `EVENT_SCOPE_PAGE`) guarantee each discovered key or scope lands on exactly one `docs/subsystems/` page ([per-subsystem regions decision](../process/2026-07-28-per-subsystem-cordis-surface-regions.md) owns the page-region mechanism). Discovery itself was only backstopped for services: an independent AST scan read every `declare module 'cordis'` Context merge and demanded each declared key be rendered or carry a named `SERVICE_WALK_EXEMPTIONS` reason.
Events had no such backstop. The projection walks only files reachable from host-face package exports, so an `interface Events` merge in client-face code — or in any file the host analyzer cannot reach — vanished with no trace: 12 declared events (`slash/input-*`, `theme/change`, `locale/change`, and the client runtime's `*/changed` invalidation signals) were documented nowhere generated and nothing would ever notice a thirteenth. The services scan also globbed only `packages/*/*/src/*.ts`, so 13 client-face Context keys declared in nested files (`src/client/**`) were invisible to the very scan meant to prevent silent vanishing.
@@ -14,7 +14,7 @@ Events had no such backstop. The projection walks only files reachable from host
Events get the exact mirror of the services backstop, and both scans read the full package source tree.
`scripts/cordis-walk.ts` gains `eventNameList` (every member name of an `interface Events` merge, read from method and property members alike so a shape the projector would reject still enters the scan) and its merge-file prefilter matches the `declare module` heads instead of the literal text `interface Context`, so an Events-only merge file is not skipped. The scan glob in `gen-cordis-catalog` deepens from `packages/*/*/src/*.ts` to `packages/*/*/src/**/*.ts`.
`scripts/cordis-walk.ts` gains `eventNameList` (every member name of an `interface Events` merge, read from method and property members alike so a shape the projector would reject still enters the scan); the scan yields every `declare module 'cordis'` block in a file (the Typert analyzer reads them all, so stopping at the first would hide a second block's face), and its quote-agnostic prefilter matches the `declare module` heads instead of the literal text `interface Context`, so an Events-only or double-quoted merge file is not skipped. The scan glob in `gen-cordis-catalog` deepens from `packages/*/*/src/*.ts` to `packages/*/*/src/**/*.{ts,tsx}` (two patterns). A third partition direction guards the scan itself: every rendered service key and event name must also be visible to the scan, so a scan regression (glob, prefilter, block walk) is a hard error rather than a silent backstop decay.
A new curated `EVENT_WALK_EXEMPTIONS` map names every declared event the projection cannot see, with the reason and the package README that owns its surface. Keys are full event names, not scopes: client-face events share scopes with rendered host events (`commands/changed` beside the host `commands/*` family), so a scope-level exemption would mask a host-face regression. The partition check is fail-closed in both directions, exactly like the service maps: an unexempted invisible event, an exemption for an event that renders, and an exemption no merge declares are all hard errors.
@@ -24,7 +24,7 @@ The audit that motivated this found the host face already complete: 48 rendered
## Verification
`scripts/gen-cordis-catalog-partition.spec.ts` proves each acceptance path: the green partition, an invisible unexempted event (named with its declaring file), a stale rendered-event exemption, a stale never-declared exemption, the service mirror of each, unmapped rendered surface in both page maps, and the scan reaching a nested Events-only merge file. Deleting one live exemption from the real tree makes `gen-cordis-catalog` fail loud with the event's name and declaring file; restoring it returns the generator to a byte-identical no-op regeneration (85 artifacts, 0 written), which also proves the new exemptions exactly cover today's surface. `verify-cordis-catalog` in doc-sync executes the partition on every run.
`scripts/gen-cordis-catalog-partition.spec.ts` proves each acceptance path: the green partition, an invisible unexempted event (named with its declaring file), a stale rendered-event exemption, a stale never-declared exemption, the service mirror of each, unmapped rendered surface in both page maps, rendered surface the scan cannot see (the third direction), and the scan reaching nested Events-only merges, every block of a multi-block file, double-quoted heads, and `.tsx` sources. Deleting one live exemption from the real tree makes `gen-cordis-catalog` fail loud with the event's name and declaring file; restoring it returns the generator to a byte-identical no-op regeneration (85 artifacts, 0 written), which also proves the new exemptions exactly cover today's surface. `verify-cordis-catalog` in doc-sync executes the partition on every run.
## Alternatives considered

View File

@@ -6,7 +6,7 @@ Status: implemented
## Problem
`gen-cordis-catalog` 渲染 Typert host face 投影发现的每个服务与事件fail-closed 的页面映射(`SERVICE_PAGE``EVENT_SCOPE_PAGE`)保证每个被发现的 key 或 scope 恰好落在一个 `docs/subsystems/` 页面上。但"发现"本身此前只对服务有兜底:一条独立的 AST 扫描读取每个 `declare module 'cordis'` Context merge要求每个声明的 key 要么被渲染、要么在 `SERVICE_WALK_EXEMPTIONS` 中给出具名理由。
`gen-cordis-catalog` 渲染 Typert host face 投影发现的每个服务与事件fail-closed 的页面映射(`SERVICE_PAGE``EVENT_SCOPE_PAGE`)保证每个被发现的 key 或 scope 恰好落在一个 `docs/subsystems/` 页面上(页面区块机制归[按子系统区块决定](../process/2026-07-28-per-subsystem-cordis-surface-regions.md)所有)。但"发现"本身此前只对服务有兜底:一条独立的 AST 扫描读取每个 `declare module 'cordis'` Context merge要求每个声明的 key 要么被渲染、要么在 `SERVICE_WALK_EXEMPTIONS` 中给出具名理由。
事件没有这样的兜底。投影只遍历从 host face 包导出可达的文件,因此 client face 代码——或 host 分析器无法触及的任何文件——里的 `interface Events` merge 会无声消失12 个已声明事件(`slash/input-*``theme/change``locale/change`,以及 client runtime 的 `*/changed` 失效信号)不出现在任何生成文档中,而且再多一个也不会有任何机制察觉。服务扫描的 glob 也只有 `packages/*/*/src/*.ts`,于是声明在嵌套文件(`src/client/**`)中的 13 个 client face Context key 恰恰对这条为防止无声消失而存在的扫描不可见。
@@ -14,7 +14,7 @@ Status: implemented
事件获得与服务兜底完全对称的机制,且两条扫描都读取完整的包源码树。
`scripts/cordis-walk.ts` 新增 `eventNameList``interface Events` merge 的每个成员名,方法与属性成员一并读取,使投影器会拒绝的形状也进入扫描),其 merge 文件预过滤改为匹配 `declare module` 头部而非字面文本 `interface Context`,从而不再跳过只含 Events 的 merge 文件。`gen-cordis-catalog` 的扫描 glob 从 `packages/*/*/src/*.ts` 加深为 `packages/*/*/src/**/*.ts`
`scripts/cordis-walk.ts` 新增 `eventNameList``interface Events` merge 的每个成员名,方法与属性成员一并读取,使投影器会拒绝的形状也进入扫描);扫描产出文件中每一个 `declare module 'cordis'`Typert 分析器读取全部块,止步于第一个会藏起第二个块的表面),其对引号风格不敏感的预过滤匹配 `declare module` 头部而非字面文本 `interface Context`,从而不再跳过只含 Events 或使用双引号的 merge 文件。`gen-cordis-catalog` 的扫描 glob 从 `packages/*/*/src/*.ts` 加深为 `packages/*/*/src/**/*.{ts,tsx}`(两个 pattern。分区新增第三个方向守住扫描自身投影渲染的每个服务 key 与事件名也必须对扫描可见使扫描回归glob、预过滤、块遍历成为硬错误而非兜底的无声退化
新的人工维护映射 `EVENT_WALK_EXEMPTIONS` 为投影看不到的每个已声明事件命名,附理由与拥有其表面的包 README。键是完整事件名而非 scopeclient face 事件与已渲染的 host 事件共享 scope`commands/changed` 与 host 的 `commands/*` 家族并存scope 级豁免会无声吞掉未来的 host face 回归。分区检查与服务映射一样双向 fail-closed未豁免的不可见事件、已渲染事件的豁免、无任何 merge 声明的豁免,皆为硬错误。
@@ -24,7 +24,7 @@ Status: implemented
## Verification
`scripts/gen-cordis-catalog-partition.spec.ts` 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面,以及扫描触达嵌套的仅含 Events 的 merge 文件。在真实源码树上删除一条现役豁免会让 `gen-cordis-catalog` 以事件名与声明文件大声失败;恢复后生成器回到字节相同的 no-op 再生成85 个 artifact0 写入这同时证明新豁免恰好覆盖当下表面。doc-sync 中的 `verify-cordis-catalog` 每次运行都会执行该分区检查。
`scripts/gen-cordis-catalog-partition.spec.ts` 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面、扫描看不到的已渲染表面(第三方向),以及扫描触达嵌套的仅含 Events 的 merge、多块文件的每个块、双引号头部与 `.tsx`文件。在真实源码树上删除一条现役豁免会让 `gen-cordis-catalog` 以事件名与声明文件大声失败;恢复后生成器回到字节相同的 no-op 再生成85 个 artifact0 写入这同时证明新豁免恰好覆盖当下表面。doc-sync 中的 `verify-cordis-catalog` 每次运行都会执行该分区检查。
## Alternatives considered

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-28-per-subsystem-cordis-surface-regions.md
2026-07-28-per-subsystem-cordis-surface-regions.md: ef65bfc4c7dadd7dafe1a38f41656e6ecc61ea50
2026-07-28-per-subsystem-cordis-surface-regions.zh.md: 1df18d7260800b89c95a9d4aeb0301adb0ee56f6
2026-07-28-per-subsystem-cordis-surface-regions.md: f6d4494d4195ba27f2898eecb27af32b433af88b
2026-07-28-per-subsystem-cordis-surface-regions.zh.md: ee2a3c9730e6259e9f53d1b722b04b99819af65b

View File

@@ -14,7 +14,7 @@ The [generated-catalog decision](../../archived/process/2026-06-20-generated-cor
`gen-cordis-catalog.ts` injects each subsystem's service and event reference INTO its own page, between `<!-- BEGIN GENERATED cordis-surface … -->` / `<!-- END GENERATED cordis-surface -->` markers, and the flat services/events catalogs are deleted. One page per subsystem now carries introduction, data structures, and the generated wiring surface.
- **Curated fail-loud partition.** `SERVICE_PAGE` maps every discovered `ctx.<key>` to exactly one page; `EVENT_SCOPE_PAGE` maps every event scope. The generator hard-errors in both directions — an unmapped discovered service/scope, and a mapped key/scope the walk no longer discovers — so the partition cannot drift from the source surface. An independent scan of EVERY `declare module 'cordis'` Context merge backstops the rendering walk's blind spot (it only sees a root `index.ts` with a same-named service class): a declared key the walk cannot render must carry a named `SERVICE_WALK_EXEMPTIONS` reason (today: the `ctx.agent` DX accessor, plus the interface-typed or non-index-declared lsp/apiProxy/appShell/tuiPrompt/tuiResumeHost), and stale exemptions hard-error; a `TODO(cordis-catalog-interface-services)` marks teaching the walk to render them.
- **Curated fail-loud partition.** `SERVICE_PAGE` maps every discovered `ctx.<key>` to exactly one page; `EVENT_SCOPE_PAGE` maps every event scope. The generator hard-errors in both directions — an unmapped discovered service/scope, and a mapped key/scope the walk no longer discovers — so the partition cannot drift from the source surface. Independent AST scans of every `declare module 'cordis'` merge block under `packages/*/*/src/**` backstop the projection's blind spots for services AND events: a declared Context key or Events member the projection cannot render must carry a named `SERVICE_WALK_EXEMPTIONS`/`EVENT_WALK_EXEMPTIONS` reason, stale exemptions hard-error, and everything rendered must also be visible to the scan ([events-backstop decision](../architecture/2026-08-09-cordis-event-walk-backstop.md) owns the scan contract); a `TODO(cordis-catalog-interface-services)` marks teaching the projection to render the interface-typed entries.
- **Byte-identical regions across the pair.** The generator writes the SAME English region bytes into `foo.md` and `foo.zh.md`, extending the existing rule that verbatim code fences match across a pair. `verify-translation-pairing` gained a dedicated region-identity check (`partitionGeneratedRegions` in `translation-pairing.ts` owns the marker grammar) that names a divergent or malformed region precisely; the whole-document structural signature still covers the region content a second time.
- **Guarded pair auto-record.** A regeneration that changes region bytes would leave every touched pair out-of-sync, so the generator re-records a pair's `.i18n.yaml` itself — but ONLY when the write is region-confined: both sides' recorded blob hashes must match the pre-write bytes, and the region-STRIPPED content must be unchanged on both sides. Human-prose drift leaves the record stale so the pairing gate still forces the normal translation flow; a brand-new pair is never auto-recorded (the author's reviewed `--write` owns that). This keeps `.i18n.yaml` as plain `git hash-object` values — no stripped-hash semantics change.
- **The inherited tier moved, not died.** The vendor `ctx` members and `internal/*`/loader/hmr/timer events render to `docs/cordis-api/inherited.md`, next to the relocated Cordis core API pages (`docs/cordis-catalog/core/``docs/cordis-api/`). Framework surface lives under a framework home; the harness pages stay repository-owned vocabulary.

View File

@@ -14,7 +14,7 @@ Status: implemented
`gen-cordis-catalog.ts` 把每个子系统的服务与事件参考注入到该子系统自己的页面内部,置于 `<!-- BEGIN GENERATED cordis-surface … -->` / `<!-- END GENERATED cordis-surface -->` 标记之间;平铺的 services/events 目录随之删除。现在每个子系统由一个页面同时承载介绍、数据结构和生成的接线表面。
- **人工维护、响亮失败的划分。** `SERVICE_PAGE` 把发现的每个 `ctx.<key>` 映射到恰好一个页面;`EVENT_SCOPE_PAGE` 映射每个事件作用域。生成器在两个方向上都会直接报错(既有被发现却未映射的服务或作用域,也有已映射但遍历不再发现的键或作用域),因此划分不可能相对源码表面发生漂移。一个独立扫描读取每一 `declare module 'cordis'` 的 Context 合并,为渲染遍历的盲区(它只看得到根 `index.ts` 中同名服务类)兜底:遍历渲染不了的已声明必须在 `SERVICE_WALK_EXEMPTIONS` 中带着点名理由(目前是 `ctx.agent` 这一 DX 访问器字段,加上接口类型或不在 index 声明的 lsp/apiProxy/appShell/tuiPrompt/tuiResumeHost陈旧豁免同样直接报错教会遍历渲染它们的后续工作由 `TODO(cordis-catalog-interface-services)` 标记。
- **人工维护、响亮失败的划分。** `SERVICE_PAGE` 把发现的每个 `ctx.<key>` 映射到恰好一个页面;`EVENT_SCOPE_PAGE` 映射每个事件作用域。生成器在两个方向上都会直接报错(既有被发现却未映射的服务或作用域,也有已映射但遍历不再发现的键或作用域),因此划分不可能相对源码表面发生漂移。独立的 AST 扫描读取 `packages/*/*/src/**`每一 `declare module 'cordis'` merge 块,为投影在服务与事件两侧的盲区兜底:投影渲染不了的已声明 Context key 或 Events 成员必须在 `SERVICE_WALK_EXEMPTIONS`/`EVENT_WALK_EXEMPTIONS` 中带着点名理由,陈旧豁免直接报错,且投影渲染的一切也必须对扫描可见(扫描契约归[事件兜底决定](../architecture/2026-08-09-cordis-event-walk-backstop.md)所有);教会投影渲染接口类型条目的后续工作由 `TODO(cordis-catalog-interface-services)` 标记。
- **区块在配对两侧按字节一致。** 生成器把同一份英文区块字节写入 `foo.md``foo.zh.md`,是对「围栏代码块在配对两侧逐字节一致」这一既有规则的延伸。`verify-translation-pairing` 新增了专门的区块一致性检查(标记语法归 `translation-pairing.ts` 中的 `partitionGeneratedRegions` 所有),能精确点名出现分歧或格式错误的区块;整篇文档的结构签名仍会把区块内容再覆盖一遍。
- **带防护的配对自动记录。** 一次改变区块字节的重新生成会让每个被触及的配对失去同步,因此生成器会自行重新记录配对的 `.i18n.yaml`,但仅限本次写入完全限定在区块内的情况:两侧记录的 blob hash 必须与写入前的字节相符,且两侧剥离区块后的内容必须没有变化。人工行文若有漂移,记录就保持陈旧,配对门禁因此仍会强制走正常翻译流程;全新的配对绝不自动记录(那归作者经评审的 `--write` 所有)。这样 `.i18n.yaml` 保持为纯粹的 `git hash-object` 值:不引入任何「剥离后 hash」的语义变化。
- **继承层搬了家,而非消亡。** vendor 的 `ctx` 成员与 `internal/*`/loader/hmr/timer 事件渲染到 `docs/cordis-api/inherited.md`,紧邻迁移后的 Cordis 核心 API 页面(`docs/cordis-catalog/core/``docs/cordis-api/`。框架表面落在框架自己的归属之下harness 页面仍是仓库自有的词汇。

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
}
/**

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' {",

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)
}