mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(doc-gates): backstop every declared cordis event; deepen the Context scan to src/**
The rendering projection walks only files reachable from host-face package exports, so client-face Events merges vanished silently (12 events) and the services backstop's top-level glob missed 13 nested client Context keys. Events now get the exact mirror of the service backstop (EVENT_WALK_EXEMPTIONS, name-keyed because client events share scopes with rendered host events), both scans read packages/*/*/src/**/*.ts, and the partition judgment is the pure walkPartitionProblems() so every acceptance path is unit-provable.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: An independent Events backstop closes the cordis-surface exhaustiveness gap
|
||||
|
||||
Status: implemented
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Decision
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
The partition judgment moved out of `computeOutputs` into the pure `walkPartitionProblems(input, maps)` so every acceptance path is provable by unit test without running the Typert projection; `computeOutputs` feeds it the rendered model plus the independent scan and keeps aggregating page-splice errors as before.
|
||||
|
||||
The audit that motivated this found the host face already complete: 48 rendered services + 10 walk exemptions covered all 58 host-visible Context keys, all 49 host events rendered, and every type name in every rendered signature is classified by the existing fail-closed `LINK_MAP`/`FOUNDATION_TYPE_NAMES`/`TYPE_LINK_EXEMPTIONS` check. The 25 findings (12 events, 13 keys) were all client-face; each now carries a named exemption pointing at its owning README, consistent with the existing `appShell`/`connection` precedent.
|
||||
|
||||
## 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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Render the client face instead of exempting it.** Analyzing `faces: ['host', 'client']` and giving client services/events generated regions is the real fix for the underlying blind spot, but it changes what the subsystems catalog IS (host-tier reference) and requires page decisions for browser-only surfaces; the existing `TODO(cordis-catalog-interface-services)` already tracks widening the projection. The backstop is the guarantee; rendering is an upgrade behind it.
|
||||
- **Scope-level event exemptions.** Smaller map, but `commands/changed` (client) shares the `commands` scope with rendered host events, so exempting a scope would swallow a future host-face event silently — the exact failure mode this note removes.
|
||||
- **Deriving exhaustiveness from Typert instead of a raw AST scan.** The projection and the backstop must fail independently: a Typert reachability bug is precisely what the backstop exists to catch, so the scan deliberately stays a plain `ts.createSourceFile` walk with no shared machinery.
|
||||
- **Gating the transitive type closure of rendered signatures.** Measured before deciding: every type name reachable in rendered signatures is already classified, and deeper field-of-field types are owned by the pages' hand-curated `type-equiv` pastes and package READMEs; a closure gate would force page homes for internals without a reader-facing need.
|
||||
|
||||
## Consequences
|
||||
|
||||
A new cordis event — host or client, any file depth — must either render onto a subsystems page or name itself in `EVENT_WALK_EXEMPTIONS` with its documentation owner; deleting one must retire its exemption. The same now holds for Context keys declared anywhere under `src/`. The curated maps grew by 25 client-face entries whose reasons all point at package READMEs, keeping the subsystems catalog a host-tier reference. `walkPartitionProblems` is the single home of the partition judgment; future backstop dimensions (e.g. rendering the client face, schema surfaces) extend it and its spec rather than re-inlining checks into `computeOutputs`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note(agent 决策记录):独立的 Events 兜底扫描补上 cordis 表面完备性缺口
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-09-cordis-event-walk-backstop.md) | 中文
|
||||
|
||||
## 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` 中给出具名理由。
|
||||
|
||||
事件没有这样的兜底。投影只遍历从 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 恰恰对这条为防止无声消失而存在的扫描不可见。
|
||||
|
||||
## Decision
|
||||
|
||||
事件获得与服务兜底完全对称的机制,且两条扫描都读取完整的包源码树。
|
||||
|
||||
`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`。
|
||||
|
||||
新的人工维护映射 `EVENT_WALK_EXEMPTIONS` 为投影看不到的每个已声明事件命名,附理由与拥有其表面的包 README。键是完整事件名而非 scope:client face 事件与已渲染的 host 事件共享 scope(`commands/changed` 与 host 的 `commands/*` 家族并存),scope 级豁免会无声吞掉未来的 host face 回归。分区检查与服务映射一样双向 fail-closed:未豁免的不可见事件、已渲染事件的豁免、无任何 merge 声明的豁免,皆为硬错误。
|
||||
|
||||
分区判定从 `computeOutputs` 中提取为纯函数 `walkPartitionProblems(input, maps)`,使每条验收路径都能以单元测试证明而无需运行 Typert 投影;`computeOutputs` 向它馈送渲染模型加独立扫描结果,页面拼接错误的聚合方式保持不变。
|
||||
|
||||
促成本决定的审计发现 host face 本已完备:48 个渲染服务 + 10 条 walk 豁免覆盖全部 58 个 host 可见 Context key,49 个 host 事件全部渲染,且每个渲染签名中的每个类型名都已被既有的 fail-closed `LINK_MAP`/`FOUNDATION_TYPE_NAMES`/`TYPE_LINK_EXEMPTIONS` 检查分类。25 条发现(12 事件、13 key)全部在 client face;现在每条都带指向其所属 README 的具名豁免,与既有的 `appShell`/`connection` 先例一致。
|
||||
|
||||
## Verification
|
||||
|
||||
`scripts/gen-cordis-catalog-partition.spec.ts` 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面,以及扫描触达嵌套的仅含 Events 的 merge 文件。在真实源码树上删除一条现役豁免会让 `gen-cordis-catalog` 以事件名与声明文件大声失败;恢复后生成器回到字节相同的 no-op 再生成(85 个 artifact,0 写入),这同时证明新豁免恰好覆盖当下表面。doc-sync 中的 `verify-cordis-catalog` 每次运行都会执行该分区检查。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **渲染 client face 而非豁免。** 以 `faces: ['host', 'client']` 分析并给 client 服务/事件生成区块才是对盲区的根治,但它改变子系统目录的定位(host 层参考),并要求为纯浏览器表面做页面归属决策;既有的 `TODO(cordis-catalog-interface-services)` 已跟踪拓宽投影。兜底是保证,渲染是其上的升级。
|
||||
- **scope 级事件豁免。** 映射更小,但 `commands/changed`(client)与已渲染的 host 事件共享 `commands` scope,豁免整个 scope 会无声吞掉未来的 host face 事件——正是本决定要消除的失败模式。
|
||||
- **用 Typert 推导完备性而非原始 AST 扫描。** 投影与兜底必须独立失败:Typert 的可达性 bug 恰是兜底要捕获的对象,因此扫描刻意保持为不共享机制的朴素 `ts.createSourceFile` 遍历。
|
||||
- **对渲染签名的传递类型闭包设门。** 决定前先测量:渲染签名中可达的每个类型名都已分类,更深的字段套字段类型由页面手工维护的 `type-equiv` 粘贴与包 README 拥有;闭包门会在没有读者需求的情况下强迫内部类型认领页面。
|
||||
|
||||
## Consequences
|
||||
|
||||
新的 cordis 事件——host 或 client、任意文件深度——必须渲染到某个子系统页面,或在 `EVENT_WALK_EXEMPTIONS` 中以其文档所有者具名;删除事件时必须一并退役其豁免。声明在 `src/` 下任意位置的 Context key 现在同样如此。人工维护映射增加了 25 条 client face 条目,理由全部指向包 README,使子系统目录保持 host 层参考的定位。`walkPartitionProblems` 是分区判定的唯一居所;未来的兜底维度(如渲染 client face、schema 表面)应扩展它及其 spec,而非把检查重新内联进 `computeOutputs`。
|
||||
+25
-2
@@ -11,7 +11,7 @@ import ts from 'typescript'
|
||||
|
||||
/**
|
||||
* Parse every file matching `pattern` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually mentions `interface Context`, yielding each file's cordis
|
||||
* 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.
|
||||
@@ -25,7 +25,7 @@ export function contextMergeFiles(
|
||||
for (const rel of globSync(pattern, { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('interface Context')) continue
|
||||
if (!text.includes("declare module 'cordis'") && !text.includes("declare module './context.ts'")) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
@@ -64,3 +64,26 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event name a `declare module 'cordis'` Events merge declares in one
|
||||
* module body. Names are the literal member keys (`'agent/created'`), read
|
||||
* from method and property members alike so a declaration shape the projector
|
||||
* would reject still enters the exhaustiveness scan.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for computed-name text extraction).
|
||||
* @returns Declared event names, in declaration order.
|
||||
*/
|
||||
export function eventNameList(body: ts.ModuleBlock, sf: ts.SourceFile): string[] {
|
||||
const names: string[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!member.name) continue
|
||||
names.push(ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
|
||||
? member.name.text
|
||||
: member.name.getText(sf))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the cordis-surface partition backstops
|
||||
* (`walkPartitionProblems` + the AST scan helpers): a declared Context key or
|
||||
* Events member the rendering projection cannot see must carry a named walk
|
||||
* exemption, an exemption must stay live in both directions, and the scan
|
||||
* itself must reach nested (`src/**`) and Events-only merge files.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ts from 'typescript'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import { walkPartitionProblems } from './gen-cordis-catalog.ts'
|
||||
import type { WalkPartitionInput, WalkPartitionMaps } from './gen-cordis-catalog.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A consistent baseline the red cases mutate one facet at a time. */
|
||||
function baseline(): { input: WalkPartitionInput; maps: WalkPartitionMaps } {
|
||||
return {
|
||||
input: {
|
||||
renderedKeys: new Map([['llm', 'packages/llm/llm/src/index.ts:10']]),
|
||||
renderedScopes: new Set(['llm']),
|
||||
renderedEventNames: new Set(['llm/request']),
|
||||
declaredKeys: new Map([
|
||||
['llm', 'packages/llm/llm/src/index.ts'],
|
||||
['theme', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
declaredEvents: new Map([
|
||||
['llm/request', 'packages/llm/llm/src/index.ts'],
|
||||
['theme/change', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
},
|
||||
maps: {
|
||||
servicePage: { llm: 'llm-streaming.md' },
|
||||
serviceWalkExemptions: { theme: 'client-side — packages/client/ui-theme/README.md owns the surface' },
|
||||
eventScopePage: { llm: 'llm-streaming.md' },
|
||||
eventWalkExemptions: { 'theme/change': 'client-face — packages/client/ui-theme/README.md owns the surface' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('walkPartitionProblems', () => {
|
||||
it('accepts a partition where every declared key and event is rendered or exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
expect(walkPartitionProblems(input, maps)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a declared event that is neither rendered nor exempted, naming its file', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, eventWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining("event 'theme/change' (packages/client/ui-theme/src/client/index.ts) is declared in an Events merge but invisible"),
|
||||
])
|
||||
})
|
||||
|
||||
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([
|
||||
expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption no Events merge declares', () => {
|
||||
const { input, maps } = baseline()
|
||||
const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
|
||||
expect(walkPartitionProblems(input, stale)).toEqual([
|
||||
expect.stringContaining("EVENT_WALK_EXEMPTIONS names 'gone/away' but no Events merge declares it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a declared Context key that is neither rendered nor exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, serviceWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining('ctx.theme (packages/client/ui-theme/src/client/index.ts) is declared in a Context merge but invisible'),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unmapped rendered service with its source pointer, and stale page maps both ways', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, {
|
||||
...maps,
|
||||
servicePage: { ghost: 'core.md' },
|
||||
eventScopePage: { specter: 'core.md' },
|
||||
})
|
||||
expect(problems).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('service ctx.llm (packages/llm/llm/src/index.ts:10) has no SERVICE_PAGE entry'),
|
||||
expect.stringContaining("event scope 'llm/*' has no EVENT_SCOPE_PAGE entry"),
|
||||
expect.stringContaining("SERVICE_PAGE maps 'ctx.ghost' but the projection discovers no such service"),
|
||||
expect.stringContaining("EVENT_SCOPE_PAGE maps 'specter/*' but the projection discovers no such scope"),
|
||||
]))
|
||||
expect(problems).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis-walk scan reach', () => {
|
||||
it('finds Context keys and Events names in nested Events-only merge files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src/client')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'index.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'x/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, 'packages/*/*/src/**/*.ts')
|
||||
expect(merges.map(m => m.rel)).toEqual(['packages/client/ui-x/src/client/index.ts'])
|
||||
const only = merges[0]
|
||||
if (!only) throw new Error('scan returned no merge')
|
||||
expect(eventNameList(only.body, only.sf)).toEqual(['x/changed'])
|
||||
expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
|
||||
})
|
||||
|
||||
it('reads string-literal and identifier member names from an Events merge', () => {
|
||||
const sf = ts.createSourceFile('x.ts', [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'scope/list'(items: string[]): void",
|
||||
' plain(): void',
|
||||
' }',
|
||||
' interface Context {',
|
||||
' thing: ThingService',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), ts.ScriptTarget.Latest, true)
|
||||
const body = sf.statements[0] && ts.isModuleDeclaration(sf.statements[0]) && sf.statements[0].body
|
||||
&& ts.isModuleBlock(sf.statements[0].body)
|
||||
? sf.statements[0].body
|
||||
: null
|
||||
if (!body) throw new Error('fixture did not parse to a module block')
|
||||
expect(eventNameList(body, sf)).toEqual(['scope/list', 'plain'])
|
||||
expect([...contextKeyMap(body, sf)]).toEqual([['thing', 'ThingService']])
|
||||
})
|
||||
})
|
||||
+147
-41
@@ -21,7 +21,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-typert-generator'
|
||||
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
|
||||
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
||||
import { contextKeyMap, contextMergeFiles } from './cordis-walk.ts'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import {
|
||||
blobHash,
|
||||
parsePairMeta,
|
||||
@@ -97,10 +97,11 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* Context merge under `packages/x/x/src/*.ts` — not only root `index.ts`
|
||||
* files with a same-named service class — so a new service can never silently
|
||||
* join this blind spot: it either enters {@link SERVICE_PAGE} or names itself
|
||||
* here.
|
||||
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
* or names itself here. Client-face keys (the projection analyzes the host
|
||||
* face only) name the package README that owns their surface.
|
||||
* TODO(cordis-catalog-interface-services): the interface-typed and
|
||||
* non-index-declared entries would all render once the projection resolves a
|
||||
* Context key through its declaring file's imports to the class declaration.
|
||||
@@ -116,14 +117,27 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface',
|
||||
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface',
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
|
||||
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
|
||||
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness event scope (the segment
|
||||
* before the first `/`). Fail-closed exactly like {@link SERVICE_PAGE}.
|
||||
* `slash` lives with the human-command surface: the client slash-input
|
||||
* protocol parses toward command invocation and `dsh-ui-slash` owns the
|
||||
* declarations, but commands.md owns the cross-package command story.
|
||||
* before the first `/`) the projection renders. Fail-closed exactly like
|
||||
* {@link SERVICE_PAGE}. Client-face events (`slash/*`, `theme/change`, …) are
|
||||
* invisible to the host-face projection and therefore never reach this map;
|
||||
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
|
||||
*/
|
||||
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'agent': 'core.md',
|
||||
@@ -145,6 +159,32 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'workflow': 'workflow.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names declared in `interface Events` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
||||
* scan reads EVERY `declare module 'cordis'` Events merge under
|
||||
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
||||
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
||||
* silently. Keys are full event names, not scopes: client-face events share
|
||||
* scopes with rendered host events (`commands/changed` beside `commands/*`),
|
||||
* so a scope-level exemption would mask a host-face regression.
|
||||
*/
|
||||
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the surface',
|
||||
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
|
||||
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* One primary subsystems page per project type used by a generated
|
||||
* signature. This stays curated because union names intentionally do not
|
||||
@@ -506,56 +546,122 @@ export function spliceRegion(content: string, region: string): string {
|
||||
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
||||
}
|
||||
|
||||
/** The declared-vs-rendered inputs {@link walkPartitionProblems} judges. */
|
||||
export interface WalkPartitionInput {
|
||||
/** Service key → source pointer, as the rendering projection produced them. */
|
||||
readonly renderedKeys: ReadonlyMap<string, string>
|
||||
/** Event scopes the rendering projection produced. */
|
||||
readonly renderedScopes: ReadonlySet<string>
|
||||
/** Event names the rendering projection produced. */
|
||||
readonly renderedEventNames: ReadonlySet<string>
|
||||
/** Context key → first declaring file, from the independent AST scan. */
|
||||
readonly declaredKeys: ReadonlyMap<string, string>
|
||||
/** Event name → first declaring file, from the independent AST scan. */
|
||||
readonly declaredEvents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** The curated partition maps {@link walkPartitionProblems} enforces. */
|
||||
export interface WalkPartitionMaps {
|
||||
readonly servicePage: Readonly<Record<string, string>>
|
||||
readonly serviceWalkExemptions: Readonly<Record<string, string>>
|
||||
readonly eventScopePage: Readonly<Record<string, string>>
|
||||
readonly eventWalkExemptions: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the rendered surface and the independent AST scan against the curated
|
||||
* 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
|
||||
* 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.
|
||||
* @returns one message per violation, empty when the partition holds.
|
||||
*/
|
||||
export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkPartitionMaps): string[] {
|
||||
const problems: string[] = []
|
||||
for (const [key, source] of input.renderedKeys) {
|
||||
if (!Object.hasOwn(maps.servicePage, key)) problems.push(`service ctx.${key} (${source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of [...input.renderedScopes].sort()) {
|
||||
if (!Object.hasOwn(maps.eventScopePage, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.servicePage)) {
|
||||
if (!input.renderedKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(maps.eventScopePage)) {
|
||||
if (!input.renderedScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. The independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
for (const [key, rel] of input.declaredKeys) {
|
||||
const rendered = input.renderedKeys.has(key)
|
||||
const exempt = Object.hasOwn(maps.serviceWalkExemptions, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.serviceWalkExemptions)) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// The event mirror of the service backstop: the projection walks only files
|
||||
// reachable from host-face package exports, so a client-face or unreachable
|
||||
// Events merge would otherwise vanish without a trace.
|
||||
for (const [name, rel] of input.declaredEvents) {
|
||||
const rendered = input.renderedEventNames.has(name)
|
||||
const exempt = Object.hasOwn(maps.eventWalkExemptions, name)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`event '${name}' (${rel}) is declared in an Events merge but invisible to the rendering projection; make it renderable (mapped via EVENT_SCOPE_PAGE) or name it in EVENT_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`event '${name}' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
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.`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every generated artifact: the inherited-tier page, the model-facing
|
||||
* runtime API module, plus, per mapped subsystems page, the pair's two updated
|
||||
* documents with the injected region. Fail-loud partition checks live here: an
|
||||
* unmapped service/event scope, a mapping whose page file does not exist, a
|
||||
* curated entry whose key/scope the projection no longer discovers, and a
|
||||
* mapped page missing its markers are all aggregated errors.
|
||||
* curated entry whose key/scope the projection no longer discovers, a declared
|
||||
* Context key or Events member the projection cannot see without a named walk
|
||||
* exemption, and a mapped page missing its markers are all aggregated errors.
|
||||
* @returns `[repo-relative path, exact content]` for every generated artifact.
|
||||
*/
|
||||
export function computeOutputs(): [string, string][] {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const services = [...model.services]
|
||||
const events = [...model.events]
|
||||
const problems: string[] = []
|
||||
|
||||
const discoveredKeys = new Set(services.map(s => s.key))
|
||||
const discoveredScopes = new Set(events.map(e => e.scope))
|
||||
for (const s of services) {
|
||||
if (!Object.hasOwn(SERVICE_PAGE, s.key)) problems.push(`service ctx.${s.key} (${s.source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of discoveredScopes) {
|
||||
if (!Object.hasOwn(EVENT_SCOPE_PAGE, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_PAGE)) {
|
||||
if (!discoveredKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(EVENT_SCOPE_PAGE)) {
|
||||
if (!discoveredScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. This independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
const declaredKeys = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/*.ts')) {
|
||||
const declaredEvents = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/**/*.ts')) {
|
||||
for (const key of contextKeyMap(body, sf).keys()) {
|
||||
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
|
||||
}
|
||||
}
|
||||
for (const [key, rel] of declaredKeys) {
|
||||
const rendered = discoveredKeys.has(key)
|
||||
const exempt = Object.hasOwn(SERVICE_WALK_EXEMPTIONS, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
for (const name of eventNameList(body, sf)) {
|
||||
if (!declaredEvents.has(name)) declaredEvents.set(name, rel)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_WALK_EXEMPTIONS)) {
|
||||
if (!declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
const problems = walkPartitionProblems({
|
||||
renderedKeys: new Map(services.map(s => [s.key, s.source])),
|
||||
renderedScopes: new Set(events.map(e => e.scope)),
|
||||
renderedEventNames: new Set(events.map(e => e.name)),
|
||||
declaredKeys,
|
||||
declaredEvents,
|
||||
}, {
|
||||
servicePage: SERVICE_PAGE,
|
||||
serviceWalkExemptions: SERVICE_WALK_EXEMPTIONS,
|
||||
eventScopePage: EVENT_SCOPE_PAGE,
|
||||
eventWalkExemptions: EVENT_WALK_EXEMPTIONS,
|
||||
})
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
|
||||
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()
|
||||
|
||||
Reference in New Issue
Block a user