SaylorTwift HF Staff commited on
Commit
57ba6cf
·
verified ·
1 Parent(s): e58e683

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. scripts/archived-agent-notes.spec.ts +95 -0
  2. scripts/attribute-chunk-bytes.mjs +0 -0
  3. scripts/benchmark-npm-resolution.ts +551 -0
  4. scripts/build-exe-for-python-sdk-native-pty.spec.ts +67 -0
  5. scripts/bundle-input-isolation.ts +95 -0
  6. scripts/check-macos-deployment-target.py +111 -0
  7. scripts/check-vendor-manifest.sh +17 -0
  8. scripts/ci-compatible-toolcache.mjs +8 -0
  9. scripts/ci-workflow.spec.ts +1164 -0
  10. scripts/client-bundle-purity.spec.ts +442 -0
  11. scripts/cordis-walk.ts +102 -0
  12. scripts/cordis-yaml.ts +54 -0
  13. scripts/coverage-exempt.spec.ts +62 -0
  14. scripts/coverage-exempt.ts +60 -0
  15. scripts/dev-web.ts +266 -0
  16. scripts/doc-budgets.manifest.json +10 -0
  17. scripts/doc-typecheck-paths.ts +22 -0
  18. scripts/gen-config-catalog.ts +976 -0
  19. scripts/gen-dependency-catalog.spec.ts +188 -0
  20. scripts/gen-doc-graphs.spec.ts +98 -0
  21. scripts/gen-doc-graphs.ts +1586 -0
  22. scripts/gen-scoped-events.ts +399 -0
  23. scripts/jsdoc.ts +201 -0
  24. scripts/lint-rule-fingerprint.spec.ts +98 -0
  25. scripts/locale-dictionary-parity.spec.ts +308 -0
  26. scripts/merge-translation-pairing-driver.sh +35 -0
  27. scripts/migrate-packed-session-fixtures.ts +21 -0
  28. scripts/package-invariants.ts +418 -0
  29. scripts/paired-markdown-derivatives.spec.ts +66 -0
  30. scripts/persistence-catalog-text.ts +43 -0
  31. scripts/persistence-changes.spec.ts +611 -0
  32. scripts/persistence-changes.ts +812 -0
  33. scripts/persistence-formats.spec.ts +476 -0
  34. scripts/persistence-schema-model.spec.ts +89 -0
  35. scripts/persistence-schema.ts +462 -0
  36. scripts/pnpm-invocation.ts +21 -0
  37. scripts/project-reference-faces.spec.ts +100 -0
  38. scripts/publication-payload.spec.ts +81 -0
  39. scripts/publication-payload.ts +55 -0
  40. scripts/publint-all.spec.ts +167 -0
  41. scripts/publint-all.ts +260 -0
  42. scripts/render-persistence-schema.ts +202 -0
  43. scripts/repo-files.ts +252 -0
  44. scripts/run-gates.spec.ts +1029 -0
  45. scripts/run-web-snapshots.ts +44 -0
  46. scripts/session-query-spill-command.spec.ts +67 -0
  47. scripts/slot-walk.ts +429 -0
  48. scripts/snapshot-workspace-parent.spec.ts +78 -0
  49. scripts/test-fixture-cleanup.ts +49 -0
  50. scripts/test-proxy-environment.spec.ts +44 -0
scripts/archived-agent-notes.spec.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ extendArchiveManifest,
4
+ gitBlobHash,
5
+ parseArchiveManifest,
6
+ renderArchiveManifest,
7
+ validateArchiveArtifacts,
8
+ validateArchiveManifestExtension,
9
+ type ArchiveManifest,
10
+ } from './archived-agent-notes.ts'
11
+ import { isArchivedAgentNotePath } from './repo-files.ts'
12
+
13
+ function fixture(): Map<string, Buffer> {
14
+ const base = '2026-07-26-example'
15
+ const source = Buffer.from(`# Agent Note: Example\n\nStatus: implemented\nArchived: 2026-07-26\n\nEnglish | [中文](${base}.zh.md)\n\n## Problem\n\nExample.\n`)
16
+ const zh = Buffer.from(`# Agent Note: 示例\n\nStatus: implemented\nArchived: 2026-07-26\n\n[English](${base}.md) | 中文\n\n## 问题\n\n示例。\n`)
17
+ const meta = Buffer.from(`${base}.md: ${gitBlobHash(source)}\n${base}.zh.md: ${gitBlobHash(zh)}\n`)
18
+ return new Map([
19
+ [`process/${base}.md`, source],
20
+ [`process/${base}.zh.md`, zh],
21
+ [`process/${base}.i18n.yaml`, meta],
22
+ ])
23
+ }
24
+
25
+ describe('archived Agent Notes', () => {
26
+ it('recognizes archived paths with POSIX and Windows separators', () => {
27
+ expect(isArchivedAgentNotePath('.agents/notes/archived/process/example.md')).toBe(true)
28
+ expect(isArchivedAgentNotePath('.agents\\notes\\archived\\process\\example.md')).toBe(true)
29
+ expect(isArchivedAgentNotePath('.agents/notes/implemented/process/example.md')).toBe(false)
30
+ })
31
+
32
+ it('accepts one complete implemented triplet with matching archive metadata', () => {
33
+ expect(validateArchiveArtifacts(fixture())).toEqual([])
34
+ })
35
+
36
+ it('rejects incomplete triplets and invalid archive headers', () => {
37
+ const artifacts = fixture()
38
+ artifacts.delete('process/2026-07-26-example.i18n.yaml')
39
+ artifacts.set(
40
+ 'process/2026-07-26-example.md',
41
+ Buffer.from('# Agent Note: Example\n\nStatus: proposed\nArchived: yesterday\n'),
42
+ )
43
+ expect(validateArchiveArtifacts(artifacts).join('\n')).toMatch(/incomplete archived triplet/)
44
+ })
45
+
46
+ it('extends the manifest without permitting a sealed change or removal', () => {
47
+ const artifacts = fixture()
48
+ const empty: ArchiveManifest = { version: 1, files: {} }
49
+ const first = extendArchiveManifest(empty, artifacts)
50
+ expect(first.errors).toEqual([])
51
+ expect(first.added).toHaveLength(3)
52
+
53
+ const sealed: ArchiveManifest = { version: 1, files: first.files }
54
+ const changed = new Map(artifacts)
55
+ changed.set('process/2026-07-26-example.md', Buffer.from('changed'))
56
+ expect(extendArchiveManifest(sealed, changed).errors).toEqual([
57
+ 'process/2026-07-26-example.md: sealed content hash changed',
58
+ ])
59
+ changed.delete('process/2026-07-26-example.zh.md')
60
+ expect(extendArchiveManifest(sealed, changed).errors).toContain(
61
+ 'process/2026-07-26-example.zh.md: sealed artifact is missing',
62
+ )
63
+ })
64
+
65
+ it('rejects replacing manifest seals alongside changed archive content', () => {
66
+ const artifacts = fixture()
67
+ const initial = extendArchiveManifest({ version: 1, files: {} }, artifacts)
68
+ const baseline: ArchiveManifest = { version: 1, files: initial.files }
69
+ const path = 'process/2026-07-26-example.md'
70
+ const changedArtifacts = new Map(artifacts)
71
+ changedArtifacts.set(path, Buffer.from('changed'))
72
+ const replacement = extendArchiveManifest({ version: 1, files: {} }, changedArtifacts)
73
+ const current: ArchiveManifest = { version: 1, files: replacement.files }
74
+
75
+ expect(extendArchiveManifest(current, changedArtifacts).errors).toEqual([])
76
+ expect(validateArchiveManifestExtension(baseline, current)).toEqual([
77
+ `${path}: sealed manifest hash changed`,
78
+ ])
79
+ const removed: ArchiveManifest = {
80
+ version: 1,
81
+ files: Object.fromEntries(Object.entries(current.files).filter(([candidate]) => candidate !== path)),
82
+ }
83
+ expect(validateArchiveManifestExtension(baseline, removed)).toContain(
84
+ `${path}: sealed manifest entry is missing`,
85
+ )
86
+ })
87
+
88
+ it('round-trips the deterministic manifest schema', () => {
89
+ const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` })
90
+ expect(parseArchiveManifest(content)).toEqual({
91
+ version: 1,
92
+ files: { 'process/z.md': `sha256:${'a'.repeat(64)}` },
93
+ })
94
+ })
95
+ })
scripts/attribute-chunk-bytes.mjs ADDED
Binary file (4.07 kB). View file
 
scripts/benchmark-npm-resolution.ts ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Benchmark npm's dependency-tree resolution against an all-local registry. */
2
+
3
+ import { execFileSync, spawn, spawnSync, type ChildProcess } from 'node:child_process'
4
+ import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { createServer, type Server } from 'node:http'
6
+ import { tmpdir } from 'node:os'
7
+ import { join, resolve } from 'node:path'
8
+ import { performance } from 'node:perf_hooks'
9
+ import { parseArgs } from 'node:util'
10
+
11
+ const TARGET_PACKAGE = '@deepseek-ai/dsh'
12
+ const DEFAULT_TIMEOUT_MS = 300_000
13
+ const TERMINATION_GRACE_MS = 1_000
14
+ const FORCED_EXIT_TIMEOUT_MS = 5_000
15
+ const WORKSPACE_MANIFEST_GLOBS = [
16
+ 'apps/*/package.json',
17
+ 'packages/*/*/package.json',
18
+ 'vendor/*/package.json',
19
+ 'native/system/package.json',
20
+ 'native/system/packages/*/package.json',
21
+ ]
22
+ const INSTALLED_MANIFEST_GLOBS = [
23
+ 'node_modules/.pnpm/*/node_modules/*/package.json',
24
+ 'node_modules/.pnpm/*/node_modules/@*/*/package.json',
25
+ ]
26
+ const PUBLISHED_FIELDS = [
27
+ 'dependencies',
28
+ 'optionalDependencies',
29
+ 'peerDependencies',
30
+ 'peerDependenciesMeta',
31
+ 'engines',
32
+ 'os',
33
+ 'cpu',
34
+ 'bin',
35
+ ] as const
36
+
37
+ interface PackageManifest {
38
+ readonly name?: unknown
39
+ readonly version?: unknown
40
+ readonly dependencies?: Record<string, string>
41
+ readonly optionalDependencies?: Record<string, string>
42
+ readonly peerDependencies?: Record<string, string>
43
+ readonly peerDependenciesMeta?: Record<string, unknown>
44
+ readonly engines?: unknown
45
+ readonly os?: unknown
46
+ readonly cpu?: unknown
47
+ readonly bin?: unknown
48
+ }
49
+
50
+ interface RegistryVersion extends PackageManifest {
51
+ readonly name: string
52
+ readonly version: string
53
+ }
54
+
55
+ /** Package versions served by the local benchmark registry. */
56
+ export type RegistryIndex = ReadonlyMap<string, ReadonlyMap<string, RegistryVersion>>
57
+
58
+ /** Parsed command-line options for one benchmark invocation. */
59
+ export interface BenchmarkOptions {
60
+ readonly ref?: string
61
+ readonly runs: number
62
+ readonly timeoutMs: number
63
+ readonly maxMs?: number
64
+ }
65
+
66
+ /** One measured npm resolution. */
67
+ export interface BenchmarkRun {
68
+ readonly durationMs: number
69
+ readonly registryRequests: number
70
+ readonly archiveRequests: number
71
+ readonly unknownPackages: readonly string[]
72
+ }
73
+
74
+ /** Published-package fields retained in npm's package-lock layout. */
75
+ export interface NpmLockPackage {
76
+ readonly name?: string
77
+ readonly version?: string
78
+ readonly dependencies?: Readonly<Record<string, string>>
79
+ readonly optionalDependencies?: Readonly<Record<string, string>>
80
+ readonly peerDependencies?: Readonly<Record<string, string>>
81
+ readonly peerDependenciesMeta?: Readonly<Record<string, { readonly optional?: boolean }>>
82
+ }
83
+
84
+ /** The installed paths selected by npm without materializing package archives. */
85
+ export interface NpmPackageLock {
86
+ readonly lockfileVersion: number
87
+ readonly packages: Readonly<Record<string, NpmLockPackage>>
88
+ }
89
+
90
+ /** npm resolution observations together with its computed install layout. */
91
+ export interface NpmPackageLockResolution extends BenchmarkRun {
92
+ readonly packageLock: NpmPackageLock
93
+ }
94
+
95
+ /** Parse one positive-integer command-line option or use its default. */
96
+ export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number {
97
+ if (raw === undefined) return fallback
98
+ const value = Number.parseInt(raw, 10)
99
+ if (!Number.isSafeInteger(value) || value < 1 || String(value) !== raw) {
100
+ throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`)
101
+ }
102
+ return value
103
+ }
104
+
105
+ /**
106
+ * Parse supported benchmark arguments.
107
+ * @param args - Command-line arguments after the script path.
108
+ * @returns Validated benchmark options.
109
+ */
110
+ export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions {
111
+ const normalized = args[0] === '--' ? args.slice(1) : args
112
+ const { values } = parseArgs({
113
+ args: [...normalized],
114
+ options: {
115
+ ref: { type: 'string' },
116
+ runs: { type: 'string' },
117
+ 'timeout-ms': { type: 'string' },
118
+ 'max-ms': { type: 'string' },
119
+ },
120
+ allowPositionals: false,
121
+ })
122
+ const maxMs = values['max-ms'] === undefined
123
+ ? undefined
124
+ : parsePositiveIntegerOption(values['max-ms'], 0, '--max-ms')
125
+ return {
126
+ runs: parsePositiveIntegerOption(values.runs, 1, '--runs'),
127
+ timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], DEFAULT_TIMEOUT_MS, '--timeout-ms'),
128
+ ...(values.ref === undefined ? {} : { ref: values.ref }),
129
+ ...(maxMs === undefined ? {} : { maxMs }),
130
+ }
131
+ }
132
+
133
+ function workspaceManifestPath(path: string): boolean {
134
+ return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/system(?:\/packages\/[^/]+)?)\/package\.json$/.test(path)
135
+ }
136
+
137
+ function workspaceManifestPaths(root: string, ref: string | undefined): string[] {
138
+ if (ref === undefined) return globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).sort()
139
+ return execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'apps', 'packages', 'vendor', 'native'], {
140
+ cwd: root,
141
+ encoding: 'utf8',
142
+ }).split('\n').filter(workspaceManifestPath).sort()
143
+ }
144
+
145
+ function readGitFiles(root: string, ref: string, paths: readonly string[]): ReadonlyMap<string, string> {
146
+ const output = execFileSync('git', ['cat-file', '--batch'], {
147
+ cwd: root,
148
+ input: paths.map(path => `${ref}:${path}\n`).join(''),
149
+ maxBuffer: 64 * 1024 * 1024,
150
+ })
151
+ const contents = new Map<string, string>()
152
+ let offset = 0
153
+ for (const path of paths) {
154
+ const headerEnd = output.indexOf(0x0a, offset)
155
+ if (headerEnd < 0) throw new Error(`git cat-file returned no header for ${ref}:${path}`)
156
+ const header = output.subarray(offset, headerEnd).toString('utf8')
157
+ if (header.endsWith(' missing')) throw new Error(`git ref ${ref} has no ${path}`)
158
+ const size = Number.parseInt(header.split(' ')[2] ?? '', 10)
159
+ if (!Number.isSafeInteger(size) || size < 0) {
160
+ throw new Error(`git cat-file returned an invalid size for ${ref}:${path}`)
161
+ }
162
+ const contentStart = headerEnd + 1
163
+ const contentEnd = contentStart + size
164
+ if (output[contentEnd] !== 0x0a) throw new Error(`git cat-file truncated ${ref}:${path}`)
165
+ contents.set(path, output.subarray(contentStart, contentEnd).toString('utf8'))
166
+ offset = contentEnd + 1
167
+ }
168
+ return contents
169
+ }
170
+
171
+ /**
172
+ * Convert a workspace protocol range to the range published by pnpm pack.
173
+ * @param range - Dependency range from a workspace manifest.
174
+ * @param targetVersion - Current version of the referenced workspace package.
175
+ * @returns The registry-facing semver range.
176
+ */
177
+ export function publishWorkspaceRange(range: string, targetVersion: string): string {
178
+ if (range === 'workspace:*') return targetVersion
179
+ if (range === 'workspace:^') return `^${targetVersion}`
180
+ if (range === 'workspace:~') return `~${targetVersion}`
181
+ if (range.startsWith('workspace:')) return range.slice('workspace:'.length)
182
+ return range
183
+ }
184
+
185
+ function copyPublishedManifest(
186
+ source: PackageManifest,
187
+ workspaceVersions: ReadonlyMap<string, string>,
188
+ ): RegistryVersion | undefined {
189
+ if (typeof source.name !== 'string' || typeof source.version !== 'string') return undefined
190
+ const output: Record<string, unknown> = { name: source.name, version: source.version }
191
+ for (const field of PUBLISHED_FIELDS) {
192
+ const value = source[field]
193
+ if (value === undefined) continue
194
+ if (field === 'dependencies' || field === 'optionalDependencies' || field === 'peerDependencies') {
195
+ output[field] = Object.fromEntries(Object.entries(value as Record<string, string>).map(([name, range]) => {
196
+ const targetVersion = workspaceVersions.get(name)
197
+ return [name, targetVersion === undefined ? range : publishWorkspaceRange(range, targetVersion)]
198
+ }))
199
+ } else {
200
+ output[field] = structuredClone(value)
201
+ }
202
+ }
203
+ return output as unknown as RegistryVersion
204
+ }
205
+
206
+ function addManifest(index: Map<string, Map<string, RegistryVersion>>, manifest: RegistryVersion): void {
207
+ const versions = index.get(manifest.name) ?? new Map<string, RegistryVersion>()
208
+ versions.set(manifest.version, manifest)
209
+ index.set(manifest.name, versions)
210
+ }
211
+
212
+ /**
213
+ * Build registry metadata from installed external packages and workspace manifests.
214
+ * @param root - Repository root containing the pnpm virtual store.
215
+ * @param ref - Optional Git ref used instead of working-tree workspace manifests.
216
+ * @returns Package metadata served by the benchmark registry.
217
+ */
218
+ export function buildRegistryIndex(root: string, ref?: string): RegistryIndex {
219
+ const index = new Map<string, Map<string, RegistryVersion>>()
220
+ for (const path of globSync(INSTALLED_MANIFEST_GLOBS, { cwd: root }).sort()) {
221
+ const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
222
+ const copied = copyPublishedManifest(manifest, new Map())
223
+ if (copied !== undefined) addManifest(index, copied)
224
+ }
225
+
226
+ const paths = workspaceManifestPaths(root, ref)
227
+ const refContents = ref === undefined ? undefined : readGitFiles(root, ref, paths)
228
+ const workspace = paths.map(path =>
229
+ JSON.parse(refContents?.get(path) ?? readFileSync(resolve(root, path), 'utf8')) as PackageManifest)
230
+ const workspaceVersions = new Map(workspace.flatMap(manifest =>
231
+ typeof manifest.name === 'string' && typeof manifest.version === 'string'
232
+ ? [[manifest.name, manifest.version] as const]
233
+ : []))
234
+ for (const manifest of workspace) {
235
+ const copied = copyPublishedManifest(manifest, workspaceVersions)
236
+ if (copied !== undefined) addManifest(index, copied)
237
+ }
238
+ return index
239
+ }
240
+
241
+ function latestVersion(versions: ReadonlyMap<string, RegistryVersion>): string {
242
+ const sorted = [...versions.keys()].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }))
243
+ const latest = sorted.at(-1)
244
+ if (latest === undefined) throw new Error('local registry package has no versions')
245
+ return latest
246
+ }
247
+
248
+ function listen(server: Server): Promise<number> {
249
+ return new Promise((resolveListen, reject) => {
250
+ server.once('error', reject)
251
+ server.listen(0, '127.0.0.1', () => {
252
+ server.off('error', reject)
253
+ const address = server.address()
254
+ if (address === null || typeof address === 'string') {
255
+ reject(new Error('local registry did not expose a TCP port'))
256
+ return
257
+ }
258
+ resolveListen(address.port)
259
+ })
260
+ })
261
+ }
262
+
263
+ function close(server: Server): Promise<void> {
264
+ return new Promise((resolveClose, reject) => {
265
+ server.close((error) => {
266
+ if (error === undefined) resolveClose()
267
+ else reject(error)
268
+ })
269
+ })
270
+ }
271
+
272
+ function npmExecutable(): string {
273
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm'
274
+ }
275
+
276
+ function delay(ms: number): Promise<void> {
277
+ return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
278
+ }
279
+
280
+ function signalProcessTree(child: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void {
281
+ if (child.pid === undefined) {
282
+ child.kill(signal)
283
+ return
284
+ }
285
+ if (process.platform === 'win32') {
286
+ const force = signal === 'SIGKILL' ? ['/F'] : []
287
+ const result = spawnSync('taskkill', ['/PID', String(child.pid), '/T', ...force], {
288
+ stdio: 'ignore',
289
+ windowsHide: true,
290
+ })
291
+ if (result.error !== undefined) throw result.error
292
+ if (result.status !== 0 && child.exitCode === null && child.signalCode === null) child.kill(signal)
293
+ return
294
+ }
295
+ try {
296
+ process.kill(-child.pid, signal)
297
+ } catch (error) {
298
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Run one command with bounded process-tree termination after its deadline.
304
+ * @param command - Executable path or name.
305
+ * @param args - Arguments passed without shell interpolation on POSIX.
306
+ * @param options - Working directory, environment, timeout, and termination grace.
307
+ * @returns Exit facts, captured output, duration, and whether timeout handling began.
308
+ */
309
+ export async function runCommandWithTimeout(
310
+ command: string,
311
+ args: readonly string[],
312
+ options: {
313
+ readonly cwd: string
314
+ readonly env: NodeJS.ProcessEnv
315
+ readonly timeoutMs: number
316
+ readonly terminationGraceMs?: number
317
+ },
318
+ ): Promise<{ status: number | null; signal: NodeJS.Signals | null; durationMs: number; output: string; timedOut: boolean }> {
319
+ const started = performance.now()
320
+ const child = spawn(command, [...args], {
321
+ cwd: options.cwd,
322
+ detached: process.platform !== 'win32',
323
+ env: options.env,
324
+ shell: process.platform === 'win32',
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ })
327
+ let output = ''
328
+ child.stdout.setEncoding('utf8')
329
+ child.stderr.setEncoding('utf8')
330
+ child.stdout.on('data', (chunk) => { output += String(chunk) })
331
+ child.stderr.on('data', (chunk) => { output += String(chunk) })
332
+ const exited = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolveExit, reject) => {
333
+ child.once('error', reject)
334
+ child.once('close', (status, signal) => { resolveExit({ status, signal }) })
335
+ })
336
+ let timeout: NodeJS.Timeout | undefined
337
+ try {
338
+ const first = await Promise.race([
339
+ exited.then(outcome => ({ type: 'exit' as const, outcome })),
340
+ new Promise<{ type: 'timeout' }>((resolveTimeout) => {
341
+ timeout = setTimeout(() => { resolveTimeout({ type: 'timeout' }) }, options.timeoutMs)
342
+ }),
343
+ ])
344
+ if (first.type === 'exit') {
345
+ return { ...first.outcome, durationMs: performance.now() - started, output, timedOut: false }
346
+ }
347
+
348
+ signalProcessTree(child, 'SIGTERM')
349
+ await delay(options.terminationGraceMs ?? TERMINATION_GRACE_MS)
350
+ signalProcessTree(child, 'SIGKILL')
351
+ const forced = await Promise.race([
352
+ exited,
353
+ delay(FORCED_EXIT_TIMEOUT_MS).then(() => undefined),
354
+ ])
355
+ if (forced === undefined) throw new Error('timed-out process tree did not exit after SIGKILL')
356
+ return { ...forced, durationMs: performance.now() - started, output, timedOut: true }
357
+ } finally {
358
+ if (timeout !== undefined) clearTimeout(timeout)
359
+ }
360
+ }
361
+
362
+ function readNpmPackageLock(path: string): NpmPackageLock {
363
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
364
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
365
+ throw new Error('npm produced an invalid package-lock.json')
366
+ }
367
+ const { lockfileVersion, packages } = parsed as Record<string, unknown>
368
+ if (!Number.isSafeInteger(lockfileVersion) || packages === null
369
+ || typeof packages !== 'object' || Array.isArray(packages)) {
370
+ throw new Error('npm produced an invalid package-lock.json')
371
+ }
372
+ return parsed as NpmPackageLock
373
+ }
374
+
375
+ async function runNpm(
376
+ cwd: string,
377
+ registry: string,
378
+ timeoutMs: number,
379
+ ): Promise<{ durationMs: number; output: string; timedOut: boolean }> {
380
+ const npmrc = join(cwd, '.npmrc')
381
+ const globalNpmrc = join(cwd, '.npmrc-global')
382
+ writeFileSync(npmrc, `registry=${registry}\n@deepseek-ai:registry=${registry}\n`)
383
+ writeFileSync(globalNpmrc, '')
384
+ const inheritedEnvironment = Object.fromEntries(Object.entries(process.env)
385
+ .filter(([name]) => !name.toLowerCase().startsWith('npm_config_')))
386
+ const result = await runCommandWithTimeout(npmExecutable(), [
387
+ 'install',
388
+ '--package-lock-only',
389
+ '--ignore-scripts',
390
+ '--no-audit',
391
+ '--no-fund',
392
+ '--loglevel=error',
393
+ '--include=peer',
394
+ '--install-strategy=hoisted',
395
+ '--legacy-peer-deps=false',
396
+ `--registry=${registry}`,
397
+ ], {
398
+ cwd,
399
+ env: {
400
+ ...inheritedEnvironment,
401
+ npm_config_cache: join(cwd, '.npm-cache'),
402
+ npm_config_globalconfig: globalNpmrc,
403
+ npm_config_userconfig: npmrc,
404
+ npm_config_update_notifier: 'false',
405
+ },
406
+ timeoutMs,
407
+ })
408
+ if (result.timedOut) return result
409
+ if (result.status !== 0) {
410
+ throw new Error(`npm install exited ${String(result.status)} after ${result.durationMs.toFixed(0)} ms\n${result.output.trim()}`)
411
+ }
412
+ return result
413
+ }
414
+
415
+ /**
416
+ * Ask npm to compute an install layout without downloading package archives.
417
+ * @param index - Package metadata exposed through the local registry.
418
+ * @param dependencies - Root dependencies whose install layout npm computes.
419
+ * @param timeoutMs - Hard wall-clock limit for the npm child process.
420
+ * @returns The package lock plus timing and registry-request observations.
421
+ */
422
+ export async function resolveNpmPackageLock(
423
+ index: RegistryIndex,
424
+ dependencies: Readonly<Record<string, string>>,
425
+ timeoutMs: number,
426
+ ): Promise<NpmPackageLockResolution> {
427
+ let registryRequests = 0
428
+ let archiveRequests = 0
429
+ const unknownPackages = new Set<string>()
430
+ let registry = ''
431
+ const server = createServer((request, response) => {
432
+ registryRequests++
433
+ const pathname = new URL(request.url ?? '/', registry).pathname
434
+ if (pathname.startsWith('/tarballs/')) {
435
+ archiveRequests++
436
+ response.writeHead(500, { 'content-type': 'application/json' })
437
+ response.end(JSON.stringify({ error: 'package-lock-only benchmark requested an archive' }))
438
+ return
439
+ }
440
+ const name = decodeURIComponent(pathname.slice(1))
441
+ const versions = index.get(name)
442
+ if (versions === undefined) {
443
+ unknownPackages.add(name)
444
+ response.writeHead(404, { 'content-type': 'application/json' })
445
+ response.end(JSON.stringify({ error: 'not_found' }))
446
+ return
447
+ }
448
+ const materialized = Object.fromEntries([...versions].map(([version, manifest]) => [version, {
449
+ ...manifest,
450
+ dist: { tarball: `${registry}tarballs/${encodeURIComponent(name)}-${version}.tgz` },
451
+ }]))
452
+ const body = JSON.stringify({
453
+ name,
454
+ 'dist-tags': { latest: latestVersion(versions) },
455
+ versions: materialized,
456
+ })
457
+ response.writeHead(200, {
458
+ 'content-type': 'application/json',
459
+ 'content-length': Buffer.byteLength(body),
460
+ })
461
+ response.end(body)
462
+ })
463
+ const port = await listen(server)
464
+ registry = `http://127.0.0.1:${String(port)}/`
465
+ const consumer = mkdtempSync(join(tmpdir(), 'dsh-npm-resolution-'))
466
+ try {
467
+ writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({
468
+ name: 'dsh-npm-resolution-benchmark',
469
+ version: '0.0.0',
470
+ private: true,
471
+ dependencies,
472
+ }, null, 2)}\n`)
473
+ const result = await runNpm(consumer, registry, timeoutMs)
474
+ if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`)
475
+ return {
476
+ durationMs: result.durationMs,
477
+ registryRequests,
478
+ archiveRequests,
479
+ unknownPackages: [...unknownPackages].sort(),
480
+ packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')),
481
+ }
482
+ } finally {
483
+ server.closeAllConnections()
484
+ await close(server)
485
+ rmSync(consumer, { recursive: true, force: true })
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Resolve the CLI install graph once without downloading package archives.
491
+ * @param index - Package metadata exposed through the local registry.
492
+ * @param targetVersion - Version of `@deepseek-ai/dsh` to install.
493
+ * @param timeoutMs - Hard wall-clock limit for the npm child process.
494
+ * @returns Timing and registry-request observations.
495
+ */
496
+ export async function benchmarkNpmResolution(
497
+ index: RegistryIndex,
498
+ targetVersion: string,
499
+ timeoutMs: number,
500
+ ): Promise<BenchmarkRun> {
501
+ const result = await resolveNpmPackageLock(index, { [TARGET_PACKAGE]: targetVersion }, timeoutMs)
502
+ return {
503
+ durationMs: result.durationMs,
504
+ registryRequests: result.registryRequests,
505
+ archiveRequests: result.archiveRequests,
506
+ unknownPackages: result.unknownPackages,
507
+ }
508
+ }
509
+
510
+ async function main(): Promise<void> {
511
+ const options = parseBenchmarkOptions(process.argv.slice(2))
512
+ const root = resolve(import.meta.dirname, '..')
513
+ const started = performance.now()
514
+ const index = buildRegistryIndex(root, options.ref)
515
+ const targetVersions = index.get(TARGET_PACKAGE)
516
+ if (targetVersions === undefined) throw new Error(`local registry contains no ${TARGET_PACKAGE}`)
517
+ const targetVersion = latestVersion(targetVersions)
518
+ const npmVersion = execFileSync(npmExecutable(), ['--version'], { encoding: 'utf8' }).trim()
519
+ console.log(
520
+ `benchmark-npm-resolution: npm ${npmVersion}, ${options.ref === undefined ? 'working tree' : options.ref}, `
521
+ + `${String(index.size)} package name(s), setup ${(performance.now() - started).toFixed(0)} ms.`,
522
+ )
523
+ const durations: number[] = []
524
+ for (let run = 1; run <= options.runs; run++) {
525
+ const result = await benchmarkNpmResolution(index, targetVersion, options.timeoutMs)
526
+ durations.push(result.durationMs)
527
+ console.log(
528
+ `benchmark-npm-resolution: run ${String(run)}/${String(options.runs)} resolved ${TARGET_PACKAGE}@${targetVersion}`
529
+ + ` in ${(result.durationMs / 1000).toFixed(2)} s with ${String(result.registryRequests)} metadata request(s)`
530
+ + ` and ${String(result.unknownPackages.length)} local 404 package name(s).`,
531
+ )
532
+ if (result.archiveRequests > 0) throw new Error('npm requested package archives during the metadata-only benchmark')
533
+ }
534
+ const minimum = Math.min(...durations)
535
+ const maximum = Math.max(...durations)
536
+ console.log(
537
+ `benchmark-npm-resolution: ${String(options.runs)} run(s), min ${(minimum / 1000).toFixed(2)} s, max ${(maximum / 1000).toFixed(2)} s.`,
538
+ )
539
+ if (options.maxMs !== undefined && maximum > options.maxMs) {
540
+ throw new Error(`npm resolution exceeded --max-ms=${String(options.maxMs)} (max ${maximum.toFixed(0)} ms)`)
541
+ }
542
+ }
543
+
544
+ if (import.meta.main) {
545
+ try {
546
+ await main()
547
+ } catch (error) {
548
+ console.error(`benchmark-npm-resolution: ${error instanceof Error ? error.message : String(error)}`)
549
+ process.exitCode = 1
550
+ }
551
+ }
scripts/build-exe-for-python-sdk-native-pty.spec.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
6
+
7
+ const roots: string[] = []
8
+
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
11
+ })
12
+
13
+ describe('resolveLinuxNodePtyAddon', () => {
14
+ it('prefers the manylinux build produced by the release workflow', () => {
15
+ const root = temporaryPackage()
16
+ const built = createAddon(root, 'build', 'Release', 'pty.node')
17
+ createAddon(root, 'prebuilds', 'linux-x64', 'pty.node')
18
+
19
+ expect(resolveLinuxNodePtyAddon(root, 'x64')).toBe(built)
20
+ })
21
+
22
+ it('uses the target prebuild after an ordinary beta install', () => {
23
+ const root = temporaryPackage()
24
+ const prebuilt = createAddon(root, 'prebuilds', 'linux-arm64', 'pty.node')
25
+
26
+ expect(resolveLinuxNodePtyAddon(root, 'arm64')).toBe(prebuilt)
27
+ })
28
+
29
+ it('reports both expected locations when no addon is installed', () => {
30
+ const root = temporaryPackage()
31
+
32
+ expect(() => resolveLinuxNodePtyAddon(root, 'x64')).toThrow(
33
+ `node-pty addon is absent from both ${join(root, 'build', 'Release', 'pty.node')} and ${join(root, 'prebuilds', 'linux-x64', 'pty.node')}`,
34
+ )
35
+ })
36
+ })
37
+
38
+ describe('resolveWindowsNodePtyAddons', () => {
39
+ it('requires both ConPTY addons from the x64 prebuild', () => {
40
+ const root = temporaryPackage()
41
+ const conpty = createAddon(root, 'prebuilds', 'win32-x64', 'conpty.node')
42
+ const consoleList = createAddon(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')
43
+
44
+ expect(resolveWindowsNodePtyAddons(root, 'x64')).toEqual([conpty, consoleList])
45
+ })
46
+
47
+ it('names every missing Windows addon', () => {
48
+ const root = temporaryPackage()
49
+
50
+ expect(() => resolveWindowsNodePtyAddons(root, 'x64')).toThrow(
51
+ `Windows node-pty addons are missing: ${join(root, 'prebuilds', 'win32-x64', 'conpty.node')}, ${join(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')}`,
52
+ )
53
+ })
54
+ })
55
+
56
+ function temporaryPackage(): string {
57
+ const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-'))
58
+ roots.push(root)
59
+ return root
60
+ }
61
+
62
+ function createAddon(root: string, ...segments: string[]): string {
63
+ const path = join(root, ...segments)
64
+ mkdirSync(dirname(path), { recursive: true })
65
+ writeFileSync(path, '')
66
+ return path
67
+ }
scripts/bundle-input-isolation.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Experimental ownership of the actual filesystem and package inputs supplied to a bundler. */
2
+
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
4
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ /** Package identities are cached only within one build. */
8
+ export class BundleInputIsolation {
9
+ private readonly packages = new Map<string, string | undefined>()
10
+ private readonly repository: string
11
+ private readonly label: string
12
+
13
+ constructor(repository: string, label: string) {
14
+ this.repository = repository
15
+ this.label = label
16
+ }
17
+
18
+ /** Discard package metadata before a new build or watch rebuild. */
19
+ reset(): void { this.packages.clear() }
20
+
21
+ /**
22
+ * Require a recorded input to have non-experimental physical and package ownership.
23
+ * @param id - bundler module id, filesystem path, or filesystem URL.
24
+ */
25
+ assertInput(id: string): void {
26
+ this.checkInput(id, false)
27
+ }
28
+
29
+ /**
30
+ * Check declared source-map ownership; dependency tarballs may omit their upstream sources.
31
+ * @param id - source-map source resolved relative to its map.
32
+ */
33
+ assertSourceMapInput(id: string): void {
34
+ this.checkInput(id, true)
35
+ }
36
+
37
+ private checkInput(id: string, allowMissing: boolean): void {
38
+ if (/(?:^|[/:\u0000])@deepseek-ai\/dsh-experimental-[^/?#]+/.test(id)) {
39
+ throw new Error(`${this.label}: experimental input ${id}`)
40
+ }
41
+ const file = physicalBundleInput(id)
42
+ if (file === undefined) return
43
+ const lexical = relative(resolve(this.repository, 'packages/experimental'), file)
44
+ if (lexical === '' || lexical !== '..' && !lexical.startsWith(`..${sep}`) && !isAbsolute(lexical)) {
45
+ throw new Error(`${this.label}: experimental input ${id}`)
46
+ }
47
+ let existing = file
48
+ if (!allowMissing && !existsSync(file)) throw new Error(`${this.label}: input ${id} is missing`)
49
+ while (!existsSync(existing)) {
50
+ const parent = dirname(existing)
51
+ if (parent === existing) throw new Error(`${this.label}: input ${id} has no existing filesystem root`)
52
+ existing = parent
53
+ }
54
+ const canonical = resolve(realpathSync(existing), relative(existing, file))
55
+ if (canonical !== file) this.checkInput(canonical, allowMissing)
56
+ const name = this.packageName(dirname(canonical))
57
+ if (name?.startsWith('@deepseek-ai/dsh-experimental-')) {
58
+ throw new Error(`${this.label}: ${id} belongs to experimental package ${name}`)
59
+ }
60
+ }
61
+
62
+ private packageName(directory: string): string | undefined {
63
+ if (this.packages.has(directory)) return this.packages.get(directory)
64
+ const manifest = resolve(directory, 'package.json')
65
+ let name: string | undefined
66
+ if (existsSync(manifest)) {
67
+ const value: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
68
+ if (value !== null && typeof value === 'object' && 'name' in value && typeof value.name === 'string') name = value.name
69
+ }
70
+ if (name === undefined && dirname(directory) !== directory) name = this.packageName(dirname(directory))
71
+ this.packages.set(directory, name)
72
+ return name
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Recover physical ownership from queries, filesystem URLs, and virtual path wrappers.
78
+ * @param id - bundler module id or recorded filesystem input.
79
+ * @returns absolute file path, or undefined for a virtual id without a physical path.
80
+ */
81
+ export function physicalBundleInput(id: string): string | undefined {
82
+ let clean = id.replaceAll('\\', '/').split(/[?#]/, 1)[0] ?? ''
83
+ clean = clean.replace(/^\u0000/, '')
84
+ if (clean.startsWith('file://')) return fileURLToPath(clean)
85
+ if (clean.startsWith('/@fs/')) {
86
+ clean = clean.slice('/@fs/'.length)
87
+ if (!isAbsolute(clean) && !/^[a-zA-Z]:\//.test(clean)) clean = `/${clean}`
88
+ }
89
+ if (!isAbsolute(clean) && !/^[a-zA-Z]:\//.test(clean)) {
90
+ const wrapped = /:(\/.*|[a-zA-Z]:\/.*)$/.exec(clean)?.[1]
91
+ if (wrapped === undefined) return undefined
92
+ clean = wrapped
93
+ }
94
+ return resolve(clean)
95
+ }
scripts/check-macos-deployment-target.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Reject runtime executables that require newer macOS than their wheel tag."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import runpy
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py"))
15
+ MACOS_PLATFORMS = {
16
+ name: details[0]
17
+ for name, details in RELEASE["PLATFORMS"].items()
18
+ if name.startswith("macos-")
19
+ }
20
+
21
+
22
+ def parse_version(value: str) -> tuple[int, ...]:
23
+ """Parse a dot-separated numeric deployment version."""
24
+ if re.fullmatch(r"\d+(?:\.\d+)*", value) is None:
25
+ raise ValueError(f"invalid macOS deployment version: {value!r}")
26
+ return tuple(int(part) for part in value.split("."))
27
+
28
+
29
+ def claimed_version(platform_tag: str) -> tuple[int, ...]:
30
+ """Return the minimum macOS version encoded by a wheel platform tag."""
31
+ match = re.fullmatch(r"macosx_(\d+)_(\d+)_(?:arm64|x86_64)", platform_tag)
32
+ if match is None:
33
+ raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}")
34
+ return int(match.group(1)), int(match.group(2))
35
+
36
+
37
+ def parse_otool_deployment_target(output: str) -> tuple[int, ...]:
38
+ """Return the newest deployment target from one or more Mach-O slices."""
39
+ versions: list[tuple[int, ...]] = []
40
+ command: str | None = None
41
+ for line in output.splitlines():
42
+ stripped = line.strip()
43
+ if re.fullmatch(r"Load command \d+", stripped):
44
+ command = None
45
+ elif stripped == "cmd LC_BUILD_VERSION":
46
+ command = "build"
47
+ elif stripped == "cmd LC_VERSION_MIN_MACOSX":
48
+ command = "minimum"
49
+ elif command == "build" and (match := re.fullmatch(r"minos\s+(\d+(?:\.\d+)*)", stripped)):
50
+ versions.append(parse_version(match.group(1)))
51
+ elif command == "minimum" and (match := re.fullmatch(r"version\s+(\d+(?:\.\d+)*)", stripped)):
52
+ versions.append(parse_version(match.group(1)))
53
+ if not versions:
54
+ raise ValueError("otool output contains no macOS deployment target load command")
55
+ return max(versions)
56
+
57
+
58
+ def deployment_target(executable: Path) -> tuple[int, ...]:
59
+ """Read one Mach-O executable's deployment target with ``otool``."""
60
+ if not executable.is_file():
61
+ raise FileNotFoundError(f"runtime executable does not exist: {executable}")
62
+ result = subprocess.run(
63
+ ["otool", "-l", str(executable)],
64
+ check=True,
65
+ capture_output=True,
66
+ text=True,
67
+ )
68
+ try:
69
+ return parse_otool_deployment_target(result.stdout)
70
+ except ValueError as error:
71
+ raise ValueError(f"{executable}: {error}") from error
72
+
73
+
74
+ def ensure_compatible(
75
+ executable: Path, actual: tuple[int, ...], platform_tag: str
76
+ ) -> None:
77
+ """Reject an executable whose deployment target exceeds its wheel claim."""
78
+ claimed = claimed_version(platform_tag)
79
+ width = max(len(actual), len(claimed))
80
+ padded_actual = actual + (0,) * (width - len(actual))
81
+ padded_claimed = claimed + (0,) * (width - len(claimed))
82
+ if padded_actual > padded_claimed:
83
+ rendered = ".".join(str(part) for part in actual)
84
+ raise RuntimeError(
85
+ f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}"
86
+ )
87
+
88
+
89
+ def validate_deployment_targets(
90
+ executables: list[Path], platform_tag: str
91
+ ) -> list[tuple[Path, tuple[int, ...]]]:
92
+ """Validate every executable and return its measured deployment target."""
93
+ measured = [(executable, deployment_target(executable)) for executable in executables]
94
+ for executable, actual in measured:
95
+ ensure_compatible(executable, actual, platform_tag)
96
+ return measured
97
+
98
+
99
+ def main() -> None:
100
+ parser = argparse.ArgumentParser(description=__doc__)
101
+ parser.add_argument("--platform", choices=tuple(MACOS_PLATFORMS), required=True)
102
+ parser.add_argument("executables", type=Path, nargs="+")
103
+ args = parser.parse_args()
104
+ platform_tag = MACOS_PLATFORMS[args.platform]
105
+ for executable, version in validate_deployment_targets(args.executables, platform_tag):
106
+ rendered = ".".join(str(part) for part in version)
107
+ print(f"{executable}: macOS {rendered} <= {platform_tag}")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
scripts/check-vendor-manifest.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Vendoring discipline, mechanized: any staged change under vendor/*/src or a
3
+ # vendored bin.js must come with a vendor/README.md change in the same commit
4
+ # (the manifest's local-modification log is the contract — see vendor/README.md).
5
+ set -euo pipefail
6
+
7
+ staged=$(git diff --cached --name-only)
8
+
9
+ vendor_src_changed=$(echo "$staged" | grep -E '^vendor/[^/]+/(src/|bin\.js)' || true)
10
+ manifest_changed=$(echo "$staged" | grep -x 'vendor/README.md' || true)
11
+
12
+ if [[ -n "$vendor_src_changed" && -z "$manifest_changed" ]]; then
13
+ echo 'vendor manifest guard: vendored SOURCE changed without updating vendor/README.md:'
14
+ echo "$vendor_src_changed" | sed 's/^/ /'
15
+ echo 'Log the modification in vendor/README.md ("Local modifications") and stage it.'
16
+ exit 1
17
+ fi
scripts/ci-compatible-toolcache.mjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict'
2
+ import { isAbsolute, join } from 'node:path'
3
+
4
+ // The Actions runner exports RUNNER_TOOL_CACHE after step env. Run inside the
5
+ // setup-node process so version installs use runner temp rather than shared state.
6
+ const temp = process.env.RUNNER_TEMP
7
+ assert(temp && isAbsolute(temp), 'Node compatibility setup requires an absolute RUNNER_TEMP')
8
+ process.env.RUNNER_TOOL_CACHE = join(temp, 'node-compat-toolcache')
scripts/ci-workflow.spec.ts ADDED
@@ -0,0 +1,1164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { runInNewContext } from 'node:vm'
4
+ import * as yaml from 'js-yaml'
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ function evaluateRunsOn(selector: unknown, context: Record<string, unknown>): unknown {
8
+ if (typeof selector !== 'string') throw new TypeError('Runner selector must be a string')
9
+ return runInNewContext(selector.trim().slice(3, -2), context, { timeout: 1000 })
10
+ }
11
+
12
+ const root = resolve(import.meta.dirname, '..')
13
+ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}$/
14
+ const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
15
+
16
+ describe('CI workflow', () => {
17
+ it('prepares confinement before Node compatibility smokes', () => {
18
+ const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-compat')
19
+ if (!Array.isArray(job.steps)) throw new TypeError('Node compatibility job must define steps')
20
+ const steps = job.steps.filter(isRecord)
21
+ const preparation = steps.findIndex(step => step.run === 'bash scripts/prepare-ci-bubblewrap.sh')
22
+ const smoke = steps.findIndex(step => step.run === 'pnpm run check:node-compat')
23
+ expect(preparation).toBeGreaterThanOrEqual(0)
24
+ expect(smoke).toBeGreaterThan(preparation)
25
+ expect(steps[preparation]).not.toHaveProperty('continue-on-error', true)
26
+ })
27
+
28
+ it.each(['ci.yml', 'ci-master.yml', 'e2e.yml', 'release.yml', 'release-vendor.yml'])(
29
+ '%s cancels superseded validation runs without crossing workflow or ref boundaries', (name) => {
30
+ const workflow = loadWorkflow('.github/workflows/' + name)
31
+ expect(workflow.concurrency).toEqual({
32
+ group: '${{ github.workflow }}-${{ github.ref }}',
33
+ 'cancel-in-progress': true,
34
+ })
35
+ },
36
+ )
37
+
38
+ it('cancels reusable CI builds without cancelling release-owned builds', () => {
39
+ const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
40
+ expect(workflow.concurrency).toEqual({
41
+ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}',
42
+ 'cancel-in-progress': '${{ !inputs.release }}',
43
+ })
44
+ })
45
+
46
+ it('does not cancel protected publication or deployment transactions', () => {
47
+ for (const name of ['release-publish.yml', 'release-vendor-publish.yml']) {
48
+ const publish = workflowJob(loadWorkflow('.github/workflows/' + name), 'publish')
49
+ expect(publish.concurrency).toMatchObject({ 'cancel-in-progress': false })
50
+ }
51
+ for (const name of ['python-release.yml', 'node-addon-system-release.yml', 'docs-pages.yml']) {
52
+ expect(loadWorkflow('.github/workflows/' + name).concurrency).toMatchObject({ 'cancel-in-progress': false })
53
+ }
54
+ })
55
+
56
+ it('skips coverage-history uploads on cancellation but retains Wine cleanup', () => {
57
+ const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'windows-coverage')
58
+ const wine = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), 'windows')
59
+ expect(coverage.steps).toContainEqual(expect.objectContaining({
60
+ name: 'Save coverage duration history', if: '${{ !cancelled() }}',
61
+ }))
62
+ expect(wine.steps).toContainEqual(expect.objectContaining({ name: 'Shut down wineserver', if: 'always()' }))
63
+ })
64
+
65
+ it('isolates every pnpm action setup destination per runner', () => {
66
+ const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml']
67
+ const setups: Array<{ jobName: string; step: unknown }> = []
68
+ for (const file of files) {
69
+ const workflow: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'))
70
+ if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
71
+ for (const [jobName, job] of Object.entries(workflow.jobs)) {
72
+ if (!isRecord(job) || !Array.isArray(job.steps)) continue
73
+ for (const step of job.steps) {
74
+ if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) continue
75
+ setups.push({ jobName, step })
76
+ }
77
+ }
78
+ }
79
+
80
+ expect(setups.length).toBeGreaterThan(0)
81
+ for (const { jobName, step } of setups) {
82
+ const stepDest = (step as { with?: { dest?: unknown } }).with?.dest
83
+ if (jobName.startsWith('windows-')) {
84
+ expect(stepDest, `${jobName} must use the native Windows pnpm destination`).toBe(nativeWindowsPnpmDestination)
85
+ expect(step).not.toMatchObject({ with: { standalone: true } })
86
+ } else {
87
+ expect(typeof stepDest, `${jobName} must use a runner-and-run-private pnpm destination`).toBe('string')
88
+ expect(stepDest as string).toMatch(runnerPrivatePnpmDestination)
89
+ }
90
+ }
91
+ })
92
+
93
+ it.each(['node-24', 'node-24-coverage', 'node-24-consumers'])(
94
+ '%s keeps tool and fixture temporary files under runner cleanup',
95
+ (jobName) => {
96
+ const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
97
+ if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
98
+ expect(job.steps[0]).toEqual({
99
+ name: 'Use runner-owned temporary storage',
100
+ run: [
101
+ 'echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"',
102
+ ...(jobName === 'node-24-consumers'
103
+ ? ['echo "PLAYWRIGHT_BROWSERS_PATH=${RUNNER_TEMP%/*}/ms-playwright" >> "$GITHUB_ENV"']
104
+ : []),
105
+ '',
106
+ ].join('\n'),
107
+ })
108
+ if (jobName === 'node-24-consumers') {
109
+ const browserCache: unknown = job.steps.find(step => isRecord(step) && isRecord(step.with)
110
+ && step.with.path === '${{ env.PLAYWRIGHT_BROWSERS_PATH }}')
111
+ expect(browserCache).toMatchObject({ uses: 'actions/cache/restore@v4' })
112
+ }
113
+ const store: unknown = job.steps.find(step => isRecord(step) && step.name === 'Configure pnpm store path')
114
+ expect(store).toMatchObject({
115
+ run: [
116
+ 'store_root="$HOME/.local/share/pnpm/store"',
117
+ 'echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"',
118
+ 'store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)',
119
+ 'echo "path=$store_path" >> "$GITHUB_OUTPUT"',
120
+ '',
121
+ ].join('\n'),
122
+ })
123
+ for (const step of job.steps) {
124
+ if (isRecord(step) && isRecord(step.env)) {
125
+ expect(step.env.TMPDIR).toBeUndefined()
126
+ expect(step.env.npm_config_cache).toBeUndefined()
127
+ }
128
+ }
129
+ },
130
+ )
131
+
132
+ it('isolates the python SDK exe pnpm setup destination per job', () => {
133
+ const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8'))
134
+ if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('build-exe-for-python-sdk.yml must define jobs')
135
+ const setups: Array<{ step: unknown }> = []
136
+ for (const job of Object.values(workflow.jobs)) {
137
+ if (!isRecord(job) || !Array.isArray(job.steps)) continue
138
+ for (const step of job.steps) {
139
+ if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) continue
140
+ setups.push({ step })
141
+ }
142
+ }
143
+ expect(setups.length).toBeGreaterThan(0)
144
+ for (const { step } of setups) {
145
+ expect(step).toMatchObject({
146
+ with: { dest: nativeWindowsPnpmDestination },
147
+ })
148
+ }
149
+ })
150
+
151
+ it('keeps split native Windows PR jobs with failover, plus a master-only standby', () => {
152
+ const workflow = loadWorkflow('.github/workflows/ci.yml')
153
+ const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
154
+ if (!isRecord(workflow.jobs)
155
+ || !isRecord(workflow.jobs['windows-build'])
156
+ || !isRecord(workflow.jobs['windows-coverage'])
157
+ || !isRecord(workflow.jobs['windows-native-tests'])
158
+ || !isRecord(workflow.jobs['windows-observational'])
159
+ || !isRecord(workflow.jobs['node-24'])
160
+ || !isRecord(workflow.jobs['node-24-coverage'])
161
+ || !isRecord(workflow.jobs['node-24-bench'])
162
+ || !isRecord(workflow.jobs['node-24-consumers'])
163
+ || !isRecord(workflow.jobs['node-compat'])
164
+ || !isRecord(workflow.jobs['all-checks-passed'])
165
+ || !isRecord(masterWorkflow.jobs)
166
+ || !isRecord(masterWorkflow.jobs['serial-windows'])) {
167
+ throw new TypeError('CI workflow must define windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define serial-windows')
168
+ }
169
+
170
+ const windowsBuild = workflow.jobs['windows-build']
171
+ const windowsCoverage = workflow.jobs['windows-coverage']
172
+ const windowsNativeTests = workflow.jobs['windows-native-tests']
173
+ const windowsObservational = workflow.jobs['windows-observational']
174
+ const serialWindows = masterWorkflow.jobs['serial-windows']
175
+ const node24 = workflow.jobs['node-24']
176
+ const node24Coverage = workflow.jobs['node-24-coverage']
177
+ const node24Bench = workflow.jobs['node-24-bench']
178
+ const node24Consumers = workflow.jobs['node-24-consumers']
179
+ const nodeCompat = workflow.jobs['node-compat']
180
+ const aggregate = workflow.jobs['all-checks-passed']
181
+ if (!Array.isArray(aggregate.needs)) {
182
+ throw new TypeError('CI aggregate must define needs')
183
+ }
184
+ // The split native jobs all resolve their pool through the Windows switch.
185
+ for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests], ['windows-observational', windowsObservational]] as const) {
186
+ expect(typeof job['runs-on']).toBe('string')
187
+ expect(job['runs-on'], `${jobName} runs-on must use the Windows failover switch`).toContain('DSH_CI_FAILOVER_WINDOWS')
188
+ expect(job['runs-on'], `${jobName} runs-on must not use the Linux failover switch`).not.toContain('DSH_CI_FAILOVER_LINUX')
189
+ expect(job['runs-on']).toContain('self-hosted')
190
+ expect(job['runs-on']).toContain('dsh-win-ci')
191
+ expect(job['runs-on']).toContain('dsh-windows-2025-16core')
192
+ expect(job['runs-on']).toContain('blacksmith-16vcpu-windows-2025')
193
+ expect(job.if).toBe("github.event_name == 'pull_request'")
194
+ }
195
+
196
+ // windows-build runs the blocking build/site pair.
197
+ expect(windowsBuild.name).toBe('windows node 24 / build')
198
+ const buildSteps = windowsBuild.steps as unknown[]
199
+ const buildCommands = buildSteps.filter((step): step is Record<string, unknown> & { run: string } => (
200
+ isRecord(step) && typeof step.run === 'string'
201
+ ))
202
+ expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking')
203
+
204
+ // The four native Windows installs branch on the workspace filesystem:
205
+ // clone (ReFS block clone) only on ReFS, plain install elsewhere. This
206
+ // keeps the TS6231 store-path leak (see the Windows ReFS store note) out
207
+ // of the self-hosted pool without forcing clone onto hosted NTFS, which
208
+ // rejects copy-on-write. The branch must stay, or a hosted fallback would
209
+ // fail installs with ERR_PNPM_LINKING_FAILED.
210
+ for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests], ['windows-observational', windowsObservational]] as const) {
211
+ const steps = job.steps as unknown[]
212
+ const install = steps.find((step): step is Record<string, unknown> & { run: string } => (
213
+ isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string'
214
+ ))
215
+ expect(install, `${jobName} must define the filesystem-branched install`).toBeDefined()
216
+ expect(install!.run).toContain("$fs -eq 'ReFS'")
217
+ expect(install!.run).toContain('--package-import-method=clone')
218
+ expect(install!.run).toContain('corepack pnpm install')
219
+ // The else branch must keep the plain hosted install as a distinct line
220
+ // (not the corepack clone line, which contains the same substring);
221
+ // dropping it or making both branches clone would force clone onto
222
+ // NTFS, which rejects copy-on-write (ERR_PNPM_LINKING_FAILED). The
223
+ // YAML folded block keeps the first statement on line 1 and folds the
224
+ // rest with leading two-space indents.
225
+ const installLines = install!.run.split('\n').map(line => line.trim())
226
+ expect(installLines).toContain('} else {')
227
+ expect(installLines.some(line => line === 'pnpm install --frozen-lockfile'), `${jobName} else branch must keep the plain hosted install`).toBe(true)
228
+ // The ReFS branch must not use the interpolated empty-flag form, which
229
+ // passes a stray "" positional argument to pnpm.
230
+ expect(install!.run).not.toContain('$cloneFlag')
231
+ }
232
+
233
+ // windows-coverage uses the lower 4-partition profile.
234
+ expect(windowsCoverage.name).toBe('windows node 24 / coverage')
235
+ expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' })
236
+ const coverageSteps = windowsCoverage.steps as unknown[]
237
+ const coverageCommands = coverageSteps.filter((step): step is Record<string, unknown> & { run: string } => (
238
+ isRecord(step) && typeof step.run === 'string'
239
+ ))
240
+ expect(coverageCommands.map(step => step.run)).toContain('pnpm run check:ci:coverage')
241
+ // Windows coverage runs zero-build like the Linux lane: workspace imports
242
+ // resolve to src through the tsconfig paths map, and the lib-consuming
243
+ // suites (webworker-packer image-loadable, webworker-runtime
244
+ // transform-corpus, client ui-trajectory client-bundle) self-skip on
245
+ // unbuilt checkouts. The regex catches a regression spelled as
246
+ // 'corepack pnpm run build' or folded into a multi-line run block, which
247
+ // an exact string match would miss.
248
+ expect(coverageCommands.every(step => !/\bpnpm\s+run\s+build(?:\s|$)/.test(step.run))).toBe(true)
249
+
250
+ // windows-native-tests runs the Windows-specific specs.
251
+ expect(windowsNativeTests.name).toBe('windows node 24 / native tests')
252
+ const nativeTestSteps = windowsNativeTests.steps as unknown[]
253
+ const nativeTestCommands = nativeTestSteps.filter((step): step is Record<string, unknown> & { run: string } => (
254
+ isRecord(step) && typeof step.run === 'string'
255
+ ))
256
+ const nativeTestCommand = nativeTestCommands.map(step => step.run).join('\n')
257
+ expect(nativeTestCommand).toContain('--no-file-parallelism')
258
+ expect(nativeTestCommand).toContain('--testTimeout 90000')
259
+ expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts')
260
+ expect(nativeTestCommand).toContain('workflow-ptc.spec.ts')
261
+
262
+ // windows-observational is non-blocking.
263
+ expect(windowsObservational.name).toBe('windows node 24 / observational')
264
+ expect(windowsObservational['continue-on-error']).toBe(true)
265
+
266
+ // serial-windows: master-only standby, self-hosted, non-blocking, lives in ci-master.
267
+ expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
268
+ expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows'])
269
+ expect(serialWindows.name).toBe('serial / windows (self-hosted standby)')
270
+ // Its store must share the ReFS workspace volume for clone; the install
271
+ // must carry the same filesystem branch as the PR jobs.
272
+ const serialSteps = serialWindows.steps as unknown[]
273
+ const serialStore = serialSteps.find((step): step is Record<string, unknown> & { run: string } => (
274
+ isRecord(step) && step.name === 'Configure persistent pnpm store' && typeof step.run === 'string'
275
+ ))
276
+ expect(serialStore).toBeDefined()
277
+ expect(serialStore!.run).toContain('[IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)')
278
+ expect(serialStore!.run).toContain("'.pnpm-store'")
279
+ const serialInstall = serialSteps.find((step): step is Record<string, unknown> & { run: string } => (
280
+ isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string'
281
+ ))
282
+ expect(serialInstall).toBeDefined()
283
+ expect(serialInstall!.run).toContain("$fs -eq 'ReFS'")
284
+ expect(serialInstall!.run).toContain('--package-import-method=clone')
285
+ expect(serialInstall!.run).toContain('corepack pnpm install')
286
+ // Distinct else-branch line, as for the PR jobs: the corepack clone line
287
+ // contains the plain-install substring too.
288
+ expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('} else {')
289
+ expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('pnpm install --frozen-lockfile')
290
+ expect(serialInstall!.run).not.toContain('$cloneFlag')
291
+ // The unsharded reference runs the whole coverage inventory at the same
292
+ // per-test budget the PR coverage lane grants; the default 5000ms times
293
+ // out load-sensitive store scans (e.g. gen-third-party-notices).
294
+ const serialGate = serialSteps.find((step): step is Record<string, unknown> & { env?: Record<string, unknown> } => (
295
+ isRecord(step) && step.name === 'Run complete unsharded Windows gate inventory serially'
296
+ ))
297
+ expect(serialGate).toBeDefined()
298
+ expect(serialGate!.env).toMatchObject({ DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' })
299
+
300
+ // windows-coverage is temporarily non-blocking while Windows ACP
301
+ // half-close tests are stabilized; observational stays out too.
302
+ expect(aggregate.needs).not.toContain('windows')
303
+ expect(aggregate.needs).toContain('windows-build')
304
+ // The benchmark lane is a required verdict input and runs alone so its
305
+ // wall-clock budgets never share a runner with a concurrent aggregate.
306
+ expect(aggregate.needs).toContain('node-24-bench')
307
+ expect(node24Bench.name).toBe('node 24 / benchmarks')
308
+ expect(node24Bench.env).toBeUndefined()
309
+ expect(node24Bench.steps).toContainEqual({
310
+ name: 'Install benchmark browser and hosted dependencies',
311
+ run: 'pnpm --filter @deepseek-ai/dsh-benchmarks exec playwright install --with-deps chromium',
312
+ })
313
+ expect(JSON.stringify(node24Bench.steps)).not.toContain('DSH_CI_FAILOVER_LINUX')
314
+ expect(node24Bench.steps).toContainEqual({
315
+ name: 'Run performance benchmarks',
316
+ env: { DSH_GATE_VERBOSE: '1' },
317
+ run: 'pnpm run check:ci:bench',
318
+ })
319
+ expect(aggregate.needs).not.toContain('windows-coverage')
320
+ expect(aggregate.needs).toContain('windows-native-tests')
321
+ expect(aggregate.needs).not.toContain('windows-observational')
322
+ expect(aggregate.needs).not.toContain('serial-windows')
323
+
324
+ // Linux failover is a separate switch: the three enterprise Linux workers
325
+ // and the verdict job resolve their pool through DSH_CI_FAILOVER_LINUX,
326
+ // never the Windows switch.
327
+ for (const [jobName, job] of [['node-24', node24], ['node-24-coverage', node24Coverage], ['node-24-consumers', node24Consumers]] as const) {
328
+ expect(typeof job['runs-on']).toBe('string')
329
+ expect(job['runs-on'], `${jobName} runs-on must use the Linux failover switch`).toContain('DSH_CI_FAILOVER_LINUX')
330
+ expect(job['runs-on'], `${jobName} runs-on must not use the Windows failover switch`).not.toContain('DSH_CI_FAILOVER_WINDOWS')
331
+ expect(job['runs-on']).toContain('vm-backup')
332
+ expect(job['runs-on']).toContain('blacksmith-16vcpu-ubuntu-2404')
333
+ }
334
+ expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
335
+ expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
336
+ expect(aggregate['runs-on']).toContain('vm-backup')
337
+ expect(aggregate['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
338
+
339
+ // Evaluating the full selector, not just substring containment, proves the
340
+ // blacksmith branch is standalone: it must not fall through to the
341
+ // self-hosted pool when the two values are mutually exclusive.
342
+ const selectors = {
343
+ linux: node24['runs-on'] as string,
344
+ linuxAggregate: aggregate['runs-on'] as string,
345
+ windows: windowsBuild['runs-on'] as string,
346
+ }
347
+ const evaluate = (expression: string, vars: Record<string, string>, login = 'maintainer'): unknown => {
348
+ return evaluateRunsOn(expression, {
349
+ vars,
350
+ fromJSON: JSON.parse,
351
+ github: { event: { pull_request: { user: { login } } } },
352
+ })
353
+ }
354
+ for (const [name, selector, variable, pool, hosted] of [
355
+ ['linux gates', selectors.linux, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'dsh-ubuntu-24-04-16core'],
356
+ ['linux aggregate', selectors.linuxAggregate, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'ubuntu-latest'],
357
+ ['windows lanes', selectors.windows, 'DSH_CI_FAILOVER_WINDOWS', ['self-hosted', 'dsh-win-ci', 'windows'], 'dsh-windows-2025-16core'],
358
+ ] as const) {
359
+ expect(evaluate(selector, { [variable]: 'blacksmith' }), `${name} blacksmith value`).toMatch(/^blacksmith-/)
360
+ expect(evaluate(selector, { [variable]: 'selfhosted' }), `${name} selfhosted value`).toEqual(pool)
361
+ // The blacksmith branch must not capture the selfhosted pool, and the
362
+ // dependabot exclusion applies to the pool, not to the blacksmith tier.
363
+ expect(evaluate(selector, { [variable]: 'selfhosted' }, 'dependabot[bot]'), `${name} dependabot on selfhosted`).toBe(hosted)
364
+ for (const mode of ['', 'hosted', 'unexpected']) {
365
+ expect(evaluate(selector, { [variable]: mode }), `${name} default on ${mode}`).toBe(hosted)
366
+ }
367
+ }
368
+
369
+ // The run-gates aggregate lanes stop at the first blocking gate failure so
370
+ // a red aggregate does not keep burning runner time on the remaining
371
+ // gates. Removing the flag silently reverts to running every independent
372
+ // gate to completion.
373
+ for (const [jobName, job] of [['node-24', node24], ['node-24-coverage', node24Coverage], ['node-24-consumers', node24Consumers], ['node-compat', nodeCompat]] as const) {
374
+ expect(job.env, `${jobName} must enable fail-fast`).toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
375
+ }
376
+
377
+ // The native Windows lanes with run-gates aggregates fail fast for the
378
+ // same reason: a failing gate aborts the sibling gate instead of waiting
379
+ // out the multi-minute instrumented coverage run.
380
+ expect(windowsBuild.env, 'windows-build must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
381
+ expect(windowsCoverage.env, 'windows-coverage must enable fail-fast').toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
382
+
383
+ // The observational lane stays complete: it is continue-on-error by design
384
+ // and exists to collect as much Windows-native evidence per run as
385
+ // possible, so the first failure must not truncate the rest.
386
+ expect(windowsObservational.env).toBeDefined()
387
+ expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
388
+ })
389
+
390
+ it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => {
391
+ const expectedFilenames = workflowJob(loadWorkflow('.github/workflows/expected-filenames.yml'), 'expected-filenames')
392
+ const sandbox = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'sandbox-e2e')
393
+ expect(expectedFilenames['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
394
+ expect(expectedFilenames['runs-on']).toContain("== 'blacksmith'")
395
+ expect(expectedFilenames['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
396
+ expect(expectedFilenames['runs-on']).toContain("'ubuntu-latest'")
397
+ expect(sandbox['runs-on']).toContain("matrix.runner == 'bwrap'")
398
+ expect(sandbox['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
399
+ expect(sandbox['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
400
+ for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark'] as const) {
401
+ const benchmark = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), name)
402
+ if (!isRecord(benchmark.strategy) || !isRecord(benchmark.strategy.matrix) || !Array.isArray(benchmark.strategy.matrix.include)) {
403
+ throw new TypeError(`${name} must define a matrix include list`)
404
+ }
405
+ expect(benchmark['runs-on']).toContain('matrix.blacksmith')
406
+ expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
407
+ expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS')
408
+ for (const row of benchmark.strategy.matrix.include as Array<Record<string, string>>) {
409
+ expect(typeof row.blacksmith, `${name} ${row.cores}-core row must declare a blacksmith label`).toBe('string')
410
+ if (row.cores === '64' || row.cores === '96') {
411
+ expect(row.blacksmith, `${name} ${row.cores}-core row has no Blacksmith tier`).toBe('')
412
+ } else {
413
+ expect(row.blacksmith).toContain(`blacksmith-${row.cores}vcpu`)
414
+ }
415
+ }
416
+ }
417
+ })
418
+
419
+ it('runs required benchmarks on standard hosted Linux independently of failover', () => {
420
+ const workflow = loadWorkflow('.github/workflows/ci.yml')
421
+ const benchmark = workflowJob(workflow, 'node-24-bench')
422
+ const aggregate = workflowJob(workflow, 'all-checks-passed')
423
+
424
+ expect(benchmark['runs-on']).toBe('ubuntu-24.04')
425
+ expect(benchmark.if).toBe("github.event_name == 'pull_request'")
426
+ expect(benchmark.needs).toBeUndefined()
427
+ expect(benchmark['continue-on-error']).toBeUndefined()
428
+ expect(benchmark.env).toBeUndefined()
429
+ expect(aggregate.needs).toContain('node-24-bench')
430
+ })
431
+
432
+ it('always restores the hosted benchmark pnpm cache', () => {
433
+ const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench')
434
+ if (!Array.isArray(benchmark.steps)) throw new TypeError('benchmark job must define steps')
435
+ const caches = benchmark.steps.filter(step => isRecord(step) && step.uses === 'actions/cache/restore@v4')
436
+
437
+ expect(caches).toHaveLength(1)
438
+ expect(caches[0]).not.toHaveProperty('if')
439
+ expect(caches[0]).toMatchObject({
440
+ with: {
441
+ path: '${{ steps.pnpm-store.outputs.path }}',
442
+ key: "${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}",
443
+ },
444
+ })
445
+ })
446
+
447
+ it('bounds the complete benchmark job to fifteen minutes', () => {
448
+ const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench')
449
+
450
+ expect(benchmark['timeout-minutes']).toBe(15)
451
+ expect(benchmark.steps).toContainEqual({
452
+ name: 'Run performance benchmarks',
453
+ env: { DSH_GATE_VERBOSE: '1' },
454
+ run: 'pnpm run check:ci:bench',
455
+ })
456
+ })
457
+
458
+ it('gives the Wine Host TypeScript compile the repository heap budget', () => {
459
+ const wineGates = readFileSync(resolve(root, 'scripts/wine-windows-gates.sh'), 'utf8')
460
+
461
+ expect(wineGates).toContain(
462
+ 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false',
463
+ )
464
+ })
465
+
466
+ it('cancels superseded master runs without changing the post-merge job inventory', () => {
467
+ const workflow = loadWorkflow('.github/workflows/ci-master.yml')
468
+ const prWorkflow = loadWorkflow('.github/workflows/ci.yml')
469
+ if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) {
470
+ throw new TypeError('ci-master workflow must define jobs and a workflow-level concurrency block')
471
+ }
472
+ if (!isRecord(prWorkflow.jobs)) {
473
+ throw new TypeError('ci workflow must define jobs')
474
+ }
475
+
476
+ expect(workflow.concurrency).toEqual({
477
+ group: '${{ github.workflow }}-${{ github.ref }}',
478
+ 'cancel-in-progress': true,
479
+ })
480
+ expect(prWorkflow.concurrency).toEqual(workflow.concurrency)
481
+
482
+ // The exact event sets are what keep master-only jobs out of the PR check
483
+ // panel: ci-master triggers only on push(master) + workflow_dispatch and
484
+ // never on pull_request; ci.yml is exactly pull_request-only. Assert the
485
+ // full sets so losing the wrong event, or gaining an extra one, fails.
486
+ if (!isRecord(workflow.on) || !isRecord(prWorkflow.on)) {
487
+ throw new TypeError('both CI workflows must define on')
488
+ }
489
+ expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch'])
490
+ expect(Object.keys(prWorkflow.on)).toEqual(['pull_request'])
491
+
492
+ // Drills share the parent run’s supersession policy.
493
+ for (const name of ['serial-linux-selfhosted', 'serial-windows']) {
494
+ const job = workflow.jobs[name]
495
+ if (!isRecord(job)) throw new TypeError(`${name} must be defined`)
496
+ expect(job.concurrency).toBeUndefined()
497
+ // Standby drills remain post-merge work, but share run cancellation.
498
+ expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
499
+ }
500
+
501
+ // Pin the post-merge runtime, Wine, and standby inventory.
502
+ const NOT_PUSH_REACHABLE = new Set([
503
+ "github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'",
504
+ "github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'",
505
+ ])
506
+ const pushReachable = Object.entries(workflow.jobs)
507
+ .filter(([, job]) => {
508
+ if (!isRecord(job)) return false
509
+ if (job.if === undefined) return true // unconditional: runs on every event
510
+ if (job.if === false) return false // `if: false` parses as a boolean
511
+ if (typeof job.if !== 'string') return true // unrecognized shape: surface it
512
+ return !NOT_PUSH_REACHABLE.has(job.if.trim())
513
+ })
514
+ .map(([name]) => name)
515
+ .sort()
516
+ expect(pushReachable).toEqual(['python-runtime', 'serial-linux-selfhosted', 'serial-windows', 'windows'])
517
+
518
+ // Manual benchmarks retain their bounded fan-out.
519
+ for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark']) {
520
+ const job = workflow.jobs[name]
521
+ if (!isRecord(job) || !isRecord(job.strategy)) {
522
+ throw new TypeError(`${name} must define a matrix strategy`)
523
+ }
524
+ expect(job.strategy['max-parallel']).toBe(12)
525
+ expect(job['timeout-minutes']).toBe(15)
526
+ }
527
+ })
528
+
529
+ it('redirects the Node compile cache to the data-volume runner temp before the first pnpm call', () => {
530
+ const prWorkflow = loadWorkflow('.github/workflows/ci.yml')
531
+ const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
532
+ const redirectLanes = [
533
+ [prWorkflow, 'node-24'],
534
+ [prWorkflow, 'node-24-coverage'],
535
+ [prWorkflow, 'node-24-consumers'],
536
+ [masterWorkflow, 'serial-linux-selfhosted'],
537
+ ] as const
538
+ for (const [workflow, jobKey] of redirectLanes) {
539
+ const job = workflowJob(workflow, jobKey)
540
+ if (!Array.isArray(job.steps)) throw new TypeError(`${jobKey} must define steps`)
541
+ const redirectStepIndex = job.steps.findIndex((step): step is Record<string, unknown> & { run: string } => (
542
+ isRecord(step) && typeof step.run === 'string'
543
+ && step.run.includes('NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache')
544
+ && step.run.includes('"$GITHUB_ENV"')
545
+ ))
546
+ // Removing this injection would send every pnpm call in the lane (setup,
547
+ // store-path probe, install, and the gate) back to the root partition's
548
+ // /tmp; rationale in
549
+ // .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
550
+ expect(redirectStepIndex, `${jobKey} must inject NODE_COMPILE_CACHE into GITHUB_ENV`).toBeGreaterThan(-1)
551
+ const pnpmSetupIndex = job.steps.findIndex((step): step is Record<string, unknown> & { uses: string } => (
552
+ isRecord(step) && typeof step.uses === 'string' && step.uses.includes('pnpm/action-setup')
553
+ ))
554
+ expect(pnpmSetupIndex, `${jobKey} must run pnpm/action-setup`).toBeGreaterThan(-1)
555
+ expect(redirectStepIndex, `${jobKey} must redirect before pnpm/action-setup runs pnpm`).toBeLessThan(pnpmSetupIndex)
556
+ }
557
+ })
558
+
559
+ it('keeps supported LSP source under native Windows coverage', () => {
560
+ const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
561
+
562
+ expect(config).not.toContain('packages/lsp/lsp-stdio/src/connection.ts')
563
+ expect(config).not.toContain('packages/lsp/lsp-stdio/src/index.ts')
564
+ expect(config).not.toContain('packages/lsp/lsp-stdio/src/instance.ts')
565
+ })
566
+
567
+ it('requires release-shaped Python runtime validation on Linux and Windows x64', () => {
568
+ const workflow = loadWorkflow('.github/workflows/ci.yml')
569
+ const pythonRuntime = workflowJob(workflow, 'python-runtime')
570
+ const aggregate = workflowJob(workflow, 'all-checks-passed')
571
+ if (!Array.isArray(aggregate.needs)) {
572
+ throw new TypeError('CI aggregate must define required job dependencies')
573
+ }
574
+
575
+ expect(pythonRuntime).toMatchObject({
576
+ if: "github.event_name == 'pull_request'",
577
+ name: 'python runtime / release-shaped matrix',
578
+ uses: './.github/workflows/build-exe-for-python-sdk.yml',
579
+ with: {
580
+ targets: 'node24-linux-x64,node24-win-x64',
581
+ ci: true,
582
+ },
583
+ secrets: {
584
+ DEEPSEEK_API_KEY_EXTERNAL: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}',
585
+ },
586
+ })
587
+ expect(aggregate.needs).toContain('python-runtime')
588
+ })
589
+
590
+ it('keeps every Vitest project process-isolated on native Windows', () => {
591
+ const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
592
+
593
+ expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'")
594
+ expect(config.match(/pool: 'forks'/g)).toHaveLength(2)
595
+ })
596
+ })
597
+
598
+ describe('Runtime and LLM e2e Blacksmith routing', () => {
599
+ it('routes DeepSeek e2e only through the Linux Blacksmith switch', () => {
600
+ const job = workflowJob(loadWorkflow('.github/workflows/e2e.yml'), 'e2e')
601
+ for (const mode of ['', 'selfhosted', 'unexpected', 'blacksmith']) {
602
+ expect(evaluateRunsOn(job['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: mode, DSH_CI_FAILOVER_WINDOWS: 'blacksmith' } }))
603
+ .toBe(mode === 'blacksmith' ? 'blacksmith-4vcpu-ubuntu-2404' : 'ubuntu-latest')
604
+ }
605
+ })
606
+
607
+ it('keeps native release and dispatch builders hosted while routing x64 CI by platform', () => {
608
+ const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
609
+ const build = workflowJob(workflow, 'build')
610
+ for (const [target, runner, variable, blacksmith] of [
611
+ ['node24-linux-x64', 'ubuntu-latest', 'DSH_CI_FAILOVER_LINUX', 'blacksmith-16vcpu-ubuntu-2404'],
612
+ ['node24-win-x64', 'windows-2025', 'DSH_CI_FAILOVER_WINDOWS', 'blacksmith-16vcpu-windows-2025'],
613
+ ['node24-linux-arm64', 'ubuntu-24.04-arm', 'DSH_CI_FAILOVER_LINUX', 'ubuntu-24.04-arm'],
614
+ ['node24-macos-arm64', 'macos-latest', 'DSH_CI_FAILOVER_LINUX', 'macos-latest'],
615
+ ['node24-macos-x64', 'macos-15-intel', 'DSH_CI_FAILOVER_LINUX', 'macos-15-intel'],
616
+ ] as const) {
617
+ for (const ci of [false, true]) {
618
+ for (const release of [false, true]) {
619
+ for (const mode of ['', 'selfhosted', 'unexpected', 'blacksmith']) {
620
+ const vars = { DSH_CI_FAILOVER_LINUX: 'blacksmith', DSH_CI_FAILOVER_WINDOWS: 'blacksmith', [variable]: mode }
621
+ expect(evaluateRunsOn(build['runs-on'], { inputs: { ci, release }, vars, matrix: { target, runner } }), `${target} ci=${ci} release=${release} mode=${mode}`)
622
+ .toBe(ci && !release && mode === 'blacksmith' ? blacksmith : runner)
623
+ }
624
+ }
625
+ }
626
+ }
627
+ })
628
+
629
+ it.each(['plan', 'sdk-wheel'])('routes runtime %s only for non-release CI', (name) => {
630
+ const job = workflowJob(loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml'), name)
631
+ for (const ci of [false, true]) {
632
+ for (const release of [false, true]) {
633
+ for (const mode of ['', 'selfhosted', 'unexpected', 'blacksmith']) {
634
+ expect(evaluateRunsOn(job['runs-on'], { inputs: { ci, release }, vars: { DSH_CI_FAILOVER_LINUX: mode } }))
635
+ .toBe(ci && !release && mode === 'blacksmith' ? 'blacksmith-4vcpu-ubuntu-2404' : 'ubuntu-latest')
636
+ }
637
+ }
638
+ }
639
+ })
640
+ })
641
+
642
+ describe('DeepSeek e2e workflow', () => {
643
+ it('prepares bubblewrap from the pinned payload without a package transaction', () => {
644
+ const workflow = loadWorkflow('.github/workflows/e2e.yml')
645
+ const e2e = workflowJob(workflow, 'e2e')
646
+ if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
647
+
648
+ const steps = e2e.steps.filter(isRecord)
649
+ expect(steps.find(step => step.name === 'Prepare bubblewrap (unrestrict userns)')).toMatchObject({
650
+ run: 'bash scripts/prepare-ci-bubblewrap.sh',
651
+ })
652
+ expect(JSON.stringify(steps)).not.toContain('apt-get')
653
+ })
654
+
655
+ it('bounds profile subprocess fan-out to the tested e2e default', () => {
656
+ const workflow = loadWorkflow('.github/workflows/e2e.yml')
657
+ const e2e = workflowJob(workflow, 'e2e')
658
+ if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
659
+
660
+ const step = e2e.steps.filter(isRecord).find(candidate => candidate.name === 'E2E tests (real DeepSeek API)')
661
+ expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 4 } })
662
+ })
663
+ })
664
+
665
+ describe('Python release workflows', () => {
666
+ it('keeps complete wheel validation separate from protected public publication', () => {
667
+ const workflow = loadWorkflow('.github/workflows/python-release.yml')
668
+ const dispatch = workflowEvent(workflow, 'workflow_dispatch')
669
+ const build = workflowJob(workflow, 'build')
670
+ const pythonCompat = workflowJob(workflow, 'python-compat')
671
+ const validate = workflowJob(workflow, 'validate')
672
+ const publishRuntime = workflowJob(workflow, 'publish-runtime')
673
+ const publishSdk = workflowJob(workflow, 'publish-sdk')
674
+ if (!isRecord(dispatch.inputs)
675
+ || !isRecord(dispatch.inputs.publish)
676
+ || !Array.isArray(pythonCompat.steps)
677
+ || !Array.isArray(validate.steps)
678
+ || !Array.isArray(publishRuntime.steps)
679
+ || !Array.isArray(publishSdk.steps)) {
680
+ throw new TypeError('Python release workflow must define publish input and release steps')
681
+ }
682
+
683
+ expect(dispatch.inputs.publish).toMatchObject({ type: 'boolean', default: false })
684
+ if (!isRecord(workflow.on)) throw new TypeError('python-release workflow must define on')
685
+ expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
686
+ expect(build).toMatchObject({
687
+ uses: './.github/workflows/build-exe-for-python-sdk.yml',
688
+ with: {
689
+ targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64',
690
+ release: true,
691
+ },
692
+ })
693
+ expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } })
694
+ const pythonCompatSteps = JSON.stringify(pythonCompat.steps)
695
+ expect(pythonCompatSteps).toContain('dist/deepseek_harness_sdk-$VERSION-py3-none-any.whl')
696
+ expect(pythonCompatSteps).toContain('dist/deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl')
697
+ expect(pythonCompatSteps).not.toContain('--find-links')
698
+ const validateSteps = JSON.stringify(validate.steps)
699
+ const authorize = validate.steps.filter(isRecord).find(step => step.name === 'Authorize publication request')
700
+ if (!isRecord(authorize) || typeof authorize.run !== 'string') {
701
+ throw new TypeError('Python release validation must authorize publication requests')
702
+ }
703
+ expect(validateSteps).toContain('PUBLIC_PYPI_RELEASE_ENABLED')
704
+ expect(authorize).toMatchObject({
705
+ env: {
706
+ PYPI_PUBLISHER_REPOSITORY: '${{ vars.PYPI_PUBLISHER_REPOSITORY }}',
707
+ REPOSITORY: '${{ github.repository }}',
708
+ },
709
+ })
710
+ expect(authorize.run).toContain('[ "$REPOSITORY" = "$PYPI_PUBLISHER_REPOSITORY" ]')
711
+ expect(validateSteps).toContain('100000000')
712
+ expect(publishRuntime).toMatchObject({
713
+ if: "github.event_name == 'workflow_dispatch' && inputs.publish",
714
+ needs: 'validate',
715
+ environment: 'pypi-runtime',
716
+ permissions: { contents: 'read', 'id-token': 'write' },
717
+ })
718
+ expect(publishSdk).toMatchObject({
719
+ if: "github.event_name == 'workflow_dispatch' && inputs.publish",
720
+ needs: ['validate', 'publish-runtime'],
721
+ environment: 'pypi',
722
+ permissions: { contents: 'read', 'id-token': 'write' },
723
+ })
724
+ const runtimeSteps = publishRuntime.steps.filter(isRecord)
725
+ const sdkSteps = publishSdk.steps.filter(isRecord)
726
+ const runtimePublish = runtimeSteps.find(step => step.name === 'Publish runtime wheels')
727
+ const sdkPublish = sdkSteps.find(step => step.name === 'Publish SDK wheel')
728
+ const runtimeHashes = runtimeSteps.find(step => step.name === 'Verify release artifact hashes')
729
+ const sdkHashes = sdkSteps.find(step => step.name === 'Verify release artifact hashes')
730
+ expect([...runtimeSteps, ...sdkSteps].some(
731
+ step => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'),
732
+ )).toBe(false)
733
+ expect([...runtimeSteps, ...sdkSteps].filter(
734
+ step => step.uses === 'pypa/gh-action-pypi-publish@release/v1',
735
+ )).toHaveLength(2)
736
+ expect(runtimePublish).toMatchObject({
737
+ with: { 'packages-dir': 'dist/runtime/', attestations: false },
738
+ })
739
+ expect(sdkPublish).toMatchObject({
740
+ with: { 'packages-dir': 'dist/sdk/', attestations: false },
741
+ })
742
+ expect(runtimeHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' })
743
+ expect(sdkHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' })
744
+ })
745
+
746
+ it('exposes the native wheel builder to the release caller with normalized versions', () => {
747
+ const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
748
+ expect(Object.keys(workflow.on as Record<string, unknown>).sort()).toEqual(['workflow_call', 'workflow_dispatch'])
749
+ const call = workflowEvent(workflow, 'workflow_call')
750
+ const plan = workflowJob(workflow, 'plan')
751
+ const build = workflowJob(workflow, 'build')
752
+ if (!isRecord(call.inputs) || !isRecord(call.secrets) || !Array.isArray(plan.steps) || !Array.isArray(build.steps)) {
753
+ throw new TypeError('Python wheel builder must define workflow_call inputs and plan steps')
754
+ }
755
+
756
+ const buildSteps: unknown[] = build.steps
757
+ const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28')
758
+ const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS payload architecture and deployment target')
759
+ const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container')
760
+ const cleanVenvPosix = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (POSIX)')
761
+ const cleanVenvWindows = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (Windows)')
762
+ const installedKeylessPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests (POSIX)')
763
+ const installedKeylessWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests (Windows)')
764
+ const realApiPreflightPosix = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (POSIX)')
765
+ const realApiPreflightWindows = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (Windows)')
766
+ const installedRealApiPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (POSIX)')
767
+ const installedRealApiWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (Windows)')
768
+ if (!isRecord(macosCheck) || typeof macosCheck.run !== 'string'
769
+ || !isRecord(cleanVenvPosix) || !isRecord(cleanVenvWindows)
770
+ || !isRecord(installedKeylessPosix) || !isRecord(installedKeylessWindows)
771
+ || !isRecord(realApiPreflightPosix) || !isRecord(realApiPreflightWindows)
772
+ || !isRecord(installedRealApiPosix) || !isRecord(installedRealApiWindows)) {
773
+ throw new TypeError('Python wheel builder must define native POSIX and Windows installed-wheel steps')
774
+ }
775
+ expect(call.inputs).toHaveProperty('targets')
776
+ expect(call.inputs).toMatchObject({
777
+ ci: { type: 'boolean', default: false },
778
+ release: { type: 'boolean', default: false },
779
+ })
780
+ expect(call.secrets).toMatchObject({
781
+ DEEPSEEK_API_KEY_EXTERNAL: { required: false },
782
+ })
783
+ expect(workflow.concurrency).toMatchObject({
784
+ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}',
785
+ })
786
+ expect(build.defaults).toBeUndefined()
787
+ expect(plan.if).toContain('inputs.ci')
788
+ expect(plan.if).toContain('inputs.release')
789
+ expect(JSON.stringify(plan.steps)).toContain('pep440_version')
790
+ const workflowJson = JSON.stringify(workflow)
791
+ expect(workflowJson).toContain('macosx_14_0_arm64')
792
+ expect(workflowJson).toContain('macosx_14_0_x86_64')
793
+ expect(workflowJson).toContain('node24-macos-x64')
794
+ expect(workflowJson).toContain('macos-15-intel')
795
+ expect(workflowJson).toContain('win_amd64')
796
+ expect(workflowJson).toContain('node24-win-x64')
797
+ expect(workflowJson).toContain('windows-2025')
798
+ expect(workflowJson).toContain('dist-python/$SDK_WHEEL')
799
+ expect(workflowJson).toContain('dist-python/$RUNTIME_WHEEL')
800
+ expect(workflowJson).toContain('/work/dist-python/$SDK_WHEEL')
801
+ expect(workflowJson).toContain('/work/dist-python/$RUNTIME_WHEEL')
802
+ expect(workflowJson).not.toContain('--find-links dist-python')
803
+ expect(workflowJson).not.toContain('--find-links /work/dist-python')
804
+ expect(workflowJson).not.toContain('cygpath')
805
+ expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" })
806
+ expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64')
807
+ expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64')
808
+ expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true pnpm run install')
809
+ expect(JSON.stringify(manylinuxAddon)).toContain('pnpm_setup_root')
810
+ expect(JSON.stringify(manylinuxAddon)).toContain('$pnpm_setup_root:$pnpm_setup_root:ro')
811
+ expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt')
812
+ expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28')
813
+ expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" })
814
+ expect(macosCheck.run).toContain('scripts/check-macos-deployment-target.py')
815
+ expect(macosCheck.run).toContain('lipo "$payload" -verify_arch')
816
+ expect(macosCheck.run).toContain('$EXE-rg')
817
+ expect(macosCheck.run).toContain('$EXE-spawn-helper')
818
+ expect(JSON.stringify(installedKeylessPosix)).toContain('--scenario all')
819
+ expect(JSON.stringify(installedKeylessPosix)).toContain('env -u PYTHONPATH')
820
+ expect(JSON.stringify(installedKeylessWindows)).toContain('--scenario all --installed-wheel')
821
+ expect(installedKeylessWindows).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' })
822
+ expect(cleanVenvWindows).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' })
823
+ expect(JSON.stringify(cleanVenvWindows)).toContain('Scripts\\\\python.exe')
824
+ expect(realApiPreflightPosix).toMatchObject({
825
+ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' },
826
+ })
827
+ expect(String(realApiPreflightPosix.if)).toContain('inputs.ci')
828
+ expect(String(realApiPreflightPosix.if)).toContain('head.repo.fork')
829
+ expect(String(realApiPreflightPosix.if)).toContain('dependabot[bot]')
830
+ expect(realApiPreflightWindows).toMatchObject({ shell: 'pwsh' })
831
+ for (const step of [installedRealApiPosix, installedRealApiWindows]) {
832
+ expect(step).toMatchObject({
833
+ env: {
834
+ DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}',
835
+ DEEPSEEK_BASE_URL: 'https://api.deepseek.com/anthropic',
836
+ },
837
+ })
838
+ }
839
+ expect(JSON.stringify(installedRealApiPosix)).toContain('--scenario sdk-live')
840
+ expect(JSON.stringify(installedRealApiPosix)).toContain('-u DSH_RUNTIME_MODE')
841
+ expect(installedRealApiWindows).toMatchObject({ shell: 'pwsh' })
842
+ expect(JSON.stringify(installedRealApiWindows)).toContain('--scenario sdk-live --installed-wheel')
843
+ expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" })
844
+ expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED')
845
+ })
846
+
847
+ it('uses the shared macOS deployment-target check in GitLab', () => {
848
+ const workflow = loadWorkflow('.gitlab-ci.yml')
849
+ const runtimeWheel = workflow['.runtime-wheel']
850
+ if (!isRecord(runtimeWheel) || !Array.isArray(runtimeWheel.script)) {
851
+ throw new TypeError('GitLab CI must define the runtime wheel script')
852
+ }
853
+ const runtimeScript: unknown[] = runtimeWheel.script
854
+ const macosCheck = runtimeScript.find(
855
+ step => typeof step === 'string' && step.includes('${PLATFORM#macos-}'),
856
+ )
857
+ if (typeof macosCheck !== 'string') {
858
+ throw new TypeError('GitLab CI must check the macOS deployment target')
859
+ }
860
+
861
+ expect(macosCheck).toContain('scripts/check-macos-deployment-target.py')
862
+ expect(macosCheck).toContain('lipo "$payload" -verify_arch')
863
+ expect(macosCheck).toContain('"$EXE" "$EXE-rg" "$EXE-spawn-helper"')
864
+ })
865
+
866
+ it('builds the macOS x64 wheel on the matching GitLab runner', () => {
867
+ const workflow = loadWorkflow('.gitlab-ci.yml')
868
+ const macosX64 = workflow['runtime-macos-x64']
869
+ const publish = workflow['publish-python']
870
+ if (!isRecord(macosX64) || !isRecord(publish) || !Array.isArray(publish.needs)) {
871
+ throw new TypeError('GitLab CI must define the macOS x64 runtime and publication jobs')
872
+ }
873
+
874
+ expect(macosX64.tags).toEqual(['macos-x64'])
875
+ expect(macosX64.variables).toMatchObject({ PKG_TARGET: 'node24-macos-x64', PLATFORM: 'macos-x64' })
876
+ expect(publish.needs).toContainEqual({ job: 'runtime-macos-x64', artifacts: true })
877
+ expect(JSON.stringify(publish.script)).toContain('macosx_14_0_x86_64.whl')
878
+ })
879
+
880
+ it('builds and black-box tests the Windows x64 wheel in GitLab', () => {
881
+ const workflow = loadWorkflow('.gitlab-ci.yml')
882
+ const windows = workflow['runtime-windows-x64']
883
+ const publish = workflow['publish-python']
884
+ if (!isRecord(windows) || !Array.isArray(windows.before_script) || !Array.isArray(windows.script)
885
+ || !isRecord(publish) || !Array.isArray(publish.needs)) {
886
+ throw new TypeError('GitLab CI must define the Windows runtime and aggregate publication jobs')
887
+ }
888
+
889
+ expect(windows.tags).toEqual(['windows-x64'])
890
+ expect(windows.variables).toMatchObject({ PKG_TARGET: 'node24-win-x64', PLATFORM: 'win-x64' })
891
+ expect(JSON.stringify(windows.before_script)).toContain('.ci-python\\\\Scripts')
892
+ expect(JSON.stringify(windows.before_script)).toContain('[IO.Path]::PathSeparator')
893
+ expect(JSON.stringify(windows.script)).toContain('win_amd64.whl')
894
+ expect(JSON.stringify(windows.script)).toContain('--scenario all --installed-wheel')
895
+ expect(publish.needs).toContainEqual({ job: 'runtime-windows-x64', artifacts: true })
896
+ })
897
+ })
898
+
899
+ describe('Weighted approval workflow', () => {
900
+ it('publishes from the trusted default branch after pull request and review updates', () => {
901
+ const publisher = loadWorkflow('.github/workflows/weighted-approval.yml')
902
+ const reviewEvent = loadWorkflow('.github/workflows/weighted-approval-review-event.yml')
903
+ const pullRequest = workflowEvent(publisher, 'pull_request_target')
904
+ const workflowRun = workflowEvent(publisher, 'workflow_run')
905
+ const review = workflowEvent(reviewEvent, 'pull_request_review')
906
+ const job = workflowJob(publisher, 'publish-status')
907
+ const recordJob = workflowJob(reviewEvent, 'record-review-event')
908
+ if (!isRecord(publisher.on)) throw new TypeError('weighted-approval workflow must define events')
909
+ if (!isRecord(reviewEvent.on)) throw new TypeError('weighted-approval review event workflow must define events')
910
+ if (!Array.isArray(job.steps)) throw new TypeError('weighted-approval job must define steps')
911
+ if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event job must define steps')
912
+ const steps = job.steps.filter(isRecord)
913
+ const checkout = steps.find(step => step.name === 'Check out trusted approval policy')
914
+ const publish = steps.find(step => step.name === 'Publish weighted approval status')
915
+ const recordSteps = recordJob.steps.filter(isRecord)
916
+ const record = recordSteps.find(step => step.name === 'Record review event')
917
+
918
+ expect(publisher.name).toBe('weighted-approval')
919
+ expect(Object.keys(publisher.on)).toEqual(['pull_request_target', 'workflow_run'])
920
+ expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
921
+ expect(workflowRun).toEqual({ workflows: ['weighted-approval-review-event'], types: ['completed'] })
922
+ expect(reviewEvent.name).toBe('weighted-approval-review-event')
923
+ expect(reviewEvent['run-name']).toBe('weighted-approval-review-event:${{ github.event.pull_request.number }}')
924
+ expect(Object.keys(reviewEvent.on)).toEqual(['pull_request_review'])
925
+ expect(review.types).toEqual(['submitted', 'edited', 'dismissed'])
926
+ expect(reviewEvent.permissions).toEqual({})
927
+ expect(publisher.permissions).toEqual({
928
+ contents: 'read',
929
+ 'pull-requests': 'read',
930
+ statuses: 'write',
931
+ })
932
+ expect(publisher.concurrency).toEqual({
933
+ group: 'weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}',
934
+ 'cancel-in-progress': false,
935
+ })
936
+ expect(job).toMatchObject({
937
+ if: "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'",
938
+ name: 'weighted approval publisher',
939
+ 'runs-on': 'ubuntu-latest',
940
+ 'timeout-minutes': 5,
941
+ })
942
+ expect(checkout).toMatchObject({
943
+ uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1',
944
+ with: {
945
+ ref: '${{ github.event.repository.default_branch }}',
946
+ 'persist-credentials': false,
947
+ },
948
+ })
949
+ expect(publish).toMatchObject({
950
+ env: {
951
+ GITHUB_TOKEN: '${{ github.token }}',
952
+ GITHUB_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
953
+ },
954
+ run: 'node .github/review-ownership/check-approval.mjs',
955
+ })
956
+ expect(recordJob).toMatchObject({
957
+ name: 'record weighted approval review event',
958
+ 'runs-on': 'ubuntu-latest',
959
+ 'timeout-minutes': 2,
960
+ })
961
+ expect(record).toBeDefined()
962
+ expect(record?.run).toBe("echo 'Recorded a weighted approval review event.'")
963
+ expect(recordSteps).toHaveLength(1)
964
+ expect(JSON.stringify(publisher)).not.toContain('github.event.pull_request.head')
965
+ expect(JSON.stringify(publisher)).not.toContain('secrets.')
966
+ expect(JSON.stringify(reviewEvent)).not.toContain('github.token')
967
+ expect(JSON.stringify(reviewEvent)).not.toContain('secrets.')
968
+ })
969
+ })
970
+
971
+ describe('Issue lifecycle workflow', () => {
972
+ it('allocates lifecycle runners only for events that can change the board', () => {
973
+ const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
974
+ const policy = loadWorkflow('.github/workflows/issue-policy.yml')
975
+ const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
976
+ if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps')
977
+
978
+ expect(lifecycle.on).toHaveProperty('pull_request')
979
+ expect(lifecycle.on).toHaveProperty('pull_request_review')
980
+ expect(lifecycleJob.if).toContain("github.event.review.state == 'changes_requested'")
981
+ expect(lifecycleJob.if).toContain('github.event.changes.body != null')
982
+ // Keep the subscription-type gates: issue-lifecycle does not re-subscribe
983
+ // ready_for_review (issue-policy owns that) and only reacts to submitted
984
+ // review events.
985
+ const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
986
+ const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
987
+ expect(lifecyclePullRequest.types).toContain('opened')
988
+ expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
989
+ expect(lifecyclePullRequest.types).toContain('review_requested')
990
+ expect(lifecycleReview.types).toEqual(['submitted'])
991
+ expect(lifecyclePullRequest.types).not.toContain('synchronize')
992
+ expect(lifecyclePullRequest.types).not.toContain('labeled')
993
+ expect(lifecyclePullRequest.types).not.toContain('unlabeled')
994
+ const issueEvents = workflowEvent(lifecycle, 'issues')
995
+ expect(issueEvents.types).not.toContain('assigned')
996
+ expect(issueEvents.types).not.toContain('unassigned')
997
+ expect(issueEvents.types).toContain('typed')
998
+ expect(issueEvents.types).toContain('untyped')
999
+ const steps = lifecycleJob.steps.filter(isRecord)
1000
+ const tokenStep = steps.find(s => s.name === 'Create project token')
1001
+ const handleStep = steps.find(s => s.name === 'Handle repository event')
1002
+ expect(tokenStep?.if).toBeUndefined()
1003
+ expect(handleStep?.if).toBeUndefined()
1004
+
1005
+ // issue-policy owns PR validation; it is read-only and a real gate.
1006
+ const policyPullRequest = workflowEvent(policy, 'pull_request')
1007
+ expect(policyPullRequest.types).toContain('ready_for_review')
1008
+ })
1009
+
1010
+ it('mints Project credentials only after preflight and always revalidates current metadata', () => {
1011
+ const policy = loadWorkflow('.github/workflows/issue-policy.yml')
1012
+ const policyJob = workflowJob(policy, 'policy')
1013
+ if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps')
1014
+ const steps = policyJob.steps.filter(isRecord)
1015
+ const tokenStep = steps.find(step => step.name === 'Create Project read token')
1016
+ const validateStep = steps.find(step => step.name === 'Validate pull request')
1017
+ const preflightStep = steps.find(step => step.id === 'preflight')
1018
+ expect(preflightStep).toMatchObject({ shell: 'bash' })
1019
+ expect(preflightStep?.run).toContain('if [ -f .github/issue-management/selective-preflight.json ]; then')
1020
+ expect(preflightStep?.run).toContain('node .github/issue-management/policy.mjs pr-preflight')
1021
+ expect(preflightStep?.if).toBeUndefined()
1022
+ expect(policyJob.if).toBeUndefined()
1023
+ expect(validateStep?.if).toBe("${{ steps.preflight.outputs.legacy-automated != 'true' }}")
1024
+
1025
+ expect(tokenStep).toMatchObject({
1026
+ id: 'app-token',
1027
+ if: "${{ steps.preflight.outputs.needs-project == 'true' }}",
1028
+ uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1',
1029
+ with: {
1030
+ 'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}',
1031
+ 'private-key': '${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}',
1032
+ owner: 'deepseek-harness',
1033
+ repositories: 'deepseek-harness',
1034
+ 'permission-issues': 'read',
1035
+ 'permission-organization-projects': 'read',
1036
+ },
1037
+ })
1038
+ expect(validateStep).toMatchObject({
1039
+ env: {
1040
+ GITHUB_TOKEN: '${{ github.token }}',
1041
+ PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}',
1042
+ },
1043
+ })
1044
+ })
1045
+ })
1046
+
1047
+ describe('npm release workflows', () => {
1048
+ it('keeps publication dispatch-only and pack in the PR workflow', () => {
1049
+ // pack stays in the PR/master release workflows so a PR proves the set packs.
1050
+ for (const file of ['release.yml', 'release-vendor.yml']) {
1051
+ const workflow = loadWorkflow(`.github/workflows/${file}`)
1052
+ if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
1053
+ expect(Object.keys(workflow.jobs).sort()).toEqual(file === 'release.yml' ? ['dependencies', 'pack'] : ['pack'])
1054
+ }
1055
+
1056
+ // publication is workflow_dispatch-only (never a PR check) and keeps the
1057
+ // npm-publish environment plus the shared dist-tag group.
1058
+ for (const file of ['release-publish.yml', 'release-vendor-publish.yml']) {
1059
+ const workflow = loadWorkflow(`.github/workflows/${file}`)
1060
+ if (!isRecord(workflow.on) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define on and jobs`)
1061
+ expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
1062
+ const publish = workflow.jobs.publish
1063
+ if (!isRecord(publish)) throw new TypeError(`${file} must define a publish job`)
1064
+ expect(publish.environment).toBe('npm-publish')
1065
+ expect(publish.concurrency).toMatchObject({ group: 'Release-publish' })
1066
+ }
1067
+ })
1068
+
1069
+ it('runs dependency policy and npm layout checks in the DSH release workflow', () => {
1070
+ const workflow = loadWorkflow('.github/workflows/release.yml')
1071
+ const dependencies = workflowJob(workflow, 'dependencies')
1072
+ if (!isRecord(workflow.on) || !Array.isArray(dependencies.steps)) {
1073
+ throw new TypeError('DSH release workflow must define triggers and dependency steps')
1074
+ }
1075
+ const commands = dependencies.steps.flatMap(step =>
1076
+ isRecord(step) && typeof step.run === 'string' ? [step.run] : [])
1077
+
1078
+ expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch'])
1079
+ expect(commands).toContain('pnpm run verify-package-dependencies')
1080
+ expect(commands).toContain('pnpm run verify-npm-install-layout')
1081
+ })
1082
+ })
1083
+
1084
+ describe('Documentation site publication', () => {
1085
+ it('keeps Pages deployment dispatch-only from a dsh-v* tag', () => {
1086
+ const workflow = loadWorkflow('.github/workflows/docs-pages.yml')
1087
+ const build = workflowJob(workflow, 'build')
1088
+ const deploy = workflowJob(workflow, 'deploy')
1089
+ if (!isRecord(workflow.on) || !isRecord(workflow.env) || !Array.isArray(build.steps)) {
1090
+ throw new TypeError('Documentation deployment must define on, env, and build steps')
1091
+ }
1092
+
1093
+ // The site presents a released snapshot: a merge must never publish it, and
1094
+ // publication must never appear as a PR check.
1095
+ expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
1096
+
1097
+ // RELEASE_PUBLISH makes release:verify reject every ref that is not a dsh-v*
1098
+ // tag naming this tree's version, so the site and the npm sequence share one
1099
+ // definition of a released version.
1100
+ const steps = build.steps.filter(isRecord)
1101
+ const verify = steps.find(step => step.name === 'Verify release version')
1102
+ const checkout = steps.find(
1103
+ step => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'),
1104
+ )
1105
+ expect(verify).toMatchObject({
1106
+ env: { RELEASE_PUBLISH: 'true' },
1107
+ run: 'pnpm run release:verify --family dsh',
1108
+ })
1109
+ // Complete history: the release scripts read tags.
1110
+ expect(checkout).toMatchObject({ with: { 'fetch-depth': 0 } })
1111
+
1112
+ // Projected source links stay on the public repository's master. That
1113
+ // repository advances only to each release commit, so its master never
1114
+ // carries unreleased work, while it retains only the most recent tags:
1115
+ // following the dispatched tag would leave every source link on a deploy
1116
+ // from an older tag unresolvable.
1117
+ expect(workflow.env.DOCS_REPOSITORY_REF).toBe('master')
1118
+
1119
+ // The environment owns the deployment tag policy and the required reviewers.
1120
+ expect(deploy.environment).toMatchObject({ name: 'github-pages' })
1121
+ })
1122
+ })
1123
+
1124
+ describe('Git hooks', () => {
1125
+ it('leaves frozen Agent Note sidecars to the archive verifier', () => {
1126
+ const lefthook = loadWorkflow('lefthook.yml')
1127
+
1128
+ for (const hookName of ['pre-commit', 'pre-merge-commit']) {
1129
+ const hook = lefthook[hookName]
1130
+ if (!isRecord(hook) || !Array.isArray(hook.jobs)) {
1131
+ throw new TypeError(`lefthook must define ${hookName} jobs`)
1132
+ }
1133
+ const pairing: unknown = hook.jobs.find(
1134
+ (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)',
1135
+ )
1136
+
1137
+ expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] })
1138
+ }
1139
+ })
1140
+ })
1141
+
1142
+ function loadWorkflow(path: string): Record<string, unknown> {
1143
+ const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
1144
+ if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`)
1145
+ return workflow
1146
+ }
1147
+
1148
+ function workflowEvent(workflow: Record<string, unknown>, event: string): Record<string, unknown> {
1149
+ if (!isRecord(workflow.on) || !isRecord(workflow.on[event])) {
1150
+ throw new TypeError(`workflow must define the ${event} event`)
1151
+ }
1152
+ return workflow.on[event]
1153
+ }
1154
+
1155
+ function workflowJob(workflow: Record<string, unknown>, job: string): Record<string, unknown> {
1156
+ if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) {
1157
+ throw new TypeError(`workflow must define the ${job} job`)
1158
+ }
1159
+ return workflow.jobs[job]
1160
+ }
1161
+
1162
+ function isRecord(value: unknown): value is Record<string, unknown> {
1163
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1164
+ }
scripts/client-bundle-purity.spec.ts ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pins shared client-bundle preset rules: module-edge purity, source-map
3
+ * chaining, and physical watch dependencies hidden behind virtual CSS Modules.
4
+ */
5
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { dirname, join, relative } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { build, type TsdownBundle, type UserConfig } from 'tsdown'
10
+ import { describe, expect, it, onTestFinished, vi } from 'vitest'
11
+ import { clientBundle, requestedExternals, staticLinked } from '../packages/client/tsdown.client.ts'
12
+
13
+ type ResolveId = (source: string) => null | { id: string; external: boolean }
14
+
15
+ interface CssModulePlugin {
16
+ name: string
17
+ resolveId?: (source: string, importer: string | undefined) => null | string
18
+ load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
19
+ }
20
+
21
+ interface SourceMapPlugin {
22
+ name: string
23
+ load?: (id: string) => Promise<unknown>
24
+ }
25
+
26
+ interface InputIsolationPlugin {
27
+ name: string
28
+ generateBundle: (this: {
29
+ getModuleInfo(id: string): { importedIds: string[]; dynamicallyImportedIds: string[] } | null
30
+ }, options: unknown, bundle: Record<string, {
31
+ type: 'chunk'
32
+ modules: Record<string, object>
33
+ imports: string[]
34
+ dynamicImports: string[]
35
+ }>) => void
36
+ }
37
+
38
+ /** A representative dynamic bundle using the shared client baseline. */
39
+ const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
40
+
41
+ function clientConfigs(id = REQUESTING_PACKAGE) {
42
+ return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
43
+ { env: { DSH_BUILD_FACE: 'client' } },
44
+ ).filter(config => config.platform === 'browser')
45
+ }
46
+
47
+ describe('client bundle build faces', () => {
48
+ it('watches source in development and consumes emitted JavaScript in the Client build', () => {
49
+ const bundle = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js'])
50
+ const development = bundle({ env: {} }).find(config => config.platform === 'browser')
51
+ const artifact = bundle({ env: { DSH_BUILD_FACE: 'client' } })
52
+ .find(config => config.platform === 'browser')
53
+
54
+ expect(development?.entry).toEqual({ client: 'src/client/index.ts' })
55
+ expect(artifact?.entry).toEqual({ client: 'lib/types/client/index.js' })
56
+ })
57
+ })
58
+
59
+ function clientSourceMapPath(packagePath: string): string {
60
+ return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
61
+ }
62
+
63
+ function purityResolveId(id = REQUESTING_PACKAGE): ResolveId {
64
+ // libEntry is spelled at every call site (no default) so the
65
+ // package-invariants text check can see the invariant entry per package.
66
+ const configs = clientConfigs(id)
67
+ const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
68
+ const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
69
+ if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
70
+ return gate.resolveId as ResolveId
71
+ }
72
+
73
+ function cssModulePlugin(): CssModulePlugin {
74
+ const configs = clientConfigs()
75
+ const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins
76
+ const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
77
+ if (plugin?.resolveId === undefined || plugin.load === undefined) {
78
+ throw new Error('CSS Modules plugin missing from client config')
79
+ }
80
+ return plugin
81
+ }
82
+
83
+ function sourceMapPlugin(): SourceMapPlugin {
84
+ const configs = clientConfigs()
85
+ const plugins = (configs[0] as { plugins: SourceMapPlugin[] }).plugins
86
+ const plugin = plugins.find(candidate => candidate.name === 'dsh-tsc-sourcemap')
87
+ if (plugin?.load === undefined) throw new Error('tsc sourcemap plugin missing from client config')
88
+ return plugin
89
+ }
90
+
91
+ describe('client bundle purity gate', () => {
92
+ const resolveId = purityResolveId()
93
+
94
+ it('leaves default externals and non-scoped specifiers alone', () => {
95
+ expect(resolveId('@deepseek-ai/dsh-client-store')).toBeNull()
96
+ expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
97
+ expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
98
+ expect(resolveId('react')).toBeNull()
99
+ expect(resolveId('zod')).toBeNull()
100
+ })
101
+
102
+ it('rejects the retired web-react platform package', () => {
103
+ expect(() => resolveId('@deepseek-ai/dsh-client-web-react')).toThrow(/purity/)
104
+ expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
105
+ })
106
+
107
+ it('lets inline-safe libraries inline', () => {
108
+ expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
109
+ expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
110
+ expect(resolveId('@deepseek-ai/dsh-deque')).toBeNull()
111
+ expect(resolveId('@deepseek-ai/dsh-util-values')).toBeNull()
112
+ expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
113
+ expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
114
+ expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
115
+ expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull()
116
+ expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/)
117
+ })
118
+
119
+ it('admits only the pure spill notice entry, not its Host policy', () => {
120
+ expect(resolveId('@deepseek-ai/dsh-spill-policy/notice')).toBeNull()
121
+ expect(resolveId('@deepseek-ai/dsh-output-retention')).toBeNull()
122
+ expect(() => resolveId('@deepseek-ai/dsh-spill-policy')).toThrow(/purity/)
123
+ expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
124
+ })
125
+
126
+ it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
127
+ expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
128
+ expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
129
+ expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/)
130
+ expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/)
131
+ })
132
+
133
+ it('throws on any other @deepseek-ai leak', () => {
134
+ expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
135
+ expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
136
+ })
137
+
138
+ it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => {
139
+ expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
140
+ expect(() => resolveId('@deepseek-ai/dsh-client-ui-session')).toThrow(/purity/)
141
+ expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
142
+ })
143
+
144
+ it('admits package-specific requests only for the declaring bundle', () => {
145
+ const requesting = purityResolveId('@deepseek-ai/dsh-api-session-controller')
146
+ expect(requesting('@deepseek-ai/dsh-api-gateway/client')).toBeNull()
147
+ expect(() => resolveId('@deepseek-ai/dsh-api-gateway/client')).toThrow(/purity/)
148
+ })
149
+
150
+ it('externalizes the baseline independently of each package manifest', () => {
151
+ const requesting = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
152
+ const plain = clientConfigs('@deepseek-ai/dsh-client-connection')[0]?.deps as {
153
+ neverBundle: (specifier: string) => boolean
154
+ }
155
+
156
+ expect(requesting.neverBundle('react')).toBe(true)
157
+ expect(requesting.neverBundle('zod')).toBe(false)
158
+ expect(plain.neverBundle('react')).toBe(true)
159
+ expect(plain.neverBundle('@deepseek-ai/dsh-client-store')).toBe(true)
160
+ })
161
+ })
162
+
163
+ describe('client bundle experimental input isolation', () => {
164
+ const experimental = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
165
+
166
+ function fixture() {
167
+ const root = mkdtempSync(join(tmpdir(), 'dsh-client-inputs-'))
168
+ onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
169
+ const owner = join(root, 'client')
170
+ const entry = join(owner, 'lib/types/client/index.js')
171
+ const prototype = join(root, 'prototype/src/index.js')
172
+ for (const file of [entry, prototype]) mkdirSync(dirname(file), { recursive: true })
173
+ writeFileSync(join(owner, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
174
+ writeFileSync(join(root, 'prototype/package.json'), JSON.stringify({ name: experimental, type: 'module' }))
175
+ writeFileSync(prototype, 'export const marker = "experimental sentinel"\n')
176
+ return { root, owner, entry, prototype }
177
+ }
178
+
179
+ function config(kind: 'static' | 'dynamic', id = REQUESTING_PACKAGE): UserConfig {
180
+ const configs = kind === 'static'
181
+ ? staticLinked(id, ['lib/types/client/index.js'])({ env: { DSH_BUILD_FACE: 'client' } })
182
+ : clientConfigs(id)
183
+ const browser = configs.find(config => config.platform === 'browser')
184
+ if (browser === undefined) throw new Error('client config missing')
185
+ return browser
186
+ }
187
+
188
+ async function bundle(owner: string, config: UserConfig): Promise<string> {
189
+ let builds: TsdownBundle[] = []
190
+ try {
191
+ builds = await build({
192
+ ...config, cwd: owner, config: false, tsconfig: false,
193
+ write: false, clean: false, exports: false, report: false, logLevel: 'silent',
194
+ })
195
+ return builds.flatMap(build => build.chunks.filter(chunk => chunk.type === 'chunk').map(chunk => chunk.code)).join('\n')
196
+ } finally {
197
+ for (const build of builds) await build[Symbol.asyncDispose]()
198
+ }
199
+ }
200
+
201
+ function importPath(from: string, to: string): string {
202
+ return relative(dirname(from), to).replaceAll('\\', '/')
203
+ }
204
+
205
+ function checkCompilerModule(module: string, imports?: string[]): void {
206
+ const plugins = config('dynamic').plugins as InputIsolationPlugin[]
207
+ const plugin = plugins.find(plugin => plugin.name === 'dsh-client-input-isolation')
208
+ if (plugin === undefined) throw new Error('client input isolation plugin missing')
209
+ plugin.generateBundle.call({
210
+ getModuleInfo: () => imports === undefined ? null : { importedIds: imports, dynamicallyImportedIds: [] },
211
+ }, {}, {
212
+ 'client.js': { type: 'chunk', modules: { [module]: {} }, imports: [], dynamicImports: [] },
213
+ })
214
+ }
215
+
216
+ it('accepts the exact compiler runtime helper without a source module record', () => {
217
+ expect(() => { checkCompilerModule('\0rolldown/runtime.js') }).not.toThrow()
218
+ })
219
+
220
+ it.each(['\0rolldown/other.js', '\0rolldown/runtime.js?user', '\0other/runtime.js'])(
221
+ 'rejects the unrecorded module %j',
222
+ (module) => {
223
+ expect(() => { checkCompilerModule(module) }).toThrow(/has no bundler module record/)
224
+ },
225
+ )
226
+
227
+ it('checks runtime helper dependencies when the compiler supplies a module record', () => {
228
+ expect(() => { checkCompilerModule('\0rolldown/runtime.js', [experimental]) })
229
+ .toThrow(/client bundle isolation.*experimental/)
230
+ })
231
+
232
+ it.each(['static', 'dynamic'] as const)('rejects experimental files folded into a %s client artifact', async (kind) => {
233
+ const { owner, entry, prototype } = fixture()
234
+ writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
235
+
236
+ await expect(bundle(owner, config(kind))).rejects.toThrow(/client bundle isolation.*experimental/)
237
+ expect(existsSync(join(owner, 'lib/client.js'))).toBe(false)
238
+ expect(existsSync(join(owner, 'lib/index.js'))).toBe(false)
239
+ })
240
+
241
+ it('proves the static library otherwise hides the experimental input in its emitted JavaScript', async () => {
242
+ const { owner, entry, prototype } = fixture()
243
+ writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
244
+ const guarded = config('static')
245
+ const plugins = guarded.plugins as Array<{ name: string }>
246
+ const unguarded: UserConfig = {
247
+ ...guarded,
248
+ plugins: plugins.filter(plugin => plugin.name !== 'dsh-client-input-isolation'),
249
+ outputOptions: { sourcemapExcludeSources: false },
250
+ }
251
+
252
+ await expect(bundle(owner, unguarded)).resolves.toContain('experimental sentinel')
253
+ await expect(bundle(owner, guarded)).rejects.toThrow(/client bundle isolation.*experimental/)
254
+ })
255
+
256
+ it.each(['static', 'dynamic'] as const)('allows an experimental %s artifact to keep its own inputs', async (kind) => {
257
+ const { owner, entry, prototype } = fixture()
258
+ writeFileSync(entry, `export { marker } from ${JSON.stringify(importPath(entry, prototype))}\n`)
259
+
260
+ await expect(bundle(owner, config(kind, experimental))).resolves.toContain('experimental sentinel')
261
+ })
262
+
263
+ it('rejects experimental ownership preserved only by an emitted compiler source map', async () => {
264
+ const { owner, entry, prototype } = fixture()
265
+ writeFileSync(entry, 'export const marker = "experimental sentinel"\n//# sourceMappingURL=index.js.map\n')
266
+ writeFileSync(`${entry}.map`, JSON.stringify({
267
+ version: 3, names: [], mappings: 'AAAA', sources: [importPath(entry, prototype)],
268
+ sourcesContent: ['export const marker = "experimental sentinel"\n'],
269
+ }))
270
+
271
+ await expect(bundle(owner, config('static'))).rejects.toThrow(/client bundle isolation.*experimental/)
272
+ })
273
+
274
+ it('checks the resolved module when an import alias conceals its experimental package', async () => {
275
+ const { owner, entry, prototype } = fixture()
276
+ writeFileSync(entry, 'export { marker } from "./ordinary.js"\n')
277
+
278
+ await expect(bundle(owner, { ...config('static'), alias: { './ordinary.js': prototype } }))
279
+ .rejects.toThrow(/client bundle isolation.*experimental/)
280
+ })
281
+
282
+ it('rejects an experimental runtime import left external by a static client library', async () => {
283
+ const { owner, entry } = fixture()
284
+ writeFileSync(entry, `export * from ${JSON.stringify(experimental)}\n`)
285
+
286
+ await expect(bundle(owner, config('static'))).rejects.toThrow(/client bundle isolation.*experimental/)
287
+ })
288
+
289
+ it.each([false, true])('checks omitted source-map files against their package owner (experimental: %s)', async (experimentalSource) => {
290
+ const { root, owner, entry } = fixture()
291
+ const dependency = join(root, 'dependency')
292
+ mkdirSync(dependency)
293
+ writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: experimentalSource ? experimental : 'ordinary-library' }))
294
+ writeFileSync(entry, 'export const marker = "mapped sentinel"\n//# sourceMappingURL=index.js.map\n')
295
+ writeFileSync(`${entry}.map`, JSON.stringify({
296
+ version: 3, names: [], mappings: 'AAAA', sourceRoot: importPath(entry, dependency),
297
+ sources: ['unshipped.ts'], sourcesContent: ['export const marker = "mapped sentinel"\n'],
298
+ }))
299
+
300
+ const result = bundle(owner, config('static'))
301
+ if (experimentalSource) await expect(result).rejects.toThrow(/client bundle isolation.*experimental/)
302
+ else await expect(result).resolves.toContain('mapped sentinel')
303
+ })
304
+
305
+ it.each(['.css', '.module.css', '.css?inline'])('rejects experimental %s inputs behind client CSS virtual loaders', async (extension) => {
306
+ const { owner, entry, prototype } = fixture()
307
+ const css = join(dirname(prototype), `style${extension.replace('?inline', '')}`)
308
+ writeFileSync(css, 'body { color: red; }\n')
309
+ const specifier = `${importPath(entry, css)}${extension.endsWith('?inline') ? '?inline' : ''}`
310
+ writeFileSync(entry, extension === '.css'
311
+ ? `import ${JSON.stringify(specifier)}\nexport const marker = true\n`
312
+ : `export { default as style } from ${JSON.stringify(specifier)}\n`)
313
+
314
+ await expect(bundle(owner, config('dynamic'))).rejects.toThrow(/client bundle isolation.*experimental/)
315
+ })
316
+
317
+ it.each(['static', 'dynamic'] as const)('keeps ordinary %s bundles and source maps buildable', async (kind) => {
318
+ const { owner, entry } = fixture()
319
+ const source = join(owner, 'src/client/index.ts')
320
+ mkdirSync(dirname(source), { recursive: true })
321
+ writeFileSync(source, 'export const marker = "stable sentinel"\n')
322
+ writeFileSync(entry, 'export const marker = "stable sentinel"\n//# sourceMappingURL=index.js.map\n')
323
+ writeFileSync(`${entry}.map`, JSON.stringify({
324
+ version: 3, names: [], mappings: 'AAAA', sources: [importPath(entry, source)],
325
+ sourcesContent: ['export const marker = "stable sentinel"\n'],
326
+ }))
327
+
328
+ await expect(bundle(owner, config(kind))).resolves.toContain('stable sentinel')
329
+ })
330
+ })
331
+
332
+ describe('client bundle module requests', () => {
333
+ it('requests what the declaration lists', () => {
334
+ const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
335
+ external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-slots'],
336
+ })
337
+
338
+ expect([...requests].sort()).toEqual([
339
+ '@deepseek-ai/dsh-client-ui-slots', 'react', 'react/jsx-runtime',
340
+ ])
341
+ })
342
+
343
+ it('requests nothing when the declaration is absent', () => {
344
+ expect(requestedExternals('@deepseek-ai/dsh-client-fixture', {}).size).toBe(0)
345
+ })
346
+
347
+ it('rejects a malformed declaration instead of reading past it', () => {
348
+ expect(() => requestedExternals('@deepseek-ai/dsh-client-fixture', { external: 'react' }))
349
+ .toThrow(/dsh\.client\.external must be a string array/)
350
+ })
351
+ })
352
+
353
+ describe('client bundle debug artifacts', () => {
354
+ it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
355
+ const configs = clientConfigs()
356
+ expect(configs[0]?.sourcemap).toBe(true)
357
+ expect(configs[0]?.outputOptions).toMatchObject({ sourcemapExcludeSources: false })
358
+ })
359
+
360
+ it('chains emitted tsc maps when the production Client build consumes lib/types', async () => {
361
+ const root = mkdtempSync(join(tmpdir(), 'dsh-client-sourcemap-'))
362
+ try {
363
+ const entry = join(root, 'lib', 'types', 'client', 'index.js')
364
+ const source = join(root, 'src', 'client', 'index.ts')
365
+ const map = { version: 3, names: [], mappings: 'AAAA', sources: ['../../../src/client/index.ts'] }
366
+ mkdirSync(join(root, 'lib', 'types', 'client'), { recursive: true })
367
+ mkdirSync(join(root, 'src', 'client'), { recursive: true })
368
+ writeFileSync(entry, 'export const marker = true\n//# sourceMappingURL=index.js.map\n')
369
+ writeFileSync(`${entry}.map`, JSON.stringify(map))
370
+ writeFileSync(source, 'export const marker: true = true\n')
371
+
372
+ await expect(sourceMapPlugin().load!(entry)).resolves.toEqual({
373
+ code: 'export const marker = true',
374
+ map: { ...map, sourcesContent: ['export const marker: true = true\n'] },
375
+ })
376
+ } finally {
377
+ rmSync(root, { recursive: true, force: true })
378
+ }
379
+ })
380
+
381
+ it('maps first-party sources to their repository package paths', () => {
382
+ const configs = clientConfigs('@deepseek-ai/dsh-client-ui-goal')
383
+ const outputOptions = configs[0]?.outputOptions
384
+ if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
385
+ const transform = outputOptions.sourcemapPathTransform
386
+ if (transform === undefined) throw new Error('client sourcemap path transform missing')
387
+
388
+ const source = transform('../src/client/GoalBar.tsx', clientSourceMapPath('client/ui-goal'))
389
+ expect(source).toBe('../../../packages/client/ui-goal/src/client/GoalBar.tsx')
390
+ const resolved = new URL(source, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-ui-goal/client.js.map')
391
+ expect(resolved.pathname).toBe('/packages/client/ui-goal/src/client/GoalBar.tsx')
392
+ })
393
+
394
+ it('maps dual-face host sources to the host package group', () => {
395
+ const configs = clientConfigs('@deepseek-ai/dsh-host-directory-picker-native')
396
+ const outputOptions = configs[0]?.outputOptions
397
+ if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
398
+ const transform = outputOptions.sourcemapPathTransform
399
+ if (transform === undefined) throw new Error('client sourcemap path transform missing')
400
+
401
+ const source = transform('../src/client/index.ts', clientSourceMapPath('host/directory-picker-native'))
402
+ expect(source).toBe('../../../packages/host/directory-picker-native/src/client/index.ts')
403
+ })
404
+
405
+ it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => {
406
+ const configs = clientConfigs('@deepseek-ai/dsh-client-connection')
407
+ const outputOptions = configs[0]?.outputOptions
408
+ if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
409
+ const transform = outputOptions.sourcemapPathTransform
410
+ if (transform === undefined) throw new Error('client sourcemap path transform missing')
411
+
412
+ const sourceMapPath = clientSourceMapPath('client/connection')
413
+ const workspaceSource = transform('../src/rpc.ts', sourceMapPath)
414
+ expect(workspaceSource).toBe('../../../packages/client/connection/src/rpc.ts')
415
+ const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map')
416
+ expect(resolved.pathname).toBe('/packages/client/connection/src/rpc.ts')
417
+
418
+ const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js'
419
+ expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource)
420
+ })
421
+ })
422
+
423
+ describe('client bundle CSS Modules watch graph', () => {
424
+ it('registers the physical stylesheet read behind a virtual module', async () => {
425
+ const plugin = cssModulePlugin()
426
+ const importer = fileURLToPath(new URL(
427
+ '../packages/client/ui-conversation/src/client/queue/QueueDock.tsx',
428
+ import.meta.url,
429
+ ))
430
+ const stylesheet = fileURLToPath(new URL(
431
+ '../packages/client/ui-conversation/src/client/queue/QueueDock.module.css',
432
+ import.meta.url,
433
+ ))
434
+ const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer)
435
+ if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved')
436
+ const addWatchFile = vi.fn()
437
+
438
+ await plugin.load?.call({ addWatchFile }, virtualId)
439
+
440
+ expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet)
441
+ })
442
+ })
scripts/cordis-walk.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AST helpers shared by the Cordis generators: locate the Cordis module merge
3
+ * in a source file and enumerate the `interface Context` keys it declares.
4
+ * The vendored core API projector consumes the merge body; the per-subsystem
5
+ * region generator's exhaustiveness backstop consumes the key scan.
6
+ */
7
+
8
+ import { globSync, readFileSync } from 'node:fs'
9
+ import { resolve, sep } from 'node:path'
10
+ import ts from 'typescript'
11
+
12
+ /** Cheap textual prefilter for a cordis module merge, quote-style agnostic
13
+ * (the AST match below reads `stmt.name.text` and never sees the quotes). */
14
+ const MERGE_HEAD = /declare module ['"](?:@deepseek-ai\/cordis|\.\/context\.ts)['"]/
15
+
16
+ /**
17
+ * Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
18
+ * that textually contains a cordis module merge, yielding one entry per merge
19
+ * BLOCK — a file may legally hold several `declare module '@deepseek-ai/cordis'` blocks
20
+ * (the Typert analyzer reads them all), so the exhaustiveness scan must too.
21
+ * Files without a merge are skipped.
22
+ * @param scanRoot - Repository root the patterns are resolved against.
23
+ * @param patterns - Glob(s) selecting the TypeScript files to scan.
24
+ * @returns One entry per cordis module block, in path then source order.
25
+ */
26
+ export function contextMergeFiles(
27
+ scanRoot: string,
28
+ patterns: string | readonly string[],
29
+ ): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
30
+ const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
31
+ const rels = [...new Set(globSync(patterns as string | string[], { cwd: scanRoot }).map(s => s.split(sep).join('/')))].sort()
32
+ for (const rel of rels) {
33
+ const abs = resolve(scanRoot, rel)
34
+ const text = readFileSync(abs, 'utf8')
35
+ if (!MERGE_HEAD.test(text)) continue
36
+ const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
37
+ for (const body of cordisModuleBodies(sf)) out.push({ rel, sf, text, body })
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Every cordis module-merge body in `sf`: `declare module '@deepseek-ai/cordis'` (harness
43
+ * packages) or `declare module './context.ts'` (vendor core), in source order.
44
+ * Module-local: consumers walk blocks through {@link contextMergeFiles}. */
45
+ function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
46
+ const bodies: ts.ModuleBlock[] = []
47
+ for (const stmt of sf.statements) {
48
+ if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
49
+ if (stmt.name.text !== '@deepseek-ai/cordis' && stmt.name.text !== './context.ts') continue
50
+ if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
51
+ }
52
+ return bodies
53
+ }
54
+
55
+ /** The FIRST cordis module-merge body in `sf`, or null without one — for the
56
+ * vendor core-API renderer whose input files carry exactly one merge; the
57
+ * exhaustiveness scan uses {@link cordisModuleBodies} to read them all. */
58
+ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
59
+ return cordisModuleBodies(sf)[0] ?? null
60
+ }
61
+
62
+ /**
63
+ * Every `key: Type` property a `declare module '@deepseek-ai/cordis'` Context merge
64
+ * declares in one module body.
65
+ * @param body - The cordis module augmentation block.
66
+ * @param sf - Owning source file (for text extraction).
67
+ * @returns key → declared type-name text, in declaration order.
68
+ */
69
+ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
70
+ const keyToType = new Map<string, string>()
71
+ for (const stmt of body.statements) {
72
+ if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
73
+ for (const member of stmt.members) {
74
+ if (!ts.isPropertySignature(member) || !member.type) continue
75
+ keyToType.set(member.name.getText(sf), member.type.getText(sf))
76
+ }
77
+ }
78
+ return keyToType
79
+ }
80
+
81
+ /**
82
+ * Every event name a `declare module '@deepseek-ai/cordis'` Events merge declares in one
83
+ * module body. Names are the literal member keys (`'agent/created'`), read
84
+ * from method and property members alike so a declaration form the projector
85
+ * would reject still enters the exhaustiveness scan.
86
+ * @param body - The cordis module augmentation block.
87
+ * @param sf - Owning source file (for computed-name text extraction).
88
+ * @returns Declared event names, in declaration order.
89
+ */
90
+ export function eventNameList(body: ts.ModuleBlock, sf: ts.SourceFile): string[] {
91
+ const names: string[] = []
92
+ for (const stmt of body.statements) {
93
+ if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
94
+ for (const member of stmt.members) {
95
+ if (!member.name) continue
96
+ names.push(ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
97
+ ? member.name.text
98
+ : member.name.getText(sf))
99
+ }
100
+ }
101
+ return names
102
+ }
scripts/cordis-yaml.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Cordis YAML parsing and Loader-entry classification shared by repository checks.
3
+ * @module scripts/cordis-yaml
4
+ */
5
+
6
+ import * as yaml from 'js-yaml'
7
+
8
+ /** A Loader `!!js` expression preserved as data instead of executed. */
9
+ export interface JsExpr {
10
+ __jsExpr: string
11
+ }
12
+
13
+ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
14
+ kind: 'scalar',
15
+ resolve: data => typeof data === 'string',
16
+ construct: (data: unknown): JsExpr => {
17
+ if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
18
+ return { __jsExpr: data }
19
+ },
20
+ })
21
+ const schema = yaml.JSON_SCHEMA.extend(jsExprType)
22
+
23
+ /**
24
+ * Parse a Cordis config while preserving Loader `!!js` expressions as data.
25
+ * @param source - Cordis YAML source text.
26
+ * @returns the parsed YAML value.
27
+ */
28
+ export function loadCordisYaml(source: string): unknown {
29
+ return yaml.load(source, { schema })
30
+ }
31
+
32
+ /**
33
+ * Test whether a value is a preserved Loader `!!js` expression.
34
+ * @param value - parsed YAML value.
35
+ * @returns whether the value contains one preserved expression.
36
+ */
37
+ export function isJsExpr(value: unknown): value is JsExpr {
38
+ return typeof value === 'object'
39
+ && value !== null
40
+ && typeof (value as Record<string, unknown>).__jsExpr === 'string'
41
+ }
42
+
43
+ /**
44
+ * Test whether a Loader entry owns nested entries in its `config` array.
45
+ * @param value - parsed Loader entry.
46
+ * @returns whether the entry is an explicit or package-named Cordis group.
47
+ */
48
+ export function isCordisGroupEntry(value: unknown): value is Record<string, unknown> & { config: unknown[] } {
49
+ return typeof value === 'object'
50
+ && value !== null
51
+ && Array.isArray((value as Record<string, unknown>).config)
52
+ && ((value as Record<string, unknown>).group === true
53
+ || (value as Record<string, unknown>).name === '@deepseek-ai/cordis-plugin-group')
54
+ }
scripts/coverage-exempt.spec.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Mechanical guard for the coverage-exempt roster: each entry's positional
3
+ * filter and exclude glob must select the same non-empty file set out of the
4
+ * repository's spec inventory, so a renamed suite cannot silently fall out of
5
+ * the uninstrumented gate while its exclude goes stale.
6
+ */
7
+
8
+ import { globSync } from 'node:fs'
9
+ import { resolve } from 'node:path'
10
+ import { describe, expect, it } from 'vitest'
11
+ import { coverageExemptHeavySuites } from './coverage-exempt.ts'
12
+
13
+ const root = resolve(import.meta.dirname, '..')
14
+
15
+ /** The spec inventory mirrored from vitest.config.ts testIncludes. */
16
+ const allSpecs = new Set([
17
+ ...globSync('packages/*/*/tests/**/*.spec.ts', { cwd: root }),
18
+ ...globSync('packages/*/*/tests/**/*.spec.tsx', { cwd: root }),
19
+ ...globSync('apps/*/tests/**/*.spec.ts', { cwd: root }),
20
+ ...globSync('scripts/**/*.spec.ts', { cwd: root }),
21
+ ].map(path => path.replaceAll('\\', '/')))
22
+
23
+ function excludeMatches(exclude: string): string[] {
24
+ return globSync(exclude, { cwd: root })
25
+ .map(path => path.replaceAll('\\', '/'))
26
+ .filter(path => allSpecs.has(path))
27
+ .sort()
28
+ }
29
+
30
+ function filterMatches(filter: string): string[] {
31
+ return [...allSpecs].filter(spec => spec.startsWith(filter)).sort()
32
+ }
33
+
34
+ describe('coverage-exempt roster', () => {
35
+ it('selects every Typert suite for the uninstrumented gate', () => {
36
+ const typertSpecs = filterMatches('packages/typert/')
37
+ const exemptSpecs = coverageExemptHeavySuites.flatMap(suite => excludeMatches(suite.exclude))
38
+ .filter(spec => spec.startsWith('packages/typert/')).sort()
39
+ expect(typertSpecs.length).toBeGreaterThan(0)
40
+ expect(exemptSpecs).toEqual(typertSpecs)
41
+ })
42
+
43
+ it.each(coverageExemptHeavySuites.map(suite => [suite.filter, suite] as const))(
44
+ 'filter and exclude select the same non-empty spec set for %s',
45
+ (_filter, suite) => {
46
+ const fromExclude = excludeMatches(suite.exclude)
47
+ const fromFilter = filterMatches(suite.filter)
48
+ expect(fromExclude.length).toBeGreaterThan(0)
49
+ expect(fromFilter).toEqual(fromExclude)
50
+ },
51
+ )
52
+
53
+ it('entries never overlap, so no suite is double-run or double-excluded', () => {
54
+ const seen = new Map<string, string>()
55
+ for (const suite of coverageExemptHeavySuites) {
56
+ for (const spec of excludeMatches(suite.exclude)) {
57
+ expect(seen.get(spec), `${spec} matched by ${seen.get(spec) ?? ''} and ${suite.exclude}`).toBeUndefined()
58
+ seen.set(spec, suite.exclude)
59
+ }
60
+ }
61
+ })
62
+ })
scripts/coverage-exempt.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Heavy suites the coverage aggregate runs uninstrumented in a parallel gate.
3
+ * Membership rule: a suite qualifies only when every coverage-measured
4
+ * file it executes in-process (`coverage.include` spans package src trees;
5
+ * Typert src is threshold-excluded in vitest.config.ts) is already
6
+ * fully covered by other suites, so removing it from the instrumented run
7
+ * changes no threshold outcome. The aggregate still runs every listed suite
8
+ * plain beside the instrumented gate, so correctness signal is unchanged —
9
+ * only the v8 instrumentation tax on compiler- and subprocess-heavy fixtures
10
+ * is dropped.
11
+ */
12
+
13
+ /** One coverage-exempt suite: a Vitest CLI filter and its exclude glob. */
14
+ export interface CoverageExemptSuite {
15
+ /** Positional file filter selecting the suite in the uninstrumented gate. */
16
+ readonly filter: string
17
+ /** Exclude glob removing the suite from the instrumented gate. */
18
+ readonly exclude: string
19
+ }
20
+
21
+ /**
22
+ * Set to `1` by the instrumented coverage gate; vitest.config.ts then drops
23
+ * the exempt suites from every project. CLI `--exclude` cannot express this:
24
+ * it does not reach per-project include resolution.
25
+ */
26
+ export const COVERAGE_EXEMPT_ENV = 'DSH_COVERAGE_EXEMPT_HEAVY'
27
+
28
+ /** Coverage-exempt heavy suites; keep filter and exclude selecting the same files. */
29
+ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [
30
+ // Typert src is threshold-excluded; compiler analysis runs uninstrumented.
31
+ // The generator's tools-catalog registry and
32
+ // tool-cordis imports are fully covered by those packages' own tests.
33
+ {
34
+ filter: 'packages/typert/',
35
+ exclude: 'packages/typert/*/tests/**',
36
+ },
37
+ // The webworker-runtime package is outside the coverage requirement by
38
+ // decision: vitest.config.ts threshold-excludes its src, so every suite
39
+ // runs uninstrumented. This tree includes the full-corpus import gate, a
40
+ // single 900s-budget case that spawns a child sweep over every built
41
+ // bundle; inside an instrumented partition it exceeds the Windows
42
+ // partition budget under load.
43
+ {
44
+ filter: 'packages/experimental/webworker-runtime/tests/',
45
+ exclude: 'packages/experimental/webworker-runtime/tests/**',
46
+ },
47
+ // Real child-process fixtures over scripts/ sources, which coverage never measures.
48
+ { filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' },
49
+ { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' },
50
+ { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' },
51
+ { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' },
52
+ // Built-artifact proof. Packer/runtime src is threshold-excluded, and the
53
+ // suite self-skips on unbuilt checkouts; the serial-windows complete
54
+ // reference still starts this uninstrumented gate after its build gate, so
55
+ // the assertions execute against complete real artifacts there.
56
+ {
57
+ filter: 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
58
+ exclude: 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
59
+ },
60
+ ]
scripts/dev-web.ts ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Watch-build for the web dev loop: rebuilds every artifact the browser reads
3
+ * from a source edit. Reload signaling is not this script's business — the host
4
+ * webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
5
+ * itself (`dsh web`), so any process that rewrites `lib/client.js` files
6
+ * triggers reloads; this script is merely the convenient way to keep them all
7
+ * rebuilt on source change.
8
+ *
9
+ * Three stages, because the compile shell links built lib products rather than
10
+ * sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
11
+ * entries are that emit, not `src`), tsdown bundles `lib/index.js` and
12
+ * `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
13
+ * serves. A missing stage does not fail — it silently shows the previous
14
+ * artifact, so an edit appears to do nothing.
15
+ *
16
+ * MUST NOT run concurrently with `pnpm run build`: both write the same
17
+ * `lib/` and `apps/web/dist/` trees.
18
+ *
19
+ * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
20
+ * `pnpm run build`: every stage is incremental over the previous stage's output
21
+ * and none of them bootstraps a missing tree. `--poll` switches the source
22
+ * watchers to polling (default 500ms): network mounts (weka) deliver no inotify
23
+ * events, so native watching sees the initial build only and never a source
24
+ * change. Polling has to reach tsc too — a native-watching tsc never re-emits
25
+ * `lib/types`, which strands the other two stages on stale input.
26
+ *
27
+ * Each package keeps its own tsdown.config.ts untouched: this script layers
28
+ * `watch` through API-level inline config (tsdown workspace mode fills inline
29
+ * keys under each package's file config, and no package config defines it).
30
+ */
31
+ import { globSync, readFileSync } from 'node:fs'
32
+ import { dirname, join, resolve, sep } from 'node:path'
33
+ import { fileURLToPath, pathToFileURL } from 'node:url'
34
+ import { execa } from 'execa'
35
+ import { build } from 'tsdown'
36
+ import type { TsdownBundle } from 'tsdown'
37
+ import {
38
+ CLIENT_BUILD_PROFILE_SELECTOR,
39
+ clientBuildProcessEnvironment,
40
+ repositoryClientBuildEnvironment,
41
+ } from './client-build-environment.ts'
42
+
43
+ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
44
+
45
+ /** Client-face type emit feeding every tsdown lib entry in the watch set. */
46
+ const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
47
+
48
+ /** Compile-shell workspace whose dist `dsh web` serves. */
49
+ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
50
+
51
+ /**
52
+ * Test infrastructure builds through the client preset but never enters the
53
+ * shell's module graph, so it is not a dev-loop artifact.
54
+ */
55
+ const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
56
+
57
+ /**
58
+ * Sample one local public environment for every long-lived watcher stage.
59
+ * @param root - repository root supplying version and Git metadata.
60
+ * @param environment - watcher launch environment supplying public extensions.
61
+ * @returns process environment shared by tsdown and spawned watcher stages.
62
+ */
63
+ export function devWebBuildEnvironment(
64
+ root: string,
65
+ environment: NodeJS.ProcessEnv = process.env,
66
+ ): NodeJS.ProcessEnv {
67
+ return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment))
68
+ }
69
+
70
+ /**
71
+ * Discover the watch workspace by declaration: every packages/<group>/<name>
72
+ * whose package.json carries `dsh.client` with platform "web" is a client
73
+ * plugin bundle emitter. Scanned once at startup — a package added while
74
+ * watching means restarting this script.
75
+ * @param root - repository root containing the grouped package directories.
76
+ * @returns workspace-relative plugin package directories.
77
+ */
78
+ export function discoverPluginDirs(root = repoRoot): string[] {
79
+ const dirs: string[] = []
80
+ for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
81
+ const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as {
82
+ dsh?: { client?: { platform?: unknown } }
83
+ }
84
+ if (manifest.dsh?.client?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
85
+ }
86
+ return dirs
87
+ }
88
+
89
+ /**
90
+ * Discover the statically linked library packages: the other half of the same
91
+ * partition {@link discoverPluginDirs} takes. A package that builds through the
92
+ * client preset without declaring `dsh.client` has no loader-delivered browser
93
+ * half, so the compile shell links its `lib/index.js` instead — and an edit to
94
+ * its source reaches the browser only once that bundle is rewritten. Deriving
95
+ * the set from the build preset rather than a hand list keeps it correct when
96
+ * dependency sections move around; deriving it from `dependencies` would not,
97
+ * because client packages declare their build inputs as devDependencies.
98
+ * @param root - repository root containing the grouped package directories.
99
+ * @returns workspace-relative library package directories.
100
+ */
101
+ export function discoverLibraryDirs(root = repoRoot): string[] {
102
+ const dirs: string[] = []
103
+ for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
104
+ const dir = dirname(configPath).split(sep).join('/')
105
+ if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
106
+ if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
107
+ const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
108
+ dsh?: { client?: unknown }
109
+ }
110
+ if (manifest.dsh?.client === undefined) dirs.push(dir)
111
+ }
112
+ return dirs
113
+ }
114
+
115
+ /**
116
+ * Start the tsdown watch build used by `pnpm run dev:web`.
117
+ * @param root - repository or fixture root passed to tsdown.
118
+ * @param pluginDirs - workspace-relative package directories to watch.
119
+ * @param pollInterval - optional source-watcher polling interval in milliseconds.
120
+ * @returns live bundles after every watcher has completed its initial build.
121
+ */
122
+ export async function watchClientPlugins(
123
+ root: string,
124
+ pluginDirs: readonly string[],
125
+ pollInterval?: number,
126
+ ): Promise<TsdownBundle[]> {
127
+ let resolveInitialBuilds: (() => void) | undefined
128
+ const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
129
+ const initialized = new WeakSet<object>()
130
+ const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 }
131
+ const bundles = await build({
132
+ cwd: root,
133
+ workspace: [...pluginDirs],
134
+ watch: true,
135
+ hooks: {
136
+ 'build:done': ({ options }) => {
137
+ if (initialized.has(options)) return
138
+ initialized.add(options)
139
+ readiness.initializedBuilds += 1
140
+ if (
141
+ readiness.expectedBuilds !== undefined
142
+ && readiness.initializedBuilds >= readiness.expectedBuilds
143
+ ) resolveInitialBuilds?.()
144
+ },
145
+ },
146
+ ...pollInterval !== undefined
147
+ ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
148
+ : {},
149
+ })
150
+ readiness.expectedBuilds = bundles.length
151
+ if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.()
152
+ await initialBuilds
153
+ return bundles
154
+ }
155
+
156
+ /**
157
+ * Live watcher processes to terminate when this script is interrupted. Stages
158
+ * register themselves as they start, so the set is complete from the first
159
+ * spawn: an interrupt during a later stage's startup still tears down the
160
+ * earlier ones instead of orphaning them.
161
+ */
162
+ const stages: StageHandle[] = []
163
+
164
+ /**
165
+ * Spawn one watcher stage, inheriting stdio, registering it for teardown, and
166
+ * failing loud if it ever exits: a dead stage leaves the artifact chain silently
167
+ * stale, which reads as "my edit did nothing" — the one failure this script
168
+ * exists to prevent.
169
+ * @param stage - command label used in the exit diagnostic.
170
+ * @param command - executable, resolved from the workspace bin when local.
171
+ * @param args - command arguments.
172
+ * @param local - whether to resolve `command` from the workspace's installed bins.
173
+ */
174
+ function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
175
+ const child = execa(command, [...args], {
176
+ cwd: repoRoot,
177
+ stdio: 'inherit',
178
+ preferLocal: local,
179
+ reject: false,
180
+ })
181
+ stages.push({ kill: () => { child.kill() } })
182
+ void child.then((result) => {
183
+ console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
184
+ process.exit(1)
185
+ })
186
+ }
187
+
188
+ /** The only capability this script needs from a live watcher process. */
189
+ interface StageHandle {
190
+ readonly kill: () => void
191
+ }
192
+
193
+ const invokedPath = process.argv[1]
194
+ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
195
+ if (isMain) {
196
+ const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env)
197
+ for (const name of Object.keys(process.env)) {
198
+ if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) {
199
+ Reflect.deleteProperty(process.env, name)
200
+ }
201
+ }
202
+ for (const [name, value] of Object.entries(buildEnvironment)) {
203
+ if (name.startsWith('DSH_CLIENT_') && value !== undefined) process.env[name] = value
204
+ }
205
+
206
+ const pluginDirs = discoverPluginDirs()
207
+ const libraryDirs = discoverLibraryDirs()
208
+ if (pluginDirs.length === 0) {
209
+ console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
210
+ process.exit(1)
211
+ }
212
+ if (libraryDirs.length === 0) {
213
+ console.error('dev-web: no client-preset library packages found under packages/ — the compile shell links their lib products, so an empty set means the discovery predicate is stale')
214
+ process.exit(1)
215
+ }
216
+
217
+ const args = process.argv.slice(2)
218
+ const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
219
+ if (args.some(a => a !== pollArg)) {
220
+ console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
221
+ process.exit(1)
222
+ }
223
+ const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
224
+ if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
225
+ console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
226
+ process.exit(1)
227
+ }
228
+
229
+ // Registered before any stage starts: `stages` is read at signal time, so an
230
+ // interrupt during tsdown's initial builds still kills whatever is running.
231
+ const stop = (): void => { for (const stage of stages) stage.kill() }
232
+ process.once('SIGINT', stop)
233
+ process.once('SIGTERM', stop)
234
+
235
+ // tsc has no polling interval flag, so `--poll` selects its fixed-interval
236
+ // watchers rather than an interval. Dropping that translation leaves tsc
237
+ // natively watching on a network mount where inotify never fires: it stops
238
+ // re-emitting lib/types, and the two later stages then rebuild forever from
239
+ // stale input without printing anything.
240
+ spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
241
+ '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
242
+ ...pollInterval !== undefined
243
+ ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
244
+ : [],
245
+ ], true)
246
+
247
+ // tsdown's initial builds are awaited before the dist watcher starts so vite's
248
+ // first build reads current lib bundles rather than whatever the last full
249
+ // build left. Its own watch then covers later lib rewrites — those files are
250
+ // in its module graph.
251
+ await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
252
+ // Through the shell's own `watch` script rather than vite's API: vite is not a
253
+ // repository-root dependency, and more importantly the vite root is its
254
+ // working directory — `resolve.dedupe` resolves react from that root, so
255
+ // running vite from anywhere but apps/web silently switches which react copy
256
+ // the bundle gets.
257
+ spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
258
+
259
+ console.log(
260
+ `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
261
+ + ` and ${String(libraryDirs.length)} statically linked library packages`
262
+ + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
263
+ + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
264
+ + [...pluginDirs, ...libraryDirs].join('\n '),
265
+ )
266
+ }
scripts/doc-budgets.manifest.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "AGENTS.md": 1950,
3
+ "docs/AGENTS.md": 1320,
4
+ "docs/architecture.md": 2400,
5
+ "docs/cordis-primer.md": 600,
6
+ "docs/defensive-patterns.md": 550,
7
+ "docs/testing.md": 1350,
8
+ "packages/AGENTS.md": 750,
9
+ "packages/README.md": 994
10
+ }
scripts/doc-typecheck-paths.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Map one workspace source alias target to its declaration-build target. */
2
+ export function builtDeclarationPath(candidate: string): string {
3
+ // Two workspace path forms exist: whole-package entries end in /src, subpath
4
+ // wildcards (browser-safe /types and /client channels) in /src/*.
5
+ if (candidate.endsWith('/src')) {
6
+ return `${candidate.slice(0, -'/src'.length)}/lib/types`
7
+ }
8
+ if (candidate.endsWith('/src/*')) {
9
+ return `${candidate.slice(0, -'/src/*'.length)}/lib/types/*`
10
+ }
11
+ const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate)
12
+ if (sourceFile?.[1] && sourceFile[2]) {
13
+ return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
14
+ }
15
+ // Directory subpath entries (for example, runtime's /client): the
16
+ // source dir maps to the same dir under lib/types (index resolution applies).
17
+ const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
18
+ if (sourceDir?.[1] && sourceDir[2]) {
19
+ return `${sourceDir[1]}/lib/types/${sourceDir[2]}`
20
+ }
21
+ throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
22
+ }
scripts/gen-config-catalog.ts ADDED
@@ -0,0 +1,976 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generate `docs/config-catalog.md` from package entry points, config types,
3
+ * JSDoc, and static Schemastery schemas. Every package must classify, referenced
4
+ * types must resolve without collisions, and every enumerable schema path must
5
+ * exist on the declared config type. External and dynamic types stay unknown;
6
+ * declared runtime-only fields need not appear in the schema. `--check` verifies
7
+ * the committed artifact.
8
+ */
9
+
10
+ import { globSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
12
+ import ts from 'typescript'
13
+ import { LINK_MAP } from './gen-cordis-catalog.ts'
14
+ import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
15
+ import { githubSlug } from './verify-md-links.ts'
16
+
17
+ const root = resolve(import.meta.dirname, '..')
18
+ const OUT = 'docs/config-catalog.md'
19
+
20
+ /** The fenced-block info string for pasted config declarations (skipped by
21
+ * doc-typecheck, since a lone declaration referencing imports is not
22
+ * standalone-compilable). */
23
+ const FENCE = 'ts config-catalog'
24
+
25
+ /** TypeScript/Node global type names a config declaration may reference
26
+ * without importing; never treated as unresolved. Extend when a new global
27
+ * legitimately appears — the generator hard-errors on unknown names, so an
28
+ * omission is loud, not silent. */
29
+ const GLOBAL_TYPES = new Set([
30
+ 'Array', 'ReadonlyArray', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit',
31
+ 'Promise', 'Map', 'Set', 'Date', 'Error', 'RegExp', 'Exclude', 'Extract', 'NonNullable',
32
+ 'ReturnType', 'Parameters', 'AbortSignal', 'URL', 'Buffer', 'NodeJS', 'Iterable', 'AsyncIterable',
33
+ ])
34
+
35
+ /** How a package classifies for the catalog. */
36
+ type Kind = 'config' | 'no-config' | 'seam' | 'library'
37
+
38
+ /** One name a pasted declaration references but the paste does not contain. */
39
+ interface TypeRef {
40
+ /** The name as it appears in the pasted text (the local import alias). */
41
+ alias: string
42
+ /** The name the source module exports it under (pre-alias). */
43
+ imported: string
44
+ /** The import module specifier (package name or external module). */
45
+ specifier: string
46
+ }
47
+
48
+ /** One verbatim declaration paste. */
49
+ interface Paste {
50
+ /** Full source text: leading JSDoc (when present) through the closing token. */
51
+ text: string
52
+ /** Source pointer `packages/…/file.ts:line` of the declaration. */
53
+ source: string
54
+ }
55
+
56
+ /** One package's catalog entry. */
57
+ export interface CatalogEntry {
58
+ /** npm package name, e.g. `@deepseek-ai/dsh-agent-loop`. */
59
+ pkg: string
60
+ /** Repo-relative package dir, e.g. `packages/core/agent-loop`. */
61
+ dir: string
62
+ /** Repo-relative entry file, `<dir>/src/index.ts`. */
63
+ entry: string
64
+ kind: Kind
65
+ /** Service keys the plugin `inject`s (empty when none declared). */
66
+ inject: string[]
67
+ /** Seam/service class name (kinds `seam` and class-based plugins). */
68
+ className?: string
69
+ /** Name of the config type (kind `config`). */
70
+ configTypeName?: string
71
+ /** Verbatim declaration pastes, the config type first (kind `config`). */
72
+ pastes?: Paste[]
73
+ /** References the pastes leave unresolved locally (kind `config`). */
74
+ refs?: TypeRef[]
75
+ /** Top-level keys and nested key paths (`agents[].id`) of the runtime
76
+ * schema, `null` when no schema exists (kind `config`). */
77
+ schemaKeys?: string[] | null
78
+ /** Package names whose schemas an intersect composes (kind `config`). */
79
+ schemaComposes?: string[]
80
+ }
81
+
82
+ /** A parsed source file plus its import map (local name → origin). */
83
+ interface FileCtx {
84
+ abs: string
85
+ rel: string
86
+ text: string
87
+ sf: ts.SourceFile
88
+ /** Local binding name → `{ imported, specifier }`; default imports record
89
+ * `imported: 'default'`. */
90
+ imports: Map<string, { imported: string; specifier: string; typeOnly?: boolean }>
91
+ }
92
+
93
+ /** Throw one aggregate error for every violation the walk collected. */
94
+ function report(violations: string[]): void {
95
+ if (violations.length === 0) return
96
+ throw new Error(
97
+ `gen-config-catalog: ${violations.length} violation(s):\n`
98
+ + violations.map(v => ` ${v}`).join('\n'),
99
+ )
100
+ }
101
+
102
+ /** Parse a source file and index its import declarations. */
103
+ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCtx {
104
+ const cached = cache.get(abs)
105
+ if (cached) return cached
106
+ const text = readFileSync(abs, 'utf8')
107
+ const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
108
+ const imports: FileCtx['imports'] = new Map()
109
+ for (const stmt of sf.statements) {
110
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
111
+ const specifier = stmt.moduleSpecifier.text
112
+ const clause = stmt.importClause
113
+ if (!clause) continue
114
+ if (clause.name) imports.set(clause.name.text, { imported: 'default', specifier })
115
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
116
+ for (const el of clause.namedBindings.elements) {
117
+ imports.set(el.name.text, {
118
+ imported: (el.propertyName ?? el.name).text,
119
+ specifier,
120
+ typeOnly: clause.phaseModifier === ts.SyntaxKind.TypeKeyword || el.isTypeOnly,
121
+ })
122
+ }
123
+ }
124
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
125
+ imports.set(clause.namedBindings.name.text, { imported: '*', specifier })
126
+ }
127
+ }
128
+ const ctx = { abs, rel, text, sf, imports }
129
+ cache.set(abs, ctx)
130
+ return ctx
131
+ }
132
+
133
+ /** A type declaration a paste can contain. */
134
+ type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration
135
+
136
+ /** Find a pasteable type declaration by name in a file, or null. */
137
+ function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
138
+ for (const stmt of ctx.sf.statements) {
139
+ if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt))
140
+ && stmt.name.text === name) return stmt
141
+ }
142
+ return null
143
+ }
144
+
145
+ /**
146
+ * Resolve a type name from a file to its declaration (following package-local
147
+ * relative imports transitively) or to the import that brings it in. Returns
148
+ * `null` when the name is neither declared, imported, nor a known global.
149
+ */
150
+ function resolveTypeName(
151
+ ctx: FileCtx,
152
+ name: string,
153
+ cache: Map<string, FileCtx>,
154
+ violations: string[],
155
+ ): { decl: TypeDecl; ctx: FileCtx } | { ref: TypeRef } | null {
156
+ const local = findTypeDecl(ctx, name)
157
+ if (local) return { decl: local, ctx }
158
+ const imp = ctx.imports.get(name)
159
+ if (!imp) return null
160
+ if (imp.specifier.startsWith('.')) {
161
+ if (!imp.specifier.endsWith('.ts')) {
162
+ violations.push(`${ctx.rel}: relative import '${imp.specifier}' lacks the explicit .ts extension the repo convention requires.`)
163
+ return null
164
+ }
165
+ if (imp.imported !== name) {
166
+ violations.push(`${ctx.rel}: '${name}' aliases '${imp.imported}' across a package-local import; the catalog pastes declarations verbatim, so keep package-local config types unaliased.`)
167
+ return null
168
+ }
169
+ const abs = resolve(dirname(ctx.abs), imp.specifier)
170
+ const rel = ctx.rel.slice(0, ctx.rel.lastIndexOf('/') + 1) + imp.specifier.replace(/^\.\//, '')
171
+ const target = loadFile(abs, rel, cache)
172
+ return resolveTypeName(target, imp.imported, cache, violations)
173
+ }
174
+ return { ref: { alias: name, imported: imp.imported, specifier: imp.specifier } }
175
+ }
176
+
177
+ /** Collect every type NAME referenced in type positions under a node. */
178
+ function collectTypeNames(node: ts.Node, out: Set<string>): void {
179
+ const visit = (n: ts.Node): void => {
180
+ if (ts.isTypeReferenceNode(n)) {
181
+ let head: ts.EntityName = n.typeName
182
+ while (ts.isQualifiedName(head)) head = head.left
183
+ out.add(head.text)
184
+ } else if (ts.isExpressionWithTypeArguments(n) && ts.isIdentifier(n.expression)) {
185
+ out.add(n.expression.text) // heritage clause: `extends X`
186
+ }
187
+ ts.forEachChild(n, visit)
188
+ }
189
+ visit(node)
190
+ }
191
+
192
+ /** The verbatim paste text of a declaration: leading JSDoc through the end. */
193
+ function pasteText(ctx: FileCtx, decl: TypeDecl): string {
194
+ const raw = rawJsDoc(ctx.text, decl)
195
+ const start = raw ? ctx.text.indexOf(raw, decl.getFullStart()) : decl.getStart(ctx.sf)
196
+ return ctx.text.slice(start, decl.end)
197
+ }
198
+
199
+ /** Enforce non-empty JSDoc prose on every property of a pasted declaration,
200
+ * recursing into nested type literals (e.g. an array-of-objects field). */
201
+ function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): void {
202
+ const walkMembers = (members: ts.NodeArray<ts.TypeElement>, path: string): void => {
203
+ for (const member of members) {
204
+ if (!ts.isPropertySignature(member)) continue
205
+ const name = member.name.getText(ctx.sf)
206
+ const where = `config field '${path}.${name}' (${pointer(ctx.rel, ctx.sf, member)})`
207
+ if (!parseJsDoc(rawJsDoc(ctx.text, member)).doc) violations.push(`${where} has no JSDoc prose.`)
208
+ if (member.type) walkNested(member.type, `${path}.${name}`)
209
+ }
210
+ }
211
+ const walkNested = (type: ts.Node, path: string): void => {
212
+ if (ts.isTypeLiteralNode(type)) walkMembers(type.members, path)
213
+ else ts.forEachChild(type, (n) => { walkNested(n, path) })
214
+ }
215
+ if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
216
+ else if (ts.isTypeAliasDeclaration(decl)) walkNested(decl.type, decl.name.text)
217
+ }
218
+
219
+ /** Cross-file resolution context for the schema-path check. */
220
+ interface World {
221
+ scanRoot: string
222
+ cache: Map<string, FileCtx>
223
+ /** Workspace package name → repo-relative package dir. */
224
+ pkgDirByName: Map<string, string>
225
+ /** Exact public export declarations used to verify shared schema imports. */
226
+ pkgExportsByName: Map<string, unknown>
227
+ /** Source-plane TypeScript resolution, loaded only for shared schema imports. */
228
+ compilerOptions?: ts.CompilerOptions
229
+ }
230
+
231
+ /** Resolve an explicitly exported workspace module to its mapped source file. */
232
+ function loadWorkspaceSource(world: World, from: FileCtx, specifier: string): FileCtx {
233
+ const pkg = specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
234
+ const dir = world.pkgDirByName.get(pkg)
235
+ if (dir === undefined) throw new Error(`schema import '${specifier}' is not a workspace package`)
236
+ const subpath = specifier === pkg ? '.' : `.${specifier.slice(pkg.length)}`
237
+ const exports = world.pkgExportsByName.get(pkg)
238
+ if (typeof exports !== 'object' || exports === null || Array.isArray(exports)
239
+ || !Object.hasOwn(exports, subpath) || (exports as Record<string, unknown>)[subpath] == null) {
240
+ throw new Error(`workspace package '${pkg}' does not explicitly export '${subpath}'`)
241
+ }
242
+ if (world.compilerOptions === undefined) {
243
+ const parsed = ts.getParsedCommandLineOfConfigFile(resolve(world.scanRoot, 'tsconfig.json'), {}, {
244
+ ...ts.sys,
245
+ onUnRecoverableConfigFileDiagnostic(diagnostic) {
246
+ throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
247
+ },
248
+ })
249
+ if (parsed === undefined || parsed.errors.length > 0) {
250
+ throw new Error(`cannot resolve schema import '${specifier}' through the workspace source tsconfig`)
251
+ }
252
+ world.compilerOptions = parsed.options
253
+ }
254
+ const resolved = ts.resolveModuleName(specifier, from.abs, world.compilerOptions, ts.sys).resolvedModule
255
+ if (resolved === undefined) throw new Error(`schema import '${specifier}' has no workspace source mapping`)
256
+ const sourcePath = relative(resolve(world.scanRoot, dir, 'src'), resolved.resolvedFileName)
257
+ if (isAbsolute(sourcePath) || sourcePath === '..' || sourcePath.startsWith(`..${sep}`)
258
+ || !sourcePath.endsWith('.ts') || sourcePath.endsWith('.d.ts')) {
259
+ throw new Error(`schema import '${specifier}' must resolve inside ${dir}/src to a TypeScript source file`)
260
+ }
261
+ const rel = relative(world.scanRoot, resolved.resolvedFileName).split(sep).join('/')
262
+ return loadFile(resolved.resolvedFileName, rel, world.cache)
263
+ }
264
+
265
+ /** Find a directly declared const schema, requiring public exports at imported entries. */
266
+ function schemaConst(ctx: FileCtx, name: string, exported: boolean): ts.Expression | null {
267
+ for (const statement of ctx.sf.statements) {
268
+ if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue
269
+ if (exported && !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
270
+ const declaration = statement.declarationList.declarations.find(item => ts.isIdentifier(item.name) && item.name.text === name)
271
+ if (declaration?.initializer !== undefined) return declaration.initializer
272
+ }
273
+ return null
274
+ }
275
+
276
+ /** Follow only const identities and named value imports; never execute schema factories. */
277
+ function schemaAlias(world: World, ctx: FileCtx, name: string): { ctx: FileCtx; expr: ts.Expression } {
278
+ const local = schemaConst(ctx, name, false)
279
+ if (local !== null) return { ctx, expr: local }
280
+ const imported = ctx.imports.get(name)
281
+ if (imported === undefined || imported.typeOnly || imported.imported === '*' || imported.imported === 'default') {
282
+ throw new Error(`schema alias '${name}' must name a const or named value import`)
283
+ }
284
+ const target = loadWorkspaceSource(world, ctx, imported.specifier)
285
+ const expr = schemaConst(target, imported.imported, true)
286
+ if (expr === null) throw new Error(`schema import '${imported.specifier}' has no exported const '${imported.imported}'`)
287
+ return { ctx: target, expr }
288
+ }
289
+
290
+ /** How a schema key path fared against the declared config type: definitely
291
+ * present, definitely absent, or crossing a type the walk cannot enumerate
292
+ * (only `missing` is a violation — `unknown` must never mis-report). */
293
+ type PathLookup = 'found' | 'missing' | 'unknown'
294
+
295
+ /** One step of a schema key path: a named member, or an array-element hop. */
296
+ type PathStep = { member: string } | { array: true }
297
+
298
+ /** Parse a schema key path (`agents[].id`) into member/array steps. */
299
+ function parsePath(path: string): PathStep[] {
300
+ const steps: PathStep[] = []
301
+ for (const seg of path.split('.')) {
302
+ let name = seg
303
+ let arrays = 0
304
+ while (name.endsWith('[]')) {
305
+ name = name.slice(0, -2)
306
+ arrays += 1
307
+ }
308
+ steps.push({ member: name })
309
+ for (let i = 0; i < arrays; i += 1) steps.push({ array: true })
310
+ }
311
+ return steps
312
+ }
313
+
314
+ /** Load a package-relative import target as a FileCtx. */
315
+ function loadRelative(world: World, from: FileCtx, specifier: string): FileCtx {
316
+ const abs = resolve(dirname(from.abs), specifier)
317
+ const rel = from.rel.slice(0, from.rel.lastIndexOf('/') + 1) + specifier.replace(/^\.\//, '')
318
+ return loadFile(abs, rel, world.cache)
319
+ }
320
+
321
+ /** Find a type declaration EXPORTED (directly or via re-export chains) from a
322
+ * file, following `export … from './x.ts'` and `export * from './x.ts'`. */
323
+ function findExportedTypeDecl(world: World, ctx: FileCtx, name: string, seen = new Set<string>()): { decl: TypeDecl; ctx: FileCtx } | null {
324
+ const key = `${ctx.abs}#${name}`
325
+ if (seen.has(key)) return null
326
+ seen.add(key)
327
+ const local = findTypeDecl(ctx, name)
328
+ if (local) return { decl: local, ctx }
329
+ for (const stmt of ctx.sf.statements) {
330
+ if (!ts.isExportDeclaration(stmt) || !stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
331
+ const spec = stmt.moduleSpecifier.text
332
+ if (!spec.startsWith('.') || !spec.endsWith('.ts')) continue
333
+ let lookFor: string | null = null
334
+ if (!stmt.exportClause) {
335
+ lookFor = name // export * from './x.ts'
336
+ } else if (ts.isNamedExports(stmt.exportClause)) {
337
+ const el = stmt.exportClause.elements.find(e => e.name.text === name)
338
+ if (el) lookFor = (el.propertyName ?? el.name).text
339
+ }
340
+ if (lookFor === null) continue
341
+ const hit = findExportedTypeDecl(world, loadRelative(world, ctx, spec), lookFor, seen)
342
+ if (hit) return hit
343
+ }
344
+ return null
345
+ }
346
+
347
+ /** Resolve a referenced type NAME to its declaration: declared locally, via a
348
+ * package-relative import, or via a workspace-package import (entry file +
349
+ * re-export chains). `'unknown'` = external or otherwise out of reach. */
350
+ function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: TypeDecl; ctx: FileCtx } | 'unknown' {
351
+ const local = findTypeDecl(ctx, name)
352
+ if (local) return { decl: local, ctx }
353
+ const imp = ctx.imports.get(name)
354
+ if (!imp) return 'unknown'
355
+ if (imp.specifier.startsWith('.')) {
356
+ if (!imp.specifier.endsWith('.ts')) return 'unknown'
357
+ return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
358
+ }
359
+ const dir = world.pkgDirByName.get(imp.specifier)
360
+ if (dir === undefined) {
361
+ try {
362
+ const target = loadWorkspaceSource(world, ctx, imp.specifier)
363
+ return findExportedTypeDecl(world, target, imp.imported) ?? 'unknown'
364
+ } catch {
365
+ // External or unsupported declarations remain unknown for type presence;
366
+ // a runtime schema alias reports the same resolution failure separately.
367
+ return 'unknown'
368
+ }
369
+ }
370
+ const entryRel = `${dir}/src/index.ts`
371
+ let entry: FileCtx
372
+ try {
373
+ entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
374
+ } catch {
375
+ // A workspace package without a readable entry is reported by its own
376
+ // classification pass; for a lookup it is out of reach.
377
+ return 'unknown'
378
+ }
379
+ return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
380
+ }
381
+
382
+ /** Utility wrappers that pass a member lookup through to their type argument. */
383
+ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNullable'])
384
+
385
+ /**
386
+ * Walk a schema key path against a declared type. This is a PRESENCE check,
387
+ * not a runtime value check: it answers "does the declared config type have a member
388
+ * here", resolving interfaces (heritage included), type aliases, literals,
389
+ * intersections, unions, arrays, indexed access, pass-through utility
390
+ * wrappers, and type references across package-local and workspace imports.
391
+ * Anything it cannot see through resolves `'unknown'`, never `'missing'`.
392
+ */
393
+ function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
394
+ if (steps.length === 0) return 'found'
395
+ // Guard only named declarations, where recursive types can loop. Structural
396
+ // children can share a source position with their parent, so guarding them
397
+ // would mistake ordinary descent for a cycle.
398
+ if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
399
+ const key = `${ctx.abs}:${node.pos}:${steps.length}`
400
+ if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
401
+ seen.add(key)
402
+ }
403
+ const step = steps[0]
404
+ if (step === undefined) return 'found'
405
+ // Combine branch results: any found wins, else any unknown taints, else missing.
406
+ const combine = (results: PathLookup[]): PathLookup => {
407
+ if (results.includes('found')) return 'found'
408
+ if (results.includes('unknown')) return 'unknown'
409
+ return 'missing'
410
+ }
411
+ const intoMembers = (members: ts.NodeArray<ts.TypeElement>): PathLookup | null => {
412
+ if (!('member' in step)) return null
413
+ for (const m of members) {
414
+ if (!ts.isPropertySignature(m) || m.name.getText(ctx.sf) !== step.member) continue
415
+ if (steps.length === 1) return 'found'
416
+ return m.type ? lookupPath(world, ctx, m.type, steps.slice(1), seen) : 'unknown'
417
+ }
418
+ return null // not among these members; caller consults heritage/parts
419
+ }
420
+ if (ts.isInterfaceDeclaration(node)) {
421
+ if (!('member' in step)) return 'unknown' // an array step cannot land on an interface
422
+ const direct = intoMembers(node.members)
423
+ if (direct !== null) return direct
424
+ const bases: PathLookup[] = []
425
+ for (const clause of node.heritageClauses ?? []) {
426
+ for (const base of clause.types) {
427
+ if (!ts.isIdentifier(base.expression)) {
428
+ bases.push('unknown')
429
+ continue
430
+ }
431
+ const resolved = declForTypeName(world, ctx, base.expression.text)
432
+ bases.push(resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen))
433
+ }
434
+ }
435
+ return bases.length ? combine(bases) : 'missing'
436
+ }
437
+ if (ts.isTypeAliasDeclaration(node)) return lookupPath(world, ctx, node.type, steps, seen)
438
+ if (ts.isTypeLiteralNode(node)) {
439
+ if (!('member' in step)) return 'unknown'
440
+ return intoMembers(node.members) ?? 'missing'
441
+ }
442
+ if (ts.isParenthesizedTypeNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
443
+ if (ts.isIntersectionTypeNode(node)) {
444
+ return combine(node.types.map(t => lookupPath(world, ctx, t, steps, seen)))
445
+ }
446
+ if (ts.isUnionTypeNode(node)) {
447
+ // Presence on a union is only definite when every branch agrees.
448
+ const results = node.types.map(t => lookupPath(world, ctx, t, steps, seen))
449
+ if (results.every(r => r === 'found')) return 'found'
450
+ if (results.every(r => r === 'missing')) return 'missing'
451
+ return 'unknown'
452
+ }
453
+ if (ts.isArrayTypeNode(node)) {
454
+ return 'array' in step ? lookupPath(world, ctx, node.elementType, steps.slice(1), seen) : 'unknown'
455
+ }
456
+ if (ts.isTypeOperatorNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
457
+ if (ts.isIndexedAccessTypeNode(node)) {
458
+ const index = node.indexType
459
+ if (ts.isLiteralTypeNode(index) && ts.isStringLiteral(index.literal)) {
460
+ return lookupPath(world, ctx, node.objectType, [{ member: index.literal.text }, ...steps], seen)
461
+ }
462
+ return 'unknown'
463
+ }
464
+ if (ts.isTypeReferenceNode(node)) {
465
+ let head: ts.EntityName = node.typeName
466
+ while (ts.isQualifiedName(head)) head = head.left
467
+ const name = head.text
468
+ if (PASSTHROUGH_WRAPPERS.has(name) && node.typeArguments?.[0]) {
469
+ return lookupPath(world, ctx, node.typeArguments[0], steps, seen)
470
+ }
471
+ if ((name === 'Array' || name === 'ReadonlyArray') && node.typeArguments?.[0]) {
472
+ return 'array' in step ? lookupPath(world, ctx, node.typeArguments[0], steps.slice(1), seen) : 'unknown'
473
+ }
474
+ if (!ts.isIdentifier(node.typeName)) return 'unknown' // namespace-qualified: out of reach
475
+ const resolved = declForTypeName(world, ctx, name)
476
+ return resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen)
477
+ }
478
+ return 'unknown'
479
+ }
480
+
481
+ /** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
482
+ function unwrapExpr(expr: ts.Expression): ts.Expression {
483
+ let e = expr
484
+ while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression
485
+ return e
486
+ }
487
+
488
+ /**
489
+ * Statically walk a schemastery schema expression to its key paths plus the
490
+ * packages whose schemas an intersect composes. A key path is the top-level
491
+ * key or a nested path through object/array compositions (`agents[].id`).
492
+ * Handles object/union calls, chained refinements, named const schema imports
493
+ * through public workspace source paths, and `z.intersect([X.Config, …])`; errors on
494
+ * anything else, so a schema the walk cannot see fails the gate instead of
495
+ * silently thinning it. Nested values that are neither `object` nor `array`
496
+ * compositions (primitives, unions, dynamic-key dicts) contribute no paths.
497
+ */
498
+ function walkSchemaExpr(
499
+ world: World,
500
+ ctx: FileCtx,
501
+ expr: ts.Expression,
502
+ where: string,
503
+ violations: string[],
504
+ aliases = new Set<string>(),
505
+ ): { keys: string[]; composes: string[] } {
506
+ const keys: string[] = []
507
+ const composes: string[] = []
508
+ // Nested paths under one object property's VALUE expression: recurse through
509
+ // chained refinements toward the base call, descending into object/array.
510
+ const collectValuePaths = (value: ts.Expression, base: string): void => {
511
+ const call = unwrapExpr(value)
512
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return
513
+ const method = call.expression.name.text
514
+ if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
515
+ for (const prop of call.arguments[0].properties) {
516
+ if (!ts.isPropertyAssignment(prop)) continue
517
+ const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
518
+ keys.push(`${base}.${key}`)
519
+ collectValuePaths(prop.initializer, `${base}.${key}`)
520
+ }
521
+ return
522
+ }
523
+ if (method === 'array' && call.arguments[0]) {
524
+ collectValuePaths(call.arguments[0], `${base}[]`)
525
+ return
526
+ }
527
+ const inner = unwrapExpr(call.expression.expression)
528
+ if (ts.isCallExpression(inner)) collectValuePaths(inner, base)
529
+ }
530
+ const visit = (e: ts.Expression): void => {
531
+ const call = unwrapExpr(e)
532
+ if (ts.isIdentifier(call)) {
533
+ const identity = `${ctx.abs}#${call.text}`
534
+ if (aliases.has(identity)) {
535
+ violations.push(`${where}: cyclic schema alias '${call.text}' in ${ctx.rel}.`)
536
+ return
537
+ }
538
+ try {
539
+ const target = schemaAlias(world, ctx, call.text)
540
+ const result = walkSchemaExpr(world, target.ctx, target.expr, where, violations, new Set([...aliases, identity]))
541
+ keys.push(...result.keys)
542
+ composes.push(...result.composes)
543
+ } catch (error) {
544
+ violations.push(`${where}: ${error instanceof Error ? error.message : String(error)}.`)
545
+ }
546
+ return
547
+ }
548
+ if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
549
+ violations.push(`${where}: schema expression is not a statically walkable schemastery call.`)
550
+ return
551
+ }
552
+ const method = call.expression.name.text
553
+ if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
554
+ for (const prop of call.arguments[0].properties) {
555
+ if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
556
+ const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
557
+ keys.push(key)
558
+ if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
559
+ } else {
560
+ violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
561
+ }
562
+ }
563
+ return
564
+ }
565
+ if (method === 'intersect' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
566
+ for (const el of call.arguments[0].elements) {
567
+ const part = unwrapExpr(el)
568
+ if (ts.isPropertyAccessExpression(part) && part.name.text === 'Config' && ts.isIdentifier(part.expression)) {
569
+ const imp = ctx.imports.get(part.expression.text)
570
+ if (imp && !imp.specifier.startsWith('.')) { composes.push(imp.specifier); continue }
571
+ }
572
+ if (ts.isCallExpression(part) || ts.isIdentifier(part)) { visit(part); continue }
573
+ violations.push(`${where}: intersect element '${part.getText(ctx.sf)}' is neither a workspace plugin's Config nor an inline schema call.`)
574
+ }
575
+ return
576
+ }
577
+ // A union of objects (discriminated union config): collect keys from all
578
+ // variants. Each variant is visited the same way as an intersect element.
579
+ if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
580
+ for (const el of call.arguments[0].elements) {
581
+ const part = unwrapExpr(el)
582
+ if (ts.isCallExpression(part) || ts.isIdentifier(part)) { visit(part); continue }
583
+ violations.push(`${where}: union element '${part.getText(ctx.sf)}' is not a schema call or const alias.`)
584
+ }
585
+ return
586
+ }
587
+ // A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
588
+ // the call the chain hangs off — keep unwrapping toward it.
589
+ const base = unwrapExpr(call.expression.expression)
590
+ if (ts.isCallExpression(base)) { visit(base); return }
591
+ violations.push(`${where}: schema call '${method}' is not object/intersect and hangs off no walkable base call.`)
592
+ }
593
+ visit(expr)
594
+ return { keys, composes }
595
+ }
596
+
597
+ /** Find a plugin's schemastery schema expression: an exported `const Config`
598
+ * in the entry file, else a `static Config` on the plugin class. */
599
+ function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): ts.Expression | null {
600
+ for (const stmt of ctx.sf.statements) {
601
+ if (!ts.isVariableStatement(stmt)) continue
602
+ if (!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) continue
603
+ for (const decl of stmt.declarationList.declarations) {
604
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'Config' && decl.initializer) return decl.initializer
605
+ }
606
+ }
607
+ for (const member of pluginClass?.members ?? []) {
608
+ if (!ts.isPropertyDeclaration(member) || member.name.getText() !== 'Config') continue
609
+ if (!member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)) continue
610
+ if (member.initializer) return member.initializer
611
+ }
612
+ return null
613
+ }
614
+
615
+ /** Read an `inject` service-key list: `export const inject = […]` in the entry
616
+ * file, else `static inject = […]` on the plugin class. */
617
+ function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] {
618
+ const fromArray = (expr: ts.Expression, where: string): string[] => {
619
+ if (!ts.isArrayLiteralExpression(expr)) {
620
+ violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new declaration form.`)
621
+ return []
622
+ }
623
+ return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf))
624
+ }
625
+ for (const stmt of ctx.sf.statements) {
626
+ if (!ts.isVariableStatement(stmt)) continue
627
+ for (const decl of stmt.declarationList.declarations) {
628
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'inject' && decl.initializer) {
629
+ return fromArray(decl.initializer, ctx.rel)
630
+ }
631
+ }
632
+ }
633
+ for (const member of pluginClass?.members ?? []) {
634
+ if (ts.isPropertyDeclaration(member) && member.name.getText() === 'inject' && member.initializer) {
635
+ return fromArray(member.initializer, ctx.rel)
636
+ }
637
+ }
638
+ return []
639
+ }
640
+
641
+ /** Resolve the entry file's default export to its class/function declaration
642
+ * (mirroring the Loader's `unwrapExports`), or null when there is none. */
643
+ function defaultExport(ctx: FileCtx): ts.ClassDeclaration | ts.FunctionDeclaration | null {
644
+ for (const stmt of ctx.sf.statements) {
645
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
646
+ const name = stmt.expression.text
647
+ for (const s of ctx.sf.statements) {
648
+ if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s)) && s.name?.text === name) return s
649
+ }
650
+ return null
651
+ }
652
+ if ((ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt))
653
+ && stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) return stmt
654
+ }
655
+ return null
656
+ }
657
+
658
+ /** Find the exported `apply` function declaration in the entry file, or null. */
659
+ function applyExport(ctx: FileCtx): ts.FunctionDeclaration | null {
660
+ for (const stmt of ctx.sf.statements) {
661
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'apply'
662
+ && stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) return stmt
663
+ }
664
+ return null
665
+ }
666
+
667
+ /**
668
+ * Walk every `packages/<group>/<pkg>` entry and build the catalog entries.
669
+ * Hard-errors (aggregated) on any violation listed in the module doc.
670
+ * `scanRoot` defaults to the repo root; tests pass a fixture dir.
671
+ */
672
+ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
673
+ const violations: string[] = []
674
+ const cache = new Map<string, FileCtx>()
675
+ const entries: CatalogEntry[] = []
676
+
677
+ // Pre-pass: package name → dir, so schema-path lookups can follow
678
+ // workspace-package imports while individual packages are still being walked.
679
+ const pkgDirByName = new Map<string, string>()
680
+ const pkgExportsByName = new Map<string, unknown>()
681
+ const manifests: { dir: string; pkg: string }[] = []
682
+ for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).map(path => path.split(sep).join('/')).sort()) {
683
+ const dir = manifestRel.slice(0, -'/package.json'.length)
684
+ const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[]; exports?: unknown }
685
+ const pkg = manifest.name
686
+ if (!pkg) {
687
+ violations.push(`${manifestRel} has no "name".`)
688
+ continue
689
+ }
690
+ if (manifest.os !== undefined && manifest.cpu !== undefined) {
691
+ // A per-platform native-binary package (npm os/cpu selection) ships no
692
+ // JavaScript at all — nothing to classify, no Config to catalog.
693
+ continue
694
+ }
695
+ pkgDirByName.set(pkg, dir)
696
+ pkgExportsByName.set(pkg, manifest.exports)
697
+ manifests.push({ dir, pkg })
698
+ }
699
+ const world: World = { scanRoot, cache, pkgDirByName, pkgExportsByName }
700
+
701
+ for (const { dir, pkg } of manifests) {
702
+ const entryRel = `${dir}/src/index.ts`
703
+ let ctx: FileCtx
704
+ try {
705
+ ctx = loadFile(resolve(scanRoot, entryRel), entryRel, cache)
706
+ } catch {
707
+ // A package without src/index.ts cannot be classified — that is the
708
+ // violation itself; nothing else in this loop body can run without it.
709
+ violations.push(`${pkg}: entry ${entryRel} is missing or unreadable.`)
710
+ continue
711
+ }
712
+
713
+ // Classify, mirroring the Loader's unwrapExports: the default export IS
714
+ // the plugin when present; else an exported `apply` makes the module
715
+ // namespace the plugin; else the package is a plain library.
716
+ const dflt = defaultExport(ctx)
717
+ const apply = applyExport(ctx)
718
+ let pluginClass: ts.ClassDeclaration | null = null
719
+ let configParam: ts.ParameterDeclaration | undefined
720
+ let kind: Kind
721
+ let className: string | undefined
722
+ if (dflt && ts.isClassDeclaration(dflt)) {
723
+ className = dflt.name?.text
724
+ if (dflt.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword)) {
725
+ kind = 'seam'
726
+ } else {
727
+ pluginClass = dflt
728
+ const ctor = dflt.members.find(ts.isConstructorDeclaration)
729
+ configParam = ctor?.parameters[1]
730
+ kind = configParam ? 'config' : 'no-config'
731
+ }
732
+ } else if (dflt) {
733
+ configParam = dflt.parameters[1]
734
+ kind = configParam ? 'config' : 'no-config'
735
+ } else if (apply) {
736
+ configParam = apply.parameters[1]
737
+ kind = configParam ? 'config' : 'no-config'
738
+ } else {
739
+ kind = 'library'
740
+ }
741
+
742
+ const entry: CatalogEntry = {
743
+ pkg,
744
+ dir,
745
+ entry: entryRel,
746
+ kind,
747
+ inject: kind === 'library' || kind === 'seam' ? [] : findInject(ctx, pluginClass, violations),
748
+ ...className !== undefined ? { className } : {},
749
+ }
750
+ entries.push(entry)
751
+ if (kind !== 'config' || !configParam) continue
752
+
753
+ // Resolve the config type and paste its package-local transitive closure.
754
+ if (!configParam.type || !ts.isTypeReferenceNode(configParam.type) || !ts.isIdentifier(configParam.type.typeName)) {
755
+ violations.push(`${pkg}: config parameter type (${pointer(entryRel, ctx.sf, configParam)}) is not a plain type-name reference; declare a named config type.`)
756
+ continue
757
+ }
758
+ const typeName = configParam.type.typeName.text
759
+ entry.configTypeName = typeName
760
+ const pastes: Paste[] = []
761
+ const refs = new Map<string, TypeRef>()
762
+ // A bare name is the fence's whole namespace: two DIFFERENT declarations
763
+ // (or a declaration in one file and an import in another) sharing a name
764
+ // cannot both render unambiguously, so every resolution is identity-checked
765
+ // by source pointer and a collision is a violation, never a silent skip.
766
+ const pastedDeclByName = new Map<string, string>()
767
+ const queue: { name: string; from: FileCtx }[] = [{ name: typeName, from: ctx }]
768
+ for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
769
+ const { name, from } = item
770
+ const resolved = resolveTypeName(from, name, cache, violations)
771
+ if (resolved === null) {
772
+ violations.push(`${pkg}: config declaration references '${name}' (via ${from.rel}), which is neither declared in the package, imported, nor a known global type.`)
773
+ continue
774
+ }
775
+ if ('ref' in resolved) {
776
+ if (name === typeName) {
777
+ violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
778
+ continue
779
+ }
780
+ if (pastedDeclByName.has(name)) {
781
+ violations.push(`${pkg}: '${name}' resolves to a package-local declaration (${pastedDeclByName.get(name) ?? ''}) in one file and an import from '${resolved.ref.specifier}' in another; rename one so the fence is unambiguous.`)
782
+ continue
783
+ }
784
+ const existing = refs.get(name)
785
+ if (existing && (existing.specifier !== resolved.ref.specifier || existing.imported !== resolved.ref.imported)) {
786
+ violations.push(`${pkg}: '${name}' is imported from both '${existing.specifier}' (${existing.imported}) and '${resolved.ref.specifier}' (${resolved.ref.imported}) across the pasted closure; disambiguate the aliases.`)
787
+ continue
788
+ }
789
+ refs.set(name, resolved.ref)
790
+ continue
791
+ }
792
+ const declKey = pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl)
793
+ const prior = pastedDeclByName.get(name)
794
+ if (prior === declKey) continue // same declaration reached again — benign
795
+ if (prior !== undefined) {
796
+ violations.push(`${pkg}: type name '${name}' resolves to two different declarations (${prior} and ${declKey}) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations.`)
797
+ continue
798
+ }
799
+ if (refs.has(name)) {
800
+ violations.push(`${pkg}: '${name}' resolves to an import from '${refs.get(name)?.specifier ?? ''}' in one file and a package-local declaration (${declKey}) in another; rename one so the fence is unambiguous.`)
801
+ continue
802
+ }
803
+ pastedDeclByName.set(name, declKey)
804
+ pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: declKey })
805
+ checkMemberDocs(resolved.ctx, resolved.decl, violations)
806
+ const names = new Set<string>()
807
+ collectTypeNames(resolved.decl, names)
808
+ for (const n of names) {
809
+ if (GLOBAL_TYPES.has(n)) continue
810
+ queue.push({ name: n, from: resolved.ctx })
811
+ }
812
+ }
813
+ entry.pastes = pastes
814
+ entry.refs = [...refs.values()].sort((a, b) => a.alias.localeCompare(b.alias))
815
+
816
+ // Statically walk the runtime schema (when one exists) for the subset check.
817
+ const schemaExpr = findSchemaExpr(ctx, pluginClass)
818
+ if (schemaExpr) {
819
+ const { keys, composes } = walkSchemaExpr(world, ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
820
+ entry.schemaKeys = keys
821
+ entry.schemaComposes = composes
822
+ } else {
823
+ entry.schemaKeys = null
824
+ }
825
+ }
826
+
827
+ // Fold composed schemas' key paths in, then check each path against the type.
828
+ // Only a definite miss fails; types the walk cannot enumerate stay unknown.
829
+ const byName = new Map(entries.map(e => [e.pkg, e]))
830
+ for (const entry of entries) {
831
+ if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
832
+ const seen = new Set<string>()
833
+ const foldComposed = (e: CatalogEntry): string[] => {
834
+ if (seen.has(e.pkg)) return []
835
+ seen.add(e.pkg)
836
+ const keys = [...e.schemaKeys ?? []]
837
+ for (const composed of e.schemaComposes ?? []) {
838
+ const target = byName.get(composed)
839
+ if (!target) {
840
+ violations.push(`${entry.pkg}: schema intersects '${composed}', which is not a workspace package the walk collected.`)
841
+ continue
842
+ }
843
+ keys.push(...foldComposed(target))
844
+ }
845
+ return keys
846
+ }
847
+ const allKeys = foldComposed(entry)
848
+ const mainPaste = entry.pastes?.[0]
849
+ const mainFile = mainPaste?.source.split(':')[0]
850
+ const mainCtx = mainFile !== undefined ? cache.get(resolve(scanRoot, mainFile)) : undefined
851
+ const mainDecl = mainCtx && entry.configTypeName !== undefined ? findTypeDecl(mainCtx, entry.configTypeName) : null
852
+ if (!mainCtx || !mainDecl) {
853
+ violations.push(`${entry.pkg}: cannot locate config type '${entry.configTypeName ?? ''}' for the schema-path check.`)
854
+ continue
855
+ }
856
+ for (const keyPath of allKeys) {
857
+ if (lookupPath(world, mainCtx, mainDecl, parsePath(keyPath), new Set()) === 'missing') {
858
+ violations.push(`${entry.pkg}: schema validates key '${keyPath}' but config type '${entry.configTypeName ?? ''}' declares no such member — the catalog paste would hide a loader-accepted field.`)
859
+ }
860
+ }
861
+ }
862
+
863
+ report(violations)
864
+ return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
865
+ }
866
+
867
+ /** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
868
+ function requiresLine(inject: string[]): string {
869
+ return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
870
+ }
871
+
872
+ /** Render one reference as a link: another plugin's config type → its section,
873
+ * a curated subsystems name → its page, any other workspace type →
874
+ * its source file, an external type → named with its module, unlinked. */
875
+ function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
876
+ const target = byName.get(ref.specifier)
877
+ if (target?.kind === 'config' && ref.imported === target.configTypeName) {
878
+ return `[\`${ref.alias}\`](#${githubSlug(target.pkg)})`
879
+ }
880
+ const page = LINK_MAP[ref.imported]
881
+ if (page) return `[\`${ref.alias}\`](subsystems/${page})`
882
+ if (target) return `[\`${ref.alias}\`](../${target.entry})`
883
+ return `\`${ref.alias}\` (\`${ref.specifier}\`)`
884
+ }
885
+
886
+ /** Render one configurable plugin's section. */
887
+ function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
888
+ const out = [`<a id="${githubSlug(entry.pkg)}"></a>`, '', `## \`${entry.pkg}\``, '']
889
+ const requires = requiresLine(entry.inject)
890
+ if (requires) out.push(requires, '')
891
+ out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
892
+ if (entry.refs && entry.refs.length > 0) {
893
+ out.push(`Depends on: ${entry.refs.map(r => refLink(r, byName)).join(' · ')}`, '')
894
+ }
895
+ const source = entry.pastes?.[0]?.source ?? entry.entry
896
+ out.push(`Source: [\`${source}\`](../${source.split(':')[0]})`, '')
897
+ return out
898
+ }
899
+
900
+ /** Render one terse list line (the no-config / seam / library sections). */
901
+ function renderTerse(entry: CatalogEntry, detail: string): string {
902
+ const requires = entry.inject.length ? ` — requires ${entry.inject.map(k => `\`${k}\``).join(' · ')}` : ''
903
+ return `- \`${entry.pkg}\`${detail}${requires} ([\`${entry.entry}\`](../${entry.entry}))`
904
+ }
905
+
906
+ /** Render the full catalog (pure, deterministic given sorted entries). */
907
+ export function render(entries: CatalogEntry[]): string {
908
+ const byName = new Map(entries.map(e => [e.pkg, e]))
909
+ const lines: string[] = [
910
+ '<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.',
911
+ ' Run `pnpm run gen-config-catalog` to regenerate. -->',
912
+ '',
913
+ '# Plugin Config Catalog',
914
+ '',
915
+ 'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the generated Cordis API region on each [subsystem page](subsystems/core.md), the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [subsystems/](subsystems/core.md) documents the types these declarations reference.',
916
+ '',
917
+ 'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
918
+ '',
919
+ 'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.',
920
+ '',
921
+ ]
922
+ for (const entry of entries.filter(e => e.kind === 'config')) {
923
+ lines.push(...renderConfigEntry(entry, byName))
924
+ }
925
+ lines.push(
926
+ '## Loadable plugins with no config',
927
+ '',
928
+ 'These load from a `cordis.yml` entry with no `config:` block; they declare no configuration API.',
929
+ '',
930
+ ...entries.filter(e => e.kind === 'no-config').map(e => renderTerse(e, '')),
931
+ '',
932
+ '## Seam packages (not directly loadable)',
933
+ '',
934
+ 'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).',
935
+ '',
936
+ ...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
937
+ '',
938
+ '## Library packages (no plugin entry)',
939
+ '',
940
+ 'Imported as libraries by other packages; a `cordis.yml` cannot load them.',
941
+ '',
942
+ ...entries.filter(e => e.kind === 'library').map(e => renderTerse(e, '')),
943
+ '',
944
+ )
945
+ return lines.join('\n')
946
+ }
947
+
948
+ /** CLI entry: default writes the catalog, `--check` fails if the committed
949
+ * copy is stale. Guarded behind an entry-point check so importing this module
950
+ * for tests neither regenerates the committed file nor calls process.exit. */
951
+ function main(): void {
952
+ const content = render(collectConfigCatalog())
953
+ if (process.argv.includes('--check')) {
954
+ let committed: string | null = null
955
+ try {
956
+ committed = readFileSync(resolve(root, OUT), 'utf8')
957
+ } catch {
958
+ // Only ENOENT (not yet generated) is expected; a present-but-unreadable
959
+ // file is not a state this repo produces. Either way the remedy is the
960
+ // same — regenerate — so treat a read failure as "stale".
961
+ committed = null
962
+ }
963
+ if (committed === content) {
964
+ console.log(`gen-config-catalog: ${OUT} is up to date.`)
965
+ process.exit(0)
966
+ }
967
+ console.error(`gen-config-catalog: ${OUT} is stale. Run \`pnpm run gen-config-catalog\` and commit ${OUT}.`)
968
+ process.exit(1)
969
+ }
970
+ writeFileSync(resolve(root, OUT), content)
971
+ console.log(`gen-config-catalog: wrote ${OUT}.`)
972
+ }
973
+
974
+ if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
975
+ main()
976
+ }
scripts/gen-dependency-catalog.spec.ts ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Production dependency selection and offline catalog freshness regressions. */
2
+
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+ import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
8
+ import {
9
+ collectDependencies, computeDependencyCatalog, createNpmResolutionEnvironment,
10
+ deduplicateDependencies, isDependencyCatalogCurrent,
11
+ } from './gen-dependency-catalog.ts'
12
+
13
+ const roots: string[] = []
14
+ afterEach(() => {
15
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
16
+ })
17
+
18
+ function lockfile() {
19
+ const lock = {
20
+ lockfileVersion: 3,
21
+ packages: {
22
+ '': { dependencies: { '@deepseek-ai/dsh': 'latest' } },
23
+ 'node_modules/@deepseek-ai/dsh': {
24
+ version: '1.0.0', dependencies: { library: '^2.0.0', alias: 'npm:original@1.0.0' },
25
+ optionalDependencies: { unavailable: '1.0.0' },
26
+ },
27
+ 'node_modules/library': { version: '1.0.0' },
28
+ 'node_modules/@deepseek-ai/dsh/node_modules/library': { version: '2.0.0', dependencies: { plugin: '*' } },
29
+ 'node_modules/alias': { name: 'original', version: '1.0.0' },
30
+ 'node_modules/plugin': {
31
+ version: '1.0.0', dependencies: { library: '^1.0.0' }, peerDependencies: { peer: '*' },
32
+ optionalDependencies: { 'native-linux': '*', 'native-helper': '*' },
33
+ },
34
+ 'node_modules/peer': { version: '3.0.0', peer: true },
35
+ 'node_modules/native-linux': { version: '1.0.0', optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'] },
36
+ 'node_modules/native-helper': { version: '1.0.0', optional: true, devOptional: true },
37
+ 'node_modules/test-only': { version: '1.0.0', dev: true },
38
+ },
39
+ }
40
+ for (const [location, entry] of Object.entries(lock.packages)) {
41
+ if (location !== '') Object.assign(entry, { resolved: `https://registry.npmjs.org/${location}/package.tgz` })
42
+ }
43
+ return lock
44
+ }
45
+
46
+ function fixture(): string {
47
+ const root = mkdtempSync(join(tmpdir(), 'dsh-dependency-catalog-test-'))
48
+ roots.push(root)
49
+ mkdirSync(join(root, 'scripts/dependency-catalog'), { recursive: true })
50
+ writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(lockfile()))
51
+ writeFileSync(join(root, 'scripts/dependency-catalog/resolution.json'), JSON.stringify({
52
+ capturedAt: '2026-09-12T00:00:00.000Z', npm: '11.17.0', node: '24.19.0',
53
+ platform: 'darwin', arch: 'arm64', registry: 'https://registry.npmjs.org/', installStrategy: 'hoisted',
54
+ }))
55
+ return root
56
+ }
57
+
58
+ describe('published npm dependency catalog', () => {
59
+ it('retains nested versions, aliases, peers, and optional candidates while excluding development-only packages', () => {
60
+ const { version, rows } = collectDependencies(lockfile())
61
+ expect(version).toBe('1.0.0')
62
+ expect(rows).toHaveLength(7)
63
+ expect(rows.filter(row => row.direct).map(row => [row.name, row.version])).toEqual([
64
+ ['library', '2.0.0'], ['original', '1.0.0'],
65
+ ])
66
+ expect(rows.find(row => row.name === 'peer')).toMatchObject({ peer: true, optional: false })
67
+ expect(rows.find(row => row.name === 'native-linux')).toMatchObject({
68
+ optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'],
69
+ })
70
+ expect(rows.find(row => row.name === 'native-helper')).toMatchObject({ optional: true })
71
+ expect(rows.map(row => row.name)).not.toContain('test-only')
72
+ expect(rows.map(row => row.name)).not.toContain('unavailable')
73
+ })
74
+
75
+ it('does not silently produce an incomplete catalog from invalid or unrelated resolutions', () => {
76
+ expect(() => collectDependencies({ lockfileVersion: 2 })).toThrow('lockfileVersion 3')
77
+ expect(() => collectDependencies({ lockfileVersion: 3, packages: {} })).toThrow('consumer must be an object')
78
+ const input = lockfile()
79
+ input.packages[''].dependencies['@deepseek-ai/dsh'] = 'next'
80
+ expect(() => collectDependencies(input)).toThrow('must request only @deepseek-ai/dsh@latest')
81
+ const missing = lockfile()
82
+ Reflect.deleteProperty(missing.packages, 'node_modules/alias')
83
+ expect(() => collectDependencies(missing)).toThrow('missing direct dependency alias')
84
+ const linked = lockfile()
85
+ Object.assign(linked.packages['node_modules/library'], { link: true })
86
+ expect(() => collectDependencies(linked)).toThrow('is a local link')
87
+ })
88
+
89
+ it('sorts packages independently of lockfile object order and keeps duplicate installation locations', () => {
90
+ const input = lockfile()
91
+ const reordered = { ...input, packages: Object.fromEntries(Object.entries(input.packages).reverse()) }
92
+ expect(collectDependencies(reordered)).toEqual(collectDependencies(input))
93
+ Object.assign(input.packages, {
94
+ 'node_modules/plugin/node_modules/library': {
95
+ version: '1.0.0', resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
96
+ },
97
+ })
98
+ expect(collectDependencies(input).rows.filter(row => row.name === 'library' && row.version === '1.0.0')).toHaveLength(2)
99
+ })
100
+
101
+ it('deduplicates identical versions without losing installation locations or optional flags', () => {
102
+ const input = lockfile()
103
+ Object.assign(input.packages, {
104
+ 'node_modules/plugin/node_modules/library': {
105
+ version: '1.0.0', optional: true, resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
106
+ },
107
+ })
108
+ const rows = collectDependencies(input).rows
109
+ const packages = deduplicateDependencies(rows)
110
+ const repeated = packages.filter(entry => entry.name === 'library' && entry.version === '1.0.0')
111
+ expect(repeated).toHaveLength(1)
112
+ expect(repeated[0]?.installations).toEqual([
113
+ { location: 'node_modules/library', direct: false, optional: false, peer: false },
114
+ { location: 'node_modules/plugin/node_modules/library', direct: false, optional: true, peer: false },
115
+ ])
116
+ expect(packages.filter(entry => entry.name === 'library').map(entry => entry.version)).toEqual(['1.0.0', '2.0.0'])
117
+ expect(deduplicateDependencies([...rows].reverse())).toEqual(packages)
118
+ expect(packages.flatMap(entry => entry.installations)).toHaveLength(rows.length)
119
+ })
120
+
121
+ it('rejects missing, edited, or stale JSON without rewriting it', () => {
122
+ const root = fixture()
123
+ const output = computeDependencyCatalog(root)
124
+ const path = join(root, 'docs/dependency-catalog.json')
125
+ expect(isDependencyCatalogCurrent(root)).toBe(false)
126
+ mkdirSync(join(root, 'docs'))
127
+ writeFileSync(path, output)
128
+ expect(isDependencyCatalogCurrent(root)).toBe(true)
129
+ writeFileSync(path, `${output}stale\n`)
130
+ expect(isDependencyCatalogCurrent(root)).toBe(false)
131
+ expect(readFileSync(path, 'utf8')).toBe(`${output}stale\n`)
132
+ writeFileSync(path, output)
133
+ const updated = lockfile()
134
+ updated.packages['node_modules/peer'].version = '3.1.0'
135
+ writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(updated))
136
+ expect(isDependencyCatalogCurrent(root)).toBe(false)
137
+ })
138
+
139
+ it('rejects non-public package sources and incorrect registry metadata', () => {
140
+ const input = lockfile()
141
+ Object.assign(input.packages['node_modules/library'], { resolved: 'https://private.invalid/library.tgz' })
142
+ expect(() => collectDependencies(input)).toThrow('not resolved from the public npm registry')
143
+ const root = fixture()
144
+ const metadataPath = join(root, 'scripts/dependency-catalog/resolution.json')
145
+ writeFileSync(metadataPath, JSON.stringify({ registry: 'https://private.invalid/', installStrategy: 'hoisted' }))
146
+ expect(() => computeDependencyCatalog(root)).toThrow('expected the public npm registry and hoisted install strategy')
147
+ })
148
+
149
+ it('isolates scoped registries, resolver settings, and caches from user, global, and environment configuration', async () => {
150
+ const root = fixture()
151
+ const userConfig = join(root, 'user.npmrc')
152
+ const globalConfig = join(root, 'global.npmrc')
153
+ writeFileSync(userConfig, '@deepseek-ai:registry=https://user-override.invalid/\nstrict-peer-deps=true\n')
154
+ writeFileSync(globalConfig, '@other:registry=https://global-override.invalid/\nprefer-dedupe=true\n')
155
+ const inherited: NodeJS.ProcessEnv = {
156
+ ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
157
+ npm_config_userconfig: userConfig,
158
+ NPM_CONFIG_GLOBALCONFIG: globalConfig,
159
+ NPM_CONFIG_INSTALL_STRATEGY: 'nested',
160
+ NPM_CONFIG_OFFLINE: 'true',
161
+ NPM_CONFIG_CACHE: join(root, 'inherited-cache'),
162
+ NPM_CONFIG_USER_AGENT: 'inherited-agent',
163
+ }
164
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
165
+ const args = ['config', 'list', '--json', '--registry=https://registry.npmjs.org/', '--loglevel=error']
166
+ const before = await runCommandWithTimeout(npm, args, { cwd: root, env: inherited, timeoutMs: 30_000 })
167
+ expect(before).toMatchObject({ status: 0, signal: null, timedOut: false })
168
+ expect(JSON.parse(before.output)).toMatchObject({
169
+ '@deepseek-ai:registry': 'https://user-override.invalid/', 'install-strategy': 'nested',
170
+ })
171
+ const isolated = createNpmResolutionEnvironment(root, inherited)
172
+ const after = await runCommandWithTimeout(npm, args, { cwd: root, env: isolated, timeoutMs: 30_000 })
173
+ expect(after).toMatchObject({ status: 0, signal: null, timedOut: false })
174
+ const settings = JSON.parse(after.output) as Record<string, unknown>
175
+ expect(settings).toMatchObject({
176
+ registry: 'https://registry.npmjs.org/', '@deepseek-ai:registry': 'https://registry.npmjs.org/',
177
+ 'install-strategy': 'hoisted', 'strict-peer-deps': false, 'prefer-dedupe': false, offline: false,
178
+ cache: join(root, '.npm-cache'), userconfig: join(root, '.npmrc-user'), globalconfig: join(root, '.npmrc-global'),
179
+ })
180
+ expect(settings['@other:registry']).toBeUndefined()
181
+ expect(isolated['NPM_CONFIG_USER_AGENT']).toBeUndefined()
182
+ expect(inherited['npm_config_userconfig']).toBe(userConfig)
183
+ })
184
+
185
+ it('keeps the checked-in JSON synchronized with its recorded npm resolution', () => {
186
+ expect(isDependencyCatalogCurrent(resolve(import.meta.dirname, '..'))).toBe(true)
187
+ })
188
+ })
scripts/gen-doc-graphs.spec.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for the event-relation collector's demand-driven call-site indexing:
3
+ * the single-file fast path and the global fallback must recover the same
4
+ * helper-parameter event names, including shapes that defeat the locality
5
+ * proof (alias escapes and global script files).
6
+ */
7
+
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { afterAll, describe, expect, it } from 'vitest'
12
+ import { collectPackageSources, EventRelationCollector } from './gen-doc-graphs.ts'
13
+ import { TypeScriptProject } from './ts-project.ts'
14
+
15
+ const FIXTURE: Record<string, string> = {
16
+ 'tsconfig.host.json': JSON.stringify({
17
+ compilerOptions: {
18
+ target: 'es2022',
19
+ module: 'esnext',
20
+ moduleResolution: 'bundler',
21
+ allowImportingTsExtensions: true,
22
+ noEmit: true,
23
+ skipLibCheck: true,
24
+ types: [],
25
+ },
26
+ include: ['vendor/**/*.ts', 'packages/**/*.ts'],
27
+ }),
28
+ 'vendor/cordis/src/context.ts': 'export class Context { private brand!: void }\n',
29
+ 'vendor/cordis/src/events.ts': [
30
+ 'export class EventsService {',
31
+ ' dispatch(type: string, args: unknown[]): unknown[] { return [type, args] }',
32
+ '}',
33
+ '',
34
+ ].join('\n'),
35
+ 'packages/core/agent/src/dispatch.ts':
36
+ 'export interface AgentEventDispatch { emit(...args: unknown[]): void }\n',
37
+ // fireLocal: every same-file reference is a direct callee, so the locality
38
+ // proof holds and only this file is indexed. fireAliased: the exported
39
+ // const is a value-position reference, so the proof fails and the global
40
+ // fallback must find the cross-file call in pkgb.
41
+ 'packages/fix/pkga/src/index.ts': [
42
+ "import { EventsService } from '../../../../vendor/cordis/src/events.ts'",
43
+ 'declare const events: EventsService',
44
+ "function fireLocal(args: [string]): void { void events.dispatch('emit', args) }",
45
+ "fireLocal(['pkga/local-event'])",
46
+ "function fireAliased(args: [string]): void { void events.dispatch('emit', args) }",
47
+ 'export const aliased = fireAliased',
48
+ '',
49
+ ].join('\n'),
50
+ 'packages/fix/pkgb/src/index.ts': [
51
+ "import { aliased } from '../../pkga/src/index.ts'",
52
+ "aliased(['pkgb/aliased-event'])",
53
+ '',
54
+ ].join('\n'),
55
+ // Global script files (no import/export): scriptFire is program-visible, so
56
+ // the cross-file call in caller.ts leaves no same-file reference. Only the
57
+ // module-ness premise check routes this helper to the global index; without
58
+ // it the proof would pass and the event would silently drop.
59
+ 'packages/fix/pkgc/src/globals.ts':
60
+ "declare var gEvents: import('../../../../vendor/cordis/src/events.ts').EventsService\n",
61
+ 'packages/fix/pkgc/src/helper.ts':
62
+ "function scriptFire(args: [string]): void { void gEvents.dispatch('emit', args) }\n",
63
+ 'packages/fix/pkgc/src/caller.ts': "scriptFire(['pkgc/script-event'])\n",
64
+ }
65
+
66
+ const root = mkdtempSync(join(tmpdir(), 'gen-doc-graphs-'))
67
+ for (const [rel, content] of Object.entries(FIXTURE)) {
68
+ mkdirSync(dirname(join(root, rel)), { recursive: true })
69
+ writeFileSync(join(root, rel), content)
70
+ }
71
+ const project = new TypeScriptProject(root)
72
+ const sources = collectPackageSources(project)
73
+
74
+ afterAll(() => {
75
+ rmSync(root, { recursive: true, force: true })
76
+ })
77
+
78
+ function dispatchersOf(pkgs: readonly string[], event: string): string[] {
79
+ const subset = sources.filter(source => pkgs.includes(source.pkg))
80
+ const relations = new EventRelationCollector(project, subset).collect()
81
+ return [...(relations.get(event)?.dispatchers.keys() ?? [])]
82
+ }
83
+
84
+ describe('event relation call-site indexing', () => {
85
+ it('recovers a proven-local helper through the single-file fast path', () => {
86
+ expect(dispatchersOf(['pkga', 'pkgb'], 'pkga/local-event')).toEqual(['pkga'])
87
+ })
88
+
89
+ it('recovers an alias-escaped helper through the global fallback', () => {
90
+ expect(dispatchersOf(['pkga', 'pkgb'], 'pkgb/aliased-event')).toEqual(['pkga'])
91
+ })
92
+
93
+ it('rejects the locality proof for global script files', () => {
94
+ // pkgc alone: the script helper is the first demand, so a wrongly passing
95
+ // proof would index helper.ts only and lose the caller.ts call site.
96
+ expect(dispatchersOf(['pkgc'], 'pkgc/script-event')).toEqual(['pkgc'])
97
+ })
98
+ })
scripts/gen-doc-graphs.ts ADDED
@@ -0,0 +1,1586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generate the relationship layer above the module, Cordis, and tool catalogs.
3
+ * Enumerable facts come from source; hybrid graphs add manifests for policy the
4
+ * source cannot infer, while curated graphs explain flow and ownership.
5
+ * `--check` verifies the generated set.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { dirname, relative, resolve } from 'node:path'
10
+ import ts from 'typescript'
11
+ import { projectCordisCatalog } from '@deepseek-ai/dsh-typert-generator'
12
+ import { CORDIS_CATALOG_POLICY } from './gen-cordis-catalog.ts'
13
+ import type { EventEntry, ServiceEntry } from '@deepseek-ai/dsh-typert-generator'
14
+ import {
15
+ collectPackageGraph,
16
+ escapeMermaidLabel as escLabel,
17
+ graphNodeId as nodeId,
18
+ type PackageGraphNode,
19
+ } from './package-graph.ts'
20
+ import { TypeScriptProject } from './ts-project.ts'
21
+
22
+ const root = resolve(import.meta.dirname, '..')
23
+ type Pkg = PackageGraphNode
24
+
25
+ interface GraphDoc {
26
+ rel: string
27
+ content: string
28
+ }
29
+
30
+ interface ServiceRole {
31
+ key: string
32
+ pkg: string
33
+ title: string
34
+ mode: 'core' | 'seam' | 'bundle'
35
+ implementations?: string[]
36
+ consumers?: string[]
37
+ companions?: string[]
38
+ note: string
39
+ }
40
+
41
+ interface ExamplePlugin {
42
+ id: string
43
+ name: string
44
+ }
45
+
46
+ interface EventRelation {
47
+ dispatchers: Map<string, Set<string>>
48
+ listeners: Set<string>
49
+ }
50
+
51
+ /** One scanned package source file and its owning package short name. */
52
+ export interface PackageSource {
53
+ /** Repository-relative path. */
54
+ rel: string
55
+ /** Package short name from the `packages/<group>/<pkg>/src` path. */
56
+ pkg: string
57
+ /** The bound program source file. */
58
+ sourceFile: ts.SourceFile
59
+ }
60
+
61
+ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
62
+
63
+ const GROUP_ORDER = [
64
+ 'util',
65
+ 'attachment',
66
+ 'llm',
67
+ 'core',
68
+ 'typert',
69
+ 'goal',
70
+ 'experimental',
71
+ 'process',
72
+ 'bash',
73
+ 'pty',
74
+ 'sandbox',
75
+ 'ssh',
76
+ 'fs',
77
+ 'skill',
78
+ 'compact',
79
+ 'subagent',
80
+ 'tasks',
81
+ 'workflow',
82
+ 'web',
83
+ 'webhook',
84
+ 'spill',
85
+ 'todo',
86
+ 'plan',
87
+ 'cordis',
88
+ 'hooks',
89
+ 'session-persistence',
90
+ 'session-query',
91
+ 'session-title',
92
+ 'telemetry',
93
+ 'storage',
94
+ 'workspace',
95
+ 'support',
96
+ 'acp',
97
+ 'ui',
98
+ ]
99
+
100
+ const SERVICE_ROLES: ServiceRole[] = [
101
+ {
102
+ key: 'mcpResources',
103
+ pkg: 'mcp-resources',
104
+ title: 'Scoped MCP resource access',
105
+ mode: 'seam',
106
+ implementations: ['mcp-client'],
107
+ consumers: ['mcp-resources'],
108
+ note: 'Connection-owned providers serve shared resource tools in the calling agent scope.',
109
+ },
110
+ {
111
+ key: 'browserUse',
112
+ pkg: 'browser-use',
113
+ title: 'Browser-use provider registration',
114
+ mode: 'seam',
115
+ implementations: ['experimental-browser-use-playwright-mcp', 'experimental-browser-use-chrome-devtools-mcp', 'experimental-browser-use-stagehand-native'],
116
+ consumers: ['experimental-browser-use-playwright-mcp', 'experimental-browser-use-chrome-devtools-mcp', 'experimental-browser-use-stagehand-native'],
117
+ note: 'One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API.',
118
+ },
119
+ {
120
+ key: 'computerUse',
121
+ pkg: 'computer-use',
122
+ title: 'Computer-use provider registration',
123
+ mode: 'seam',
124
+ implementations: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
125
+ consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
126
+ note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.',
127
+ },
128
+ {
129
+ key: 'attachments',
130
+ pkg: 'attachment',
131
+ title: 'Durable binary attachment storage',
132
+ mode: 'seam',
133
+ implementations: ['attachment-local'],
134
+ consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'],
135
+ note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.',
136
+ },
137
+ {
138
+ key: 'fileUploads',
139
+ pkg: 'client-file-upload',
140
+ title: 'Agent-scoped staged file uploads',
141
+ mode: 'core',
142
+ consumers: ['api-session-controller'],
143
+ note: 'Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions.',
144
+ },
145
+ {
146
+ key: 'llm',
147
+ pkg: 'llm',
148
+ title: 'LLM adapter registry',
149
+ mode: 'seam',
150
+ implementations: ['llm-deepseek', 'llm-pi-ai', 'llm-replay'],
151
+ consumers: ['agent-loop', 'compaction-basic'],
152
+ note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
153
+ },
154
+ {
155
+ key: 'deepseekLlmApiExtensions',
156
+ pkg: 'deepseek-llm-api-extensions',
157
+ title: 'Official DeepSeek request extensions',
158
+ mode: 'seam',
159
+ implementations: ['session-log-deepseek', 'plugin-package-inventory-deepseek'],
160
+ consumers: ['llm-deepseek'],
161
+ note: 'Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance.',
162
+ },
163
+ {
164
+ key: 'tokenMeter',
165
+ pkg: 'token-meter',
166
+ title: 'Replay token measurement',
167
+ mode: 'core',
168
+ consumers: ['compaction-basic'],
169
+ note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
170
+ },
171
+ {
172
+ key: 'toolResultPruner',
173
+ pkg: 'compaction-tool-result-pruner',
174
+ title: 'Model-free tool-result pruning',
175
+ mode: 'core',
176
+ consumers: ['compaction-basic'],
177
+ note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
178
+ },
179
+ {
180
+ key: 'sessions',
181
+ pkg: 'session',
182
+ title: 'In-memory session store',
183
+ mode: 'core',
184
+ consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-in-process-driver', 'invariants', 'message-feedback'],
185
+ note: 'Owns append-only Session instances and emits the durable session event feed.',
186
+ },
187
+ {
188
+ key: 'sessionController',
189
+ pkg: 'api-session-controller',
190
+ title: 'Host Session Remote controller',
191
+ mode: 'core',
192
+ note: 'Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy.',
193
+ },
194
+ {
195
+ key: 'sessionFileReferences',
196
+ pkg: 'api-session-controller',
197
+ title: 'Session-addressed file-reference Remote adapter',
198
+ mode: 'core',
199
+ note: 'Delegates file-reference discovery through the Session Controller\'s established Agent lookup policy.',
200
+ },
201
+ {
202
+ key: 'sessionSkillCatalog',
203
+ pkg: 'api-session-controller',
204
+ title: 'Session-addressed skill Remote adapter',
205
+ mode: 'core',
206
+ note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.',
207
+ },
208
+ {
209
+ key: 'credentialsController',
210
+ pkg: 'api-settings-controller',
211
+ title: 'Host credential-surface Remote controller',
212
+ mode: 'core',
213
+ note: 'Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition.',
214
+ },
215
+ {
216
+ key: 'settingsController',
217
+ pkg: 'api-settings-controller',
218
+ title: 'Host settings-surface Remote controller',
219
+ mode: 'core',
220
+ note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.',
221
+ },
222
+ {
223
+ key: 'workspaceFiles',
224
+ pkg: 'api-workspace-files',
225
+ title: 'Host workspace file Remote service',
226
+ mode: 'core',
227
+ note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.',
228
+ },
229
+ {
230
+ key: 'terminalController',
231
+ pkg: 'api-terminal-controller',
232
+ title: 'Session interactive terminal Remote controller',
233
+ mode: 'core',
234
+ note: 'Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport.',
235
+ },
236
+ {
237
+ key: 'workspaceController',
238
+ pkg: 'api-workspace-controller',
239
+ title: 'Host Workspace Remote controller',
240
+ mode: 'core',
241
+ note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.',
242
+ },
243
+ {
244
+ key: 'directoryPickerController',
245
+ pkg: 'api-workspace-controller',
246
+ title: 'Host directory-picking Remote controller',
247
+ mode: 'core',
248
+ note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.',
249
+ },
250
+ {
251
+ key: 'invariants',
252
+ pkg: 'invariants',
253
+ title: 'Package-owned invariant registry',
254
+ mode: 'core',
255
+ consumers: ['session', 'agent', 'scope', 'agent-loop'],
256
+ note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.',
257
+ },
258
+ {
259
+ key: 'typert',
260
+ pkg: 'typert-registry',
261
+ title: 'Runtime type registry',
262
+ mode: 'core',
263
+ consumers: ['typert-loader', 'api-gateway'],
264
+ note: 'Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges.',
265
+ },
266
+ {
267
+ key: 'typertGateway',
268
+ pkg: 'api-gateway',
269
+ title: 'Typert Host invocation gateway',
270
+ mode: 'core',
271
+ note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.',
272
+ },
273
+ {
274
+ key: 'sessionPersistence',
275
+ pkg: 'session-persistence',
276
+ title: 'Durable session persistence seam',
277
+ mode: 'seam',
278
+ implementations: ['session-persistence-jsonl'],
279
+ consumers: ['agent-loop', 'tool-bash', 'hooks-claude-code', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
280
+ note: 'The JSONL backend persists the SessionEvent vocabulary as one artifact per Session.',
281
+ },
282
+ {
283
+ key: 'settings',
284
+ pkg: 'settings',
285
+ title: 'User-settings seam',
286
+ mode: 'seam',
287
+ implementations: ['settings-file'],
288
+ consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
289
+ note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer.',
290
+ },
291
+ {
292
+ key: 'subagentModelSelection',
293
+ pkg: 'tool-subagent',
294
+ title: 'Subagent model-selection preference',
295
+ mode: 'core',
296
+ consumers: ['tool-subagent'],
297
+ note: 'Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session.',
298
+ },
299
+ {
300
+ key: 'credentials',
301
+ pkg: 'credentials',
302
+ title: 'Credential seam',
303
+ mode: 'seam',
304
+ implementations: ['credentials-local'],
305
+ consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
306
+ note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.',
307
+ },
308
+ {
309
+ key: 'authorization',
310
+ pkg: 'authorization',
311
+ title: 'Authorization flow registry',
312
+ mode: 'seam',
313
+ implementations: [],
314
+ consumers: ['llm-pi-ai'],
315
+ note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.',
316
+ },
317
+ {
318
+ key: 'sessionTelemetry',
319
+ pkg: 'session-telemetry',
320
+ title: 'Session telemetry seam',
321
+ mode: 'seam',
322
+ implementations: ['session-telemetry-otel'],
323
+ consumers: [],
324
+ note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.',
325
+ },
326
+ {
327
+ key: 'storage',
328
+ pkg: 'storage',
329
+ title: 'Non-session storage hub',
330
+ mode: 'seam',
331
+ implementations: ['storage-json', 'storage-sqlite'],
332
+ consumers: ['storage-domain'],
333
+ note: 'Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives.',
334
+ },
335
+ {
336
+ key: 'storageDomain',
337
+ pkg: 'storage-domain',
338
+ title: 'Domain data facility',
339
+ mode: 'core',
340
+ consumers: ['workspace'],
341
+ note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.',
342
+ },
343
+ {
344
+ key: 'messageFeedback',
345
+ pkg: 'message-feedback',
346
+ title: 'Lifecycle-bound message feedback',
347
+ mode: 'core',
348
+ note: 'Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy.',
349
+ },
350
+ {
351
+ key: 'sessionFeedback',
352
+ pkg: 'command-feedback',
353
+ title: 'Session-level feedback recorder',
354
+ mode: 'core',
355
+ note: 'Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer.',
356
+ },
357
+ {
358
+ key: 'workspaceRegistry',
359
+ pkg: 'workspace',
360
+ title: 'Workspace entity registry',
361
+ mode: 'core',
362
+ consumers: ['api-workspace-controller', 'api-session-controller'],
363
+ note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.',
364
+ },
365
+ {
366
+ key: 'sessionQuery',
367
+ pkg: 'session-query',
368
+ title: 'Session reads, traces, filters, and search',
369
+ mode: 'seam',
370
+ implementations: ['session-query-sqlite'],
371
+ consumers: ['session-reference', 'tool-session-query'],
372
+ note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.',
373
+ },
374
+ {
375
+ key: 'fileReferences',
376
+ pkg: 'file-reference',
377
+ title: 'File reference discovery',
378
+ mode: 'seam',
379
+ implementations: ['file-reference-local'],
380
+ consumers: ['api-session-controller'],
381
+ note: 'The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents.',
382
+ },
383
+ {
384
+ key: 'sessionReferenceResolver',
385
+ pkg: 'session-reference',
386
+ title: 'Cross-session snapshot preparation',
387
+ mode: 'core',
388
+ note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax.',
389
+ },
390
+ {
391
+ key: 'sessionTitle',
392
+ pkg: 'session-title',
393
+ title: 'Log-backed session titles',
394
+ mode: 'seam',
395
+ implementations: ['session-title-first-prompt-llm', 'session-title-all-prompts-llm'],
396
+ note: 'Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration.',
397
+ },
398
+ {
399
+ key: 'systemPrompt',
400
+ pkg: 'system-prompt',
401
+ title: 'System prompt assembly registry',
402
+ mode: 'core',
403
+ consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-terminal', 'tool-web'],
404
+ note: 'Collects prompt sections and model-facing tool schemas for each step.',
405
+ },
406
+ {
407
+ key: 'tools',
408
+ pkg: 'tools',
409
+ title: 'Tool registry and guarded execution pipeline',
410
+ mode: 'core',
411
+ consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'],
412
+ note: 'Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
413
+ },
414
+ {
415
+ key: 'userQuestions',
416
+ pkg: 'user-questions',
417
+ title: 'Human question/answer seam',
418
+ mode: 'seam',
419
+ consumers: ['tool-ask-user'],
420
+ note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
421
+ },
422
+ {
423
+ key: 'planMode',
424
+ pkg: 'plan-mode',
425
+ title: 'Plan collaboration state',
426
+ mode: 'core',
427
+ note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
428
+ },
429
+ {
430
+ key: 'agentPresets',
431
+ pkg: 'agent-presets',
432
+ title: 'Per-session agent composition',
433
+ mode: 'core',
434
+ note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.',
435
+ },
436
+ {
437
+ key: 'commands',
438
+ pkg: 'commands',
439
+ title: 'Human command registry',
440
+ mode: 'core',
441
+ note: 'Plugins register direct human commands without sending invocations to the model.',
442
+ },
443
+ {
444
+ key: 'sessionProjections',
445
+ pkg: 'session-projection',
446
+ title: 'Session projection units',
447
+ mode: 'core',
448
+ consumers: ['api-session-controller', 'tool-todo', 'session-title'],
449
+ note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values.',
450
+ },
451
+ {
452
+ key: 'sessionProjectionCache',
453
+ pkg: 'session-projection-cache',
454
+ title: 'Persisted projection cache',
455
+ mode: 'core',
456
+ consumers: ['api-session-controller', 'session-query', 'session-reference', 'subagent'],
457
+ note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.',
458
+ },
459
+ {
460
+ key: 'skills',
461
+ pkg: 'skill',
462
+ title: 'Skill provider registry',
463
+ mode: 'seam',
464
+ implementations: ['skill-badge', 'skill-filesystem'],
465
+ consumers: ['tool-skill'],
466
+ note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
467
+ },
468
+ {
469
+ key: 'agents',
470
+ pkg: 'agent',
471
+ title: 'Agent service',
472
+ mode: 'core',
473
+ consumers: ['agent-loop', 'acp', 'subagent-in-process-driver'],
474
+ note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
475
+ },
476
+ {
477
+ key: 'agentDefaultModel',
478
+ pkg: 'agent-default-model',
479
+ title: 'Default Agent model selection',
480
+ mode: 'core',
481
+ consumers: ['api-session-controller', 'headless'],
482
+ note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.',
483
+ },
484
+ {
485
+ key: 'agentLoop',
486
+ pkg: 'agent-loop',
487
+ title: 'Concrete loop driver',
488
+ mode: 'bundle',
489
+ consumers: ['base', 'sdk-minimal'],
490
+ note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
491
+ },
492
+ {
493
+ key: 'goals',
494
+ pkg: 'goal',
495
+ title: 'Same-session goal domain',
496
+ mode: 'core',
497
+ note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
498
+ },
499
+ {
500
+ key: 'ssh',
501
+ pkg: 'ssh',
502
+ title: 'POSIX SSH connection owner',
503
+ mode: 'core',
504
+ consumers: ['fs-ssh', 'subprocess-ssh', 'sandbox-ssh'],
505
+ note: 'Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers.',
506
+ },
507
+ {
508
+ key: 'subprocess',
509
+ pkg: 'subprocess',
510
+ title: 'Subprocess seam',
511
+ mode: 'seam',
512
+ implementations: ['subprocess-local', 'subprocess-ssh'],
513
+ consumers: ['bash-local', 'bash-sandbox', 'terminal-bash', 'lsp-stdio', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'],
514
+ note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.',
515
+ },
516
+ {
517
+ key: 'shell',
518
+ pkg: 'shell',
519
+ title: 'Bash executor seam',
520
+ mode: 'seam',
521
+ implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'],
522
+ consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude-code', 'hooks-codex'],
523
+ note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.',
524
+ },
525
+ {
526
+ key: 'shellEnv',
527
+ pkg: 'shell-env',
528
+ title: 'Managed bash environment registry',
529
+ mode: 'core',
530
+ consumers: ['tool-bash', 'tool-pwsh'],
531
+ note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.',
532
+ },
533
+ {
534
+ key: 'terminals',
535
+ pkg: 'terminal',
536
+ title: 'Persistent PTY session registry',
537
+ mode: 'seam',
538
+ implementations: ['terminal-bash'],
539
+ consumers: ['tool-terminal'],
540
+ note: 'The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-terminal exposes the owner-scoped model tools.',
541
+ },
542
+ {
543
+ key: 'sandbox',
544
+ pkg: 'sandbox',
545
+ title: 'Process-sandbox seam',
546
+ mode: 'seam',
547
+ implementations: ['sandbox-local', 'sandbox-ssh'],
548
+ consumers: ['bash-sandbox', 'terminal-bash'],
549
+ note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
550
+ },
551
+ {
552
+ key: 'sandboxPolicy',
553
+ pkg: 'sandbox-policy',
554
+ title: 'Sandbox policy home',
555
+ mode: 'core',
556
+ implementations: [],
557
+ consumers: ['bash-sandbox', 'fs-sandbox', 'terminal-bash'],
558
+ note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.',
559
+ },
560
+ {
561
+ key: 'approval',
562
+ pkg: 'user-approval',
563
+ title: 'Approval seam',
564
+ mode: 'seam',
565
+ implementations: [],
566
+ consumers: ['tools', 'tool-bash', 'acp'],
567
+ note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.',
568
+ },
569
+ {
570
+ key: 'permissionPresets',
571
+ pkg: 'permission-presets',
572
+ title: 'Permission presets',
573
+ mode: 'core',
574
+ implementations: [],
575
+ note: 'User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.',
576
+ },
577
+ {
578
+ key: 'ptcRuntime',
579
+ pkg: 'ptc-runtime',
580
+ title: 'PTC execution seam',
581
+ mode: 'seam',
582
+ implementations: ['ptc-runtime-node', 'experimental-ptc-runtime-python'],
583
+ consumers: ['tools', 'workflow-ptc'],
584
+ note: 'Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration.',
585
+ },
586
+ {
587
+ key: 'fs',
588
+ pkg: 'fs',
589
+ title: 'Filesystem provider seam',
590
+ mode: 'seam',
591
+ implementations: ['fs-local', 'fs-sandbox', 'fs-ssh'],
592
+ consumers: ['tool-fs'],
593
+ companions: ['fs-observation-policy'],
594
+ note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate.',
595
+ },
596
+ {
597
+ key: 'compaction',
598
+ pkg: 'compaction',
599
+ title: 'Compaction seam',
600
+ mode: 'seam',
601
+ implementations: ['compaction-basic'],
602
+ consumers: ['compaction-basic'],
603
+ note: 'The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool.',
604
+ },
605
+ {
606
+ key: 'subagents',
607
+ pkg: 'subagent',
608
+ title: 'Subagent provider and continuation service',
609
+ mode: 'seam',
610
+ implementations: ['subagent-spawn-in-process', 'subagent-fork-in-process', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'],
611
+ consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
612
+ note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.',
613
+ },
614
+ {
615
+ key: 'agentTeams',
616
+ pkg: 'experimental-agent-team',
617
+ title: 'Agent Teams coordination domain',
618
+ mode: 'core',
619
+ consumers: ['experimental-tool-agent-team', 'experimental-client-ui-agent-team'],
620
+ note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution.',
621
+ },
622
+ {
623
+ key: 'inspector',
624
+ pkg: 'inspector',
625
+ title: 'Cross-realm runtime inspection',
626
+ mode: 'core',
627
+ note: 'Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API.',
628
+ },
629
+ {
630
+ key: 'jobs',
631
+ pkg: 'jobs',
632
+ title: 'Background job registry',
633
+ mode: 'seam',
634
+ implementations: ['jobs-local'],
635
+ consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'],
636
+ note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
637
+ },
638
+ {
639
+ key: 'web',
640
+ pkg: 'web',
641
+ title: 'Web access provider registry',
642
+ mode: 'seam',
643
+ implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-http'],
644
+ consumers: ['tool-web'],
645
+ note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
646
+ },
647
+ {
648
+ key: 'spillStore',
649
+ pkg: 'spill',
650
+ title: 'Spill storage seam',
651
+ mode: 'seam',
652
+ implementations: ['spill-local'],
653
+ consumers: ['spill-policy'],
654
+ note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
655
+ },
656
+ {
657
+ key: 'directoryPicker',
658
+ pkg: 'host-directory-picker',
659
+ title: 'Workspace-directory picking seam',
660
+ mode: 'seam',
661
+ implementations: ['host-directory-picker-native', 'host-directory-picker-browse'],
662
+ consumers: ['api-workspace-controller'],
663
+ note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).',
664
+ },
665
+ {
666
+ key: 'webServer',
667
+ pkg: 'host-webserver',
668
+ title: 'HTTP route registration',
669
+ mode: 'core',
670
+ consumers: ['client-connection', 'client-modules', 'client-hmr'],
671
+ note: 'Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes.',
672
+ },
673
+ {
674
+ key: 'clientModules',
675
+ pkg: 'client-modules',
676
+ title: 'Client plugin graph host',
677
+ mode: 'core',
678
+ consumers: ['client-hmr'],
679
+ note: 'Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
680
+ },
681
+ {
682
+ key: 'workflowEngine',
683
+ pkg: 'workflow',
684
+ title: 'Workflow script engine',
685
+ mode: 'seam',
686
+ implementations: ['workflow-ptc'],
687
+ consumers: ['tool-workflow', 'tool-ralph'],
688
+ note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
689
+ },
690
+ {
691
+ key: 'webhookRuntime',
692
+ pkg: 'webhook',
693
+ title: 'Webhook rule runtime',
694
+ mode: 'core',
695
+ consumers: ['webhook-github'],
696
+ note: 'Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state.',
697
+ },
698
+ {
699
+ key: 'lsp',
700
+ pkg: 'lsp',
701
+ title: 'Language-server navigation seam',
702
+ mode: 'seam',
703
+ implementations: ['lsp-stdio'],
704
+ consumers: ['tool-lsp'],
705
+ note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.',
706
+ },
707
+ {
708
+ key: 'dynamicCordisRunner',
709
+ pkg: 'cordis-host-runner',
710
+ title: 'Dynamic Cordis package host runner',
711
+ mode: 'core',
712
+ consumers: ['tool-cordis'],
713
+ note: 'Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace.',
714
+ },
715
+ {
716
+ key: 'cordisInspect',
717
+ pkg: 'cordis-host-runner',
718
+ title: 'Dynamic Cordis inspect registry',
719
+ mode: 'core',
720
+ consumers: ['tool-cordis'],
721
+ note: 'Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport.',
722
+ },
723
+ ]
724
+
725
+ function generatedHeader(title: string): string[] {
726
+ return [
727
+ '<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
728
+ ' Run `pnpm run gen-doc-graphs` to regenerate. -->',
729
+ '',
730
+ `# ${title}`,
731
+ '',
732
+ ]
733
+ }
734
+
735
+ function maintenanceFooter(source: string): string[] {
736
+ return [`Maintenance mode: ${source}.`, '']
737
+ }
738
+
739
+ function graphIndexLink(rel: string): string {
740
+ return relative('docs', rel).replaceAll('\\', '/')
741
+ }
742
+
743
+ function linkFromDoc(docRel: string, targetRel: string): string {
744
+ return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
745
+ }
746
+
747
+ function mermaidCode(value: string): string {
748
+ return `<code>${value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code>`
749
+ }
750
+
751
+ function repoLink(path: string, label: string, up = '..'): string {
752
+ return `[${label}](${up}/${path})`
753
+ }
754
+
755
+ function sourceLink(source: string, up = '..'): string {
756
+ return repoLink(source.split(':')[0] ?? source, `\`${source}\``, up)
757
+ }
758
+
759
+ function pkgLink(pkg: Pkg | undefined, fallback: string, up = '..'): string {
760
+ return pkg ? repoLink(pkg.rel, `\`${pkg.short}\``, up) : `\`${fallback}\``
761
+ }
762
+
763
+ function pkgList(names: string[] | undefined, pkgsByShort: Map<string, Pkg>): string {
764
+ if (!names || names.length === 0) return '-'
765
+ return names.map(name => pkgLink(pkgsByShort.get(name), name)).join(', ')
766
+ }
767
+
768
+ function tableCell(value: string): string {
769
+ return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>')
770
+ }
771
+
772
+ function assertServiceRolesComplete(services: readonly ServiceEntry[]): void {
773
+ const discovered = new Set(services.map(service => service.key))
774
+ const classified = new Set(SERVICE_ROLES.map(role => role.key))
775
+ const missing = [...discovered].filter(key => !classified.has(key)).sort()
776
+ const stale = [...classified].filter(key => !discovered.has(key)).sort()
777
+ if (missing.length || stale.length) {
778
+ throw new Error([
779
+ missing.length ? `missing service role classification: ${missing.join(', ')}` : '',
780
+ stale.length ? `stale service role classification: ${stale.join(', ')}` : '',
781
+ ].filter(Boolean).join('; '))
782
+ }
783
+ }
784
+
785
+ function renderCapabilitySeams(pkgs: Pkg[], services: readonly ServiceEntry[]): string {
786
+ assertServiceRolesComplete(services)
787
+ const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
788
+ const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard'
789
+ const nodes = new Map<string, string>()
790
+ const edges = new Set<string>()
791
+ const companionEdges = new Set<string>()
792
+ const addNode = (id: string, label: string): void => {
793
+ if (!nodes.has(id)) nodes.set(id, ` ${id}["${escLabel(label)}"]`)
794
+ }
795
+ const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) }
796
+ const lines = generatedHeader('Capability Seams And Core Services')
797
+ lines.push(
798
+ 'A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.',
799
+ '',
800
+ '```mermaid',
801
+ 'flowchart LR',
802
+ )
803
+ for (const role of SERVICE_ROLES) {
804
+ const svc = nodeId('svc', role.key)
805
+ const owner = nodeId('pkg', role.pkg)
806
+ addNode(owner, role.pkg)
807
+ addNode(svc, `ctx.${role.key}<br/>${role.title}`)
808
+ addEdge(owner, svc)
809
+ for (const impl of role.implementations ?? []) {
810
+ addNode(nodeId('pkg', impl), impl)
811
+ addEdge(nodeId('pkg', impl), svc)
812
+ }
813
+ for (const consumer of role.consumers ?? []) {
814
+ addNode(nodeId('pkg', consumer), consumer)
815
+ addEdge(svc, nodeId('pkg', consumer))
816
+ }
817
+ for (const companion of role.companions ?? []) {
818
+ addNode(nodeId('pkg', companion), companion)
819
+ companionEdges.add(` ${svc} -. event gate .-> ${nodeId('pkg', companion)}`)
820
+ }
821
+ }
822
+ lines.push(...nodes.values(), ...[...edges].sort(), ...[...companionEdges].sort())
823
+ lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |', '| --- | --- | --- | --- | --- | --- | --- |')
824
+ for (const role of SERVICE_ROLES) {
825
+ lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${pkgList(role.companions, pkgsByShort)} | ${tableCell(role.note)} |`)
826
+ }
827
+ lines.push('', ...maintenanceFooter(maintenance))
828
+ return lines.join('\n')
829
+ }
830
+
831
+ function parseExampleCordis(rel: string): ExamplePlugin[] {
832
+ const text = readFileSync(resolve(root, rel), 'utf8')
833
+ const plugins: ExamplePlugin[] = []
834
+ let current: { id: string; name?: string } | null = null
835
+ const flush = (): void => {
836
+ if (current?.name) plugins.push({ id: current.id, name: current.name })
837
+ }
838
+ for (const line of text.split('\n')) {
839
+ // Top-level rows (`- id:`) and bundle-patch insert rows (` - id:`).
840
+ const id = /^\s*-\s+id:\s+(.+?)\s*$/.exec(line)
841
+ if (id?.[1] !== undefined) {
842
+ flush()
843
+ current = { id: stripYamlScalar(id[1]) }
844
+ continue
845
+ }
846
+ const name = /^\s+name:\s+(.+?)\s*$/.exec(line)
847
+ if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1])
848
+ }
849
+ flush()
850
+ return plugins
851
+ }
852
+
853
+ function stripYamlScalar(value: string): string {
854
+ return value.trim().replace(/^['"]|['"]$/g, '')
855
+ }
856
+
857
+ const APP_EXAMPLES = [
858
+ {
859
+ id: 'dsh_base',
860
+ rel: 'apps/cli/composition.md',
861
+ title: 'DSH Base Composition',
862
+ label: 'packages/bundle/base/cordis.patch.yml',
863
+ config: 'packages/bundle/base/cordis.patch.yml',
864
+ summary: 'The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree.',
865
+ },
866
+ ]
867
+
868
+ type AppExample = typeof APP_EXAMPLES[number]
869
+
870
+ function renderAppComposition(example: AppExample): string {
871
+ const plugins = parseExampleCordis(example.config)
872
+ const maintenance = 'hybrid: the patch row list is parsed from its `cordis.yml`; app package expansion is curated from package source'
873
+ const lines = generatedHeader(example.title)
874
+ lines.push(
875
+ example.summary,
876
+ '',
877
+ '```mermaid',
878
+ 'flowchart LR',
879
+ ` cfg["${escLabel(example.label)}<br/>cordis.yml"]`,
880
+ )
881
+ for (const plugin of plugins) {
882
+ const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
883
+ lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
884
+ lines.push(` cfg --> ${pluginNode}`)
885
+ }
886
+ lines.push(
887
+ '```',
888
+ '',
889
+ '| Plugin id | Package / module |',
890
+ '| --- | --- |',
891
+ ...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`),
892
+ '',
893
+ `Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`,
894
+ )
895
+ lines.push('', ...maintenanceFooter(maintenance))
896
+ return lines.join('\n')
897
+ }
898
+
899
+ type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>
900
+
901
+ /**
902
+ * The only method names visitSource classifies; receiver typing runs on these
903
+ * alone. Obligation: every method name matched by a branch inside visitSource
904
+ * must appear here — the prefilter drops non-members before any branch runs,
905
+ * so a branch for an unlisted name is silently dead.
906
+ */
907
+ const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
908
+
909
+ /**
910
+ * Collect event dispatch/listener relations from real cross-file receiver types.
911
+ *
912
+ * TODO: the program is seeded from the host aggregate alone (ts-project.ts
913
+ * documents why: one program cannot hold both faces' Context merges), so a
914
+ * Client package enters only when a host file imports it. Client-face
915
+ * listeners on client-face events are therefore under-reported —
916
+ * `connection/reset` omits `ui-skill`/`ui-agent-preset`. Closing it needs a
917
+ * second Client program whose relations merge into these, not a wider seed.
918
+ */
919
+ export class EventRelationCollector {
920
+ private readonly relations = new Map<string, EventRelation>()
921
+ private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
922
+ private readonly localCalleeProofs = new Map<ts.FunctionDeclaration, boolean>()
923
+ private globalCallSites: CallSiteIndex | null = null
924
+ private readonly contextType: ts.Type
925
+ private readonly agentDispatchType: ts.Type
926
+ private readonly eventsServiceType: ts.Type
927
+ private readonly packageSourceFiles: ReadonlySet<ts.SourceFile>
928
+
929
+ constructor(
930
+ private readonly project: TypeScriptProject,
931
+ private readonly sources: readonly PackageSource[],
932
+ ) {
933
+ this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
934
+ this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
935
+ this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
936
+ this.packageSourceFiles = new Set(sources.map(source => source.sourceFile))
937
+ }
938
+
939
+ /** Return all event relations discovered from the Program. */
940
+ collect(): Map<string, EventRelation> {
941
+ for (const source of this.sources) this.visitSource(source)
942
+ return this.relations
943
+ }
944
+
945
+ /** Resolve one named class/interface declaration to its merged instance type. */
946
+ private declaredType(relativePath: string, name: string): ts.Type {
947
+ const sourceFile = this.project.sourceFile(relativePath)
948
+ const declaration = sourceFile.statements.find((statement): statement is ts.ClassDeclaration | ts.InterfaceDeclaration => {
949
+ return (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && statement.name?.text === name
950
+ })
951
+ const symbol = declaration?.name && this.project.checker.getSymbolAtLocation(declaration.name)
952
+ if (!symbol) throw new Error(`cannot resolve TypeScript type ${name} from ${relativePath}`)
953
+ return this.project.checker.getDeclaredTypeOfSymbol(symbol)
954
+ }
955
+
956
+ /** Index resolved function calls in the given files for narrow argument-flow recovery. */
957
+ private buildCallSiteIndex(files: Iterable<ts.SourceFile>): CallSiteIndex {
958
+ const index: CallSiteIndex = new Map()
959
+ const visit = (node: ts.Node): void => {
960
+ if (ts.isCallExpression(node)) {
961
+ const declaration = this.project.checker.getResolvedSignature(node)?.declaration
962
+ if (declaration) {
963
+ const calls = index.get(declaration) ?? []
964
+ calls.push(node)
965
+ index.set(declaration, calls)
966
+ }
967
+ }
968
+ ts.forEachChild(node, visit)
969
+ }
970
+ for (const file of files) visit(file)
971
+ return index
972
+ }
973
+
974
+ /**
975
+ * Return every indexed call resolving to one local helper declaration.
976
+ * Fast path: when every same-file reference to the non-exported helper is
977
+ * provably a direct callee, module scoping confines all of its calls to that
978
+ * file, so only that file is indexed. Any other reference form may alias
979
+ * the function value outward, so the original full package-source index
980
+ * decides instead.
981
+ */
982
+ private callSitesFor(owner: ts.FunctionDeclaration): ts.CallExpression[] {
983
+ if (!this.globalCallSites && !this.provenLocalCallee(owner)) {
984
+ this.globalCallSites = this.buildCallSiteIndex(this.packageSourceFiles)
985
+ }
986
+ if (this.globalCallSites) return this.globalCallSites.get(owner) ?? []
987
+ const file = owner.getSourceFile()
988
+ let index = this.fileCallSites.get(file)
989
+ if (!index) {
990
+ index = this.buildCallSiteIndex([file])
991
+ this.fileCallSites.set(file, index)
992
+ }
993
+ return index.get(owner) ?? []
994
+ }
995
+
996
+ /**
997
+ * Prove every same-file reference to one helper is a direct callee. The
998
+ * proof owns its premises: an exported helper or a helper in a global
999
+ * script file (no import/export means program-wide scope, callable from
1000
+ * another file with no same-file reference at all) fails immediately.
1001
+ * Alias escapes (re-export statements, default exports, value reads)
1002
+ * resolve back to the owner symbol at a non-callee position and fail the
1003
+ * proof, as does anything the scan cannot positively classify.
1004
+ */
1005
+ private provenLocalCallee(owner: ts.FunctionDeclaration): boolean {
1006
+ const cached = this.localCalleeProofs.get(owner)
1007
+ if (cached !== undefined) return cached
1008
+ if (hasExportModifier(owner) || !ts.isExternalModule(owner.getSourceFile())) {
1009
+ this.localCalleeProofs.set(owner, false)
1010
+ return false
1011
+ }
1012
+ const name = owner.name
1013
+ const ownerSymbol = name && this.project.checker.getSymbolAtLocation(name)
1014
+ let proven = !!ownerSymbol
1015
+ const refersToOwner = (identifier: ts.Identifier): boolean => {
1016
+ // Shorthand properties resolve to the property symbol; ask for the value side.
1017
+ const local = ts.isShorthandPropertyAssignment(identifier.parent)
1018
+ ? this.project.checker.getShorthandAssignmentValueSymbol(identifier.parent)
1019
+ : this.project.checker.getSymbolAtLocation(identifier)
1020
+ if (!local) return false
1021
+ const symbol = local.flags & ts.SymbolFlags.Alias
1022
+ ? this.project.checker.getAliasedSymbol(local)
1023
+ : local
1024
+ return symbol === ownerSymbol
1025
+ }
1026
+ const visit = (node: ts.Node): void => {
1027
+ if (!proven) return
1028
+ if (ts.isIdentifier(node) && node !== name && node.text === name?.text
1029
+ && !isDirectCallee(node) && refersToOwner(node)) {
1030
+ proven = false
1031
+ return
1032
+ }
1033
+ ts.forEachChild(node, visit)
1034
+ }
1035
+ visit(owner.getSourceFile())
1036
+ this.localCalleeProofs.set(owner, proven)
1037
+ return proven
1038
+ }
1039
+
1040
+ /** Walk one package source file and classify event API calls by receiver type. */
1041
+ private visitSource(source: PackageSource): void {
1042
+ const visit = (node: ts.Node): void => {
1043
+ if (ts.isCallExpression(node)) {
1044
+ if (this.isAgentEventEmitter(node.expression)) {
1045
+ const event = node.arguments[2]
1046
+ if (event) {
1047
+ for (const name of this.finiteStringValues(event) ?? []) {
1048
+ this.addDispatcher(name, source.pkg, 'emitAgentEvent')
1049
+ }
1050
+ }
1051
+ } else if (ts.isPropertyAccessExpression(node.expression) && EVENT_API_METHODS.has(node.expression.name.text)) {
1052
+ const receiverKind = this.receiverKind(node.expression.expression)
1053
+ const method = node.expression.name.text
1054
+ if (receiverKind === 'events-service' && method === 'dispatch') {
1055
+ const argumentList = node.arguments[1]
1056
+ if (argumentList) {
1057
+ for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
1058
+ this.addDispatcher(event, source.pkg, 'events.dispatch')
1059
+ }
1060
+ }
1061
+ } else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
1062
+ const eventNames = this.eventNamesFromCall(node, receiverKind)
1063
+ if (method === 'on' || method === 'once') {
1064
+ for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
1065
+ } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
1066
+ for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+ ts.forEachChild(node, visit)
1072
+ }
1073
+ visit(source.sourceFile)
1074
+ }
1075
+
1076
+ /** Match the exported contained-notification helper by declaration identity. */
1077
+ private isAgentEventEmitter(expression: ts.Expression): boolean {
1078
+ if (!ts.isIdentifier(expression)) return false
1079
+ const local = this.project.checker.getSymbolAtLocation(expression)
1080
+ if (!local) return false
1081
+ const symbol = local.flags & ts.SymbolFlags.Alias
1082
+ ? this.project.checker.getAliasedSymbol(local)
1083
+ : local
1084
+ const declarations = symbol.declarations ?? []
1085
+ return declarations.some((declaration) => {
1086
+ return ts.isFunctionDeclaration(declaration)
1087
+ && declaration.name?.text === 'emitAgentEvent'
1088
+ && this.project.relativePath(declaration.getSourceFile()) === 'packages/core/agent/src/dispatch.ts'
1089
+ })
1090
+ }
1091
+
1092
+ /** Classify a receiver using assignability to the repository's actual event API types. */
1093
+ private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
1094
+ const type = this.project.checker.getTypeAtLocation(receiver)
1095
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined
1096
+ if (this.project.checker.isTypeAssignableTo(type, this.eventsServiceType)) return 'events-service'
1097
+ if (this.project.checker.isTypeAssignableTo(type, this.contextType)) return 'context'
1098
+ if (this.project.checker.isTypeAssignableTo(type, this.agentDispatchType)) return 'agent-dispatch'
1099
+ return undefined
1100
+ }
1101
+
1102
+ /** Resolve the event-name argument for Context and fused agent dispatch calls. */
1103
+ private eventNamesFromCall(call: ts.CallExpression, receiverKind: Exclude<EventReceiverKind, 'events-service'>): Set<string> {
1104
+ const candidates = receiverKind === 'context' ? call.arguments.slice(0, 2) : call.arguments.slice(0, 1)
1105
+ for (const candidate of candidates) {
1106
+ const values = this.finiteStringValues(candidate)
1107
+ if (values) return values
1108
+ }
1109
+ return new Set()
1110
+ }
1111
+
1112
+ /** Recover the event slot from the argument array handed to EventsService.dispatch(). */
1113
+ private eventNamesFromArgumentList(expression: ts.Expression, seen: Set<ts.Node>): Set<string> {
1114
+ const current = unwrapExpression(expression)
1115
+ if (seen.has(current)) return new Set()
1116
+ seen.add(current)
1117
+
1118
+ if (ts.isArrayLiteralExpression(current)) {
1119
+ for (const element of current.elements.slice(0, 2)) {
1120
+ if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) continue
1121
+ const values = this.finiteStringValues(element)
1122
+ if (values) return values
1123
+ }
1124
+ return new Set()
1125
+ }
1126
+ if (ts.isConditionalExpression(current)) {
1127
+ return unionSets(
1128
+ this.eventNamesFromArgumentList(current.whenTrue, new Set(seen)),
1129
+ this.eventNamesFromArgumentList(current.whenFalse, new Set(seen)),
1130
+ )
1131
+ }
1132
+ if (!ts.isIdentifier(current)) return new Set()
1133
+
1134
+ const symbol = this.project.checker.getSymbolAtLocation(current)
1135
+ if (!symbol) return new Set()
1136
+ const events = new Set<string>()
1137
+ for (const declaration of symbol.declarations ?? []) {
1138
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer && isConstDeclaration(declaration)) {
1139
+ addAll(events, this.eventNamesFromArgumentList(declaration.initializer, new Set(seen)))
1140
+ } else if (ts.isParameter(declaration)) {
1141
+ addAll(events, this.eventNamesFromParameter(declaration, seen))
1142
+ }
1143
+ }
1144
+ return events
1145
+ }
1146
+
1147
+ /** Follow a non-exported local helper parameter back to every resolved call site. */
1148
+ private eventNamesFromParameter(parameter: ts.ParameterDeclaration, seen: Set<ts.Node>): Set<string> {
1149
+ const owner = parameter.parent
1150
+ if (!ts.isFunctionDeclaration(owner) || hasExportModifier(owner)) return new Set()
1151
+ const index = owner.parameters.indexOf(parameter)
1152
+ if (index < 0) return new Set()
1153
+ const events = new Set<string>()
1154
+ for (const call of this.callSitesFor(owner)) {
1155
+ const argument = call.arguments[index]
1156
+ if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
1157
+ }
1158
+ return events
1159
+ }
1160
+
1161
+ /** Return a finite string-literal value set, rejecting widened and generic strings. */
1162
+ private finiteStringValues(expression: ts.Expression): Set<string> | undefined {
1163
+ const current = unwrapExpression(expression)
1164
+ if (ts.isStringLiteralLike(current)) return new Set([current.text])
1165
+ if (this.isForwardedAgentEventParameter(current)) return undefined
1166
+ return finiteStringTypeValues(this.project.checker.getTypeAtLocation(current))
1167
+ }
1168
+
1169
+ /** Reject the contextual parameter inside the AgentEventDispatch forwarding object. */
1170
+ private isForwardedAgentEventParameter(expression: ts.Expression): boolean {
1171
+ if (!ts.isIdentifier(expression)) return false
1172
+ const declarations = this.project.checker.getSymbolAtLocation(expression)?.declarations ?? []
1173
+ return declarations.some((declaration) => {
1174
+ if (!ts.isParameter(declaration)) return false
1175
+ const method = declaration.parent
1176
+ if (!ts.isMethodDeclaration(method) || !ts.isObjectLiteralExpression(method.parent)) return false
1177
+ const contextualType = this.project.checker.getContextualType(method.parent)
1178
+ return contextualType !== undefined
1179
+ && this.project.checker.isTypeAssignableTo(contextualType, this.agentDispatchType)
1180
+ })
1181
+ }
1182
+
1183
+ /** Get or create one relation row. */
1184
+ private ensure(event: string): EventRelation {
1185
+ const existing = this.relations.get(event)
1186
+ if (existing) return existing
1187
+ const relation = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
1188
+ this.relations.set(event, relation)
1189
+ return relation
1190
+ }
1191
+
1192
+ /** Add one dispatcher method without duplicating package/method labels. */
1193
+ private addDispatcher(event: string, pkg: string, method: string): void {
1194
+ const relation = this.ensure(event)
1195
+ const methods = relation.dispatchers.get(pkg) ?? new Set<string>()
1196
+ methods.add(method)
1197
+ relation.dispatchers.set(pkg, methods)
1198
+ }
1199
+ }
1200
+
1201
+ /** Return whether an identifier is the callee of a call, seen through value-preserving wrappers. */
1202
+ function isDirectCallee(identifier: ts.Identifier): boolean {
1203
+ let current: ts.Node = identifier
1204
+ while (
1205
+ ts.isParenthesizedExpression(current.parent)
1206
+ || ts.isAsExpression(current.parent)
1207
+ || ts.isTypeAssertionExpression(current.parent)
1208
+ || ts.isNonNullExpression(current.parent)
1209
+ || ts.isSatisfiesExpression(current.parent)
1210
+ ) {
1211
+ current = current.parent
1212
+ }
1213
+ return ts.isCallExpression(current.parent) && current.parent.expression === current
1214
+ }
1215
+
1216
+ /** Peel syntax-only wrappers that do not change an expression's runtime value. */
1217
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
1218
+ let current = expression
1219
+ while (
1220
+ ts.isParenthesizedExpression(current)
1221
+ || ts.isAsExpression(current)
1222
+ || ts.isTypeAssertionExpression(current)
1223
+ || ts.isNonNullExpression(current)
1224
+ || ts.isSatisfiesExpression(current)
1225
+ ) {
1226
+ current = current.expression
1227
+ }
1228
+ return current
1229
+ }
1230
+
1231
+ /** Return every value only when a type is a closed string-literal union. */
1232
+ function finiteStringTypeValues(type: ts.Type): Set<string> | undefined {
1233
+ if (type.flags & ts.TypeFlags.StringLiteral) {
1234
+ return new Set([(type as ts.StringLiteralType).value])
1235
+ }
1236
+ if (type.flags & ts.TypeFlags.Never) return new Set()
1237
+ if (!type.isUnion()) return undefined
1238
+ const values = new Set<string>()
1239
+ for (const member of type.types) {
1240
+ const memberValues = finiteStringTypeValues(member)
1241
+ if (!memberValues) return undefined
1242
+ addAll(values, memberValues)
1243
+ }
1244
+ return values
1245
+ }
1246
+
1247
+ /** Return whether a variable declaration belongs to a const declaration list. */
1248
+ function isConstDeclaration(declaration: ts.VariableDeclaration): boolean {
1249
+ return (declaration.parent.flags & ts.NodeFlags.Const) !== 0
1250
+ }
1251
+
1252
+ /** Return whether a declaration is visible to callers outside its source module. */
1253
+ function hasExportModifier(node: ts.Node): boolean {
1254
+ return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => {
1255
+ return modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword
1256
+ }) ?? false)
1257
+ }
1258
+
1259
+ /** Add every member of source to target. */
1260
+ function addAll<T>(target: Set<T>, source: ReadonlySet<T>): void {
1261
+ for (const value of source) target.add(value)
1262
+ }
1263
+
1264
+ /** Return the union of two sets without mutating either input. */
1265
+ function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
1266
+ const out = new Set(left)
1267
+ addAll(out, right)
1268
+ return out
1269
+ }
1270
+
1271
+ /**
1272
+ * Select the package source files of one project in deterministic order.
1273
+ * @param project - the loaded repository TypeScript project.
1274
+ * @returns `packages/<group>/<pkg>/src` files tagged with their package name.
1275
+ */
1276
+ export function collectPackageSources(project: TypeScriptProject): PackageSource[] {
1277
+ return project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
1278
+ const rel = project.relativePath(sourceFile)
1279
+ const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
1280
+ return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
1281
+ }).sort((left, right) => left.rel.localeCompare(right.rel))
1282
+ }
1283
+
1284
+ function collectEventRelations(): Map<string, EventRelation> {
1285
+ const project = new TypeScriptProject(root)
1286
+ return new EventRelationCollector(project, collectPackageSources(project)).collect()
1287
+ }
1288
+
1289
+ function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
1290
+ if (map.size === 0) return '-'
1291
+ return [...map.entries()]
1292
+ .sort(([a], [b]) => a.localeCompare(b))
1293
+ .map(([pkg, methods]) => `${pkgLink(pkgsByShort.get(pkg), pkg)} (${[...methods].sort().map(m => `\`${m}\``).join(', ')})`)
1294
+ .join(', ')
1295
+ }
1296
+
1297
+ function listenerPackages(listeners: Set<string>, pkgsByShort: Map<string, Pkg>): string {
1298
+ if (listeners.size === 0) return '-'
1299
+ return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ')
1300
+ }
1301
+
1302
+ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): string {
1303
+ const relations = collectEventRelations()
1304
+ const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
1305
+ const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
1306
+ const lines = generatedHeader('Event Producer And Consumer Matrix')
1307
+ lines.push(
1308
+ 'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. Events are many-to-many, so the dense relation data is presented as a table rather than one large graph. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
1309
+ '',
1310
+ '| Event | Mode | Declared in | Dispatchers | Listeners |',
1311
+ '| --- | --- | --- | --- | --- |',
1312
+ )
1313
+ for (const event of [...events].sort((a, b) => a.name.localeCompare(b.name))) {
1314
+ const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
1315
+ lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
1316
+ }
1317
+ // Every declared event needs a dispatcher: zero means dead vocabulary or an
1318
+ // unrecognized semantic dispatch form. Listener-free extension points remain
1319
+ // valid. Client-declared events are exempt: the relation scan seeds the HOST
1320
+ // aggregate program only (host+client cannot share one program — the cordis
1321
+ // Context merges collide), so client dispatch sites are structurally
1322
+ // invisible here; their rows stay in the table for the declarations' sake.
1323
+ const undispatched = [...events]
1324
+ .filter(event => !event.source.startsWith('packages/client/'))
1325
+ .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
1326
+ .map(event => event.name)
1327
+ .sort()
1328
+ if (undispatched.length > 0) {
1329
+ throw new Error(
1330
+ `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
1331
+ + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch form the semantic scan misses `
1332
+ + '(teach scripts/gen-doc-graphs.ts that form)',
1333
+ )
1334
+ }
1335
+ const declared = new Set(events.map(event => event.name))
1336
+ const extra = [...relations.keys()].filter(event => !declared.has(event)).sort()
1337
+ if (extra.length > 0) {
1338
+ lines.push('', '## Non-harness or undeclared event strings seen in package source', '', '| Event string | Dispatchers | Listeners |', '| --- | --- | --- |')
1339
+ for (const event of extra) {
1340
+ const relation = relations.get(event)
1341
+ if (!relation) continue
1342
+ lines.push(`| \`${event}\` | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
1343
+ }
1344
+ }
1345
+ lines.push('', ...maintenanceFooter(maintenance))
1346
+ return lines.join('\n')
1347
+ }
1348
+
1349
+ function renderLifecycle(): string {
1350
+ const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
1351
+ return [
1352
+ ...generatedHeader('Agent Turn And Step Lifecycle'),
1353
+ 'This sequence is the visual companion to [architecture.md](architecture.md#turn-flow). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
1354
+ '',
1355
+ '```mermaid',
1356
+ 'sequenceDiagram',
1357
+ ' participant User',
1358
+ ' participant Agent',
1359
+ ' participant Driver',
1360
+ ' participant Hooks as hook listeners',
1361
+ ' participant Prompt as ctx.systemPrompt',
1362
+ ' participant LLM as ctx.llm',
1363
+ ' participant Tools as ctx.tools',
1364
+ ' participant Session',
1365
+ ' participant SDK as UI or SDK listener',
1366
+ ' User->>Agent: followup(content)',
1367
+ ` Agent-->>SDK: ${mermaidCode('agent/inbox/spliced')}`,
1368
+ ` Agent-->>SDK: ${mermaidCode('agent/inbox/inserted')} { message }`,
1369
+ ' Agent->>Driver: queued work wakes driver',
1370
+ ` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
1371
+ ` Driver->>Session: ${mermaidCode('turn/start')}`,
1372
+ ' Note over Agent,Driver: claim pending next-step input plus one queued prompt',
1373
+ ` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`,
1374
+ ` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
1375
+ ` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
1376
+ ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
1377
+ ' Hooks-->>Driver: authoritative reject or enter(messages)',
1378
+ ' alt proposed step rejected, first batch empty, or pre-step failed',
1379
+ ' Driver-->>Driver: claimed batch stays removed, the open turn spends no step',
1380
+ ' else enter proposed step',
1381
+ ` Driver->>Session: ${mermaidCode('step/start')}`,
1382
+ ` Driver->>Hooks: ${mermaidCode('agent/request')} waterfall`,
1383
+ ' Driver->>LLM: prepareCall(config, signal)',
1384
+ ' Note over Driver,LLM: cancellation during either async phase commits neither system nor users',
1385
+ ' Note over Driver,Session: synchronous admission using the prepared call capability',
1386
+ ` Driver->>Session: ${mermaidCode('system/message')} ordered per-node reconciliation`,
1387
+ ` Driver->>Session: ${mermaidCode('user/message')} per entered message`,
1388
+ ` Driver->>Session: ${mermaidCode('request/header')} and ${mermaidCode('request/context')} as needed`,
1389
+ ' Driver->>Driver: derive and freeze request from the log',
1390
+ ` Driver->>LLM: bound prepared call through ${mermaidCode('llm/stream')} waterfall`,
1391
+ ' LLM-->>Driver: StreamChunk*',
1392
+ ` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} chunk*`,
1393
+ ' alt final adapter or terminal in-band request failure',
1394
+ ` Driver->>Session: ${mermaidCode('assistant/attempt')}`,
1395
+ ` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`,
1396
+ ` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`,
1397
+ ' Hooks-->>Driver: return retry action or preserve the original error',
1398
+ ' Note over Driver,LLM: retry in the open step: prepare and reconcile the same rendered assembly without repeating pre-step or users',
1399
+ ' else model request succeeded',
1400
+ ` Driver->>Session: ${mermaidCode('assistant/message')}`,
1401
+ ` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`,
1402
+ ' Driver->>Tools: classify pending call by executionMode',
1403
+ ' loop barriers and bounded rolling pool, reclassify before start',
1404
+ ' opt call starts',
1405
+ ` Driver->>Session: ${mermaidCode('tool/call')}`,
1406
+ ' Driver->>Tools: ordered pre, concurrent execute',
1407
+ ' Tools-->>Session: tool-owned events when applicable',
1408
+ ' end',
1409
+ ' opt next model-order result ready',
1410
+ ' Driver->>Tools: ordered post',
1411
+ ` Driver->>Session: ${mermaidCode('tool/result')}`,
1412
+ ' end',
1413
+ ' end',
1414
+ ` Driver->>Session: ${mermaidCode('step/end')}`,
1415
+ ' opt natural stop and next-step inbox empty',
1416
+ ` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
1417
+ ' end',
1418
+ ' opt next-step input is pending',
1419
+ ' Driver-->>Driver: claim pending next-step input',
1420
+ ` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
1421
+ ` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
1422
+ ' Hooks-->>Driver: authoritative reject or enter(messages)',
1423
+ ' end',
1424
+ ' end',
1425
+ ' end',
1426
+ ` Driver->>Session: ${mermaidCode('turn/end')}`,
1427
+ ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
1428
+ '```',
1429
+ '',
1430
+ 'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or stream-error attempt that reaches settlement without a surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement, and a hard process loss before settlement leaves no durable attempt stream.',
1431
+ '',
1432
+ '`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call and reconciles the retained rendered assembly before request derivation, without repeating assembly, pre-step, or user admission.',
1433
+ '',
1434
+ 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.',
1435
+ '',
1436
+ 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.',
1437
+ '',
1438
+ ...maintenanceFooter(maintenance),
1439
+ ].join('\n')
1440
+ }
1441
+
1442
+ function renderToolPipeline(): string {
1443
+ const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
1444
+ return [
1445
+ ...generatedHeader('Tool Execution Pipeline'),
1446
+ 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.',
1447
+ '',
1448
+ '```mermaid',
1449
+ 'flowchart TD',
1450
+ ' model["Assistant message contains tool-call block"]',
1451
+ ` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
1452
+ ' presentCall["UI pending card<br/>presentCall(args)"]',
1453
+ ` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
1454
+ ' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
1455
+ ' denied["denied or approval refused<br/>tool body skipped"]',
1456
+ ` approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
1457
+ ` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
1458
+ ' toolBody["Registered tool execute() body"]',
1459
+ ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
1460
+ ` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/ptc-dispatch')}"]`,
1461
+ ` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
1462
+ ' normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]',
1463
+ ' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
1464
+ ` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
1465
+ ' context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"]',
1466
+ ` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
1467
+ ' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
1468
+ ' presentResult["UI completed card<br/>presentResult(args, result)"]',
1469
+ ' model --> toolCall',
1470
+ ' toolCall --> presentCall',
1471
+ ' toolCall --> pre',
1472
+ ' pre -->|allow| guards',
1473
+ ' guards -->|allow| around',
1474
+ ' guards -->|deny| denied',
1475
+ ' guards -.->|throw| normalized',
1476
+ ' around --> toolBody',
1477
+ ' pre -->|deny| denied',
1478
+ ' pre -->|ask| approval',
1479
+ ' approval -->|allowed-once| guards',
1480
+ ' approval -->|rejected, cancelled, unavailable| denied',
1481
+ ' approval -.->|throw| normalized',
1482
+ ' denied --> post',
1483
+ ' pre -.->|throw| normalized',
1484
+ ' toolBody --> fsGate',
1485
+ ' fsGate --> toolBody',
1486
+ ' toolBody --> owned',
1487
+ ' toolBody --> around',
1488
+ ' around --> post',
1489
+ ' around -.->|wrapper throws| normalized',
1490
+ ' post -.->|throw| normalized',
1491
+ ' post --> finalize',
1492
+ ' normalized --> finalize',
1493
+ ' finalize --> final',
1494
+ ' final --> toolResult',
1495
+ ' toolResult --> presentResult',
1496
+ ' toolResult --> allResults',
1497
+ ' allResults --> context',
1498
+ '```',
1499
+ '',
1500
+ 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
1501
+ '',
1502
+ ...maintenanceFooter(maintenance),
1503
+ ].join('\n')
1504
+ }
1505
+
1506
+ function renderDocs(): GraphDoc[] {
1507
+ const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
1508
+ const { model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
1509
+ const docs: GraphDoc[] = [
1510
+ { rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs, model.services) },
1511
+ ...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
1512
+ { rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs, model.events) },
1513
+ { rel: 'docs/agent-lifecycle.md', content: renderLifecycle() },
1514
+ { rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() },
1515
+ ]
1516
+ docs.unshift({ rel: 'docs/graph-atlas.md', content: renderIndex(docs) })
1517
+ return docs
1518
+ }
1519
+
1520
+ function renderIndex(docs: GraphDoc[]): string {
1521
+ const labels: Record<string, string> = {
1522
+ 'docs/capability-seams.md': 'capability seams and core services',
1523
+ 'apps/cli/composition.md': 'dsh shared base composition',
1524
+ 'docs/event-producer-consumer.md': 'event producer/consumer matrix',
1525
+ 'docs/agent-lifecycle.md': 'agent turn and step lifecycle',
1526
+ 'docs/tool-execution-pipeline.md': 'tool execution pipeline',
1527
+ }
1528
+ const modes: Record<string, string> = {
1529
+ 'docs/capability-seams.md': 'hybrid generated',
1530
+ 'apps/cli/composition.md': 'hybrid generated',
1531
+ 'docs/event-producer-consumer.md': 'hybrid generated',
1532
+ 'docs/agent-lifecycle.md': 'curated',
1533
+ 'docs/tool-execution-pipeline.md': 'curated',
1534
+ }
1535
+ const rows = [
1536
+ '| [module dependency graph](module-graph.md) | `generated` |',
1537
+ '| [tool schema catalog and package map](tool-catalog.md) | `generated` |',
1538
+ ...docs.map((doc) => {
1539
+ const link = graphIndexLink(doc.rel)
1540
+ return `| [${labels[doc.rel] ?? link}](${link}) | \`${modes[doc.rel] ?? 'generated'}\` |`
1541
+ }),
1542
+ ]
1543
+ const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
1544
+ return [
1545
+ ...generatedHeader('Documentation Graph Index'),
1546
+ 'These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated Cordis API regions) and [tool-catalog.md](tool-catalog.md).',
1547
+ '',
1548
+ 'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).',
1549
+ '',
1550
+ '| Graph | Mode |',
1551
+ '| --- | --- |',
1552
+ ...rows,
1553
+ '',
1554
+ 'Regenerate with `pnpm run gen-doc-graphs`; verify freshness with `pnpm run verify-doc-graphs`.',
1555
+ '',
1556
+ ...maintenanceFooter(maintenance),
1557
+ ].join('\n')
1558
+ }
1559
+
1560
+ function main(): void {
1561
+ const docs = renderDocs()
1562
+ if (process.argv.includes('--check')) {
1563
+ const stale: string[] = []
1564
+ for (const doc of docs) {
1565
+ const abs = resolve(root, doc.rel)
1566
+ const committed = existsSync(abs) ? readFileSync(abs, 'utf8') : null
1567
+ if (committed !== doc.content) stale.push(doc.rel)
1568
+ }
1569
+ if (stale.length === 0) {
1570
+ console.log(`gen-doc-graphs: ${docs.length} graph doc(s) are up to date.`)
1571
+ return
1572
+ }
1573
+ console.error(`gen-doc-graphs: stale graph doc(s): ${stale.join(', ')}. Run \`pnpm run gen-doc-graphs\` and commit the result.`)
1574
+ process.exit(1)
1575
+ }
1576
+
1577
+ for (const doc of docs) {
1578
+ mkdirSync(dirname(resolve(root, doc.rel)), { recursive: true })
1579
+ writeFileSync(resolve(root, doc.rel), doc.content)
1580
+ }
1581
+ console.log(`gen-doc-graphs: wrote ${docs.length} graph doc(s).`)
1582
+ }
1583
+
1584
+ if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
1585
+ main()
1586
+ }
scripts/gen-scoped-events.ts ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generate dsh-scope's invariant resolver map from the repository TypeScript
3
+ * Program.
4
+ *
5
+ * A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
6
+ * calls establish the routing-key type for that base. The generator searches
7
+ * every event payload parameter and one property level for exactly one type
8
+ * equivalent to that key. Each generated resolver compiles against the merged
9
+ * `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
10
+ * multiple matches are ambiguous and always fail loud.
11
+ *
12
+ * `tsx scripts/gen-scoped-events.ts` -> write the generated source
13
+ * `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { resolve } from 'node:path'
18
+ import ts from 'typescript'
19
+ import { pointer, rawJsDoc } from './jsdoc.ts'
20
+ import { TypeScriptProject } from './ts-project.ts'
21
+
22
+ const root = resolve(import.meta.dirname, '..')
23
+ const OUT = 'packages/core/scope/src/scoped-events.generated.ts'
24
+ const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
25
+
26
+ interface ScopeTargetContract {
27
+ baseType: ts.Type
28
+ keyType: ts.Type
29
+ source: string
30
+ }
31
+
32
+ interface SubjectCandidate {
33
+ path: string
34
+ parameter: number
35
+ property?: string
36
+ type: ts.Type
37
+ }
38
+
39
+ interface ScopedEventResolver {
40
+ event: string
41
+ candidate: SubjectCandidate | null
42
+ }
43
+
44
+ interface ScopeTag {
45
+ present: boolean
46
+ unsupported: boolean
47
+ }
48
+
49
+ /** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
50
+ class ScopedEventGenerator {
51
+ private readonly checker: ts.TypeChecker
52
+ private readonly packageSources: ts.SourceFile[]
53
+ private readonly scopeTargetDeclaration: ts.FunctionDeclaration
54
+ private readonly scopedSymbol: ts.Symbol
55
+ private readonly violations: string[] = []
56
+
57
+ constructor(private readonly project: TypeScriptProject) {
58
+ this.checker = project.checker
59
+ this.packageSources = project.sourceFiles().filter((sourceFile) => {
60
+ return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
61
+ })
62
+ this.scopeTargetDeclaration = this.functionDeclaration(
63
+ 'packages/core/scope/src/index.ts',
64
+ 'scopeTarget',
65
+ )
66
+ this.scopedSymbol = this.typeAliasSymbol(
67
+ 'packages/core/scope/src/index.ts',
68
+ 'Scoped',
69
+ )
70
+ }
71
+
72
+ /** Render the complete generated TypeScript module or throw every contract violation. */
73
+ render(): string {
74
+ const contracts = this.collectScopeTargetContracts()
75
+ const resolvers = this.collectScopedEventResolvers(contracts)
76
+ if (this.violations.length > 0) {
77
+ throw new Error(
78
+ `gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
79
+ + this.violations.map(violation => ` - ${violation}`).join('\n'),
80
+ )
81
+ }
82
+ return [
83
+ '/**',
84
+ ' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.',
85
+ ' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
86
+ ' *',
87
+ ' * @module @deepseek-ai/dsh-scope/scoped-events.generated',
88
+ ' */',
89
+ '',
90
+ 'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
91
+ '',
92
+ 'const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({',
93
+ ...resolvers.map(({ event, candidate }) => {
94
+ if (candidate === null) return ` '${event}': null,`
95
+ const subject = candidate.property === undefined
96
+ ? `args[${candidate.parameter}]`
97
+ : `(args[${candidate.parameter}] as Record<string, unknown>)[${quote(candidate.property)}]`
98
+ return ` '${event}': args => ${subject},`
99
+ }),
100
+ '})',
101
+ '',
102
+ '/**',
103
+ ' * Resolve the routing key named by one scoped event payload. A null',
104
+ ' * resolver means the payload cannot expose its external routing key, so the',
105
+ ' * invariant checks carrier presence only.',
106
+ ' * @param event - runtime Cordis event name.',
107
+ ' * @returns the generated subject resolver, null for presence-only,',
108
+ ' * or undefined when the event is not scope-filtered.',
109
+ ' */',
110
+ 'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
111
+ ' return scopedSubjectResolvers[event]',
112
+ '}',
113
+ '',
114
+ ].join('\n')
115
+ }
116
+
117
+ /** Resolve one named function declaration from a known source file. */
118
+ private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
119
+ const sourceFile = this.project.sourceFile(relativePath)
120
+ const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
121
+ return ts.isFunctionDeclaration(statement) && statement.name?.text === name
122
+ })
123
+ if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
124
+ return declaration
125
+ }
126
+
127
+ /** Resolve one named type-alias symbol from a known source file. */
128
+ private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
129
+ const sourceFile = this.project.sourceFile(relativePath)
130
+ const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
131
+ return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
132
+ })
133
+ const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
134
+ if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
135
+ return symbol
136
+ }
137
+
138
+ /** Collect every real scopeTarget(base, key) base/key type contract. */
139
+ private collectScopeTargetContracts(): ScopeTargetContract[] {
140
+ const contracts: ScopeTargetContract[] = []
141
+ const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
142
+ if (ts.isCallExpression(node)
143
+ && this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
144
+ const base = node.arguments[0]
145
+ const key = node.arguments[1]
146
+ if (!base || !key) {
147
+ const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
148
+ this.violations.push(`${source} calls scopeTarget without base and key arguments`)
149
+ } else {
150
+ contracts.push({
151
+ baseType: this.checker.getTypeAtLocation(base),
152
+ keyType: this.checker.getTypeAtLocation(key),
153
+ source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
154
+ })
155
+ }
156
+ }
157
+ ts.forEachChild(node, (child) => { visit(sourceFile, child) })
158
+ }
159
+ for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
160
+ return contracts
161
+ }
162
+
163
+ /** Collect every Events member and derive its generated resolver. */
164
+ private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
165
+ const resolvers: ScopedEventResolver[] = []
166
+ for (const sourceFile of this.packageSources) {
167
+ const rel = this.project.relativePath(sourceFile)
168
+ const visit = (node: ts.Node): void => {
169
+ if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
170
+ for (const member of node.members) {
171
+ if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
172
+ const event = member.name.text
173
+ const raw = rawJsDoc(sourceFile.text, member)
174
+ const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
175
+ const tag = parseScopeTag(raw, where, this.violations)
176
+ const thisParameter = member.parameters.find(isThisParameter)
177
+ const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
178
+ if (!scopedBase) {
179
+ if (raw.includes(SCOPE_DOC_MARKER)) {
180
+ this.violations.push(
181
+ `${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
182
+ )
183
+ }
184
+ if (tag.present) {
185
+ this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
186
+ }
187
+ continue
188
+ }
189
+ if (!raw.includes(SCOPE_DOC_MARKER)) {
190
+ this.violations.push(
191
+ `${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
192
+ )
193
+ }
194
+ const keyType = this.routingKeyType(where, scopedBase, contracts)
195
+ if (!keyType) continue
196
+ const candidates = this.subjectCandidates(member)
197
+ .filter(candidate => this.typesEquivalent(candidate.type, keyType))
198
+ if (candidates.length > 1) {
199
+ this.violations.push(
200
+ `${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
201
+ + candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
202
+ )
203
+ continue
204
+ }
205
+ if (candidates.length === 0) {
206
+ if (!tag.unsupported) {
207
+ const keyLabel = this.typeText(keyType)
208
+ this.violations.push(
209
+ `${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
210
+ + 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
211
+ )
212
+ }
213
+ resolvers.push({ event, candidate: null })
214
+ continue
215
+ }
216
+ if (tag.unsupported) {
217
+ this.violations.push(
218
+ `${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
219
+ )
220
+ continue
221
+ }
222
+ resolvers.push({ event, candidate: candidates[0] ?? null })
223
+ }
224
+ }
225
+ ts.forEachChild(node, visit)
226
+ }
227
+ visit(sourceFile)
228
+ }
229
+ return resolvers.sort((left, right) => left.event.localeCompare(right.event))
230
+ }
231
+
232
+ /** Extract the Base type from one exact this: Scoped<Base> parameter. */
233
+ private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
234
+ const type = this.checker.getTypeAtLocation(parameter)
235
+ if (type.aliasSymbol !== this.scopedSymbol) return undefined
236
+ return type.aliasTypeArguments?.[0]
237
+ }
238
+
239
+ /** Resolve one unambiguous key type for a scoped carrier base. */
240
+ private routingKeyType(
241
+ where: string,
242
+ scopedBase: ts.Type,
243
+ contracts: readonly ScopeTargetContract[],
244
+ ): ts.Type | undefined {
245
+ const matches = contracts.filter((contract) => {
246
+ return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
247
+ })
248
+ if (matches.length === 0) {
249
+ this.violations.push(
250
+ `${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
251
+ )
252
+ return undefined
253
+ }
254
+ const keyTypes: ts.Type[] = []
255
+ for (const match of matches) {
256
+ if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
257
+ }
258
+ if (keyTypes.length > 1) {
259
+ this.violations.push(
260
+ `${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
261
+ + matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
262
+ )
263
+ return undefined
264
+ }
265
+ return keyTypes[0]
266
+ }
267
+
268
+ /** Enumerate every payload parameter and every accessible one-level property. */
269
+ private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
270
+ const candidates: SubjectCandidate[] = []
271
+ let runtimeIndex = 0
272
+ for (const parameter of member.parameters) {
273
+ if (isThisParameter(parameter)) continue
274
+ const directPath = `args[${runtimeIndex}]`
275
+ const parameterType = this.checker.getTypeAtLocation(parameter)
276
+ candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
277
+ for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
278
+ const name = property.getName()
279
+ if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
280
+ candidates.push({
281
+ path: `${directPath}.${name}`,
282
+ parameter: runtimeIndex,
283
+ property: name,
284
+ type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
285
+ })
286
+ }
287
+ runtimeIndex += 1
288
+ }
289
+ return dedupeCandidates(candidates)
290
+ }
291
+
292
+ /** Compare exact Program type identities after removing null and undefined. */
293
+ private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
294
+ const normalizedLeft = this.normalizedType(left)
295
+ const normalizedRight = this.normalizedType(right)
296
+ if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
297
+ if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
298
+ return normalizedLeft === normalizedRight
299
+ }
300
+
301
+ /** Remove null and undefined from a routing or candidate type. */
302
+ private normalizedType(type: ts.Type): ts.Type {
303
+ return this.checker.getNonNullableType(type)
304
+ }
305
+
306
+ /** Render a stable diagnostic type label. */
307
+ private typeText(type: ts.Type): string {
308
+ return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
309
+ }
310
+ }
311
+
312
+ /** Return whether an Events interface is inside declare module '@deepseek-ai/cordis'. */
313
+ function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
314
+ const block = node.parent
315
+ const declaration = block.parent
316
+ return ts.isModuleBlock(block)
317
+ && ts.isModuleDeclaration(declaration)
318
+ && ts.isStringLiteral(declaration.name)
319
+ && declaration.name.text === '@deepseek-ai/cordis'
320
+ }
321
+
322
+ /** Return whether a parameter is the explicit TypeScript this receiver. */
323
+ function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
324
+ return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
325
+ }
326
+
327
+ /** Parse and validate the optional @dshScopeScan unsupported tag. */
328
+ function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
329
+ const tags = raw
330
+ .replace(/^\/\*\*/, '')
331
+ .replace(/\*\/$/, '')
332
+ .split('\n')
333
+ .map(line => line.replace(/^\s*\*?\s?/, '').trim())
334
+ .filter(line => line.startsWith('@dshScopeScan'))
335
+ if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
336
+ if (tags.length === 0) return { present: false, unsupported: false }
337
+ const unsupported = tags[0] === '@dshScopeScan unsupported'
338
+ if (!unsupported) {
339
+ violations.push(
340
+ `${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
341
+ )
342
+ }
343
+ return { present: true, unsupported }
344
+ }
345
+
346
+ /** Return whether a property has a private or protected declaration. */
347
+ function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
348
+ return (symbol.declarations ?? []).some((declaration) => {
349
+ if (!ts.canHaveModifiers(declaration)) return false
350
+ return ts.getModifiers(declaration)?.some((modifier) => {
351
+ return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
352
+ }) ?? false
353
+ })
354
+ }
355
+
356
+ /** Deduplicate candidate paths contributed by merged/intersection types. */
357
+ function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
358
+ const seen = new Set<string>()
359
+ return candidates.filter((candidate) => {
360
+ if (seen.has(candidate.path)) return false
361
+ seen.add(candidate.path)
362
+ return true
363
+ })
364
+ }
365
+
366
+ /** Quote a generated property key as a single-quoted TypeScript string. */
367
+ function quote(value: string): string {
368
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
369
+ }
370
+
371
+ /**
372
+ * Render the generated scoped-event resolver module for one repository root.
373
+ * @param projectRoot - repository root carrying tsconfig.host.json.
374
+ * @returns complete generated TypeScript source.
375
+ */
376
+ export function renderScopedEvents(projectRoot: string = root): string {
377
+ return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
378
+ }
379
+
380
+ /** Generate or freshness-check the fixed dsh-scope source file. */
381
+ function main(): void {
382
+ const content = renderScopedEvents()
383
+ const output = resolve(root, OUT)
384
+ if (process.argv.includes('--check')) {
385
+ const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
386
+ if (committed === content) {
387
+ console.log(`gen-scoped-events: ${OUT} is up to date.`)
388
+ return
389
+ }
390
+ console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
391
+ process.exit(1)
392
+ }
393
+ writeFileSync(output, content)
394
+ console.log(`gen-scoped-events: wrote ${OUT}.`)
395
+ }
396
+
397
+ if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
398
+ main()
399
+ }
scripts/jsdoc.ts ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared JSDoc parsing and completeness checks for the Cordis, persistence,
3
+ * and config catalogs and the exported-API gate.
4
+ */
5
+
6
+ import ts from 'typescript'
7
+
8
+ /** Repo-relative source pointer `file:line` for a node's first character. */
9
+ export function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
10
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
11
+ return `${rel}:${line + 1}`
12
+ }
13
+
14
+ /** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */
15
+ export function rawJsDoc(text: string, node: ts.Node): string {
16
+ const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
17
+ const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
18
+ return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
19
+ }
20
+
21
+ /** A dispatch mode, rendered as the badge after an event name in the catalog. */
22
+ export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' | 'bail'
23
+
24
+ /**
25
+ * Parse a raw JSDoc block into description prose and an optional `@mode`. Prose
26
+ * ends at the first block tag, paragraphs collapse to one line, bullet items
27
+ * remain separate lines, and `{@link X}` renders as `X`.
28
+ * @param raw - the raw comment text including the JSDoc delimiters.
29
+ * @returns the collapsed description prose, parsed valid `@mode` (or null),
30
+ * and whether any `@mode` tag was present.
31
+ */
32
+ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMode: boolean } {
33
+ const inner = raw
34
+ .replace(/^\/\*\*/, '')
35
+ .replace(/\*\/$/, '')
36
+ .split('\n')
37
+ .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
38
+ let mode: Mode | null = null
39
+ let hasMode = false
40
+ let inTags = false
41
+ const blocks: string[] = []
42
+ let para: string[] = []
43
+ let list: string[] = []
44
+ let item: string[] = []
45
+ const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
46
+ const flushItem = (): void => {
47
+ if (item.length) list.push(join(item))
48
+ item = []
49
+ }
50
+ const flushList = (): void => {
51
+ flushItem()
52
+ if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
53
+ list = []
54
+ }
55
+ const flushPara = (): void => {
56
+ flushList()
57
+ if (para.length) blocks.push(join(para))
58
+ para = []
59
+ }
60
+ for (const line of inner) {
61
+ const tagLine = line.trimStart()
62
+ const m = /^@mode\s+(emit|waterfall|parallel|serial|bail)\s*$/.exec(tagLine)
63
+ if (m) { mode = m[1] as Mode; hasMode = true; flushPara(); inTags = true; continue }
64
+ if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
65
+ if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
66
+ if (inTags) continue // block-tag territory: continuations are never prose
67
+ if (line.trim() === '') { flushPara(); continue }
68
+ if (/^-\s+/.test(line)) {
69
+ // A list item starts: a pending paragraph (e.g. an intro line directly
70
+ // above the list, no blank between) flushes FIRST so it renders above.
71
+ flushItem()
72
+ if (para.length) { blocks.push(join(para)); para = [] }
73
+ item.push(line)
74
+ continue
75
+ }
76
+ if (item.length) { item.push(line); continue } // continuation of current item
77
+ para.push(line)
78
+ }
79
+ flushPara()
80
+ const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
81
+ return { doc, mode, hasMode }
82
+ }
83
+
84
+ /**
85
+ * Parse `@param` and `@returns` descriptions, including continuation lines.
86
+ * Parameter separators are optional and `[optional]` names unwrap.
87
+ * @param raw - the raw comment text including the JSDoc delimiters.
88
+ * @returns the `@param` name→description map plus the `@returns` description
89
+ * (null when the tag is absent, '' when present but empty).
90
+ */
91
+ export function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
92
+ const inner = raw
93
+ .replace(/^\/\*\*/, '')
94
+ .replace(/\*\/$/, '')
95
+ .split('\n')
96
+ .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
97
+ const params = new Map<string, string>()
98
+ let returns: string | null = null
99
+ let sink: ((text: string) => void) | null = null
100
+ for (const line of inner) {
101
+ const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
102
+ if (param) {
103
+ const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
104
+ let acc = param[2] ?? ''
105
+ params.set(name, acc)
106
+ sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
107
+ continue
108
+ }
109
+ const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
110
+ if (ret) {
111
+ let acc = ret[1] ?? ''
112
+ returns = acc
113
+ sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
114
+ continue
115
+ }
116
+ if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
117
+ sink?.(line.trim())
118
+ }
119
+ return { params, returns }
120
+ }
121
+
122
+ /**
123
+ * Require a non-empty tag for each non-exempt identifier parameter, reject
124
+ * binding-pattern parameters, and reject stale tags. Exempt parameters may
125
+ * still be documented.
126
+ * @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
127
+ * @param apiKind - API kind used in binding-pattern diagnostics.
128
+ * @param parameters - the declaration's parameter list.
129
+ * @param tags - the parsed `@param` name→description map from parseTags.
130
+ * @param sf - source file used to render binding patterns.
131
+ * @param isExempt - parameters whose tag is optional, such as `this` or waterfall `next`.
132
+ * @param violations - the aggregate list violations append to.
133
+ */
134
+ export function checkParams(
135
+ where: string,
136
+ apiKind: string,
137
+ parameters: readonly ts.ParameterDeclaration[],
138
+ tags: Map<string, string>,
139
+ sf: ts.SourceFile,
140
+ isExempt: (p: ts.ParameterDeclaration) => boolean,
141
+ violations: string[],
142
+ ): void {
143
+ for (const p of parameters) {
144
+ if (!ts.isIdentifier(p.name)) {
145
+ violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${apiKind} API needs simple identifier parameters so @param can name them.`)
146
+ continue
147
+ }
148
+ if (isExempt(p)) continue
149
+ const desc = tags.get(p.name.text)
150
+ if (desc === undefined) violations.push(`${where} is missing @param ${p.name.text}.`)
151
+ else if (!desc.trim()) violations.push(`${where}: @param ${p.name.text} has an empty description.`)
152
+ }
153
+ for (const tag of tags.keys()) {
154
+ if (!parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
155
+ violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Check the `@returns` half of the completeness contract: a non-`void` / `Promise<void>`
162
+ * return needs a non-empty `@returns`, and the return type must be ANNOTATED — a pure-AST
163
+ * walk cannot classify an inferred return. Void returns may still carry an
164
+ * optional tag, for example to document resolution timing.
165
+ * @param where - the offender label violations open with.
166
+ * @param typeNode - the declared return type annotation, or undefined when inferred.
167
+ * @param returns - the parsed `@returns` description from parseTags (null when absent).
168
+ * @param sf - the source file (for rendering the annotation's text).
169
+ * @param violations - the aggregate list violations append to.
170
+ */
171
+ export function checkReturns(
172
+ where: string,
173
+ typeNode: ts.TypeNode | undefined,
174
+ returns: string | null,
175
+ sf: ts.SourceFile,
176
+ violations: string[],
177
+ ): void {
178
+ if (typeNode === undefined) {
179
+ violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
180
+ return
181
+ }
182
+ const rt = typeNode.getText(sf).replace(/\s+/g, ' ')
183
+ if (/^(void|Promise<void>)$/.test(rt)) return
184
+ if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
185
+ else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
186
+ }
187
+
188
+ /**
189
+ * Throw one aggregate error for every completeness violation a walk collected.
190
+ * Aggregation (vs failing fast) is deliberate: a remediation pass sees the
191
+ * whole list at once instead of replaying the gate once per offender.
192
+ * @param gate - the reporting gate's name, prefixed to the error message.
193
+ * @param violations - the collected violation lines; no-op when empty.
194
+ */
195
+ export function reportViolations(gate: string, violations: string[]): void {
196
+ if (violations.length === 0) return
197
+ throw new Error(
198
+ `${gate}: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
199
+ + violations.map(v => ` ${v}`).join('\n'),
200
+ )
201
+ }
scripts/lint-rule-fingerprint.spec.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ type Rules = Record<string, unknown>
8
+
9
+ interface Profile {
10
+ readonly count: number
11
+ readonly indexes: readonly number[]
12
+ readonly sha256: string
13
+ }
14
+
15
+ // A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2
16
+ // mapped @typescript-eslint/* to typescript/* and four extension rules to their
17
+ // Oxlint core equivalents. These fingerprints pin the resulting repository
18
+ // snapshot; they do not re-evaluate that deleted baseline or track its preset.
19
+ const profiles = {
20
+ source: {
21
+ count: 89,
22
+ indexes: [0, 1, 4, 5],
23
+ sha256: '3e8b8455827254d6da59afb803d30f062b9fe1264b4df75ce4c8cd6e742b93dd',
24
+ },
25
+ example: {
26
+ count: 88,
27
+ indexes: [0, 1, 2, 4, 5],
28
+ sha256: 'e2b944c028ef1971302d47b2eb0e95c05e740623c7cbaebb4b1312dbf73bf156',
29
+ },
30
+ test: {
31
+ count: 84,
32
+ indexes: [0, 3, 4, 5],
33
+ sha256: '7e4e1ed0cfca06ac94eeb26081195b5f753e7527775d5f5b95ccfefac9d2b137',
34
+ },
35
+ } as const satisfies Record<string, Profile>
36
+
37
+ function isRecord(value: unknown): value is Record<string, unknown> {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
39
+ }
40
+
41
+ function isUnknownArray(value: unknown): value is unknown[] {
42
+ return Array.isArray(value)
43
+ }
44
+
45
+ function severity(value: unknown): 0 | 1 | 2 {
46
+ const level = isUnknownArray(value) ? value[0] : value
47
+ if (level === 'off' || level === 0) return 0
48
+ if (level === 'warn' || level === 'warning' || level === 1) return 1
49
+ if (level === 'error' || level === 2) return 2
50
+ throw new Error(`unsupported lint severity: ${JSON.stringify(level)}`)
51
+ }
52
+
53
+ function normalizedRules(rules: Rules): Rules {
54
+ return Object.fromEntries(Object.entries(rules)
55
+ .filter(([, value]) => severity(value) > 0)
56
+ .sort(([left], [right]) => left.localeCompare(right))
57
+ .map(([name, value]) => {
58
+ const options = isUnknownArray(value) ? value.slice(1) : []
59
+ return [name, [severity(value), ...options]]
60
+ }))
61
+ }
62
+
63
+ function mergedRules(overrides: readonly unknown[], indexes: readonly number[]): Rules {
64
+ const merged: Rules = {}
65
+ for (const index of indexes) {
66
+ const override = overrides[index]
67
+ if (!isRecord(override) || !isRecord(override.rules)) {
68
+ throw new Error(`.oxlintrc.json override ${index} must contain a rules object`)
69
+ }
70
+ Object.assign(merged, override.rules)
71
+ }
72
+ return normalizedRules(merged)
73
+ }
74
+
75
+ describe('Oxlint repository rule fingerprint', () => {
76
+ const path = fileURLToPath(new URL('../.oxlintrc.json', import.meta.url))
77
+ const result = parseConfigFileTextToJson(path, readFileSync(path, 'utf8'))
78
+ if (result.error !== undefined) {
79
+ throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
80
+ }
81
+ const parsed: unknown = result.config
82
+ if (!isRecord(parsed) || !Array.isArray(parsed.overrides)) {
83
+ throw new Error('.oxlintrc.json must contain an overrides array')
84
+ }
85
+ const overrides: readonly unknown[] = parsed.overrides
86
+
87
+ it('pins every override field', () => {
88
+ expect(overrides).toHaveLength(9)
89
+ })
90
+
91
+ it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => {
92
+ const rules = mergedRules(overrides, profile.indexes)
93
+ const fingerprint = createHash('sha256').update(JSON.stringify(rules)).digest('hex')
94
+
95
+ expect(Object.keys(rules)).toHaveLength(profile.count)
96
+ expect(fingerprint).toBe(profile.sha256)
97
+ })
98
+ })
scripts/locale-dictionary-parity.spec.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary
3
+ * declares the same keys in `zh` and `en`.
4
+ *
5
+ * The locale runtime resolves a key through the active locale, then through
6
+ * the single fallback locale (`en`), then surfaces the key itself. With
7
+ * symmetric dictionaries that middle step always resolves, so one constant can
8
+ * serve as both the opening locale and the dictionary fallback. A key added to
9
+ * only one side breaks that: a reader of the other language sees a bare key
10
+ * such as `list.aria` instead of text. This gate fails on the asymmetry rather
11
+ * than waiting for the bare key to reach a UI.
12
+ *
13
+ * Discovery is deliberately broad, because a gate that silently narrows is
14
+ * worse than no gate. It sweeps every workspace package (not just
15
+ * `packages/client`), reads dictionaries wherever they are declared —
16
+ * `locales.ts`, a `locales/` directory, or inline in the plugin body — and
17
+ * pairs `zh`/`en` across sibling files as well as within one module. A `zh`
18
+ * dictionary whose `en` counterpart cannot be found anywhere is an error, not
19
+ * a skip.
20
+ */
21
+
22
+ import type { Dirent } from 'node:fs'
23
+ import { readdirSync, readFileSync } from 'node:fs'
24
+ import { dirname, resolve } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import ts from 'typescript'
27
+ import { describe, expect, it } from 'vitest'
28
+
29
+ const root = fileURLToPath(new URL('..', import.meta.url))
30
+
31
+ /** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */
32
+ function relative(file: string): string {
33
+ return file.slice(root.length).replaceAll('\\', '/')
34
+ }
35
+
36
+ /** Every `.ts` source file under each workspace package's `src`, excluding declarations. */
37
+ function sourceFiles(): string[] {
38
+ const files: string[] = []
39
+ const packagesRoot = resolve(root, 'packages')
40
+ for (const group of directories(packagesRoot)) {
41
+ for (const pkg of directories(resolve(packagesRoot, group))) {
42
+ walk(resolve(packagesRoot, group, pkg, 'src'), files)
43
+ }
44
+ }
45
+ return files.sort()
46
+ }
47
+
48
+ /** Immediate subdirectory names, or none when the path is not a directory. */
49
+ function directories(dir: string): string[] {
50
+ return readEntries(dir).filter(entry => entry.isDirectory()).map(entry => entry.name)
51
+ }
52
+
53
+ /**
54
+ * Directory entries, treating only a genuinely absent directory as empty.
55
+ * Any other failure (`EACCES`, I/O) rethrows: silently reading it as "absent"
56
+ * would narrow the sweep and let the gate pass while checking less.
57
+ * @param dir - absolute directory path.
58
+ * @returns entries, or none when the directory does not exist.
59
+ */
60
+ function readEntries(dir: string): Dirent[] {
61
+ try {
62
+ return readdirSync(dir, { withFileTypes: true })
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
65
+ throw error
66
+ }
67
+ }
68
+
69
+ function walk(dir: string, out: string[]): void {
70
+ for (const entry of readEntries(dir)) {
71
+ const full = resolve(dir, entry.name)
72
+ if (entry.isDirectory()) walk(full, out)
73
+ else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full)
74
+ }
75
+ }
76
+
77
+ /** One discovered dictionary: which file and export name declared it. */
78
+ interface Dictionary {
79
+ /** Repo-relative declaring file. */
80
+ file: string
81
+ /** Export name, or the registration site for an inline literal. */
82
+ name: string
83
+ /** Declared keys, sorted. */
84
+ keys: string[]
85
+ }
86
+
87
+ /**
88
+ * Keys of every top-level `export const <name> = { ... }` object literal whose
89
+ * name identifies a locale dictionary, plus inline `register(ns, locale, {...})`
90
+ * literals. Read from the AST so the gate never executes package code.
91
+ * @param file - absolute path of a candidate module.
92
+ * @returns discovered dictionaries, keyed by locale-bearing name.
93
+ */
94
+ function dictionariesIn(file: string): Dictionary[] {
95
+ const text = readFileSync(file, 'utf8')
96
+ // Cheap pre-filter: parsing every package source is wasteful. The pattern
97
+ // must admit every shape `localeOf` accepts, or a file would be skipped
98
+ // before parsing — the silent narrowing this gate exists to prevent. A bare
99
+ // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold
100
+ // between `h` and an uppercase letter.
101
+ if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return []
102
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
103
+ const found: Dictionary[] = []
104
+ const rel = relative(file)
105
+
106
+ // Module-scope variable declarations, keyed by name. A 3-arg
107
+ // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier —
108
+ // e.g. a local dictionary variable rather than an inline literal — resolves
109
+ // through here so the gate still verifies its symmetry.
110
+ const moduleConsts = new Map<string, ts.Expression>()
111
+ for (const statement of source.statements) {
112
+ if (!ts.isVariableStatement(statement)) continue
113
+ for (const decl of statement.declarationList.declarations) {
114
+ if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) {
115
+ moduleConsts.set(decl.name.text, decl.initializer)
116
+ }
117
+ }
118
+ }
119
+
120
+ for (const statement of source.statements) {
121
+ if (!ts.isVariableStatement(statement)) continue
122
+ if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue
123
+ for (const decl of statement.declarationList.declarations) {
124
+ if (!ts.isIdentifier(decl.name)) continue
125
+ const literal = unwrap(decl.initializer)
126
+ if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
127
+ if (localeOf(decl.name.text) === undefined) continue
128
+ found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) })
129
+ }
130
+ }
131
+
132
+ // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we
133
+ // cannot turn into an object literal. We refuse instead of skipping: a
134
+ // registration we cannot measure is exactly the silent narrowing this gate
135
+ // exists to catch.
136
+ const refuse = (ns: string, tag: string, why: string): never => {
137
+ throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`)
138
+ }
139
+
140
+ // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair
141
+ // handed to a registration loop keys off the enclosing array; separate
142
+ // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the
143
+ // namespace argument, so the two calls pair with each other.
144
+ const visit = (node: ts.Node): void => {
145
+ if (ts.isCallExpression(node)) {
146
+ const callee = node.expression
147
+ const name = ts.isPropertyAccessExpression(callee)
148
+ ? callee.name.text
149
+ : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined
150
+ if (name === 'register' && node.arguments.length >= 3) {
151
+ const [ns, tag, dict] = node.arguments
152
+ if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return
153
+ if (tag.text !== 'zh' && tag.text !== 'en') return
154
+ const raw = unwrap(dict)
155
+ const literal = raw !== undefined && ts.isIdentifier(raw)
156
+ ? (() => {
157
+ const resolved = moduleConsts.get(raw.text)
158
+ return resolved === undefined ? undefined : unwrap(resolved)
159
+ })()
160
+ : raw
161
+ const why = raw !== undefined && ts.isIdentifier(raw)
162
+ ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal`
163
+ : 'third argument is neither an object literal nor a resolvable dictionary variable'
164
+ if (literal === undefined || !ts.isObjectLiteralExpression(literal)) {
165
+ // The dictionary argument must resolve to an object literal; the
166
+ // gate refuses rather than skips, so the symmetry it verifies never
167
+ // silently narrows.
168
+ refuse(ns.getText(source), tag.text, why)
169
+ }
170
+ const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression
171
+ // The namespace expression's source text identifies the pair, so the
172
+ // zh and en calls for one namespace meet and calls for different
173
+ // namespaces stay apart.
174
+ found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) })
175
+ }
176
+ }
177
+ if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
178
+ const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
179
+ for (const element of node.elements) {
180
+ if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue
181
+ const [tag, dict] = element.elements
182
+ const literal = unwrap(dict)
183
+ if (tag === undefined || !ts.isStringLiteral(tag)) continue
184
+ if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
185
+ if (tag.text !== 'zh' && tag.text !== 'en') continue
186
+ found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) })
187
+ }
188
+ }
189
+ ts.forEachChild(node, visit)
190
+ }
191
+ visit(source)
192
+ return found
193
+ }
194
+
195
+ /** Declared property names of an object literal, sorted. */
196
+ function keysOf(literal: ts.ObjectLiteralExpression): string[] {
197
+ const keys: string[] = []
198
+ for (const prop of literal.properties) {
199
+ if (!ts.isPropertyAssignment(prop)) continue
200
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text)
201
+ }
202
+ return keys.sort()
203
+ }
204
+
205
+ /** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */
206
+ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
207
+ let current = node
208
+ while (
209
+ current !== undefined
210
+ && (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current))
211
+ ) {
212
+ current = current.expression
213
+ }
214
+ return current
215
+ }
216
+
217
+ /**
218
+ * The locale a dictionary name declares, and the namespace-ish remainder that
219
+ * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`,
220
+ * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix
221
+ * shape requires an uppercase ASCII letter at the third position (`[A-Z]`),
222
+ * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe`
223
+ * cannot be treated as dictionaries in one place and skipped in another.
224
+ * @param name - export name or synthetic inline name.
225
+ * @returns locale plus pair key, or undefined when the name names no locale.
226
+ */
227
+ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined {
228
+ for (const locale of ['zh', 'en'] as const) {
229
+ const other = locale === 'zh' ? 'Zh' : 'En'
230
+ if (name === locale) return { locale, pair: '' }
231
+ // Synthetic names for inline shapes carry their own pair key after the
232
+ // first ':' (the enclosing array's line, or the namespace expression).
233
+ if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) }
234
+ if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) {
235
+ return { locale, pair: name.slice(2) }
236
+ }
237
+ if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) }
238
+ }
239
+ return undefined
240
+ }
241
+
242
+ describe('shipped locale dictionaries', () => {
243
+ it('declares the same keys in zh and en, so the single fallback locale always resolves', () => {
244
+ const files = sourceFiles()
245
+ // Guard the discovery itself: an empty or narrowed sweep would pass every
246
+ // assertion below while checking nothing.
247
+ expect(files.length).toBeGreaterThan(500)
248
+
249
+ // Pair within a file first; a dictionary whose counterpart is not in the
250
+ // same module then pairs with a sibling in the same directory. Both shapes
251
+ // ship here: `locales/settings.ts` exports zh+en together, while
252
+ // `locales/zh.ts` + `locales/en.ts` split the common pair across files.
253
+ const perFile = new Map<string, Dictionary[]>()
254
+ for (const file of files) {
255
+ const dicts = dictionariesIn(file)
256
+ if (dicts.length > 0) perFile.set(relative(file), dicts)
257
+ }
258
+
259
+ const groups = new Map<string, Map<'zh' | 'en', Dictionary>>()
260
+ const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => {
261
+ const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>()
262
+ if (slot.has(locale)) {
263
+ throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`)
264
+ }
265
+ slot.set(locale, dict)
266
+ groups.set(key, slot)
267
+ }
268
+
269
+ for (const [rel, dicts] of perFile) {
270
+ for (const dict of dicts) {
271
+ const parsed = localeOf(dict.name)
272
+ if (parsed === undefined) continue
273
+ const sameFileCounterpart = dicts.some((other) => {
274
+ const otherParsed = localeOf(other.name)
275
+ return otherParsed !== undefined
276
+ && otherParsed.pair === parsed.pair
277
+ && otherParsed.locale !== parsed.locale
278
+ })
279
+ // Same-file pairs key by file so two pairs in one directory stay
280
+ // distinct; split pairs key by directory so siblings meet.
281
+ const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}`
282
+ place(key, parsed.locale, dict)
283
+ }
284
+ }
285
+
286
+ const problems: string[] = []
287
+ let comparedPairs = 0
288
+ for (const [key, slot] of [...groups].sort()) {
289
+ const zh = slot.get('zh')
290
+ const en = slot.get('en')
291
+ if (zh === undefined || en === undefined) {
292
+ const present = zh ?? en
293
+ problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`)
294
+ continue
295
+ }
296
+ comparedPairs++
297
+ const zhOnly = zh.keys.filter(k => !en.keys.includes(k))
298
+ const enOnly = en.keys.filter(k => !zh.keys.includes(k))
299
+ if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
300
+ if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
301
+ }
302
+
303
+ // The shipped dictionary count only grows; a collapse means discovery or
304
+ // pairing broke, which would hide real asymmetry.
305
+ expect(comparedPairs).toBeGreaterThan(25)
306
+ expect(problems).toEqual([])
307
+ })
308
+ })
scripts/merge-translation-pairing-driver.sh ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+
3
+ if [ "$#" -ne 4 ]; then
4
+ echo 'merge-translation-pairing: expected <ancestor> <current> <other> <repository-path>' >&2
5
+ exit 129
6
+ fi
7
+
8
+ ancestor_path=$1
9
+ current_path=$2
10
+ other_path=$3
11
+ meta_path=$4
12
+ driver_directory=$(CDPATH= cd -P "$(dirname "$0")" && pwd) || exit 129
13
+ driver_path=$driver_directory/merge-translation-pairing.ts
14
+
15
+ if command -v node >/dev/null 2>&1 \
16
+ && node --import tsx/esm "$driver_path" --probe >/dev/null 2>&1; then
17
+ exec node --import tsx/esm "$driver_path" \
18
+ "$ancestor_path" "$current_path" "$other_path" "$meta_path"
19
+ fi
20
+
21
+ echo "merge-translation-pairing: runtime is unavailable; leaving an ordinary text conflict in $meta_path" >&2
22
+ git merge-file \
23
+ -L "$meta_path:current" \
24
+ -L "$meta_path:ancestor" \
25
+ -L "$meta_path:other" \
26
+ -- "$current_path" "$ancestor_path" "$other_path"
27
+ fallback_status=$?
28
+ echo 'merge-translation-pairing: restore Node dependencies, then rerun the merge or `pnpm run resolve-translation-pairing-conflicts`; use `git merge --abort` to cancel' >&2
29
+
30
+ # A clean text merge is still unverified pairing metadata, so the driver must
31
+ # leave Git's index stages unresolved until the repository-aware resolver runs.
32
+ if [ "$fallback_status" -gt 127 ]; then
33
+ exit "$fallback_status"
34
+ fi
35
+ exit 1
scripts/migrate-packed-session-fixtures.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Temporary branch-convergence command for canonical projected session fixtures.
4
+ *
5
+ * @see ../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md
6
+ */
7
+
8
+ import { writeFileSync } from 'node:fs'
9
+ import { resolve } from 'node:path'
10
+ import { inspectSessionFixtureLayouts } from './session-fixture-layout.ts'
11
+
12
+ if (process.argv.length > 2) throw new Error('migrate:packed-session-fixtures takes no arguments')
13
+
14
+ const root = resolve(import.meta.dirname, '..')
15
+ const fixtures = inspectSessionFixtureLayouts(root)
16
+ const changed = fixtures.filter(fixture => fixture.source !== fixture.canonical)
17
+ for (const fixture of changed) {
18
+ writeFileSync(resolve(root, fixture.path), fixture.canonical)
19
+ console.log(fixture.path)
20
+ }
21
+ console.log(`session snapshot fixtures: ${changed.length} rewritten, ${fixtures.length} inspected`)
scripts/package-invariants.ts ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Package-invariant companion discovery and structural checks.
3
+ * The runtime registry stays product-independent; this gate keeps each
4
+ * published companion complete without requiring synthetic empty companions.
5
+ */
6
+
7
+ import { existsSync, globSync, readFileSync } from 'node:fs'
8
+ import { dirname, relative, resolve, sep } from 'node:path'
9
+ import ts from 'typescript'
10
+ import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
11
+
12
+ /** Package README sentence that records why an invariant companion is omitted. */
13
+ const OMITTED_COMPANION_REASON = /No (?:(?:runtime )?invariant )?companion is published(?: because|[.:;—])\s+\S/i
14
+
15
+ interface PackageManifest {
16
+ name?: string
17
+ dsh?: unknown
18
+ exports?: Record<string, { types?: string; default?: string } | string | null | undefined>
19
+ files?: string[]
20
+ peerDependencies?: Record<string, string>
21
+ devDependencies?: Record<string, string>
22
+ }
23
+
24
+ /** One package and the files participating in its invariant publication rules. */
25
+ export interface PackageInvariantOwner {
26
+ readonly dir: string
27
+ readonly manifestPath: string
28
+ readonly sourcePath: string
29
+ readonly packageName: string
30
+ }
31
+
32
+ /** One gate violation with a repo-relative owner path. */
33
+ export interface PackageInvariantViolation {
34
+ readonly path: string
35
+ readonly message: string
36
+ }
37
+
38
+ /** Discover packages that own an invariant companion. */
39
+ export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
40
+ return packageInvariantPackages(root)
41
+ .filter(owner => existsSync(resolve(root, owner.sourcePath)))
42
+ }
43
+
44
+ /** Discover every package under the repository package tree. */
45
+ function packageInvariantPackages(root: string): PackageInvariantOwner[] {
46
+ return globSync('packages/*/*/package.json', { cwd: root })
47
+ .map(path => path.split(sep).join('/'))
48
+ .sort()
49
+ .map((manifestPath) => {
50
+ const manifest = readManifest(resolve(root, manifestPath))
51
+ if (manifest.name === undefined || manifest.name === '') {
52
+ throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
53
+ }
54
+ const dir = dirname(manifestPath)
55
+ return {
56
+ dir,
57
+ manifestPath,
58
+ sourcePath: `${dir}/src/invariant.ts`,
59
+ packageName: manifest.name,
60
+ }
61
+ })
62
+ }
63
+
64
+ /** Return all violations of the package-invariant companion rules. */
65
+ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
66
+ const violations: PackageInvariantViolation[] = []
67
+ for (const owner of packageInvariantPackages(root)) {
68
+ const manifest = readManifest(resolve(root, owner.manifestPath))
69
+ const hasCompanion = existsSync(resolve(root, owner.sourcePath))
70
+ checkManifest(owner, manifest, hasCompanion, violations)
71
+ checkBuild(owner, root, hasCompanion, violations)
72
+ if (hasCompanion) {
73
+ checkSource(owner, root, violations)
74
+ } else {
75
+ checkOmissionReason(owner, root, violations)
76
+ }
77
+ }
78
+ return violations
79
+ }
80
+
81
+ function readManifest(path: string): PackageManifest {
82
+ return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
83
+ }
84
+
85
+ function addViolation(
86
+ violations: PackageInvariantViolation[],
87
+ path: string,
88
+ message: string,
89
+ ): void {
90
+ violations.push({ path, message })
91
+ }
92
+
93
+ function checkManifest(
94
+ owner: PackageInvariantOwner,
95
+ manifest: PackageManifest,
96
+ hasCompanion: boolean,
97
+ violations: PackageInvariantViolation[],
98
+ ): void {
99
+ const invariantExport = manifest.exports?.['./invariant']
100
+ if (!hasCompanion) {
101
+ if (invariantExport !== undefined) {
102
+ addViolation(
103
+ violations,
104
+ owner.manifestPath,
105
+ 'exports["./invariant"] must be omitted when src/invariant.ts is absent',
106
+ )
107
+ }
108
+ if (manifest.files?.includes('lib/invariant.js')) {
109
+ addViolation(
110
+ violations,
111
+ owner.manifestPath,
112
+ 'files must omit lib/invariant.js when src/invariant.ts is absent',
113
+ )
114
+ }
115
+ return
116
+ }
117
+ if (typeof invariantExport !== 'object'
118
+ || invariantExport === null
119
+ || invariantExport.types !== './lib/types/invariant.d.ts'
120
+ || invariantExport.default !== './lib/invariant.js') {
121
+ addViolation(
122
+ violations,
123
+ owner.manifestPath,
124
+ 'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
125
+ )
126
+ }
127
+ if (!manifest.files?.includes('lib/invariant.js')) {
128
+ addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
129
+ }
130
+ if (owner.packageName === '@deepseek-ai/dsh-invariants') return
131
+ const developmentOnlyInvariant = usesFlattenedPackageDependencies(
132
+ owner.manifestPath,
133
+ owner.packageName,
134
+ manifest.dsh,
135
+ )
136
+ const expectedRange = 'workspace:^'
137
+ const peerRange = manifest.peerDependencies?.['@deepseek-ai/dsh-invariants']
138
+ if (developmentOnlyInvariant ? peerRange !== undefined : peerRange !== expectedRange) {
139
+ addViolation(violations, owner.manifestPath, developmentOnlyInvariant
140
+ ? '@deepseek-ai/dsh-invariants must not be a peerDependency under this package dependency policy'
141
+ : '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency')
142
+ }
143
+ if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== expectedRange) {
144
+ addViolation(
145
+ violations,
146
+ owner.manifestPath,
147
+ `@deepseek-ai/dsh-invariants must be a ${expectedRange} devDependency`,
148
+ )
149
+ }
150
+ }
151
+
152
+ function checkBuild(
153
+ owner: PackageInvariantOwner,
154
+ root: string,
155
+ hasCompanion: boolean,
156
+ violations: PackageInvariantViolation[],
157
+ ): void {
158
+ const tsconfigPath = `${owner.dir}/tsconfig.json`
159
+ if (hasCompanion
160
+ && owner.packageName !== '@deepseek-ai/dsh-invariants'
161
+ && !projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
162
+ addViolation(
163
+ violations,
164
+ tsconfigPath,
165
+ 'TypeScript project references must include ../../runtime-diagnostics/invariants',
166
+ )
167
+ } else if (!hasCompanion && projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
168
+ addViolation(
169
+ violations,
170
+ tsconfigPath,
171
+ 'TypeScript project references must omit ../../runtime-diagnostics/invariants when src/invariant.ts is absent',
172
+ )
173
+ }
174
+
175
+ const configPath = `${owner.dir}/tsdown.config.ts`
176
+ if (!existsSync(resolve(root, configPath))) return
177
+ const source = readFileSync(resolve(root, configPath), 'utf8')
178
+ const bundlesCompanion = source.includes('lib/types/invariant.js')
179
+ if (hasCompanion && !bundlesCompanion) {
180
+ addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
181
+ } else if (!hasCompanion && bundlesCompanion) {
182
+ addViolation(
183
+ violations,
184
+ configPath,
185
+ 'package build override must omit lib/types/invariant.js when src/invariant.ts is absent',
186
+ )
187
+ }
188
+ }
189
+
190
+ function checkOmissionReason(
191
+ owner: PackageInvariantOwner,
192
+ root: string,
193
+ violations: PackageInvariantViolation[],
194
+ ): void {
195
+ const readmePath = `${owner.dir}/README.md`
196
+ const absolutePath = resolve(root, readmePath)
197
+ if (!existsSync(absolutePath) || !OMITTED_COMPANION_REASON.test(readFileSync(absolutePath, 'utf8'))) {
198
+ addViolation(
199
+ violations,
200
+ readmePath,
201
+ 'omitted companion requires a README "No ... companion is published" reason sentence',
202
+ )
203
+ }
204
+ }
205
+
206
+ function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean {
207
+ const ownerRoot = resolve(root, ownerDir)
208
+ const target = resolve(root, 'packages/runtime-diagnostics/invariants')
209
+ const pending = [resolve(root, entryPath)]
210
+ const visited = new Set<string>()
211
+ while (pending.length > 0) {
212
+ const configPath = pending.pop()
213
+ if (configPath === undefined) break
214
+ if (visited.has(configPath)) continue
215
+ visited.add(configPath)
216
+ const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
217
+ references?: Array<{ path?: string }>
218
+ }
219
+ for (const reference of config.references ?? []) {
220
+ if (reference.path === undefined) continue
221
+ const referenced = resolve(dirname(configPath), reference.path)
222
+ if (referenced === target) return true
223
+ if (!referenced.startsWith(`${ownerRoot}${sep}`)) continue
224
+ const childConfig = referenced.endsWith('.json') ? referenced : resolve(referenced, 'tsconfig.json')
225
+ if (existsSync(childConfig)) pending.push(childConfig)
226
+ }
227
+ }
228
+ return false
229
+ }
230
+
231
+ function checkSource(
232
+ owner: PackageInvariantOwner,
233
+ root: string,
234
+ violations: PackageInvariantViolation[],
235
+ ): void {
236
+ const absolutePath = resolve(root, owner.sourcePath)
237
+ const sourceText = readFileSync(absolutePath, 'utf8')
238
+ if (sourceText.includes('@generated')) {
239
+ addViolation(
240
+ violations,
241
+ owner.sourcePath,
242
+ 'invariant companions must be hand-owned and may not carry @generated markers',
243
+ )
244
+ }
245
+
246
+ const sourceFile = ts.createSourceFile(
247
+ absolutePath,
248
+ sourceText,
249
+ ts.ScriptTarget.Latest,
250
+ true,
251
+ ts.ScriptKind.TS,
252
+ )
253
+ const constants = topLevelStringConstants(sourceFile)
254
+ const registrations: string[] = []
255
+ const unresolved: number[] = []
256
+ const mismatchedInstallers: number[] = []
257
+ const visit = (node: ts.Node): void => {
258
+ if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
259
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
260
+ const argument = node.arguments[0]
261
+ const packageName = argument === undefined ? undefined : stringValue(argument, constants)
262
+ if (packageName === undefined) unresolved.push(line)
263
+ else registrations.push(packageName)
264
+ const installer = node.arguments[1]
265
+ if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
266
+ mismatchedInstallers.push(line)
267
+ }
268
+ }
269
+ ts.forEachChild(node, visit)
270
+ }
271
+ visit(sourceFile)
272
+
273
+ for (const line of unresolved) {
274
+ addViolation(
275
+ violations,
276
+ owner.sourcePath,
277
+ `line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
278
+ )
279
+ }
280
+ for (const line of mismatchedInstallers) {
281
+ addViolation(
282
+ violations,
283
+ owner.sourcePath,
284
+ `line ${line}: ctx.invariants.register must use the checked local install function`,
285
+ )
286
+ }
287
+ if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
288
+ addViolation(
289
+ violations,
290
+ owner.sourcePath,
291
+ `must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
292
+ )
293
+ }
294
+ for (const exportedName of ['name', 'inject', 'apply']) {
295
+ if (!hasNamedExport(sourceFile, exportedName)) {
296
+ addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
297
+ }
298
+ }
299
+ if (hasDefaultExport(sourceFile)) {
300
+ addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
301
+ }
302
+ checkInstaller(owner, sourceFile, violations)
303
+ }
304
+
305
+ function checkInstaller(
306
+ owner: PackageInvariantOwner,
307
+ sourceFile: ts.SourceFile,
308
+ violations: PackageInvariantViolation[],
309
+ ): void {
310
+ let initializer: ts.Expression | undefined
311
+ for (const statement of sourceFile.statements) {
312
+ if (!ts.isVariableStatement(statement)) continue
313
+ for (const declaration of statement.declarationList.declarations) {
314
+ if (ts.isIdentifier(declaration.name)
315
+ && declaration.name.text === 'install'
316
+ && declaration.initializer !== undefined) {
317
+ initializer = declaration.initializer
318
+ }
319
+ }
320
+ }
321
+ const installer = initializer === undefined ? undefined : installerFunction(initializer)
322
+ if (installer === undefined) {
323
+ addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
324
+ return
325
+ }
326
+ if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
327
+ addViolation(
328
+ violations,
329
+ owner.sourcePath,
330
+ 'empty install function is unnecessary; omit the companion and its publication wiring',
331
+ )
332
+ return
333
+ }
334
+ const reporter = installer.parameters[1]?.name
335
+ if (reporter === undefined || !ts.isIdentifier(reporter)) {
336
+ addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
337
+ return
338
+ }
339
+ if (!usesIdentifier(installer.body, reporter.text)) {
340
+ addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
341
+ }
342
+ }
343
+
344
+ function usesIdentifier(node: ts.Node, name: string): boolean {
345
+ return ts.isIdentifier(node) && node.text === name
346
+ || node.getChildren().some(child => usesIdentifier(child, name))
347
+ }
348
+
349
+ function installerFunction(
350
+ initializer: ts.Expression,
351
+ ): ts.ArrowFunction | ts.FunctionExpression | undefined {
352
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
353
+ if (ts.isCallExpression(initializer)
354
+ && ts.isPropertyAccessExpression(initializer.expression)
355
+ && ts.isIdentifier(initializer.expression.expression)
356
+ && initializer.expression.expression.text === 'Object'
357
+ && initializer.expression.name.text === 'assign') {
358
+ const target = initializer.arguments[0]
359
+ if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
360
+ }
361
+ return undefined
362
+ }
363
+
364
+ function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
365
+ const constants = new Map<string, string>()
366
+ for (const statement of sourceFile.statements) {
367
+ if (!ts.isVariableStatement(statement)) continue
368
+ for (const declaration of statement.declarationList.declarations) {
369
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
370
+ const value = stringValue(declaration.initializer, constants)
371
+ if (value !== undefined) constants.set(declaration.name.text, value)
372
+ }
373
+ }
374
+ return constants
375
+ }
376
+
377
+ function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
378
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
379
+ if (ts.isIdentifier(node)) return constants.get(node.text)
380
+ return undefined
381
+ }
382
+
383
+ function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
384
+ return ts.isPropertyAccessExpression(expression)
385
+ && expression.name.text === 'register'
386
+ && ts.isPropertyAccessExpression(expression.expression)
387
+ && expression.expression.name.text === 'invariants'
388
+ }
389
+
390
+ function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
391
+ return sourceFile.statements.some((statement) => {
392
+ if (!ts.isVariableStatement(statement)
393
+ || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
394
+ return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
395
+ })
396
+ }
397
+
398
+ function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
399
+ return sourceFile.statements.some((statement) => {
400
+ if (ts.isExportAssignment(statement)) return true
401
+ const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
402
+ if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
403
+ if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
404
+ if (ts.isNamespaceExport(statement.exportClause)) {
405
+ return statement.exportClause.name.text === 'default'
406
+ }
407
+ return statement.exportClause.elements.some(element => element.name.text === 'default')
408
+ })
409
+ }
410
+
411
+ /** Format violations for the command-line gate. */
412
+ export function formatPackageInvariantViolation(
413
+ root: string,
414
+ violation: PackageInvariantViolation,
415
+ ): string {
416
+ const path = resolve(root, violation.path)
417
+ return `${relative(root, path)}: ${violation.message}`
418
+ }
scripts/paired-markdown-derivatives.spec.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
3
+
4
+ interface Block {
5
+ doc: string
6
+ kind: string
7
+ code: string
8
+ }
9
+
10
+ const partition = (blocks: Block[]) => partitionPairedMarkdownDerivatives(
11
+ blocks,
12
+ block => block.doc,
13
+ block => `${block.kind}\0${block.code}`,
14
+ )
15
+
16
+ describe('partitionPairedMarkdownDerivatives', () => {
17
+ it('treats a complete byte-identical Chinese sequence as derivative', () => {
18
+ const english = [
19
+ { doc: 'docs/example.md', kind: 'ts', code: 'const one = 1' },
20
+ { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' },
21
+ ]
22
+ const chinese = english.map(block => ({ ...block, doc: 'docs/example.zh.md' }))
23
+ const unrelated = { doc: 'docs/other.md', kind: 'ts', code: 'const other = 2' }
24
+
25
+ expect(partition([...english, ...chinese, unrelated])).toEqual({
26
+ primary: [...english, unrelated],
27
+ derivatives: chinese,
28
+ })
29
+ })
30
+
31
+ it('keeps reordered, changed, partial, and orphan Chinese sequences primary', () => {
32
+ const sequence = (doc: string) => [
33
+ { doc, kind: 'ts', code: 'const one = 1' },
34
+ { doc, kind: 'ts', code: 'const two = 2' },
35
+ ]
36
+ const english = sequence('docs/example.md')
37
+ const changed = english.map((block, index) => ({
38
+ ...block,
39
+ doc: 'docs/example.zh.md',
40
+ code: index === 0 ? 'const one = 0' : block.code,
41
+ }))
42
+ const reorderedEnglish = sequence('docs/reordered.md')
43
+ const reordered = [...reorderedEnglish].reverse().map(block => ({ ...block, doc: 'docs/reordered.zh.md' }))
44
+ const partialEnglish = sequence('docs/partial.md')
45
+ const partial = [{ ...partialEnglish[0]!, doc: 'docs/partial.zh.md' }]
46
+ const orphan = [{ doc: 'docs/orphan.zh.md', kind: 'ts', code: 'const orphan = true' }]
47
+ const blocks = [
48
+ ...english,
49
+ ...changed,
50
+ ...reorderedEnglish,
51
+ ...reordered,
52
+ ...partialEnglish,
53
+ ...partial,
54
+ ...orphan,
55
+ ]
56
+
57
+ expect(partition(blocks)).toEqual({ primary: blocks, derivatives: [] })
58
+ })
59
+
60
+ it('requires the fence kind to match as well as the body', () => {
61
+ const english = { doc: 'docs/example.md', kind: 'type-equiv', code: 'interface Example {}' }
62
+ const chinese = { ...english, doc: 'docs/example.zh.md', kind: 'public-api' }
63
+
64
+ expect(partition([english, chinese])).toEqual({ primary: [english, chinese], derivatives: [] })
65
+ })
66
+ })
scripts/persistence-catalog-text.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Authored language pairs for generated persistence reference prose. */
2
+
3
+ /** Language of a generated persistence reference. */
4
+ export type PersistenceCatalogLocale = 'en' | 'zh'
5
+
6
+ const english = {
7
+ title: 'Session Persistence Event Catalog',
8
+ intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
9
+ generation: 'Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.',
10
+ envelopeIntro: 'The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and conditional `surfaceOp` / `sourceEventSeqs`. A **surface** event produces model history; a **log-only** event does not. The inventory covers this repository; external plugin types require their own declarations and are outside this catalog.',
11
+ envelope: 'Event envelope', events: 'Events', sources: 'Sources: ', source: 'Source: ', types: 'Types: ',
12
+ fingerprints: 'Persistence type fingerprints',
13
+ fingerprintsIntro: 'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and harmless field, union, or intersection reordering do not affect these fingerprints. Tuple order, property names, value types, and optionality do. Catalog text and source locations can still produce a diff when digests stay unchanged.',
14
+ historyIntro: 'The [format references](persistence-changes/historical-formats/README.md) cover every historical Session format. The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
15
+ rootColumns: '| Root | Kind | SHA-256 | Resolved type |',
16
+ definitions: 'Resolved persistence types',
17
+ definitionsIntro: 'Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.',
18
+ propertyColumns: '| Property | Presence | Type |', positionColumns: '| Position | Presence | Type |',
19
+ optional: 'optional', required: 'required', rest: 'rest', index: 'index signature',
20
+ emptyObject: 'Object with no declared properties.', arrayPrefix: 'Array of ', arraySuffix: '.',
21
+ oneOf: 'One of:', opaque: ' (opaque)', opaqueExplanation: ": the declaration does not expose the stored value's internal fields.",
22
+ }
23
+
24
+ const chinese: Record<keyof typeof english, string> = {
25
+ title: '会话持久化事件目录',
26
+ intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
27
+ generation: '运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。',
28
+ envelopeIntro: '信封包含 `type`、`seq`、`time`、`data`、可选的 `ignorable` 以及条件字段 `surfaceOp` / `sourceEventSeqs`。**surface** 事件产生模型历史,**log-only** 事件不产生模型历史。目录覆盖本仓库;外部插件类型需要独立声明,不属于本目录。',
29
+ envelope: '事件信封', events: '事件', sources: '来源:', source: '来源:', types: '类型:',
30
+ fingerprints: '持久化类型指纹',
31
+ fingerprintsIntro: '[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记以及无语义变化的字段、联合类型或交叉类型重排不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。摘要不变时,目录文本和源码位置仍可能产生 diff。',
32
+ historyIntro: '[格式参考](persistence-changes/historical-formats/README.zh.md)覆盖每个历史 Session 格式。[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转��。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
33
+ rootColumns: '| 根类型 | 类别 | SHA-256 | 已解析类型 |',
34
+ definitions: '已解析的持久化类型',
35
+ definitionsIntro: '每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。',
36
+ propertyColumns: '| 属性 | 存在性 | 类型 |', positionColumns: '| 位置 | 存在性 | 类型 |',
37
+ optional: '可选', required: '必需', rest: '剩余项', index: '索引签名',
38
+ emptyObject: '无已声明属性的对象。', arrayPrefix: '', arraySuffix: ' 的数组。',
39
+ oneOf: '以下类型之一:', opaque: '(不透明)', opaqueExplanation: ':此声明未暴露存储值的内部字段。',
40
+ }
41
+
42
+ /** Complete translated prose; adding an English key requires its Chinese counterpart. */
43
+ export const persistenceCatalogText = { en: english, zh: chinese }
scripts/persistence-changes.spec.ts ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Current-tree persistence history rejects uncovered and incorrectly acknowledged type changes. */
2
+
3
+ import { spawnSync } from 'node:child_process'
4
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join, resolve } from 'node:path'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+ import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
9
+ import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty } from './persistence-schema-model.ts'
10
+ import { extractPersistenceSchema } from './persistence-schema.ts'
11
+ import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
12
+ import {
13
+ classifyPersistenceChange,
14
+ loadPersistenceHistory,
15
+ parseHistoricalPersistenceSnapshot,
16
+ parsePersistenceSnapshot,
17
+ runPersistenceChanges as executePersistenceChanges,
18
+ validatePersistenceHistory,
19
+ verifyPersistenceChanges,
20
+ } from './persistence-changes.ts'
21
+ import type { PersistenceChangeRecord, PersistenceHistoryEntry } from './persistence-changes.ts'
22
+
23
+ function runPersistenceChanges(
24
+ args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
25
+ ): string {
26
+ return executePersistenceChanges(args, root, extract, (_root, current) => [{
27
+ path: 'docs/persistence-schema.json', content: JSON.stringify(current, null, 2) + '\n',
28
+ }])
29
+ }
30
+
31
+ function jsonResult(source: string): { ok: boolean; files: readonly string[] } {
32
+ return JSON.parse(source) as { ok: boolean; files: readonly string[] }
33
+ }
34
+
35
+ const AUTHORED_PROSE = {
36
+ en: { summary: 'Adds optional metadata.', compatibility: 'Readers may omit the metadata.', verification: 'The focused tests passed.' },
37
+ zh: { summary: '添加可选元数据。', compatibility: '读取方可省略元数据。', verification: '定向测试通过。' },
38
+ }
39
+
40
+ function proseFile(root: string): string {
41
+ const path = join(root, 'prose.json')
42
+ writeFileSync(path, JSON.stringify(AUTHORED_PROSE))
43
+ return path
44
+ }
45
+
46
+ const roots: string[] = []
47
+ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
48
+
49
+ function fixture(): string {
50
+ const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-changes-'))
51
+ roots.push(root)
52
+ mkdirSync(join(root, 'docs/persistence-changes'), { recursive: true })
53
+ return root
54
+ }
55
+
56
+ type Shape = 'string' | 'number' | 'boolean' | { readonly [property: string]: Shape }
57
+
58
+ function typeRoot(key: string, shape: Shape, version = 3): PersistenceRoot {
59
+ const nodes: SchemaNode[] = []
60
+ function add(shape: Shape): number {
61
+ const index = nodes.length
62
+ nodes.push({ kind: 'primitive', type: 'never' })
63
+ nodes[index] = typeof shape === 'string' ? { kind: 'primitive', type: shape } : {
64
+ kind: 'object', indices: [], properties: Object.entries(shape).map(([name, child]) => ({
65
+ name: name.replace(/\?$/u, ''), optional: name.endsWith('?'), type: add(child),
66
+ })),
67
+ }
68
+ return index
69
+ }
70
+ if (key === 'SessionHeader') {
71
+ const index = add(shape)
72
+ const node = nodes[index]!
73
+ if (node.kind !== 'object') throw new Error('header fixture requires an object')
74
+ nodes[index] = { ...node, properties: [...node.properties, { name: 'version', optional: false, type: nodes.length }] }
75
+ nodes.push({ kind: 'literal', value: version })
76
+ } else if (key.startsWith('event:')) {
77
+ add({ data: shape, type: 'string' })
78
+ const node = nodes[0]!
79
+ if (node.kind !== 'object') throw new Error('event fixture requires an object')
80
+ const type = node.properties.find(item => item.name === 'type')!.type
81
+ nodes[type] = { kind: 'literal', value: key.slice(6) }
82
+ } else add(shape)
83
+ const schema = canonicalizeSchema(nodes, 0)
84
+ return { key, kind: ['SessionHeader', 'JsonlHeaderLine'].includes(key) ? 'header' : key === 'SessionEventEnvelope' ? 'envelope' : 'event',
85
+ ...(key.startsWith('event:') ? { event: key.slice(6), surface: false } : {}), schema, digest: schemaDigest(schema) }
86
+ }
87
+
88
+ function inventory(eventShape: Shape = { value: 'string' }, version = 3): PersistenceSchemaInventory {
89
+ return { formatVersion: 1, types: [], roots: [typeRoot('SessionHeader', { id: 'string' }, version), typeRoot('SessionEventEnvelope', { type: 'string' }), typeRoot('event:example/value', eventShape), typeRoot('JsonlHeaderLine', { version: 'number' })] }
90
+ }
91
+
92
+ function entry(
93
+ id: string, schema: PersistenceSchemaInventory, previous: string | null,
94
+ baseline = false, decision: 'same-version' | 'version-bump' = 'same-version',
95
+ ): PersistenceHistoryEntry {
96
+ return {
97
+ record: { schemaVersion: 1, id, baseline, changes: schema.roots.map(root => ({
98
+ root: root.key, previous, after: root.digest, decision,
99
+ })) },
100
+ snapshot: schema,
101
+ }
102
+ }
103
+
104
+ const BASE_ID = '2026-09-11-baseline'
105
+ const NEXT_ID = '2026-09-11-change'
106
+
107
+ function historicalSurface(operation: 'required' | 'optional' | 'absent', event = 'example/value'): PersistenceSchemaInventory {
108
+ const nodes: SchemaNode[] = [
109
+ { kind: 'object', indices: [], properties: [
110
+ { name: 'type', type: 1, optional: false },
111
+ ...(operation === 'absent' ? [] : [{ name: 'surfaceOp', type: 2, optional: operation === 'optional' }]),
112
+ ] },
113
+ { kind: 'literal', value: event },
114
+ { kind: 'primitive', type: 'string' },
115
+ ]
116
+ const schema = canonicalizeSchema(nodes, 0)
117
+ return { formatVersion: 1, types: [], roots: [{
118
+ key: 'event:example/value', kind: 'event', event: 'example/value', surface: true, schema, digest: schemaDigest(schema),
119
+ }] }
120
+ }
121
+
122
+ describe('historical persistence snapshot parsing', () => {
123
+ it('preserves source paths and fingerprints in historical inventories', () => {
124
+ const snapshot = historicalSurface('required')
125
+ const root = snapshot.roots[0]!
126
+ const sources = ['packages/core/example/src/types.ts']
127
+ const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: ['Example'], sources }] }
128
+ expect(parseHistoricalPersistenceSnapshot(complete)).toEqual(complete)
129
+ })
130
+
131
+ it.each([':8', ':8:3', '#L8', '#L8-L12'])('rejects source coordinates %s only in historical inventories', (suffix) => {
132
+ const snapshot = historicalSurface('required')
133
+ const root = snapshot.roots[0]!
134
+ const sources = [`packages/core/example/src/types.ts${suffix}`]
135
+ const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: [], sources }] }
136
+ expect(() => parseHistoricalPersistenceSnapshot(complete)).toThrow('historical schema sources must omit line numbers')
137
+ expect(parsePersistenceSnapshot(complete)).toEqual(complete)
138
+ })
139
+
140
+ it('admits optional surface operations only through the historical parser', () => {
141
+ const snapshot = historicalSurface('optional')
142
+ expect(() => parsePersistenceSnapshot(snapshot)).toThrow('surface metadata')
143
+ expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(snapshot)
144
+ expect(parsePersistenceSnapshot(historicalSurface('required'))).toEqual(historicalSurface('required'))
145
+ })
146
+
147
+ it('still rejects absent operations, mismatched event tags, and invalid roots', () => {
148
+ expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('absent'))).toThrow('surface metadata')
149
+ expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('optional', 'wrong/tag'))).toThrow('type does not match')
150
+ const snapshot = historicalSurface('optional')
151
+ expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, key: 'event:wrong/key' })) })).toThrow('invalid event root key')
152
+ expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, surface: false })) })).toThrow('surface metadata')
153
+ })
154
+
155
+ it('preserves graph and digest validation in historical mode', () => {
156
+ const snapshot = historicalSurface('optional')
157
+ expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, digest: 'f'.repeat(64) })) })).toThrow('digest mismatch')
158
+ expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, schema: { root: 0, nodes: [{ kind: 'array', element: 9 }] } })) })).toThrow('unknown schema node')
159
+ })
160
+ })
161
+
162
+ function onlyEvent(schema: PersistenceSchemaInventory): PersistenceSchemaInventory {
163
+ return { ...schema, roots: schema.roots.filter(root => root.kind === 'event') }
164
+ }
165
+
166
+ function finishDocuments(root: string, id: string): void {
167
+ for (const suffix of ['.md', '.zh.md']) {
168
+ const path = join(root, 'docs/persistence-changes', id + suffix)
169
+ writeFileSync(path, readFileSync(path, 'utf8').replaceAll('TODO: explain this change.', 'Optional payload metadata preserves the recorded value.')
170
+ .replaceAll('TODO: record validation evidence.', 'The focused persistence-history tests passed.'))
171
+ }
172
+ }
173
+
174
+ function commitCurrent(root: string, schema: PersistenceSchemaInventory): void {
175
+ writeFileSync(join(root, 'docs/persistence-schema.json'), JSON.stringify(schema, null, 2) + '\n')
176
+ }
177
+
178
+ function baseline(root: string, schema = inventory()): void {
179
+ runPersistenceChanges(['--baseline', BASE_ID], root, () => schema)
180
+ finishDocuments(root, BASE_ID)
181
+ commitCurrent(root, schema)
182
+ }
183
+
184
+ function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoot {
185
+ const nodes: SchemaNode[] = [
186
+ { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
187
+ { kind: 'union', types: arms.map((_, index) => 5 + index) },
188
+ { kind: 'primitive', type: 'string' },
189
+ { kind: 'primitive', type: 'number' },
190
+ { kind: 'primitive', type: 'boolean' },
191
+ ...arms.map(properties => ({ kind: 'object' as const, indices: [], properties })),
192
+ ]
193
+ const schema = canonicalizeSchema(nodes, 0)
194
+ return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
195
+ }
196
+
197
+ describe('persistence change classification', () => {
198
+ it('treats a new optional payload subtree as one additive change even with required descendants', () => {
199
+ const before = typeRoot('event:example/value', { value: 'string' })
200
+ const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
201
+ expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresVersionBump: false }])
202
+ })
203
+
204
+ it('permits required-to-optional payload properties while rejecting opposite changes, type changes, and removals', () => {
205
+ const required = typeRoot('event:example/value', { value: 'string' })
206
+ const optional = typeRoot('event:example/value', { 'value?': 'string' })
207
+ expect(classifyPersistenceChange(required, optional).every(change => !change.requiresVersionBump)).toBe(true)
208
+ for (const after of [required, typeRoot('event:example/value', { 'value?': 'number' }), typeRoot('event:example/value', {})]) {
209
+ expect(classifyPersistenceChange(optional, after).some(change => change.requiresVersionBump)).toBe(true)
210
+ }
211
+ })
212
+
213
+ it('pairs multiple discriminated union arms when a shared payload gains an optional property', () => {
214
+ function unionRoot(optional: boolean): PersistenceRoot {
215
+ const nodes: SchemaNode[] = [
216
+ { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
217
+ { kind: 'union', types: [2, 3] },
218
+ { kind: 'object', indices: [], properties: [{ name: 'kind', type: 4, optional: false }, { name: 'payload', type: 6, optional: false }] },
219
+ { kind: 'object', indices: [], properties: [{ name: 'kind', type: 5, optional: false }, { name: 'payload', type: 6, optional: false }] },
220
+ { kind: 'literal', value: 'left' }, { kind: 'literal', value: 'right' },
221
+ { kind: 'object', indices: [], properties: [{ name: 'value', type: 7, optional: false }, ...(optional ? [{ name: 'label', type: 7, optional: true }] : [])] },
222
+ { kind: 'primitive', type: 'string' },
223
+ ]
224
+ const schema = canonicalizeSchema(nodes, 0)
225
+ return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
226
+ }
227
+ const differences = classifyPersistenceChange(unionRoot(false), unionRoot(true))
228
+ expect(differences.length).toBeGreaterThan(0)
229
+ expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
230
+ })
231
+
232
+ it('allows optional additions across undiscriminated union arms', () => {
233
+ const first = [{ name: 'a', type: 2, optional: false }]
234
+ const second = [{ name: 'b', type: 3, optional: false }]
235
+ const extra = { name: 'x', type: 4, optional: true }
236
+ const differences = classifyPersistenceChange(unionBody([first, second]), unionBody([[...first, extra], [...second, extra]]))
237
+ expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresVersionBump: false })])
238
+ })
239
+
240
+ it('allows required-to-optional fields across undiscriminated union arms', () => {
241
+ const before = unionBody([[{ name: 'a', type: 2, optional: false }], [{ name: 'b', type: 3, optional: false }]])
242
+ const after = unionBody([[{ name: 'a', type: 2, optional: true }], [{ name: 'b', type: 3, optional: true }]])
243
+ const differences = classifyPersistenceChange(before, after)
244
+ expect(differences.map(change => change.path).sort()).toEqual(['event:example/value.data.a', 'event:example/value.data.b'])
245
+ expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresVersionBump)).toBe(true)
246
+ })
247
+
248
+ it('finds a complete matching when one union arm has multiple additive successors', () => {
249
+ const required = [{ name: 'a', type: 2, optional: false }]
250
+ const optional = [{ name: 'a', type: 2, optional: true }]
251
+ const flexible = [{ name: '0', type: 4, optional: true }, ...optional]
252
+ const constrained = [...required, { name: 'z', type: 4, optional: true }]
253
+ const before = unionBody([required, optional])
254
+ for (const after of [unionBody([flexible, constrained]), unionBody([constrained, flexible])]) {
255
+ const differences = classifyPersistenceChange(before, after)
256
+ expect(differences.length).toBeGreaterThan(0)
257
+ expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
258
+ }
259
+ const noCompleteMatching = unionBody([flexible, [{ name: 'different', type: 4, optional: true }]])
260
+ expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresVersionBump)).toBe(true)
261
+ })
262
+
263
+ it('keeps union cardinality, property removal, new required fields, and value types strict', () => {
264
+ const first = [{ name: 'a', type: 2, optional: false }]
265
+ const second = [{ name: 'b', type: 3, optional: false }]
266
+ const third = [{ name: 'c', type: 4, optional: false }]
267
+ const before = unionBody([first, second])
268
+ const variants = [
269
+ unionBody([first, second, third]),
270
+ unionBody([first]),
271
+ unionBody([first, third]),
272
+ unionBody([[...first, { name: 'x', type: 4, optional: false }], [...second, { name: 'x', type: 4, optional: true }]]),
273
+ unionBody([[{ name: 'a', type: 3, optional: true }], [{ name: 'b', type: 3, optional: true }]]),
274
+ ]
275
+ for (const after of variants) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
276
+ })
277
+
278
+ it('checks all recursive union arms without retaining provisional successes between candidates', () => {
279
+ const next = { name: 'next', type: 1, optional: true }
280
+ const first = [{ name: 'a', type: 2, optional: false }, next]
281
+ const second = [{ name: 'b', type: 3, optional: false }, next]
282
+ const extra = { name: 'x', type: 4, optional: true }
283
+ const before = unionBody([first, second])
284
+ const additive = unionBody([[...first, extra], [...second, extra]])
285
+ expect(classifyPersistenceChange(before, additive).every(change => !change.requiresVersionBump)).toBe(true)
286
+ const invalid = unionBody([[...first, extra], [{ name: 'b', type: 2, optional: false }, next, extra]])
287
+ expect(classifyPersistenceChange(before, invalid).some(change => change.requiresVersionBump)).toBe(true)
288
+ expect(classifyPersistenceChange(invalid, before).some(change => change.requiresVersionBump)).toBe(true)
289
+ })
290
+
291
+ it('preserves body scope through arrays and tuples and rejects a simultaneous value-type change', () => {
292
+ function wrapped(optional: boolean, primitive: 'string' | 'number' = 'string'): PersistenceRoot {
293
+ const nodes: SchemaNode[] = [
294
+ { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
295
+ { kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }] },
296
+ { kind: 'array', element: 3 },
297
+ { kind: 'object', indices: [], properties: [{ name: 'value', type: 4, optional }] },
298
+ { kind: 'primitive', type: primitive },
299
+ ]
300
+ const schema = canonicalizeSchema(nodes, 0)
301
+ return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
302
+ }
303
+ expect(classifyPersistenceChange(wrapped(false), wrapped(true)).every(change => !change.requiresVersionBump)).toBe(true)
304
+ expect(classifyPersistenceChange(wrapped(false), wrapped(true, 'number')).some(change => change.requiresVersionBump)).toBe(true)
305
+ })
306
+
307
+ it('keeps optional header/envelope properties and surface event additions strict', () => {
308
+ for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
309
+ expect(classifyPersistenceChange(typeRoot(key, {}), typeRoot(key, { 'metadata?': 'string' }))[0]?.requiresVersionBump).toBe(true)
310
+ }
311
+ const added = typeRoot('event:example/added', { value: 'string' })
312
+ expect(classifyPersistenceChange(null, added)[0]?.requiresVersionBump).toBe(false)
313
+ expect(classifyPersistenceChange(null, { ...added, surface: true })[0]?.requiresVersionBump).toBe(true)
314
+ expect(classifyPersistenceChange(added, null)[0]?.requiresVersionBump).toBe(true)
315
+ })
316
+ })
317
+
318
+ describe('persistence history verification', () => {
319
+ it('accepts a baseline and a successive optional addition without comparing the historical schema to current', () => {
320
+ const before = inventory()
321
+ const after = inventory({ value: 'string', 'label?': 'string' })
322
+ const history = validatePersistenceHistory([entry(BASE_ID, before, null, true), entry(NEXT_ID, onlyEvent(after), BASE_ID)])
323
+ expect(history.tips.get('event:example/value')?.root?.digest).toBe(after.roots[2]?.digest)
324
+ expect(history.tips.get('SessionHeader')?.id).toBe(BASE_ID)
325
+ })
326
+
327
+ it('rejects missing predecessors, forks, cycles, duplicates, and missing baseline', () => {
328
+ const base = entry(BASE_ID, inventory(), null, true)
329
+ const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
330
+ expect(() => validatePersistenceHistory([next])).toThrow('exactly one baseline')
331
+ expect(() => validatePersistenceHistory([base, entry(NEXT_ID, next.snapshot, '2026-09-11-missing')])).toThrow('missing predecessor')
332
+ expect(() => validatePersistenceHistory([base, next, entry('2026-09-11-fork', next.snapshot, BASE_ID)])).toThrow('forked')
333
+ expect(() => validatePersistenceHistory([base, base])).toThrow('exactly one baseline')
334
+ const cycle = entry('2026-09-11-cycle', { ...next.snapshot, roots: [typeRoot('event:cycle/event', {})] }, '2026-09-11-cycle')
335
+ expect(() => validatePersistenceHistory([base, cycle])).toThrow('cycle')
336
+ })
337
+
338
+ it('binds breaking decisions to the same record header version transition', () => {
339
+ const base = entry(BASE_ID, inventory(), null, true)
340
+ const breaking = inventory({ value: 'number' }, 4)
341
+ expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID)])).toThrow('requires a format version bump')
342
+ expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID, false, 'version-bump')])).toThrow("this record's own")
343
+ const touched = { ...breaking, roots: breaking.roots.filter(root => root.key === 'SessionHeader' || root.kind === 'event') }
344
+ expect(validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump')]).tips.get('SessionHeader')?.id).toBe(NEXT_ID)
345
+ const later = entry('2026-09-11-later', onlyEvent(inventory({ value: 'boolean' }, 4)), NEXT_ID, false, 'version-bump')
346
+ expect(() => validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump'), later])).toThrow("this record's own")
347
+ })
348
+
349
+ it('retains deletion tombstones and requires re-added roots to continue them', () => {
350
+ const base = entry(BASE_ID, inventory(), null, true)
351
+ const after = inventory({}, 4)
352
+ const header = after.roots[0]!
353
+ const record: PersistenceChangeRecord = { schemaVersion: 1, id: NEXT_ID, baseline: false, changes: [
354
+ { root: 'SessionHeader', previous: BASE_ID, after: header.digest, decision: 'version-bump' },
355
+ { root: 'event:example/value', previous: BASE_ID, after: null, decision: 'version-bump' },
356
+ ] }
357
+ const deletion = { record, snapshot: { ...after, roots: [header] } }
358
+ expect(validatePersistenceHistory([base, deletion]).tips.get('event:example/value')?.root).toBeNull()
359
+ const restored = entry('2026-09-11-restore', onlyEvent(inventory()), NEXT_ID)
360
+ expect(validatePersistenceHistory([base, deletion, restored]).tips.get('event:example/value')?.id).toBe(restored.record.id)
361
+ expect(() => validatePersistenceHistory([base, deletion, entry('2026-09-11-restore', restored.snapshot, null)])).toThrow('forked')
362
+ })
363
+
364
+ it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
365
+ const schema = inventory()
366
+ const malformed = structuredClone(schema) as unknown as { roots: Array<{ schema: { nodes: unknown[] }; digest: string }> }
367
+ malformed.roots[0]!.schema.nodes = [{ kind: 'array', element: 99 }]
368
+ expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node')
369
+ malformed.roots[0]!.schema.nodes = [{ kind: 'future' }]
370
+ expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node kind')
371
+ const tampered = structuredClone(schema)
372
+ Object.assign(tampered.roots[0]!, { digest: '0'.repeat(64) })
373
+ expect(() => parsePersistenceSnapshot(tampered)).toThrow('digest mismatch')
374
+ expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 2 })).toThrow('normalization version')
375
+ const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
376
+ expect(() => validatePersistenceHistory([entry(BASE_ID, schema, null, true), { ...next, snapshot: inventory() }])).toThrow('snapshot roots')
377
+ })
378
+ })
379
+
380
+ describe('persistence changes current-tree commands', () => {
381
+ it('requires completed record prose, rejects stale generated output, and reports unacknowledged paths', () => {
382
+ const root = fixture()
383
+ const before = inventory()
384
+ runPersistenceChanges(['--baseline', BASE_ID], root, () => before)
385
+ expect(() => loadPersistenceHistory(root)).toThrow('complete compatibility')
386
+ finishDocuments(root, BASE_ID)
387
+ rmSync(join(root, 'docs/persistence-schema.json'))
388
+ expect(() => verifyPersistenceChanges(root, before)).toThrow('is missing')
389
+ commitCurrent(root, before)
390
+ const after = inventory({ value: 'string', 'label?': 'string' })
391
+ expect(() => verifyPersistenceChanges(root, after)).toThrow('is stale')
392
+ commitCurrent(root, after)
393
+ expect(() => verifyPersistenceChanges(root, after)).toThrow('event:example/value.data.label: optional property added (same-version allowed)')
394
+ runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
395
+ finishDocuments(root, NEXT_ID)
396
+ expect(runPersistenceChanges([], root, () => after)).toContain('4 roots match 2 history records')
397
+ expect(() => runPersistenceChanges(['--baseline', '2026-09-11-reset'], root, () => after)).toThrow('cannot reset history')
398
+ expect(() => runPersistenceChanges(['--record', '2026-09-11-empty', '--decision', 'same-version'], root, () => after)).toThrow('no persistence type changes')
399
+ })
400
+
401
+ it('rejects changed bilingual machine declarations and unreferenced snapshots', () => {
402
+ const root = fixture()
403
+ baseline(root)
404
+ const chinese = join(root, 'docs/persistence-changes', `${BASE_ID}.zh.md`)
405
+ const original = readFileSync(chinese, 'utf8')
406
+ writeFileSync(chinese, original.replace('baseline: true', 'baseline: false'))
407
+ expect(() => loadPersistenceHistory(root)).toThrow('bilingual machine records differ')
408
+ writeFileSync(chinese, original)
409
+ writeFileSync(join(root, 'docs/persistence-changes/2026-09-11-orphan.schema.json'), '{}\n')
410
+ expect(() => loadPersistenceHistory(root)).toThrow('unreferenced')
411
+ })
412
+
413
+ it('excludes nested historical format documents and schemas from acknowledgements', () => {
414
+ const root = fixture()
415
+ baseline(root)
416
+ const directory = join(root, 'docs/persistence-changes/historical-formats')
417
+ mkdirSync(directory)
418
+ writeFileSync(join(directory, 'v0.md'), '---\nkind: persistence-format\n---\n')
419
+ writeFileSync(join(directory, 'v0.schema.json'), '{}\n')
420
+ expect(loadPersistenceHistory(root).entries.map(entry => entry.record.id)).toEqual([BASE_ID])
421
+ })
422
+
423
+ it.each([
424
+ ['optional field', inventory({ value: 'string', 'label?': 'string' })],
425
+ ['required-to-optional field', inventory({ 'value?': 'string' })],
426
+ ['ordinary event addition', { ...inventory(), roots: [...inventory().roots, typeRoot('event:example/added', { count: 'number' })] }],
427
+ ] as const)('infers same-version for %s', (_name, after) => {
428
+ const root = fixture()
429
+ baseline(root)
430
+ runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
431
+ const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
432
+ expect(changes?.length).toBeGreaterThan(0)
433
+ expect(changes?.every(change => change.decision === 'same-version')).toBe(true)
434
+ verifyPersistenceChanges(root, after)
435
+ })
436
+
437
+ it('infers a version bump when the source includes its own increasing header version', () => {
438
+ const root = fixture()
439
+ baseline(root)
440
+ const after = inventory({ value: 'number' }, 4)
441
+ runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
442
+ const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
443
+ expect(changes?.map(change => change.root).sort()).toEqual(['SessionHeader', 'event:example/value'])
444
+ expect(changes?.every(change => change.decision === 'version-bump')).toBe(true)
445
+ verifyPersistenceChanges(root, after)
446
+ })
447
+
448
+ it('rejects inferred bumps without a header transition and explicit incorrect assertions before writing', () => {
449
+ const root = fixture()
450
+ baseline(root)
451
+ const prose = proseFile(root)
452
+ const directory = join(root, 'docs/persistence-changes')
453
+ const files = readdirSync(directory).sort()
454
+ const inventoryPath = join(root, 'docs/persistence-schema.json')
455
+ const before = readFileSync(inventoryPath, 'utf8')
456
+ for (const [args, code] of [
457
+ [[], 'version-transition-required'],
458
+ [['--decision', 'same-version'], 'version-bump-required'],
459
+ ] as const) {
460
+ const result = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--prose', prose, '--json', ...args], root,
461
+ () => inventory({ value: 'number' })))
462
+ expect(result).toMatchObject({ ok: false, code, files: [], changes: [expect.objectContaining({ requiresVersionBump: true })] })
463
+ expect(readFileSync(inventoryPath, 'utf8')).toBe(before)
464
+ expect(readdirSync(directory).sort()).toEqual(files)
465
+ }
466
+ })
467
+
468
+ it('authors a complete pair from explicit prose and reports source changes even when generated artifacts are stale', () => {
469
+ const root = fixture()
470
+ baseline(root)
471
+ const after = inventory({ value: 'string', 'label?': 'string' })
472
+ const pending = jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after))
473
+ expect(pending).toMatchObject({ ok: false, operation: 'check', code: 'stale-artifacts',
474
+ roots: [{ root: 'event:example/value', kind: 'event', before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }], changes: [
475
+ { root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresVersionBump: false },
476
+ ] })
477
+ const written = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root), '--json'], root, () => after))
478
+ expect(written).toMatchObject({ ok: true, operation: 'record', recordId: NEXT_ID })
479
+ expect(written.files).toEqual([
480
+ 'docs/persistence-schema.json', `docs/persistence-changes/${NEXT_ID}.md`, `docs/persistence-changes/${NEXT_ID}.zh.md`,
481
+ `docs/persistence-changes/${NEXT_ID}.i18n.yaml`, `docs/persistence-changes/${NEXT_ID}.schema.json`,
482
+ ])
483
+ expect(written).toMatchObject({ roots: [{ root: 'event:example/value', kind: 'event',
484
+ before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }] })
485
+ expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).toContain(AUTHORED_PROSE.en.compatibility)
486
+ expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after)).ok).toBe(true)
487
+ })
488
+
489
+ it('refreshes only a terminal acknowledgement while preserving its authored prose and predecessor', () => {
490
+ const root = fixture()
491
+ baseline(root)
492
+ const after = inventory({ value: 'string', 'label?': 'string' })
493
+ const prose = proseFile(root)
494
+ runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
495
+ const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
496
+ const beforeText = readFileSync(path, 'utf8')
497
+ const refreshed = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number' })
498
+ runPersistenceChanges(['--update', NEXT_ID], root, () => refreshed)
499
+ const stripRecord = (value: string): string => value.replace(/```yaml persistence-change[\s\S]*?```/u, '')
500
+ expect(stripRecord(readFileSync(path, 'utf8'))).toBe(stripRecord(beforeText))
501
+ expect(loadPersistenceHistory(root).tips.get('event:example/value')?.id).toBe(NEXT_ID)
502
+ expect(loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes[0]?.previous).toBe(BASE_ID)
503
+ expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => refreshed)).ok).toBe(true)
504
+ const final = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number', 'last?': 'boolean' })
505
+ runPersistenceChanges(['--record', '2026-09-11-successor', '--decision', 'same-version', '--prose', prose], root, () => final)
506
+ expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => final)).toThrow('with successors')
507
+ expect(() => runPersistenceChanges(['--update', BASE_ID, '--decision', 'same-version'], root, () => final)).toThrow('cannot update the persistence baseline')
508
+ })
509
+
510
+ it('completes a scaffold through update and rejects invalid prose or decisions before changing files', () => {
511
+ const root = fixture()
512
+ baseline(root)
513
+ const after = inventory({ value: 'string', 'label?': 'string' })
514
+ runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
515
+ const prose = proseFile(root)
516
+ runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
517
+ expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
518
+ expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).not.toContain('TODO:')
519
+ const pairedPaths = ['.md', '.zh.md', '.i18n.yaml'].map(suffix => join(root, `docs/persistence-changes/${NEXT_ID}${suffix}`))
520
+ const completed = pairedPaths.map(path => readFileSync(path, 'utf8'))
521
+ runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
522
+ expect(pairedPaths.map(path => readFileSync(path, 'utf8'))).toEqual(completed)
523
+ const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
524
+ const before = readFileSync(path, 'utf8')
525
+ const invalid = inventory({ value: 'number' })
526
+ const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--json'], root, () => invalid))
527
+ expect(result).toMatchObject({ ok: false, code: 'version-bump-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })] })
528
+ expect(readFileSync(path, 'utf8')).toBe(before)
529
+ for (const value of [
530
+ { ...AUTHORED_PROSE, extra: 'unsupported' },
531
+ { ...AUTHORED_PROSE, en: { ...AUTHORED_PROSE.en, compatibility: ' ' } },
532
+ { ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: 'TODO: record validation evidence.' } },
533
+ { ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: '```text\nUnpaired code.\n```' } },
534
+ ]) {
535
+ writeFileSync(prose, JSON.stringify(value))
536
+ expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)).toThrow()
537
+ expect(readFileSync(path, 'utf8')).toBe(before)
538
+ }
539
+ })
540
+
541
+ // Six real CLI processes and TypeScript extraction share the Windows coverage test budget.
542
+ it('executes the real CLI against a source-only temporary checkout without Git history', { timeout: 90_000 }, () => {
543
+ const root = fixture()
544
+ const physical = join(root, 'packages/session/session-persistence-jsonl/src')
545
+ mkdirSync(physical, { recursive: true })
546
+ writeFileSync(join(physical, 'format.ts'), "interface HeaderLine { type: 'session'; version: number; id: string; delegationDepth: number }; export {}\n")
547
+ const session = join(root, 'packages/core/session/src')
548
+ mkdirSync(session, { recursive: true })
549
+ writeFileSync(join(session, '../package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-session' }))
550
+ writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ compilerOptions: {
551
+ target: 'ESNext', module: 'ESNext', moduleResolution: 'Bundler', types: [], skipLibCheck: true,
552
+ paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
553
+ } }))
554
+ const source = [
555
+ '/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
556
+ '/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
557
+ "'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
558
+ '/** Persisted event names. */', 'export type SessionEventType = keyof SessionEventMap',
559
+ '/** Surface placement. */', "export type SurfaceOp = 'append'", '/** Persisted event. */',
560
+ 'export type SessionEvent<K extends keyof SessionEventMap = keyof SessionEventMap> =',
561
+ ' { [P in K]: { type: P; seq: number; data: SessionEventMap[P]; surfaceOp: SurfaceOp } }[K]', '',
562
+ ].join('\n')
563
+ writeFileSync(join(session, 'types.ts'), source)
564
+ const script = resolve(import.meta.dirname, 'persistence-changes.ts')
565
+ const cli = (...args: string[]): ReturnType<typeof spawnSync> => spawnSync(process.execPath, ['--import', import.meta.resolve('tsx/esm'), script, '--root', root, ...args], {
566
+ cwd: root, encoding: 'utf8', timeout: 120_000,
567
+ })
568
+ const prose = proseFile(root)
569
+ const initialized = cli('--baseline', BASE_ID, '--prose', prose)
570
+ expect(initialized.error).toBeUndefined()
571
+ expect(initialized.signal).toBeNull()
572
+ expect(initialized.status, String(initialized.stderr)).toBe(0)
573
+ const accepted = cli('--check')
574
+ expect(accepted.error).toBeUndefined()
575
+ expect(accepted.signal).toBeNull()
576
+ expect(accepted.status, String(accepted.stderr)).toBe(0)
577
+ const optional = source.replace('value: string', 'value: string; label?: string')
578
+ writeFileSync(join(session, 'types.ts'), optional)
579
+ const authored = cli('--record', NEXT_ID, '--prose', prose, '--json')
580
+ expect(authored.error).toBeUndefined()
581
+ expect(authored.signal).toBeNull()
582
+ expect(authored.status, String(authored.stderr)).toBe(0)
583
+ expect(JSON.parse(String(authored.stdout)) as unknown).toMatchObject({ ok: true, operation: 'record' })
584
+ const beforeUpdate = readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')
585
+ writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number'))
586
+ const updated = cli('--update', NEXT_ID, '--json')
587
+ expect(updated.error).toBeUndefined()
588
+ expect(updated.signal).toBeNull()
589
+ expect(updated.status, String(updated.stderr)).toBe(0)
590
+ expect(JSON.parse(String(updated.stdout)) as unknown).toMatchObject({ ok: true, operation: 'update' })
591
+ expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8').replace(/```yaml persistence-change[\s\S]*?```/u, ''))
592
+ .toBe(beforeUpdate.replace(/```yaml persistence-change[\s\S]*?```/u, ''))
593
+ const generated = extractPersistenceSchema(root)
594
+ for (const file of persistenceCatalogArtifacts(root, generated)) expect(readFileSync(join(root, file.path), 'utf8')).toBe(file.content)
595
+ verifyPersistenceChanges(root, generated)
596
+ writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number').replace('value: string', 'value: number'))
597
+ commitCurrent(root, extractPersistenceSchema(root))
598
+ const rejected = cli('--check')
599
+ expect(rejected.error).toBeUndefined()
600
+ expect(rejected.signal).toBeNull()
601
+ expect(rejected.status).toBe(1)
602
+ expect(String(rejected.stderr)).toContain('version-bump required')
603
+ const structured = cli('--check', '--json')
604
+ expect(structured.error).toBeUndefined()
605
+ expect(structured.signal).toBeNull()
606
+ expect(structured.status).toBe(1)
607
+ expect(JSON.parse(String(structured.stdout)) as unknown).toMatchObject({
608
+ ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
609
+ })
610
+ })
611
+ })
scripts/persistence-changes.ts ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Verify and acknowledge persistence type changes from current-tree schema history. */
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
4
+ import { basename, join, resolve } from 'node:path'
5
+ import { parseArgs } from 'node:util'
6
+ import { JSON_SCHEMA, load } from 'js-yaml'
7
+ import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
8
+ import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
9
+ import { extractPersistenceSchema } from './persistence-schema.ts'
10
+ import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
11
+ import { renderPersistencePair } from './persistence-artifacts.ts'
12
+ import type { PersistenceArtifact } from './persistence-artifacts.ts'
13
+
14
+ const HISTORY_DIRECTORY = 'docs/persistence-changes'
15
+ const CURRENT_SCHEMA = 'docs/persistence-schema.json'
16
+ const ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/u
17
+ const DIGEST_PATTERN = /^[a-f0-9]{64}$/u
18
+ const EXPLANATION_PLACEHOLDER = 'TODO: explain this change.'
19
+ const EVIDENCE_PLACEHOLDER = 'TODO: record validation evidence.'
20
+
21
+ /** The author's acknowledgement of one mechanically classified transition. */
22
+ export type PersistenceDecision = 'same-version' | 'version-bump'
23
+
24
+ /** One root's successor; null after values preserve a deletion in its history. */
25
+ export interface PersistenceChange {
26
+ readonly root: string
27
+ readonly previous: string | null
28
+ readonly after: string | null
29
+ readonly decision: PersistenceDecision
30
+ }
31
+
32
+ /** A document's machine record, independent of its translated prose. */
33
+ export interface PersistenceChangeRecord {
34
+ readonly schemaVersion: 1
35
+ readonly id: string
36
+ readonly baseline: boolean
37
+ readonly changes: readonly PersistenceChange[]
38
+ }
39
+
40
+ /** A parsed acknowledgement and its self-contained after schemas. */
41
+ export interface PersistenceHistoryEntry {
42
+ readonly record: PersistenceChangeRecord
43
+ readonly snapshot: PersistenceSchemaInventory
44
+ }
45
+
46
+ /** One detected type change, with a path that reviewers can locate. */
47
+ export interface PersistenceTypeChange {
48
+ readonly kind: PersistenceTypeChangeKind
49
+ readonly path: string
50
+ readonly description: string
51
+ readonly requiresVersionBump: boolean
52
+ }
53
+
54
+ const CHANGE_DESCRIPTIONS = {
55
+ 'root-added': 'root added',
56
+ 'root-removed': 'root removed',
57
+ 'root-classification-changed': 'root classification changed',
58
+ 'type-changed': 'type changed',
59
+ 'property-removed': 'property removed',
60
+ 'property-made-optional': 'property made optional',
61
+ 'property-made-required': 'property made required',
62
+ 'optional-property-added': 'optional property added',
63
+ 'required-property-added': 'required property added',
64
+ 'index-signature-changed': 'index signature changed',
65
+ 'tuple-length-changed': 'tuple length changed',
66
+ 'tuple-element-cardinality-changed': 'tuple element cardinality changed',
67
+ 'union-variants-changed': 'union variants changed',
68
+ } as const
69
+
70
+ /** Stable structural classification independent of diagnostic prose. */
71
+ export type PersistenceTypeChangeKind = keyof typeof CHANGE_DESCRIPTIONS
72
+
73
+ /** Author-supplied paragraphs used to complete one language of a change record. */
74
+ export interface PersistenceChangeProse {
75
+ readonly summary: string
76
+ readonly compatibility: string
77
+ readonly verification: string
78
+ }
79
+
80
+ /** Explicit bilingual prose; the CLI supplies no compatibility or validation claims. */
81
+ export interface PersistenceChangeProsePair {
82
+ readonly en: PersistenceChangeProse
83
+ readonly zh: PersistenceChangeProse
84
+ }
85
+
86
+ interface ReportedChange extends PersistenceTypeChange {
87
+ readonly root: string
88
+ }
89
+
90
+ interface RootTransition {
91
+ readonly root: string
92
+ readonly kind: PersistenceRoot['kind']
93
+ readonly before: string | null
94
+ readonly after: string | null
95
+ }
96
+
97
+ class PersistenceChangeFailure extends Error {
98
+ constructor(
99
+ message: string, readonly code: string, readonly changes: readonly ReportedChange[] = [],
100
+ readonly roots: readonly RootTransition[] = [],
101
+ ) {
102
+ super(message)
103
+ }
104
+ }
105
+
106
+ interface CommandResult {
107
+ readonly schemaVersion: 1
108
+ readonly ok: boolean
109
+ readonly operation: 'check' | 'baseline' | 'record' | 'update'
110
+ readonly message: string
111
+ readonly changes: readonly ReportedChange[]
112
+ readonly roots: readonly RootTransition[]
113
+ readonly files: readonly string[]
114
+ readonly recordId?: string
115
+ readonly code?: string
116
+ }
117
+
118
+ interface Tip {
119
+ readonly id: string
120
+ readonly root: PersistenceRoot | null
121
+ }
122
+
123
+ /** Verified per-root history tips; historical schemas need not match the current tree. */
124
+ export interface PersistenceHistory {
125
+ readonly entries: readonly PersistenceHistoryEntry[]
126
+ readonly tips: ReadonlyMap<string, Tip>
127
+ }
128
+
129
+ function record(value: unknown, label: string): Record<string, unknown> {
130
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
131
+ return value as Record<string, unknown>
132
+ }
133
+
134
+ function keys(value: Record<string, unknown>, expected: readonly string[], label: string, optional: readonly string[] = []): void {
135
+ const missing = expected.find(key => !Object.hasOwn(value, key))
136
+ const unexpected = Object.keys(value).find(key => !expected.includes(key) && !optional.includes(key))
137
+ if (missing !== undefined || unexpected !== undefined) throw new Error(`${label}: ${missing === undefined ? `unknown field ${unexpected}` : `missing field ${missing}`}`)
138
+ }
139
+
140
+ function array(value: unknown, label: string): unknown[] {
141
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`)
142
+ return value
143
+ }
144
+
145
+ function textValue(value: unknown, label: string): string {
146
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be a non-empty string`)
147
+ return value
148
+ }
149
+
150
+ function bool(value: unknown, label: string): boolean {
151
+ if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
152
+ return value
153
+ }
154
+
155
+ function identifier(value: unknown, label: string): string {
156
+ const id = textValue(value, label)
157
+ if (!ID_PATTERN.test(id)) throw new Error(`${label} must be YYYY-MM-DD-slug`)
158
+ return id
159
+ }
160
+
161
+ function digest(value: unknown, label: string): string {
162
+ const result = textValue(value, label)
163
+ if (!DIGEST_PATTERN.test(result)) throw new Error(`${label} must be a SHA-256 digest`)
164
+ return result
165
+ }
166
+
167
+ function reference(value: unknown, count: number, label: string): number {
168
+ if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= count) throw new Error(`${label} references an unknown schema node`)
169
+ return value as number
170
+ }
171
+
172
+ function parseSchema(value: unknown, label: string): CanonicalSchema {
173
+ const input = record(value, label)
174
+ keys(input, ['root', 'nodes'], label)
175
+ if (input.root !== 0) throw new Error(`${label}.root must be zero`)
176
+ const nodes = array(input.nodes, `${label}.nodes`)
177
+ if (nodes.length === 0) throw new Error(`${label}.nodes must not be empty`)
178
+ const ref = (value: unknown): number => reference(value, nodes.length, label)
179
+ for (const [index, raw] of nodes.entries()) {
180
+ const node = record(raw, `${label}.nodes[${index}]`)
181
+ switch (node.kind) {
182
+ case 'primitive':
183
+ keys(node, ['kind', 'type'], label)
184
+ if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`)
185
+ break
186
+ case 'literal':
187
+ keys(node, ['kind', 'value'], label)
188
+ if (!['string', 'boolean', 'number'].includes(typeof node.value)
189
+ || typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`)
190
+ break
191
+ case 'opaque':
192
+ keys(node, ['kind', 'reason'], label)
193
+ if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
194
+ break
195
+ case 'array':
196
+ keys(node, ['kind', 'element'], label)
197
+ ref(node.element)
198
+ break
199
+ case 'tuple':
200
+ keys(node, ['kind', 'elements'], label)
201
+ for (const rawElement of array(node.elements, label)) {
202
+ const element = record(rawElement, label)
203
+ keys(element, ['type', 'optional', 'rest'], label)
204
+ ref(element.type)
205
+ bool(element.optional, label)
206
+ bool(element.rest, label)
207
+ }
208
+ break
209
+ case 'object': {
210
+ keys(node, ['kind', 'properties', 'indices'], label)
211
+ const names = new Set<string>()
212
+ for (const rawProperty of array(node.properties, label)) {
213
+ const property = record(rawProperty, label)
214
+ keys(property, ['name', 'type', 'optional'], label)
215
+ if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
216
+ const name = property.name
217
+ if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
218
+ names.add(name)
219
+ ref(property.type)
220
+ bool(property.optional, label)
221
+ }
222
+ for (const rawIndex of array(node.indices, label)) {
223
+ const entry = record(rawIndex, label)
224
+ keys(entry, ['key', 'value'], label)
225
+ ref(entry.key)
226
+ ref(entry.value)
227
+ }
228
+ break
229
+ }
230
+ case 'union':
231
+ keys(node, ['kind', 'types'], label)
232
+ if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
233
+ for (const item of node.types as unknown[]) ref(item)
234
+ break
235
+ default:
236
+ throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
237
+ }
238
+ }
239
+ const schema = input as unknown as CanonicalSchema
240
+ const canonical = canonicalizeSchema(schema.nodes, schema.root)
241
+ if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
242
+ return schema
243
+ }
244
+
245
+ /** Parse a persisted schema inventory, rejecting malformed graphs and digest drift.
246
+ * @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
247
+ * @returns the validated inventory.
248
+ */
249
+ export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
250
+ return parseSnapshot(value, false)
251
+ }
252
+
253
+ /** Parse a historical inventory with path-only source references and optional surface operations.
254
+ * @param value - JSON captured from a historical source tree.
255
+ * @returns the validated inventory; current acknowledgements use the strict parser.
256
+ */
257
+ export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
258
+ return parseSnapshot(value, true)
259
+ }
260
+
261
+ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
262
+ const input = record(value, 'schema inventory')
263
+ keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
264
+ if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version')
265
+ const names = new Set<string>()
266
+ for (const rawRoot of array(input.roots, 'schema roots')) {
267
+ const root = record(rawRoot, 'schema root')
268
+ keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
269
+ const key = textValue(root.key, 'root key')
270
+ if (names.has(key)) throw new Error(`duplicate schema root ${key}`)
271
+ names.add(key)
272
+ if (root.kind === 'event') {
273
+ if (key !== `event:${textValue(root.event, 'event name')}`) throw new Error(`invalid event root key ${key}`)
274
+ bool(root.surface, 'event surface membership')
275
+ } else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
276
+ && (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
277
+ if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
278
+ const schema = parseSchema(root.schema, key)
279
+ if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
280
+ if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
281
+ }
282
+ for (const rawType of array(input.types, 'schema types')) {
283
+ const type = record(rawType, 'schema type')
284
+ keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
285
+ const schema = parseSchema(type.schema, 'shared schema')
286
+ if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
287
+ for (const name of array(type.names, 'type names')) textValue(name, 'type name')
288
+ for (const source of array(type.sources, 'type sources')) {
289
+ const location = textValue(source, 'type source')
290
+ if (historical && /:\d+(?::\d+)?$|#L\d+(?:-L\d+)?$/u.test(location)) {
291
+ throw new Error('historical schema sources must omit line numbers')
292
+ }
293
+ }
294
+ }
295
+ return input as unknown as PersistenceSchemaInventory
296
+ }
297
+
298
+ function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
299
+ const pending = [0]
300
+ const visited = new Set<number>()
301
+ while (pending.length > 0) {
302
+ const index = pending.pop() as number
303
+ if (visited.has(index)) continue
304
+ visited.add(index)
305
+ const node = schema.nodes[index] as SchemaNode
306
+ if (node.kind === 'union') { pending.push(...node.types); continue }
307
+ if (node.kind !== 'object') throw new Error(`${event}: event schema must be an object`)
308
+ const tag = node.properties.find(property => property.name === 'type')
309
+ const value = tag === undefined ? undefined : schema.nodes[tag.type]
310
+ if (tag?.optional !== false || value?.kind !== 'literal' || value.value !== event) throw new Error(`${event}: event schema type does not match its root`)
311
+ const operation = node.properties.find(property => property.name === 'surfaceOp')
312
+ if (surface ? operation === undefined || !historical && operation.optional : operation !== undefined) throw new Error(`${event}: surface metadata does not match its schema`)
313
+ }
314
+ }
315
+
316
+ function subDigest(schema: CanonicalSchema, node: number): string {
317
+ return schemaDigest(canonicalizeSchema(schema.nodes, node))
318
+ }
319
+
320
+ function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined {
321
+ const owners = new Map<number, number>()
322
+ function assign(previous: number, visited: Set<number>): boolean {
323
+ for (const next of candidates[previous] ?? []) {
324
+ if (visited.has(next)) continue
325
+ visited.add(next)
326
+ const owner = owners.get(next)
327
+ if (owner === undefined || assign(owner, visited)) {
328
+ owners.set(next, previous)
329
+ return true
330
+ }
331
+ }
332
+ return false
333
+ }
334
+ for (let previous = 0; previous < candidates.length; previous += 1) {
335
+ if (!assign(previous, new Set())) return undefined
336
+ }
337
+ const matches: number[] = []
338
+ for (const [next, previous] of owners) matches[previous] = next
339
+ return matches
340
+ }
341
+
342
+ /** Classify structural differences; only optional payload properties and ordinary event additions are additive.
343
+ * @param before - predecessor root, or absence for an addition.
344
+ * @param after - successor root, or absence for deletion.
345
+ * @returns concrete changes and their format-bump requirement.
346
+ */
347
+ export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
348
+ if (before === null) {
349
+ return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
350
+ requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
351
+ }
352
+ if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
353
+ const key = after.key
354
+ const oldRoot = before
355
+ const newRoot = after
356
+ const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
357
+ ({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
358
+ const changes: PersistenceTypeChange[] = []
359
+ if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
360
+ if (before.digest === after.digest) return changes
361
+ const fingerprints = [new Map<number, string>(), new Map<number, string>()] as const
362
+ const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => {
363
+ let result = fingerprints[side].get(index)
364
+ if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) }
365
+ return result
366
+ }
367
+ type Scope = 'event' | 'body' | 'strict'
368
+ function compare(
369
+ oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet<string>,
370
+ ): PersistenceTypeChange[] {
371
+ if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return []
372
+ const pair = `${oldIndex}:${newIndex}:${scope}`
373
+ if (ancestors.has(pair)) return []
374
+ // Recursive pairs are assumptions for this candidate only. A failed sibling
375
+ // or unmatched union arm cannot leave a cached success for another candidate.
376
+ const active = new Set(ancestors).add(pair)
377
+ const differences: PersistenceTypeChange[] = []
378
+ const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
379
+ differences.push(describe(path, kind, requiresVersionBump))
380
+ }
381
+ const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
382
+ differences.push(...compare(oldType, newType, child, childScope, active))
383
+ }
384
+ const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
385
+ const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
386
+ if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
387
+ if (oldNode.kind === 'object' && newNode.kind === 'object') {
388
+ const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
389
+ const newProps = new Map(newNode.properties.map(property => [property.name, property]))
390
+ for (const [name, property] of oldProps) {
391
+ const next = newProps.get(name)
392
+ const child = `${path}.${name}`
393
+ if (next === undefined) { add(child, 'property-removed'); continue }
394
+ if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
395
+ descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
396
+ }
397
+ for (const [name, property] of newProps) {
398
+ if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
399
+ }
400
+ const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry]))
401
+ const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry]))
402
+ if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed')
403
+ for (const [index, entry] of oldIndices) {
404
+ const next = newIndices.get(index)
405
+ if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict')
406
+ }
407
+ return differences
408
+ }
409
+ if (oldNode.kind === 'array' && newNode.kind === 'array') {
410
+ return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active)
411
+ }
412
+ if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
413
+ if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')]
414
+ for (const [index, element] of oldNode.elements.entries()) {
415
+ const next = newNode.elements[index] as SchemaTupleElement
416
+ if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed')
417
+ descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict')
418
+ }
419
+ return differences
420
+ }
421
+ if (oldNode.kind === 'union' && newNode.kind === 'union') {
422
+ if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
423
+ const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
424
+ const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
425
+ candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
426
+ if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
427
+ const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
428
+ const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
429
+ const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
430
+ const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
431
+ if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
432
+ return [describe(path, 'union-variants-changed')]
433
+ }
434
+ return [describe(path, 'type-changed')]
435
+ }
436
+ changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
437
+ if (changes.length === 0) changes.push(describe(key, 'type-changed'))
438
+ return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
439
+ }
440
+
441
+ function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
442
+ const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source)
443
+ if (frontmatter === null || record(load(frontmatter[1] as string, { schema: JSON_SCHEMA }), filename).kind !== 'persistence-change') throw new Error(`${filename}: kind must be persistence-change`)
444
+ const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)]
445
+ const block = /^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu.exec(source)
446
+ if (openings.length !== 1 || block === null) throw new Error(`${filename}: expected exactly one persistence-change block`)
447
+ const input = record(load(block[1] as string, { schema: JSON_SCHEMA }), filename)
448
+ keys(input, ['schemaVersion', 'id', 'baseline', 'changes'], filename)
449
+ if (input.schemaVersion !== 1) throw new Error(`${filename}: unsupported acknowledgement schema version`)
450
+ const id = identifier(input.id, filename)
451
+ if (basename(filename) !== `${id}.md`) throw new Error(`${filename}: record id does not match filename`)
452
+ bool(input.baseline, filename)
453
+ const roots = new Set<string>()
454
+ for (const value of array(input.changes, `${filename} changes`)) {
455
+ const change = record(value, filename)
456
+ keys(change, ['root', 'previous', 'after', 'decision'], filename)
457
+ const root = textValue(change.root, 'changed root')
458
+ if (roots.has(root)) throw new Error(`${filename}: duplicate change for ${root}`)
459
+ roots.add(root)
460
+ if (change.previous !== null) identifier(change.previous, 'previous record')
461
+ if (change.after !== null) digest(change.after, 'after digest')
462
+ if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`)
463
+ }
464
+ if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`)
465
+ if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`)
466
+ return input as unknown as PersistenceChangeRecord
467
+ }
468
+
469
+ function headerVersion(root: PersistenceRoot | null): number | undefined {
470
+ if (root === null) return undefined
471
+ const node = root.schema.nodes[0]
472
+ if (node?.kind !== 'object') return undefined
473
+ const property = node.properties.find(item => item.name === 'version')
474
+ if (property === undefined) return undefined
475
+ const version = root.schema.nodes[property.type]
476
+ return version?.kind === 'literal' && typeof version.value === 'number' && Number.isSafeInteger(version.value) ? version.value : undefined
477
+ }
478
+
479
+ /** Check every historical transition and return each root's unique current tip.
480
+ * @param entries - parsed documents and their self-contained schema snapshots.
481
+ * @returns validated history and tips, without consulting Git or current source.
482
+ */
483
+ export function validatePersistenceHistory(entries: readonly PersistenceHistoryEntry[]): PersistenceHistory {
484
+ if (entries.filter(entry => entry.record.baseline).length !== 1) throw new Error('persistence history requires exactly one baseline')
485
+ const records = new Map<string, PersistenceHistoryEntry>()
486
+ for (const entry of entries) {
487
+ if (records.has(entry.record.id)) throw new Error(`duplicate persistence record ${entry.record.id}`)
488
+ records.set(entry.record.id, entry)
489
+ const expected = entry.record.changes.filter(change => change.after !== null).map(change => change.root).sort()
490
+ if (JSON.stringify(expected) !== JSON.stringify(entry.snapshot.roots.map(root => root.key).sort())) throw new Error(`${entry.record.id}: snapshot roots do not match acknowledged after schemas`)
491
+ for (const change of entry.record.changes) {
492
+ const root = entry.snapshot.roots.find(root => root.key === change.root)
493
+ if ((root?.digest ?? null) !== change.after) throw new Error(`${entry.record.id}: after digest mismatch for ${change.root}`)
494
+ if (entry.record.baseline && (change.previous !== null || change.after === null || change.decision !== 'same-version')) throw new Error(`${entry.record.id}: invalid baseline transition`)
495
+ }
496
+ }
497
+ const states = new Map<string, 'visiting' | 'visited'>()
498
+ const successors = new Map<string, string>()
499
+ const nodes = new Map<string, { entry: PersistenceHistoryEntry; change: PersistenceChange }>()
500
+ const tips = new Map<string, Tip>()
501
+ const nodeKey = (id: string | null, root: string): string => JSON.stringify([id, root])
502
+ for (const entry of entries) for (const change of entry.record.changes) {
503
+ const parentKey = nodeKey(change.previous, change.root)
504
+ if (successors.has(parentKey)) throw new Error(`forked persistence history for ${change.root}: ${successors.get(parentKey)} and ${entry.record.id}`)
505
+ successors.set(parentKey, entry.record.id)
506
+ nodes.set(nodeKey(entry.record.id, change.root), { entry, change })
507
+ }
508
+ function visit(id: string, root: string): PersistenceRoot | null {
509
+ const key = nodeKey(id, root)
510
+ const found = nodes.get(key)
511
+ if (found === undefined) throw new Error(`missing predecessor ${id} for ${root}`)
512
+ if (states.get(key) === 'visiting') throw new Error(`cycle in persistence history for ${root}`)
513
+ const after = found.entry.snapshot.roots.find(item => item.key === root) ?? null
514
+ if (states.get(key) === 'visited') return after
515
+ states.set(key, 'visiting')
516
+ const before = found.change.previous === null ? null : visit(found.change.previous, root)
517
+ if (!found.entry.record.baseline) {
518
+ const differences = classifyPersistenceChange(before, after)
519
+ if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
520
+ if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
521
+ throw new PersistenceChangeFailure(
522
+ `${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
523
+ 'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
524
+ )
525
+ }
526
+ if (found.change.decision === 'version-bump') {
527
+ const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
528
+ const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader')
529
+ const from = headerVersion(oldHeader)
530
+ const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null)
531
+ if (from === undefined || to === undefined || to <= from) {
532
+ throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`,
533
+ 'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
534
+ }
535
+ }
536
+ }
537
+ states.set(key, 'visited')
538
+ if (!successors.has(key)) tips.set(root, { id, root: after })
539
+ return after
540
+ }
541
+ for (const entry of entries) for (const change of entry.record.changes) visit(entry.record.id, change.root)
542
+ const baseline = entries.find(entry => entry.record.baseline) as PersistenceHistoryEntry
543
+ if (!baseline.snapshot.roots.some(root => root.key === 'SessionHeader')
544
+ || !baseline.snapshot.roots.some(root => root.key === 'SessionEventEnvelope')
545
+ || !baseline.snapshot.roots.some(root => root.key === 'JsonlHeaderLine')) {
546
+ throw new Error('baseline requires SessionHeader, JsonlHeaderLine, and SessionEventEnvelope roots')
547
+ }
548
+ return { entries, tips }
549
+ }
550
+
551
+ /** Read and validate all current-tree persistence acknowledgement files.
552
+ * @param root - checkout or isolated fixture root.
553
+ * @returns checked history without comparing its tips to current source.
554
+ */
555
+ export function loadPersistenceHistory(root: string): PersistenceHistory {
556
+ return validatePersistenceHistory(readPersistenceEntries(root))
557
+ }
558
+
559
+ function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] {
560
+ const directory = join(root, HISTORY_DIRECTORY)
561
+ if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization')
562
+ const files = readdirSync(directory).sort()
563
+ const documents = files.filter(file => file.endsWith('.md') && !file.endsWith('.zh.md') && file !== 'README.md' && file !== 'AGENTS.md')
564
+ const snapshots = new Set(files.filter(file => file.endsWith('.schema.json')))
565
+ const entries = documents.map((filename) => {
566
+ const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n')
567
+ const allowIncomplete = filename === `${allowIncompleteId}.md`
568
+ const change = parseDocument(source, filename, allowIncomplete)
569
+ const snapshotName = `${change.id}.schema.json`
570
+ if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
571
+ const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
572
+ const translatedName = `${change.id}.zh.md`
573
+ if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`)
574
+ const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n')
575
+ const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1]
576
+ const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
577
+ if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`)
578
+ if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`)
579
+ return { record: change, snapshot }
580
+ })
581
+ if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`)
582
+ return entries
583
+ }
584
+
585
+ function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] {
586
+ const roots = new Map(current.roots.map(root => [root.key, root]))
587
+ const differences: string[] = []
588
+ for (const key of new Set([...history.tips.keys(), ...roots.keys()])) {
589
+ if (classifyPersistenceChange(history.tips.get(key)?.root ?? null, roots.get(key) ?? null).length !== 0) differences.push(key)
590
+ }
591
+ return differences.sort()
592
+ }
593
+
594
+ /** Verify current generated output and acknowledgement tips together.
595
+ * @param root - checkout or isolated fixture root.
596
+ * @param current - freshly extracted current-source inventory.
597
+ * @returns verified history.
598
+ */
599
+ export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
600
+ const history = loadPersistenceHistory(root)
601
+ const differences = reportedDifferences(history, current)
602
+ const transitions = rootTransitions(history, current)
603
+ const committedPath = join(root, CURRENT_SCHEMA)
604
+ if (!existsSync(committedPath)) {
605
+ throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
606
+ }
607
+ const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
608
+ if (JSON.stringify(committed) !== JSON.stringify(current)) {
609
+ throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
610
+ }
611
+ if (differences.length !== 0) {
612
+ const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
613
+ throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
614
+ }
615
+ return history
616
+ }
617
+
618
+ function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition {
619
+ const root = (after ?? before) as PersistenceRoot
620
+ return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null }
621
+ }
622
+
623
+ function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] {
624
+ const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current)
625
+ return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null))
626
+ }
627
+
628
+ function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] {
629
+ return currentDifferences(history, current).flatMap(root => classifyPersistenceChange(
630
+ history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null,
631
+ ).map(change => ({ root, ...change })))
632
+ }
633
+
634
+ function machineBlock(change: PersistenceChangeRecord): string {
635
+ return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:',
636
+ ...change.changes.flatMap(item => [` - root: ${JSON.stringify(item.root)}`, ` previous: ${item.previous === null ? 'null' : JSON.stringify(item.previous)}`, ` after: ${item.after === null ? 'null' : JSON.stringify(item.after)}`, ` decision: ${item.decision}`]), '```'].join('\n')
637
+ }
638
+
639
+ function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
640
+ const summary = chinese ? '概述' : 'Summary'
641
+ const compatibility = chinese ? '兼容性' : 'Compatibility'
642
+ const verification = chinese ? '验证' : 'Verification'
643
+ return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '',
644
+ `# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '',
645
+ `## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
646
+ `- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '',
647
+ '<a id="declaration"></a>', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '',
648
+ '<a id="compatibility"></a>', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '',
649
+ '<a id="verification"></a>', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
650
+ }
651
+
652
+ /** Parse explicit authored prose without supplying compatibility or validation claims.
653
+ * @param value - decoded JSON supplied through --prose.
654
+ * @returns complete English and Chinese section text.
655
+ */
656
+ export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair {
657
+ const pair = record(value, 'persistence prose')
658
+ keys(pair, ['en', 'zh'], 'persistence prose')
659
+ for (const locale of ['en', 'zh']) {
660
+ const sections = record(pair[locale], `persistence prose ${locale}`)
661
+ keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`)
662
+ for (const [name, value] of Object.entries(sections)) {
663
+ const text = textValue(value, `${locale}.${name}`)
664
+ if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) {
665
+ throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`)
666
+ }
667
+ }
668
+ }
669
+ return pair as unknown as PersistenceChangeProsePair
670
+ }
671
+
672
+ function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
673
+ source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change))
674
+ if (prose === undefined) return source
675
+ const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification']
676
+ for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) {
677
+ const lines = source.split('\n')
678
+ const heading = `## ${headings[index]}`
679
+ const start = lines.indexOf(heading)
680
+ if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`)
681
+ let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line))
682
+ if (end < 0) end = lines.length
683
+ let anchor = end
684
+ while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1
685
+ if (/^<a id="[^"]+"><\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1
686
+ lines.splice(start + 1, end - start - 1, '', text.trim(), '')
687
+ source = lines.join('\n')
688
+ }
689
+ return source
690
+ }
691
+
692
+ function commandOperation(args: readonly string[]): CommandResult['operation'] {
693
+ if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline'
694
+ if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record'
695
+ if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update'
696
+ return 'check'
697
+ }
698
+
699
+ function executeCommand(
700
+ args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
701
+ artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[],
702
+ ): CommandResult {
703
+ const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: {
704
+ check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' },
705
+ decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' },
706
+ } })
707
+ if (values.root !== undefined) root = resolve(values.root)
708
+ const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined]
709
+ if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID')
710
+ if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update')
711
+ const operation = commandOperation(args)
712
+ if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
713
+ const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
714
+ const current = parsePersistenceSnapshot(extract(root))
715
+ if (operation === 'check') {
716
+ const history = verifyPersistenceChanges(root, current)
717
+ return { schemaVersion: 1, ok: true, operation,
718
+ message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] }
719
+ }
720
+ const baseline = operation === 'baseline'
721
+ const update = operation === 'update'
722
+ const id = identifier(values.baseline ?? values.record ?? values.update, 'record id')
723
+ const directory = join(root, HISTORY_DIRECTORY)
724
+ if (baseline && existsSync(directory) && readdirSync(directory).some(file => file.endsWith('.schema.json') || ID_PATTERN.test(file.replace(/\.md$/u, '')))) throw new Error('persistence baseline already exists; baseline creation cannot reset history')
725
+ if (values.decision !== undefined && values.decision !== 'same-version' && values.decision !== 'version-bump') {
726
+ throw new Error('--decision must be same-version or version-bump')
727
+ }
728
+ const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
729
+ const existing = update ? entries.find(entry => entry.record.id === id) : undefined
730
+ if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
731
+ if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
732
+ if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) {
733
+ throw new Error(`${id}: cannot update an acknowledgement with successors`)
734
+ }
735
+ const prior = entries.filter(entry => entry !== existing)
736
+ const history = baseline ? undefined : validatePersistenceHistory(prior)
737
+ const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
738
+ if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
739
+ const differences = history === undefined ? [] : reportedDifferences(history, current)
740
+ const decision = values.decision ?? (differences.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version')
741
+ const roots = current.roots.filter(root => changed.includes(root.key))
742
+ const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({
743
+ root: key, previous: history?.tips.get(key)?.id ?? null,
744
+ after: roots.find(root => root.key === key)?.digest ?? null, decision,
745
+ })) }
746
+ const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
747
+ validatePersistenceHistory([...prior, { record: change, snapshot }])
748
+ const document = (chinese: boolean): string => {
749
+ const supplied = chinese ? prose?.zh : prose?.en
750
+ return existing === undefined ? scaffold(change, chinese, supplied)
751
+ : updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied)
752
+ }
753
+ const english = document(false)
754
+ const chinese = document(true)
755
+ parseDocument(english, `${id}.md`, prose === undefined && existing === undefined)
756
+ parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
757
+ const recordFiles = [
758
+ ...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
759
+ { path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
760
+ ]
761
+ if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
762
+ const outputs = [...artifacts(root, current), ...recordFiles]
763
+ for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true })
764
+ for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
765
+ const completion = baseline
766
+ ? 'Complete both record documents and refresh their translation pairing.'
767
+ : `Complete the compatibility and verification prose with --update ${id} --prose FILE.`
768
+ const message = existing === undefined && prose === undefined
769
+ ? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
770
+ : `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
771
+ return { schemaVersion: 1, ok: true, operation, recordId: id, message,
772
+ changes: differences,
773
+ roots: rootTransitions(history, current), files: outputs.map(file => file.path) }
774
+ }
775
+
776
+ /** Execute tree-only verification or author an explicit persistence acknowledgement.
777
+ * @param args - check, baseline, record, or update arguments; --json selects structured output.
778
+ * @param root - checkout root; defaults to this script's repository.
779
+ * @param extract - current-source extraction function; fixtures supply their own source reader.
780
+ * @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact.
781
+ * @returns text or one JSON result; JSON failures retain ok:false for the process entry point.
782
+ */
783
+ export function runPersistenceChanges(
784
+ args: readonly string[],
785
+ root: string = resolve(import.meta.dirname, '..'),
786
+ extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
787
+ artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts,
788
+ ): string {
789
+ try {
790
+ const result = executeCommand(args, root, extract, artifacts)
791
+ return args.includes('--json') ? JSON.stringify(result) : result.message
792
+ } catch (error: unknown) {
793
+ if (!args.includes('--json')) throw error
794
+ const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args),
795
+ message: error instanceof Error ? error.message : String(error),
796
+ code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed',
797
+ changes: error instanceof PersistenceChangeFailure ? error.changes : [],
798
+ roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] }
799
+ return JSON.stringify(result)
800
+ }
801
+ }
802
+
803
+ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
804
+ try {
805
+ const output = runPersistenceChanges(process.argv.slice(2))
806
+ console.log(output)
807
+ if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1
808
+ } catch (error: unknown) {
809
+ console.error(error instanceof Error ? error.message : String(error))
810
+ process.exitCode = 1
811
+ }
812
+ }
scripts/persistence-formats.spec.ts ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Format reference coverage follows the writer version and retains complete historical schemas. */
2
+
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { dump } from 'js-yaml'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+ import { loadPersistenceFormats, runPersistenceFormats } from './persistence-formats.ts'
9
+ import { parseHistoricalPersistenceSnapshot } from './persistence-changes.ts'
10
+ import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
11
+ import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
12
+
13
+ const temporary: string[] = []
14
+ afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
15
+
16
+ function rootSchema(metadata: Omit<PersistenceRoot, 'schema' | 'digest'>, nodes: readonly SchemaNode[]): PersistenceRoot {
17
+ const schema = canonicalizeSchema(nodes, 0)
18
+ return { ...metadata, schema, digest: schemaDigest(schema) }
19
+ }
20
+
21
+ function completeInventory(roots: readonly PersistenceRoot[]): PersistenceSchemaInventory {
22
+ const types = new Map<string, PersistenceType>()
23
+ for (const root of roots) {
24
+ root.schema.nodes.forEach((_, index) => {
25
+ const schema = canonicalizeSchema(root.schema.nodes, index)
26
+ const digest = schemaDigest(schema)
27
+ types.set(digest, { digest, schema, names: [], sources: [] })
28
+ })
29
+ }
30
+ return { formatVersion: 1, roots, types: [...types.values()] }
31
+ }
32
+
33
+ function header(key: string, version: number | 'number', optional = false): PersistenceRoot {
34
+ return rootSchema({ key, kind: 'header' }, [
35
+ { kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional }] },
36
+ version === 'number' ? { kind: 'primitive', type: 'number' } : { kind: 'literal', value: version },
37
+ ])
38
+ }
39
+
40
+ function inventory(version: number): PersistenceSchemaInventory {
41
+ return completeInventory([
42
+ header('SessionHeader', version), header('JsonlHeaderLine', 'number'),
43
+ rootSchema({ key: 'SessionEventEnvelope', kind: 'envelope' }, [{ kind: 'object', indices: [], properties: [] }]),
44
+ rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: false }, [
45
+ { kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }] },
46
+ { kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
47
+ ]),
48
+ ])
49
+ }
50
+
51
+ function write(root: string, path: string, content: string): void {
52
+ writeFileSync(join(root, path), content)
53
+ }
54
+
55
+ function schemaPath(version: number, currentVersion: number): string {
56
+ return version === currentVersion ? 'docs/persistence-schema.json' : `docs/persistence-changes/historical-formats/v${version}.schema.json`
57
+ }
58
+
59
+ function replaceSnapshot(root: string, version: number, currentVersion: number, snapshot: PersistenceSchemaInventory): void {
60
+ write(root, schemaPath(version, currentVersion), JSON.stringify(snapshot))
61
+ }
62
+
63
+ function saveReference(root: string, version: number, currentVersion: number, snapshot = inventory(version)): void {
64
+ const current = version === currentVersion
65
+ const filename = current ? 'persistence-catalog' : `persistence-changes/historical-formats/v${version}`
66
+ const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
67
+ const record = {
68
+ schemaVersion: 1, sessionFormatVersion: version,
69
+ source: version === 1 ? { pullRequest: 3349 } : { tag: 'dsh-v0.1.0-rc.5' },
70
+ roots: Object.fromEntries(snapshot.roots.map(root => [root.key, root.digest])),
71
+ }
72
+ const lines = [
73
+ ...current ? [] : ['---', 'kind: persistence-format', '---', '', '```yaml persistence-format', dump(record).trimEnd(), '```', ''],
74
+ `# Session format v${version}`, '', `[Inventory](${schemaName})`, '', '| Root | Kind | SHA-256 |', '|---|---|---|',
75
+ ...snapshot.roots.map(root => `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`), '',
76
+ ]
77
+ for (const suffix of ['.md', '.zh.md']) write(root, `docs/${filename}${suffix}`, lines.join('\n'))
78
+ replaceSnapshot(root, version, currentVersion, snapshot)
79
+ }
80
+
81
+ function fixture(currentVersion = 3): string {
82
+ const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-formats-'))
83
+ temporary.push(root)
84
+ mkdirSync(join(root, 'packages/core/session/src'), { recursive: true })
85
+ mkdirSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
86
+ write(root, 'packages/core/session/src/types.ts', `export const SESSION_FORMAT_VERSION = ${currentVersion} as const\n`)
87
+ for (let version = 0; version <= currentVersion; version += 1) saveReference(root, version, currentVersion)
88
+ return root
89
+ }
90
+
91
+ function edit(root: string, path: string, from: string, to: string): void {
92
+ const source = readFileSync(join(root, path), 'utf8')
93
+ expect(source).toContain(from)
94
+ write(root, path, source.replace(from, to))
95
+ }
96
+
97
+ function editPair(root: string, version: number, from: string, to: string): void {
98
+ for (const suffix of ['.md', '.zh.md']) edit(root, `docs/persistence-changes/historical-formats/v${version}${suffix}`, from, to)
99
+ }
100
+
101
+ function prepareFacts(root: string, currentVersion = 3): void {
102
+ for (const suffix of ['.md', '.zh.md']) {
103
+ for (let version = 0; version < currentVersion; version += 1) {
104
+ const path = `docs/persistence-changes/historical-formats/v${version}${suffix}`
105
+ const switcher = suffix === '.md' ? `English | [中文](v${version}.zh.md)` : `[English](v${version}.md) | 中文`
106
+ write(root, path, readFileSync(join(root, path), 'utf8') + `\n${switcher}\n\n<!-- persistence-format-schema:start -->\n\nPending schemas.\n\n<!-- persistence-format-schema:end -->\n`)
107
+ }
108
+ const switcher = suffix === '.md' ? 'English | [中文](README.zh.md)' : '[English](README.md) | 中文'
109
+ write(root, `docs/persistence-changes/historical-formats/README${suffix}`, `# Session formats\n\n${switcher}\n\nAuthored context.\n\n<!-- persistence-format-index:start -->\n\nPending formats.\n\n<!-- persistence-format-index:end -->\n`)
110
+ }
111
+ }
112
+
113
+ describe('archive current persistence format', () => {
114
+ it('keeps every reachable type and drops extractor-retained types and source line numbers', () => {
115
+ const root = fixture()
116
+ const original = inventory(3)
117
+ const sources = [
118
+ 'packages/example/src/types.ts:12', 'packages/other/src/types.ts:8:2', 'packages/example/src/types.ts:34:5',
119
+ 'packages/plain/src/types.ts', 'packages/fragment/src/types.ts#L8', 'packages/fragment/src/types.ts#L8-L12',
120
+ ]
121
+ const types = original.types.map(type => ({ ...type, names: ['Example'], sources }))
122
+ const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
123
+ replaceSnapshot(root, 3, 3, { ...original, types: [...types, { schema, digest: schemaDigest(schema), names: [], sources: [] }] })
124
+ const before = readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')
125
+
126
+ expect(runPersistenceFormats(['--archive', '3'], root)).toBe('Archived Session format v3 to docs/persistence-changes/historical-formats/v3.schema.json.')
127
+ const serialized = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')
128
+ const archived = parseHistoricalPersistenceSnapshot(JSON.parse(serialized))
129
+ expect(archived).toEqual({
130
+ ...original,
131
+ types: types.map(type => ({ ...type, sources: [
132
+ 'packages/example/src/types.ts', 'packages/other/src/types.ts', 'packages/plain/src/types.ts', 'packages/fragment/src/types.ts',
133
+ ] })),
134
+ })
135
+ expect(serialized).toBe(JSON.stringify(archived, null, 2) + '\n')
136
+ expect(readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')).toBe(before)
137
+ expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.md'))).toBe(false)
138
+
139
+ saveReference(root, 3, 4, archived)
140
+ write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
141
+ saveReference(root, 4, 4)
142
+ expect(loadPersistenceFormats(root).entries[3]?.inventory).toEqual(archived)
143
+ })
144
+
145
+ it('creates the first archive directory and honors --root', () => {
146
+ const root = fixture(0)
147
+ rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
148
+ expect(runPersistenceFormats(['--root', root, '--archive', '0'])).toBe('Archived Session format v0 to docs/persistence-changes/historical-formats/v0.schema.json.')
149
+ expect(JSON.parse(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v0.schema.json'), 'utf8'))).toEqual(inventory(0))
150
+ })
151
+
152
+ it.each(['-1', '1.5', '01', '3junk', '9007199254740992'])('rejects an invalid archive version %s', (version) => {
153
+ const root = fixture()
154
+ expect(() => runPersistenceFormats([`--archive=${version}`], root)).toThrow('--archive must be a non-negative safe integer')
155
+ expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
156
+ })
157
+
158
+ it.each(['2', '4'])('rejects archive v%s when the current writer is v3', (version) => {
159
+ const root = fixture()
160
+ expect(() => runPersistenceFormats(['--archive', version], root)).toThrow(`Cannot archive v${version}: current writer is v3`)
161
+ expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
162
+ })
163
+
164
+ it('refuses to overwrite an existing archive', () => {
165
+ const root = fixture()
166
+ write(root, 'docs/persistence-changes/historical-formats/v3.schema.json', 'Retained history.\n')
167
+ expect(() => runPersistenceFormats(['--archive', '3'], root)).toThrow('docs/persistence-changes/historical-formats/v3.schema.json already exists')
168
+ expect(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')).toBe('Retained history.\n')
169
+ })
170
+
171
+ it('rejects --archive with --write before writing', () => {
172
+ const root = fixture()
173
+ expect(() => runPersistenceFormats(['--archive', '3', '--write'], root)).toThrow('--archive and --write cannot be combined')
174
+ expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
175
+ })
176
+
177
+ it.each(['invalid-json', 'digest-drift', 'missing-type', 'missing-source-metadata', 'outdated-header'])('rejects %s before creating an archive', (variant) => {
178
+ const root = fixture()
179
+ const original = inventory(3)
180
+ if (variant === 'invalid-json') write(root, 'docs/persistence-schema.json', '{')
181
+ else if (variant === 'digest-drift') replaceSnapshot(root, 3, 3, { ...original, roots: [{ ...original.roots[0]!, digest: '0'.repeat(64) }, ...original.roots.slice(1)] })
182
+ else if (variant === 'missing-type') replaceSnapshot(root, 3, 3, { ...original, types: original.types.slice(1) })
183
+ else if (variant === 'missing-source-metadata') edit(root, 'docs/persistence-schema.json', ',"sources":[]', '')
184
+ else replaceSnapshot(root, 3, 3, inventory(2))
185
+ expect(() => runPersistenceFormats(['--archive', '3'], root)).toThrow(variant === 'invalid-json' ? SyntaxError
186
+ : variant === 'digest-drift' ? 'schema digest mismatch' : variant === 'missing-type' ? 'cover every reachable type'
187
+ : variant === 'missing-source-metadata' ? 'missing field sources' : 'SessionHeader.version must match')
188
+ expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'))).toBe(false)
189
+ })
190
+ })
191
+
192
+ describe('complete persistence format references', () => {
193
+ it.each(['.md', '.zh.md'])('rejects source coordinates in authored format evidence in %s', (suffix) => {
194
+ const root = fixture()
195
+ const path = `docs/persistence-changes/historical-formats/v1${suffix}`
196
+ const original = readFileSync(join(root, path), 'utf8')
197
+ for (const position of [':36', ':36:2', '#L36', '#L36-L38']) {
198
+ write(root, path, original + `\nSource: \`packages/core/session/src/types.ts${position}\`.\n`)
199
+ expect(() => loadPersistenceFormats(root)).toThrow('historical source references must omit line numbers')
200
+ }
201
+ write(root, path, original + '\nSource: `packages/core/session/src/types.ts`.\n')
202
+ expect(loadPersistenceFormats(root).entries).toHaveLength(4)
203
+ })
204
+
205
+ it('loads a contiguous archive and the current catalog without Git or historical package sources', () => {
206
+ const formats = loadPersistenceFormats(fixture())
207
+ expect(formats.currentVersion).toBe(3)
208
+ expect(formats.entries.map(entry => entry.version)).toEqual([0, 1, 2, 3])
209
+ expect(formats.entries[0]?.source).toEqual({ tag: 'dsh-v0.1.0-rc.5' })
210
+ expect(formats.entries[1]?.source).toEqual({ pullRequest: 3349 })
211
+ expect(formats.entries[3]).toMatchObject({ document: 'docs/persistence-catalog.md', schemaPath: 'docs/persistence-schema.json' })
212
+ expect(formats.entries[3]?.source).toBeUndefined()
213
+ })
214
+
215
+ it('accepts a version-zero writer without a historical directory', () => {
216
+ const root = fixture(0)
217
+ rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
218
+ expect(loadPersistenceFormats(root).entries).toHaveLength(1)
219
+ })
220
+
221
+ it.each(['.md', '.zh.md', '.schema.json'])('requires every historical companion %s', (suffix) => {
222
+ const root = fixture()
223
+ rmSync(join(root, `docs/persistence-changes/historical-formats/v1${suffix}`))
224
+ expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact v1${suffix}`)
225
+ })
226
+
227
+ it('rejects a gap even when all remaining versions have complete companions', () => {
228
+ const root = fixture()
229
+ for (const suffix of ['.md', '.zh.md', '.schema.json']) rmSync(join(root, `docs/persistence-changes/historical-formats/v1${suffix}`))
230
+ expect(() => loadPersistenceFormats(root)).toThrow('v1: missing persistence format artifact')
231
+ })
232
+
233
+ it.each(['persistence-catalog.md', 'persistence-catalog.zh.md', 'persistence-schema.json'])('requires the current reference artifact %s', (filename) => {
234
+ const root = fixture()
235
+ rmSync(join(root, `docs/${filename}`))
236
+ expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact docs/${filename}`)
237
+ })
238
+
239
+ it('requires a formerly current archive when the writer advances', () => {
240
+ const root = fixture()
241
+ write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
242
+ saveReference(root, 4, 4)
243
+ expect(() => loadPersistenceFormats(root)).toThrow('v3: missing persistence format artifact')
244
+ saveReference(root, 3, 4)
245
+ expect(loadPersistenceFormats(root).entries.map(entry => entry.version)).toEqual([0, 1, 2, 3, 4])
246
+ })
247
+
248
+ it.each(['v3.md', 'v9.schema.json', 'v01.md', 'v-1.md', 'v2.schema.yaml', 'v3.i18n.yaml'])('rejects unexpected or duplicate format filename %s', (filename) => {
249
+ const root = fixture()
250
+ write(root, `docs/persistence-changes/historical-formats/${filename}`, '{}')
251
+ expect(() => loadPersistenceFormats(root)).toThrow(`unexpected persistence format artifact ${filename}`)
252
+ })
253
+
254
+ it('rejects bilingual declarations that differ', () => {
255
+ const root = fixture()
256
+ edit(root, 'docs/persistence-changes/historical-formats/v1.zh.md', 'pullRequest: 3349', 'pullRequest: 3397')
257
+ expect(() => loadPersistenceFormats(root)).toThrow('bilingual machine records differ')
258
+ })
259
+
260
+ it.each([
261
+ ['kind: persistence-format', 'kind: persistence-release', 'frontmatter kind'],
262
+ ['sessionFormatVersion: 1', 'sessionFormatVersion: 0', 'must match its filename'],
263
+ ['schemaVersion: 1', 'schemaVersion: 2', 'unsupported persistence format record schema version'],
264
+ ['pullRequest: 3349', 'pullRequest: 0', 'positive integer'],
265
+ ['pullRequest: 3349', 'pullRequest: 1.5', 'positive integer'],
266
+ ['pullRequest: 3349', 'pullRequest: 3349\n tag: dsh-v0.1.0', 'expected fields'],
267
+ ['pullRequest: 3349', 'other: 3349', 'expected fields'],
268
+ ['source:\n pullRequest: 3349', 'source: null', 'expected an object'],
269
+ ['schemaVersion: 1', 'schemaVersion: 1\nunknown: true', 'expected fields'],
270
+ ])('rejects inconsistent machine data: %s', (from, to, error) => {
271
+ const root = fixture()
272
+ editPair(root, 1, from, to)
273
+ expect(() => loadPersistenceFormats(root)).toThrow(error)
274
+ })
275
+
276
+ it('rejects a source tag outside the DSH release namespace', () => {
277
+ const root = fixture()
278
+ editPair(root, 0, 'tag: dsh-v0.1.0-rc.5', 'tag: latest')
279
+ expect(() => loadPersistenceFormats(root)).toThrow('invalid source tag')
280
+ })
281
+
282
+ it.each(['missing', 'extra', 'wrong-digest'])('rejects a %s recorded root', (variant) => {
283
+ const root = fixture()
284
+ const digest = inventory(1).roots[0]!.digest
285
+ const from = variant === 'extra' ? 'roots:\n' : ` SessionHeader: ${digest}\n`
286
+ const to = variant === 'missing' ? '' : variant === 'extra' ? `roots:\n event:undeclared: ${'f'.repeat(64)}\n`
287
+ : ` SessionHeader: ${'f'.repeat(64)}\n`
288
+ editPair(root, 1, from, to)
289
+ expect(() => loadPersistenceFormats(root)).toThrow('recorded roots do not match the complete schema inventory')
290
+ })
291
+
292
+ it.each(['unknown-key', 'invalid-digest', 'duplicate-key', 'not-a-mapping'])('rejects invalid recorded roots: %s', (variant) => {
293
+ const root = fixture()
294
+ const digest = inventory(1).roots[0]!.digest
295
+ const from = variant === 'not-a-mapping' ? dump({ roots: Object.fromEntries(inventory(1).roots.map(root => [root.key, root.digest])) })
296
+ : ` SessionHeader: ${digest}\n`
297
+ const to = variant === 'unknown-key' ? ` unknown: ${digest}\n` : variant === 'invalid-digest' ? ' SessionHeader: invalid\n'
298
+ : variant === 'duplicate-key' ? from + from : 'roots: []\n'
299
+ editPair(root, 1, from, to)
300
+ expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-key' ? 'duplicated mapping key'
301
+ : variant === 'not-a-mapping' ? 'roots must be a mapping' : 'invalid recorded root')
302
+ })
303
+
304
+ it.each(['missing', 'duplicate'])('rejects a %s machine block', (variant) => {
305
+ const root = fixture()
306
+ for (const suffix of ['.md', '.zh.md']) {
307
+ const path = `docs/persistence-changes/historical-formats/v1${suffix}`
308
+ const text = readFileSync(join(root, path), 'utf8')
309
+ write(root, path, variant === 'missing' ? text.replace('```yaml persistence-format', '```yaml') : text + '\n```yaml persistence-format\n{}\n```\n')
310
+ }
311
+ expect(() => loadPersistenceFormats(root)).toThrow('expected exactly one persistence-format machine record')
312
+ })
313
+
314
+ it.each(['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope', 'event:example/value'])('rejects an inventory missing %s', (key) => {
315
+ const root = fixture()
316
+ replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.filter(root => root.key !== key)))
317
+ expect(() => loadPersistenceFormats(root)).toThrow(key.startsWith('event:') ? 'must include an event root' : `missing schema root ${key}`)
318
+ })
319
+
320
+ it.each([0, 1, 2, 3])('rejects a mismatched header version at v%s', (version) => {
321
+ const root = fixture()
322
+ replaceSnapshot(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, version + 1) : root)))
323
+ expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match')
324
+ })
325
+
326
+ it('retains historical number declarations but requires the current logical header literal', () => {
327
+ const root = fixture()
328
+ for (const version of [0, 1, 2]) {
329
+ saveReference(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
330
+ }
331
+ expect(loadPersistenceFormats(root).entries).toHaveLength(4)
332
+ replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
333
+ expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match the current writer version')
334
+ })
335
+
336
+ it.each(['SessionHeader', 'JsonlHeaderLine'])('requires a present %s version property', (key) => {
337
+ const root = fixture()
338
+ replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.key === key ? header(key, 1, true) : root)))
339
+ expect(() => loadPersistenceFormats(root)).toThrow(`${key}.version must match`)
340
+ })
341
+
342
+ it('uses historical surface rules only for historical references', () => {
343
+ const root = fixture()
344
+ const event = rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: true }, [
345
+ { kind: 'object', indices: [], properties: [
346
+ { name: 'type', type: 1, optional: false }, { name: 'surfaceOp', type: 2, optional: true },
347
+ ] },
348
+ { kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
349
+ ])
350
+ saveReference(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.kind === 'event' ? event : root)))
351
+ expect(loadPersistenceFormats(root).entries).toHaveLength(4)
352
+ replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.kind === 'event' ? event : root)))
353
+ expect(() => loadPersistenceFormats(root)).toThrow('surface metadata does not match its schema')
354
+ })
355
+
356
+ it.each(['missing', 'duplicate', 'extra'])('rejects %s reachable type coverage', (variant) => {
357
+ const root = fixture()
358
+ const original = inventory(1)
359
+ const extraSchema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
360
+ const types = variant === 'missing' ? original.types.slice(1)
361
+ : variant === 'duplicate' ? [...original.types, original.types[0]!]
362
+ : [...original.types, { schema: extraSchema, digest: schemaDigest(extraSchema), names: [], sources: [] }]
363
+ replaceSnapshot(root, 1, 3, { ...original, types })
364
+ expect(() => loadPersistenceFormats(root)).toThrow(variant === 'missing' ? 'cover every reachable type' : variant === 'duplicate' ? 'duplicate schema type' : 'unreferenced schema type')
365
+ })
366
+
367
+ it('permits extractor-retained unreachable types only in the current inventory', () => {
368
+ const root = fixture()
369
+ const original = inventory(3)
370
+ const schema = canonicalizeSchema([{ kind: 'literal', value: false }], 0)
371
+ replaceSnapshot(root, 3, 3, {
372
+ ...original, types: [...original.types, { schema, digest: schemaDigest(schema), names: [], sources: [] }],
373
+ })
374
+ expect(loadPersistenceFormats(root).entries).toHaveLength(4)
375
+ })
376
+
377
+ it.each(['duplicate-root', 'invalid-digest', 'invalid-graph', 'unexpected-field'])('rejects malformed schema inventory: %s', (variant) => {
378
+ const root = fixture()
379
+ const original = inventory(1)
380
+ const first = original.roots[0]!
381
+ const changed = variant === 'duplicate-root' ? { ...original, roots: [...original.roots, first] }
382
+ : variant === 'invalid-digest' ? { ...original, roots: [{ ...first, digest: '0'.repeat(64) }, ...original.roots.slice(1)] }
383
+ : variant === 'invalid-graph' ? { ...original, roots: [{ ...first, schema: { root: 0, nodes: [{ kind: 'array', element: 99 }] } }, ...original.roots.slice(1)] }
384
+ : { ...original, extra: true }
385
+ write(root, schemaPath(1, 3), JSON.stringify(changed))
386
+ expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-root' ? 'duplicate schema root' : variant === 'invalid-digest' ? 'schema digest mismatch' : variant === 'invalid-graph' ? 'unknown schema node' : 'unknown field extra')
387
+ })
388
+
389
+ it.each(['persistence-changes/historical-formats/v1.md', 'persistence-changes/historical-formats/v1.zh.md', 'persistence-catalog.md', 'persistence-catalog.zh.md'])('requires the matching schema link in %s', (document) => {
390
+ const root = fixture()
391
+ const schema = document.startsWith('persistence-changes/historical-formats/') ? 'v1.schema.json' : 'persistence-schema.json'
392
+ edit(root, `docs/${document}`, `](${schema})`, '](wrong.schema.json)')
393
+ expect(() => loadPersistenceFormats(root)).toThrow(`missing link to ${schema}`)
394
+ })
395
+
396
+ it.each(['persistence-catalog.md', 'persistence-catalog.zh.md'])('rejects a placeholder current catalog in %s', (document) => {
397
+ const root = fixture()
398
+ write(root, `docs/${document}`, '# Placeholder\n\n[Inventory](persistence-schema.json)\n')
399
+ expect(() => loadPersistenceFormats(root)).toThrow('missing schema index entry for SessionHeader')
400
+ })
401
+
402
+ it('checks generated schema regions and refreshes them without changing the recorded schemas or authored context', () => {
403
+ const root = fixture()
404
+ prepareFacts(root)
405
+ const paths = [0, 1, 2, 3].map(version => schemaPath(version, 3))
406
+ const before = paths.map(path => readFileSync(join(root, path), 'utf8'))
407
+ expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
408
+ expect(runPersistenceFormats(['--write'], root)).toContain('Refreshed')
409
+ expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v3 verified (4 complete references).')
410
+ expect(paths.map(path => readFileSync(join(root, path), 'utf8'))).toEqual(before)
411
+ const index = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')
412
+ expect(index).toContain('Authored context.')
413
+ expect(index).toContain('[V1](v1.md) | [JSON](v1.schema.json)')
414
+ expect(index).toContain('[Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json)')
415
+ const translatedIndex = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.zh.md'), 'utf8')
416
+ expect(translatedIndex).toContain('[V1](v1.zh.md) | [JSON](v1.schema.json)')
417
+ expect(translatedIndex).toContain('[当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json)')
418
+ const english = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v1.md'), 'utf8')
419
+ expect(english).toContain('pullRequest: 3349')
420
+ const [visible, collapsed] = english.split('<details>')
421
+ expect(visible).toContain('\n## Complete schemas\n')
422
+ expect(visible).toContain('\n### Persistence type fingerprints\n')
423
+ expect(collapsed).toContain('\n### Resolved persistence types\n')
424
+ expect(collapsed).toContain('\n#### `SessionHeader`\n')
425
+ expect(english.match(/^## /gmu)).toHaveLength(1)
426
+ expect(english.match(/^### /gmu)).toHaveLength(2)
427
+ expect(english.match(/^#### /gmu)).toHaveLength(inventory(1).types.length)
428
+ edit(root, 'docs/persistence-changes/historical-formats/v1.md', '<summary>Complete resolved types</summary>', '<summary>Missing definitions</summary>')
429
+ expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
430
+ })
431
+
432
+ it('renders a format index for a current version-zero writer', () => {
433
+ const root = fixture(0)
434
+ prepareFacts(root, 0)
435
+ runPersistenceFormats(['--write'], root)
436
+ expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v0 verified (1 complete reference).')
437
+ })
438
+
439
+ it.each(['missing', 'stale'])('rejects a %s format pairing record and refreshes it with --write', (variant) => {
440
+ const root = fixture()
441
+ prepareFacts(root)
442
+ runPersistenceFormats(['--write'], root)
443
+ const sidecar = 'docs/persistence-changes/historical-formats/v1.i18n.yaml'
444
+ if (variant === 'missing') rmSync(join(root, sidecar))
445
+ else write(root, sidecar, 'schemaVersion: 1\n')
446
+ expect(() => runPersistenceFormats([], root)).toThrow(`Stale persistence format facts: ${sidecar}`)
447
+ runPersistenceFormats(['--write'], root)
448
+ expect(() => runPersistenceFormats([], root)).not.toThrow()
449
+ })
450
+
451
+ it('refuses to refresh facts until every format reference passes validation', () => {
452
+ const root = fixture()
453
+ prepareFacts(root)
454
+ const index = readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')
455
+ rmSync(join(root, 'docs/persistence-changes/historical-formats/v1.schema.json'))
456
+ expect(() => runPersistenceFormats(['--write'], root)).toThrow('missing persistence format artifact')
457
+ expect(readFileSync(join(root, 'docs/persistence-changes/historical-formats/README.md'), 'utf8')).toBe(index)
458
+ })
459
+
460
+ it('refuses to refresh an archive after an event and all its types are removed from its schema', () => {
461
+ const root = fixture()
462
+ const retained = rootSchema({ key: 'event:example/retained', kind: 'event', event: 'example/retained', surface: false }, [
463
+ { kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }] },
464
+ { kind: 'literal', value: 'example/retained' },
465
+ ])
466
+ const complete = completeInventory([...inventory(1).roots, retained])
467
+ saveReference(root, 1, 3, complete)
468
+ prepareFacts(root)
469
+ runPersistenceFormats(['--write'], root)
470
+ const document = 'docs/persistence-changes/historical-formats/v1.md'
471
+ const before = readFileSync(join(root, document), 'utf8')
472
+ replaceSnapshot(root, 1, 3, completeInventory(complete.roots.filter(root => root.key !== 'event:example/value')))
473
+ expect(() => runPersistenceFormats(['--write'], root)).toThrow('recorded roots do not match the complete schema inventory')
474
+ expect(readFileSync(join(root, document), 'utf8')).toBe(before)
475
+ })
476
+ })
scripts/persistence-schema-model.spec.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode } from './persistence-schema-model.ts'
3
+
4
+ const string: SchemaNode = { kind: 'primitive', type: 'string' }
5
+ const number: SchemaNode = { kind: 'primitive', type: 'number' }
6
+
7
+ describe('canonical persisted type graphs', () => {
8
+ it('ignores source ordering and shared versus duplicated subtypes', () => {
9
+ const left: SchemaNode[] = [
10
+ { kind: 'object', properties: [{ name: 'b', type: 1, optional: false }, { name: 'a', type: 1, optional: true }], indices: [] }, string,
11
+ ]
12
+ const right: SchemaNode[] = [
13
+ string, string,
14
+ { kind: 'object', properties: [{ name: 'a', type: 0, optional: true }, { name: 'b', type: 1, optional: false }], indices: [] },
15
+ ]
16
+ expect(canonicalizeSchema(left, 0)).toEqual(canonicalizeSchema(right, 2))
17
+ })
18
+
19
+ it('minimizes differently factored recursive aliases to the same graph', () => {
20
+ const self: SchemaNode[] = [
21
+ { kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'value', type: 1, optional: false }], indices: [] }, string,
22
+ ]
23
+ const mutual: SchemaNode[] = [
24
+ { kind: 'object', properties: [{ name: 'value', type: 2, optional: false }, { name: 'next', type: 1, optional: true }], indices: [] },
25
+ { kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'value', type: 3, optional: false }], indices: [] }, string, string,
26
+ ]
27
+ const normalized = canonicalizeSchema(self, 0)
28
+ expect(canonicalizeSchema(mutual, 0)).toEqual(normalized)
29
+ expect(canonicalizeSchema(normalized.nodes, 0)).toEqual(normalized)
30
+ mutual[3] = number
31
+ expect(schemaDigest(canonicalizeSchema(mutual, 0))).not.toBe(schemaDigest(normalized))
32
+ })
33
+
34
+ it('flattens reordered unions and removes structurally equal alternatives', () => {
35
+ const flat: SchemaNode[] = [{ kind: 'union', types: [1, 2] }, string, number]
36
+ const factored: SchemaNode[] = [string, number, { kind: 'union', types: [0, 3] }, { kind: 'union', types: [1, 0] }]
37
+ expect(canonicalizeSchema(flat, 0)).toEqual(canonicalizeSchema(factored, 2))
38
+ expect(canonicalizeSchema([{ kind: 'union', types: [1, 2] }, string, string], 0)).toEqual(canonicalizeSchema([string], 0))
39
+ expect(canonicalizeSchema([{ kind: 'union', types: [1, 2] }, { kind: 'literal', value: true }, { kind: 'literal', value: false }], 0))
40
+ .toEqual(canonicalizeSchema([{ kind: 'primitive', type: 'boolean' }], 0))
41
+ })
42
+
43
+ it('ignores recursive union, field, index signature and graph numbering order together', () => {
44
+ const original: SchemaNode[] = [
45
+ { kind: 'union', types: [1, 2, 3] },
46
+ { kind: 'object', properties: [{ name: 'label', type: 5, optional: false }, { name: 'next', type: 0, optional: true }], indices: [] },
47
+ { kind: 'array', element: 0 },
48
+ { kind: 'object', properties: [], indices: [{ key: 5, value: 0 }, { key: 4, value: 1 }] },
49
+ number,
50
+ string,
51
+ ]
52
+ const reordered: SchemaNode[] = [
53
+ string,
54
+ { kind: 'object', properties: [], indices: [{ key: 3, value: 5 }, { key: 0, value: 4 }] },
55
+ { kind: 'array', element: 4 },
56
+ number,
57
+ { kind: 'union', types: [1, 2, 5] },
58
+ { kind: 'object', properties: [{ name: 'next', type: 4, optional: true }, { name: 'label', type: 0, optional: false }], indices: [] },
59
+ ]
60
+ expect(canonicalizeSchema(reordered, 4)).toEqual(canonicalizeSchema(original, 0))
61
+ })
62
+
63
+ it('preserves tuple positions, element absence, and rest elements', () => {
64
+ const baseline: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 1, optional: false, rest: false }, { type: 2, optional: true, rest: false }] }, string, number]
65
+ const reversed: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }, { type: 1, optional: true, rest: false }] }, string, number]
66
+ const rest: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 1, optional: false, rest: false }, { type: 2, optional: false, rest: true }] }, string, number]
67
+ const digest = schemaDigest(canonicalizeSchema(baseline, 0))
68
+ expect(schemaDigest(canonicalizeSchema(reversed, 0))).not.toBe(digest)
69
+ expect(schemaDigest(canonicalizeSchema(rest, 0))).not.toBe(digest)
70
+ })
71
+
72
+ it('recognizes arbitrary recursive JSON structurally and retains its definition', () => {
73
+ const json: SchemaNode[] = [
74
+ { kind: 'union', types: [1, 2, 3, 4, 5, 6] },
75
+ { kind: 'primitive', type: 'null' }, { kind: 'primitive', type: 'boolean' }, number, string,
76
+ { kind: 'array', element: 0 }, { kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
77
+ ]
78
+ const schema = canonicalizeSchema(json, 0)
79
+ expect(isArbitraryJsonSchema(schema)).toBe(true)
80
+ expect(schema.nodes.some(node => node.kind === 'array' && node.element === 0)).toBe(true)
81
+ json[0] = { kind: 'union', types: [1, 2, 3, 4, 5] }
82
+ expect(isArbitraryJsonSchema(canonicalizeSchema(json, 0))).toBe(false)
83
+ })
84
+
85
+ it('rejects missing references and unproductive union cycles', () => {
86
+ expect(() => canonicalizeSchema([{ kind: 'array', element: 5 }], 0)).toThrow('missing node 5')
87
+ expect(() => canonicalizeSchema([{ kind: 'union', types: [0] }], 0)).toThrow('union cycle')
88
+ })
89
+ })
scripts/persistence-schema.ts ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Extract complete persistent Session record types from one source-only compiler program. */
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, extname, relative, resolve, sep } from 'node:path'
5
+ import ts from 'typescript'
6
+ import { collectLogEvents } from './persistence-catalog-source.ts'
7
+ import {
8
+ canonicalizeSchema,
9
+ schemaChildren,
10
+ schemaDigest,
11
+ type PersistenceRoot,
12
+ type PersistenceSchemaInventory,
13
+ type PersistenceType,
14
+ type SchemaNode,
15
+ type SchemaProperty,
16
+ } from './persistence-schema-model.ts'
17
+
18
+ interface DeclarationMetadata {
19
+ readonly names: Set<string>
20
+ readonly sources: Set<string>
21
+ }
22
+
23
+ interface RootInput {
24
+ readonly key: string
25
+ readonly kind: PersistenceRoot['kind']
26
+ readonly event?: string
27
+ readonly surface?: boolean
28
+ readonly node: number
29
+ }
30
+
31
+ /** A reachable TypeScript type that cannot be represented as a persisted JSON type. */
32
+ export class PersistenceSchemaError extends Error {
33
+ override name = 'PersistenceSchemaError'
34
+ }
35
+
36
+ /**
37
+ * Extract every repository event, its complete envelope, and the Session header.
38
+ * @param root - repository root, with source paths in tsconfig.host.json.
39
+ * @returns canonical root fingerprints and all reachable type definitions.
40
+ */
41
+ export function extractPersistenceSchema(root: string): PersistenceSchemaInventory {
42
+ root = resolve(root)
43
+ const events = collectLogEvents(root).sort((left, right) => compare(left.name, right.name))
44
+ if (events.length === 0) throw new PersistenceSchemaError('persistence schema: no Session events were discovered')
45
+ const filename = resolve(root, 'scripts/__persistence_schema_roots__.ts')
46
+ const source = [
47
+ "import type { SessionHeader, SessionEvent, SessionEventMap, SurfaceEventType } from '@deepseek-ai/dsh-session/types'",
48
+ 'export type HeaderRoot = SessionHeader',
49
+ 'export type SurfaceRoot = SurfaceEventType',
50
+ 'export type EventNamesRoot = keyof SessionEventMap',
51
+ ...events.map((event, index) => `export type EventRoot${String(index)} = SessionEvent<${JSON.stringify(event.name)}>`),
52
+ '',
53
+ ].join('\n')
54
+ const configPath = resolve(root, 'tsconfig.host.json')
55
+ const config = ts.readConfigFile(configPath, file => readFileSync(file, 'utf8'))
56
+ if (config.error !== undefined) throw new PersistenceSchemaError(diagnosticText(config.error))
57
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
58
+ const configErrors = parsed.errors.filter(error => error.code !== 18003)
59
+ if (configErrors.length > 0) throw new PersistenceSchemaError(configErrors.map(diagnosticText).join('\n'))
60
+ const options: ts.CompilerOptions = {
61
+ ...parsed.options,
62
+ composite: false,
63
+ incremental: false,
64
+ noEmit: true,
65
+ rootDir: root,
66
+ noUnusedLocals: false,
67
+ noUnusedParameters: false,
68
+ // Reachable declaration-file errors must not become opaque any values.
69
+ skipLibCheck: false,
70
+ strict: true,
71
+ exactOptionalPropertyTypes: true,
72
+ }
73
+ const host = ts.createCompilerHost(options)
74
+ const originalGetSourceFile = host.getSourceFile.bind(host)
75
+ host.getSourceFile = (path, languageVersion, onError, shouldCreateNewSourceFile) =>
76
+ resolve(path) === filename
77
+ ? ts.createSourceFile(filename, source, languageVersion, true)
78
+ : originalGetSourceFile(path, languageVersion, onError, shouldCreateNewSourceFile)
79
+ const files = [...new Set([
80
+ ...hostSourceFiles(root, configPath),
81
+ ...events.map(event => resolve(root, event.source.slice(0, event.source.lastIndexOf(':')))),
82
+ ])]
83
+ const program = ts.createProgram({ rootNames: [filename, ...files], options, host })
84
+ const rootSource = program.getSourceFile(filename)
85
+ if (rootSource === undefined) throw new PersistenceSchemaError('persistence schema: compiler omitted the requested roots')
86
+ const diagnostics = [
87
+ ...program.getOptionsDiagnostics(), ...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics(rootSource),
88
+ ]
89
+ if (diagnostics.length > 0) throw new PersistenceSchemaError(diagnostics.map(diagnosticText).join('\n'))
90
+ const checker = program.getTypeChecker()
91
+ const declarations = new Map(rootSource.statements.filter(ts.isTypeAliasDeclaration)
92
+ .map(declaration => [declaration.name.text, declaration]))
93
+ const declaration = (name: string): ts.TypeAliasDeclaration => {
94
+ const found = declarations.get(name)
95
+ if (found === undefined) throw new PersistenceSchemaError(`persistence schema: missing compiler root ${name}`)
96
+ return found
97
+ }
98
+ const eventNamesDeclaration = declaration('EventNamesRoot')
99
+ const compiledEvents = stringLiterals(checker.getTypeFromTypeNode(eventNamesDeclaration.type), 'keyof SessionEventMap')
100
+ const discoveredEvents = new Set(events.map(event => event.name))
101
+ const omitted = [...compiledEvents].filter(name => !discoveredEvents.has(name))
102
+ const uncompiled = [...discoveredEvents].filter(name => !compiledEvents.has(name))
103
+ if (omitted.length > 0 || uncompiled.length > 0) {
104
+ throw new PersistenceSchemaError(`persistence schema: compiler and source event discovery disagree; omitted: ${omitted.join(', ')}; uncompiled: ${uncompiled.join(', ')}`)
105
+ }
106
+ const surfaceDeclaration = declaration('SurfaceRoot')
107
+ const surface = stringLiterals(checker.getTypeFromTypeNode(surfaceDeclaration.type), 'SurfaceEventType')
108
+ for (const name of surface) {
109
+ if (!events.some(event => event.name === name)) throw new PersistenceSchemaError(`persistence schema: surface event ${name} has no declaration`)
110
+ }
111
+ const header = declaration('HeaderRoot')
112
+ const physicalFile = resolve(root, 'packages/session/session-persistence-jsonl/src/format.ts')
113
+ const physical = program.getSourceFile(physicalFile)?.statements
114
+ .filter((node): node is ts.InterfaceDeclaration | ts.TypeAliasDeclaration =>
115
+ ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node))
116
+ .filter(declaration => declaration.name.text === 'HeaderLine')
117
+ if (physical?.length !== 1) throw new PersistenceSchemaError('persistence schema: expected one current JSONL HeaderLine declaration')
118
+ const physicalHeader = physical[0] as ts.InterfaceDeclaration | ts.TypeAliasDeclaration
119
+ const declarationSources = validateReachableDeclarations(program, root, [...declarations.values(), physicalHeader])
120
+ const extraction = new SchemaExtractor(root, program, declarationSources)
121
+ const roots: RootInput[] = [{
122
+ key: 'SessionHeader',
123
+ kind: 'header',
124
+ node: extraction.convert(checker.getTypeFromTypeNode(header.type), header),
125
+ }]
126
+ roots.push({ key: 'JsonlHeaderLine', kind: 'header', node: extraction.convert(checker.getTypeAtLocation(physicalHeader), physicalHeader) })
127
+ const envelopes: number[] = []
128
+ for (const [index, event] of events.entries()) {
129
+ const item = declaration(`EventRoot${String(index)}`)
130
+ const node = extraction.convert(checker.getTypeFromTypeNode(item.type), item)
131
+ roots.push({ key: `event:${event.name}`, kind: 'event', event: event.name, surface: surface.has(event.name), node })
132
+ envelopes.push(extraction.envelope(node, event.name))
133
+ }
134
+ roots.splice(2, 0, { key: 'SessionEventEnvelope', kind: 'envelope', node: extraction.union(envelopes) })
135
+ return extraction.inventory(roots)
136
+ }
137
+
138
+ function hostSourceFiles(root: string, configPath: string, seen = new Set<string>()): string[] {
139
+ configPath = resolve(configPath)
140
+ if (seen.has(configPath)) return []
141
+ seen.add(configPath)
142
+ const read = ts.readConfigFile(configPath, file => readFileSync(file, 'utf8'))
143
+ if (read.error !== undefined) throw new PersistenceSchemaError(diagnosticText(read.error))
144
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath)
145
+ const errors = parsed.errors.filter(error => error.code !== 18003)
146
+ if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
147
+ return [
148
+ ...parsed.fileNames.filter(file => /^packages\/[^/]+\/[^/]+\/src\//.test(slash(relative(root, file)))),
149
+ ...(parsed.projectReferences ?? []).flatMap(reference =>
150
+ hostSourceFiles(root, extname(reference.path) === '.json' ? reference.path : resolve(reference.path, 'tsconfig.json'), seen)),
151
+ ]
152
+ }
153
+
154
+ function validateReachableDeclarations(
155
+ program: ts.Program,
156
+ root: string,
157
+ roots: readonly ts.Node[],
158
+ ): ReadonlyMap<ts.Type, readonly ts.Node[]> {
159
+ const checker = program.getTypeChecker()
160
+ const visited = new Set<ts.Node>()
161
+ const declarations = new Map<ts.SourceFile, ts.Node[]>()
162
+ const definitions = new Map<ts.Type, ts.Node[]>()
163
+ const visit = (node: ts.Node): void => {
164
+ if (visited.has(node)) return
165
+ visited.add(node)
166
+ const file = node.getSourceFile()
167
+ if (trackedSource(slash(relative(root, file.fileName)))) {
168
+ const scopes = declarations.get(file) ?? []
169
+ scopes.push(node)
170
+ declarations.set(file, scopes)
171
+ if (isNamedTypeDeclaration(node) || ts.isTypeLiteralNode(node)) {
172
+ // Primitive aliases share checker types with unaliased properties.
173
+ const type = checker.getTypeAtLocation(node)
174
+ const entries = definitions.get(type) ?? []
175
+ entries.push(node)
176
+ definitions.set(type, entries)
177
+ }
178
+ }
179
+ const target = ts.isTypeReferenceNode(node) ? node.typeName
180
+ : ts.isExpressionWithTypeArguments(node) ? node.expression
181
+ : ts.isTypeQueryNode(node) ? node.exprName
182
+ : ts.isImportTypeNode(node) ? node.qualifier
183
+ : undefined
184
+ if (target !== undefined) {
185
+ let symbol = checker.getSymbolAtLocation(target)
186
+ if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol)
187
+ for (const declaration of symbol?.declarations ?? []) {
188
+ if (trackedSource(slash(relative(root, declaration.getSourceFile().fileName)))) visit(declaration)
189
+ }
190
+ }
191
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return
192
+ ts.forEachChild(node, visit)
193
+ }
194
+ for (const root of roots) visit(root)
195
+ const errors = [...declarations].flatMap(([file, scopes]) => program.getSemanticDiagnostics(file)
196
+ .filter((error) => {
197
+ const start = error.start
198
+ return start !== undefined && scopes.some(scope => start >= scope.getStart() && start < scope.end)
199
+ }))
200
+ if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
201
+ return definitions
202
+ }
203
+
204
+ class SchemaExtractor {
205
+ readonly nodes: SchemaNode[] = []
206
+ private readonly cache = new Map<ts.Type, number>()
207
+ private readonly declarationMetadata = new Map<number, DeclarationMetadata>()
208
+
209
+ private readonly checker: ts.TypeChecker
210
+
211
+ constructor(
212
+ private readonly root: string,
213
+ program: ts.Program,
214
+ private readonly declarationSources: ReadonlyMap<ts.Type, readonly ts.Node[]>,
215
+ ) {
216
+ this.checker = program.getTypeChecker()
217
+ }
218
+
219
+ convert(type: ts.Type, site: ts.Node): number {
220
+ const cached = this.cache.get(type)
221
+ if (cached !== undefined) {
222
+ this.record(cached, type)
223
+ return cached
224
+ }
225
+ const id = this.nodes.length
226
+ this.nodes.push({ kind: 'primitive', type: 'never' })
227
+ this.cache.set(type, id)
228
+ this.record(id, type)
229
+ const add = (node: SchemaNode): number => { this.nodes[id] = node; return id }
230
+ const flags = type.flags
231
+ if (flags & ts.TypeFlags.Any) {
232
+ if ((type as ts.Type & { intrinsicName?: string }).intrinsicName === 'error') this.fail(type, site, 'unresolved compiler type')
233
+ return add({ kind: 'opaque', reason: 'any' })
234
+ }
235
+ if (flags & ts.TypeFlags.Unknown) return add({ kind: 'opaque', reason: 'unknown' })
236
+ if (flags & ts.TypeFlags.Never) return add({ kind: 'primitive', type: 'never' })
237
+ if (flags & ts.TypeFlags.Null) return add({ kind: 'primitive', type: 'null' })
238
+ if (flags & ts.TypeFlags.String) return add({ kind: 'primitive', type: 'string' })
239
+ if (flags & ts.TypeFlags.Number) return add({ kind: 'primitive', type: 'number' })
240
+ if (flags & ts.TypeFlags.Boolean) return add({ kind: 'primitive', type: 'boolean' })
241
+ if (flags & ts.TypeFlags.StringLiteral) return add({ kind: 'literal', value: (type as ts.StringLiteralType).value })
242
+ if (flags & ts.TypeFlags.NumberLiteral) return add({ kind: 'literal', value: (type as ts.NumberLiteralType).value })
243
+ if (flags & ts.TypeFlags.BooleanLiteral) return add({ kind: 'literal', value: this.checker.typeToString(type) === 'true' })
244
+ if (type.isUnion()) return add(this.unionNode(type.types.map(member => this.convert(member, site))))
245
+ if (type.isIntersection()) {
246
+ const material = type.types.filter(member => !this.phantom(member))
247
+ for (const member of material) this.rejectClass(member, site)
248
+ if (material.length === 1) {
249
+ const target = this.convert(material[0] as ts.Type, site)
250
+ return add({ kind: 'union', types: [target] })
251
+ }
252
+ if (material.length === 0 || material.some(member => (member.flags & ts.TypeFlags.Object) === 0)) {
253
+ this.fail(type, site, 'unsupported material intersection')
254
+ }
255
+ return add(this.object(type, site))
256
+ }
257
+ if (this.checker.isTupleType(type)) {
258
+ const reference = type as ts.TypeReference
259
+ const target = reference.target as ts.TupleType
260
+ return add({
261
+ kind: 'tuple',
262
+ elements: this.checker.getTypeArguments(reference).map((element, index) => {
263
+ const flags = target.elementFlags[index] ?? ts.ElementFlags.Required
264
+ const optional = (flags & ts.ElementFlags.Optional) !== 0
265
+ const rest = (flags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0
266
+ return { type: this.valueType(element, site, optional), optional, rest }
267
+ }),
268
+ })
269
+ }
270
+ if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
271
+ const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
272
+ if (element === undefined) this.fail(type, site, 'array element is unavailable')
273
+ return add({ kind: 'array', element: this.convert(element, site) })
274
+ }
275
+ if (flags & ts.TypeFlags.Object) return add(this.object(type, site))
276
+ this.fail(type, site, 'type has no supported JSON representation')
277
+ }
278
+
279
+ private valueType(type: ts.Type, site: ts.Node, optional: boolean): number {
280
+ if (!optional) return this.convert(type, site)
281
+ const members = (type.isUnion() ? type.types : [type]).filter(member => !(member.flags & ts.TypeFlags.Undefined))
282
+ return this.union(members.map(member => this.convert(member, site)))
283
+ }
284
+
285
+ private object(type: ts.Type, site: ts.Node): SchemaNode {
286
+ this.rejectClass(type, site)
287
+ if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) this.fail(type, site, 'callable data is not JSON')
288
+ const properties: SchemaProperty[] = []
289
+ for (const property of this.checker.getPropertiesOfType(type).sort((left, right) => compare(left.name, right.name))) {
290
+ if (property.getName().startsWith('__@')) this.fail(type, site, 'symbol-keyed data is not a JSON field')
291
+ const declaration = property.valueDeclaration ?? property.declarations?.[0] ?? site
292
+ const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration)
293
+ const optional = (property.flags & ts.SymbolFlags.Optional) !== 0
294
+ if (optional && (propertyType.isUnion() ? propertyType.types : [propertyType])
295
+ .every(member => (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0)) continue
296
+ const child = this.valueType(propertyType, declaration, optional)
297
+ properties.push({ name: property.getName(), type: child, optional })
298
+ }
299
+ const indices = this.checker.getIndexInfosOfType(type).map(info => ({
300
+ key: this.convert(info.keyType, site),
301
+ value: this.convert(info.type, site),
302
+ }))
303
+ if (properties.length === 0 && indices.length === 0) this.fail(type, site, 'unconstrained empty object type is not an explicit JSON record')
304
+ return { kind: 'object', properties, indices }
305
+ }
306
+
307
+ private rejectClass(type: ts.Type, site: ts.Node): void {
308
+ const symbol = type.getSymbol()
309
+ if (symbol?.declarations?.some(declaration => ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
310
+ this.fail(type, site, 'class instances require an explicit persisted representation')
311
+ }
312
+ }
313
+
314
+ private phantom(type: ts.Type): boolean {
315
+ if (!(type.flags & ts.TypeFlags.Object)) return false
316
+ const properties = this.checker.getPropertiesOfType(type)
317
+ return properties.length > 0 && properties.every(property => property.getName().startsWith('__@'))
318
+ && type.getCallSignatures().length === 0 && type.getConstructSignatures().length === 0
319
+ && this.checker.getIndexInfosOfType(type).length === 0
320
+ }
321
+
322
+ union(types: readonly number[]): number {
323
+ const node = this.unionNode(types)
324
+ if (node.kind === 'union' && node.types.length === 1) return node.types[0] as number
325
+ this.nodes.push(node)
326
+ return this.nodes.length - 1
327
+ }
328
+
329
+ private unionNode(types: readonly number[]): SchemaNode {
330
+ if (types.length === 0) return { kind: 'primitive', type: 'never' }
331
+ return { kind: 'union', types: [...new Set(types)] }
332
+ }
333
+
334
+ envelope(id: number, event: string): number {
335
+ const node = this.nodes[id] as SchemaNode
336
+ if (node.kind === 'union') return this.union(node.types.map(type => this.envelope(type, event)))
337
+ if (node.kind !== 'object') throw new PersistenceSchemaError('persistence schema: SessionEvent must resolve to object alternatives')
338
+ const tag = node.properties.find(property => property.name === 'type')
339
+ const data = node.properties.find(property => property.name === 'data')
340
+ const literal = tag === undefined ? undefined : canonicalizeSchema(this.nodes, tag.type).nodes[0]
341
+ if (tag?.optional !== false || data?.optional !== false || literal?.kind !== 'literal' || literal.value !== event) {
342
+ throw new PersistenceSchemaError(`persistence schema: SessionEvent<${JSON.stringify(event)}> must retain its required type and data fields`)
343
+ }
344
+ const string = this.nodes.length
345
+ this.nodes.push({ kind: 'primitive', type: 'string' })
346
+ const envelope: SchemaNode = {
347
+ kind: 'object',
348
+ properties: node.properties.filter(property => property.name !== 'data')
349
+ .map(property => property.name === 'type' ? { ...property, type: string } : property),
350
+ indices: node.indices,
351
+ }
352
+ this.nodes.push(envelope)
353
+ return this.nodes.length - 1
354
+ }
355
+
356
+ inventory(inputs: readonly RootInput[]): PersistenceSchemaInventory {
357
+ const roots = inputs.map((input) => {
358
+ const schema = canonicalizeSchema(this.nodes, input.node)
359
+ return {
360
+ key: input.key,
361
+ kind: input.kind,
362
+ ...(input.event === undefined ? {} : { event: input.event }),
363
+ ...(input.surface === undefined ? {} : { surface: input.surface }),
364
+ digest: schemaDigest(schema),
365
+ schema,
366
+ }
367
+ })
368
+ const found = new Set<number>()
369
+ const paths = new Map<number, Set<string>>()
370
+ const visit = (id: number, path: string): void => {
371
+ const names = paths.get(id) ?? new Set<string>()
372
+ names.add(path)
373
+ paths.set(id, names)
374
+ if (found.has(id)) return
375
+ found.add(id)
376
+ const node = this.nodes[id] as SchemaNode
377
+ if (node.kind === 'object') {
378
+ for (const property of node.properties) visit(property.type, `${path}.${property.name}`)
379
+ for (const index of node.indices) { visit(index.key, `${path}.[key]`); visit(index.value, `${path}.[value]`) }
380
+ } else {
381
+ for (const [index, child] of schemaChildren(node).entries()) visit(child, `${path}[${String(index)}]`)
382
+ }
383
+ }
384
+ for (const input of inputs) visit(input.node, input.key)
385
+ const types = new Map<string, { schema: PersistenceType['schema']; names: Set<string>; sources: Set<string> }>()
386
+ for (const id of found) {
387
+ const schema = canonicalizeSchema(this.nodes, id)
388
+ const digest = schemaDigest(schema)
389
+ const item = types.get(digest) ?? { schema, names: new Set<string>(), sources: new Set<string>() }
390
+ const declarationMetadata = this.declarationMetadata.get(id)
391
+ for (const name of declarationMetadata?.names ?? []) item.names.add(name)
392
+ for (const source of declarationMetadata?.sources ?? []) item.sources.add(source)
393
+ const kind = schema.nodes[0]?.kind
394
+ if (kind !== 'primitive' && kind !== 'literal' && (declarationMetadata === undefined || declarationMetadata.names.size === 0)) {
395
+ for (const path of paths.get(id) ?? []) item.names.add(path)
396
+ }
397
+ types.set(digest, item)
398
+ }
399
+ return {
400
+ formatVersion: 1,
401
+ roots,
402
+ types: [...types].sort(([left], [right]) => compare(left, right)).map(([digest, item]) => ({
403
+ digest,
404
+ schema: item.schema,
405
+ names: [...item.names].sort(compare),
406
+ sources: [...item.sources].sort(compare),
407
+ })),
408
+ }
409
+ }
410
+
411
+ private record(id: number, type: ts.Type): void {
412
+ const item = this.declarationMetadata.get(id) ?? { names: new Set<string>(), sources: new Set<string>() }
413
+ const declarations = new Set([
414
+ ...this.declarationSources.get(type) ?? [],
415
+ ...type.aliasSymbol?.declarations ?? [],
416
+ ...type.getSymbol()?.declarations ?? [],
417
+ ])
418
+ for (const declaration of declarations) {
419
+ if (!isNamedTypeDeclaration(declaration) && !ts.isTypeLiteralNode(declaration) && !ts.isEnumMember(declaration)) continue
420
+ const source = declaration.getSourceFile()
421
+ const file = slash(relative(this.root, source.fileName))
422
+ if (!trackedSource(file)) continue
423
+ if (isNamedTypeDeclaration(declaration)) item.names.add(`${file}#${declaration.name.text}`)
424
+ const position = source.getLineAndCharacterOfPosition(declaration.getStart())
425
+ item.sources.add(`${file}:${String(position.line + 1)}`)
426
+ }
427
+ this.declarationMetadata.set(id, item)
428
+ }
429
+
430
+ private fail(type: ts.Type, site: ts.Node, reason: string): never {
431
+ const source = slash(relative(this.root, site.getSourceFile().fileName))
432
+ const position = site.getSourceFile().getLineAndCharacterOfPosition(site.getStart())
433
+ throw new PersistenceSchemaError(`persistence schema: ${source}:${String(position.line + 1)}: ${reason}: ${this.checker.typeToString(type)}`)
434
+ }
435
+ }
436
+
437
+ function isNamedTypeDeclaration(node: ts.Node): node is ts.TypeAliasDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration {
438
+ return ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node)
439
+ }
440
+
441
+ function stringLiterals(type: ts.Type, name: string): Set<string> {
442
+ if (type.flags & ts.TypeFlags.Never) return new Set()
443
+ const members = type.isUnion() ? type.types : [type]
444
+ if (members.some(member => !(member.flags & ts.TypeFlags.StringLiteral))) throw new PersistenceSchemaError(`persistence schema: ${name} is not a closed string-literal union`)
445
+ return new Set(members.map(member => (member as ts.StringLiteralType).value))
446
+ }
447
+
448
+ function diagnosticText(diagnostic: ts.Diagnostic): string {
449
+ return `persistence schema: TypeScript TS${String(diagnostic.code)}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`
450
+ }
451
+
452
+ function compare(left: string, right: string): number {
453
+ return left < right ? -1 : left > right ? 1 : 0
454
+ }
455
+
456
+ function slash(path: string): string {
457
+ return path.split(sep).join('/')
458
+ }
459
+
460
+ function trackedSource(file: string): boolean {
461
+ return (file.startsWith('packages/') || file.startsWith('vendor/')) && !file.includes('/node_modules/')
462
+ }
scripts/pnpm-invocation.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Resolve shell-free child-process invocations for the pnpm process that launched a package script. */
2
+
3
+ /**
4
+ * Resolve pnpm's executable and arguments from its lifecycle environment.
5
+ * @param args - Arguments to pass to pnpm.
6
+ * @param environment - Lifecycle environment containing `npm_execpath`.
7
+ * @returns A command and argument array suitable for `spawn` or `spawnSync` without a shell.
8
+ */
9
+ export function pnpmInvocation(
10
+ args: readonly string[],
11
+ environment: NodeJS.ProcessEnv = process.env,
12
+ ): { command: string; args: string[] } {
13
+ const entrypoint = environment.npm_execpath
14
+ if (entrypoint === undefined || entrypoint === '') {
15
+ throw new Error('pnpm invocation: npm_execpath is unavailable; invoke the script through pnpm run.')
16
+ }
17
+ if (/\.[cm]?js$/iu.test(entrypoint)) {
18
+ return { command: process.execPath, args: [entrypoint, ...args] }
19
+ }
20
+ return { command: entrypoint, args: [...args] }
21
+ }
scripts/project-reference-faces.spec.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
6
+
7
+ const roots: string[] = []
8
+
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
11
+ })
12
+
13
+ function writeJson(path: string, value: unknown): void {
14
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
15
+ }
16
+
17
+ function workspaceFixture(options: {
18
+ readonly host: readonly string[]
19
+ readonly client: readonly string[]
20
+ }): string {
21
+ const root = mkdtempSync(join(tmpdir(), 'dsh-project-reference-faces-'))
22
+ roots.push(root)
23
+ const shared = join(root, 'packages/core/shared')
24
+ const split = join(root, 'packages/api/split')
25
+ mkdirSync(shared, { recursive: true })
26
+ mkdirSync(split, { recursive: true })
27
+ writeJson(join(root, 'tsconfig.base.json'), {})
28
+ writeJson(join(root, 'tsconfig.base.client.json'), { extends: './tsconfig.base.json' })
29
+ writeJson(join(shared, 'package.json'), { name: '@deepseek-ai/dsh-shared' })
30
+ writeJson(join(shared, 'tsconfig.json'), {
31
+ extends: '../../../tsconfig.base.json',
32
+ references: [],
33
+ })
34
+ writeJson(join(split, 'package.json'), { name: '@deepseek-ai/dsh-split' })
35
+ writeJson(join(split, 'tsconfig.json'), {
36
+ files: [],
37
+ references: [{ path: './tsconfig.host.json' }, { path: './tsconfig.client.json' }],
38
+ })
39
+ writeJson(join(split, 'tsconfig.host.json'), { references: [{ path: '../../core/shared' }] })
40
+ writeJson(join(split, 'tsconfig.client.json'), { references: [{ path: '../../core/shared' }] })
41
+ writeJson(join(root, 'tsconfig.host.json'), {
42
+ references: options.host.map(path => ({ path })),
43
+ })
44
+ writeJson(join(root, 'tsconfig.client.json'), {
45
+ references: options.client.map(path => ({ path })),
46
+ })
47
+ return root
48
+ }
49
+
50
+ describe('Project Reference compiler faces', () => {
51
+ it('allows neutral projects in either graph and matching split leaves', () => {
52
+ const root = workspaceFixture({
53
+ host: ['./packages/core/shared', './packages/api/split/tsconfig.host.json'],
54
+ client: ['./packages/core/shared', './packages/api/split/tsconfig.client.json'],
55
+ })
56
+
57
+ expect(collectProjectReferenceFaceViolations(root)).toEqual([])
58
+ })
59
+
60
+ it('rejects the opposite leaf and the solution root of a split project', () => {
61
+ const root = workspaceFixture({
62
+ host: [
63
+ './packages/api/split/tsconfig.host.json',
64
+ './packages/api/split/tsconfig.client.json',
65
+ ],
66
+ client: ['./packages/api/split'],
67
+ })
68
+
69
+ expect(collectProjectReferenceFaceViolations(root)).toEqual([
70
+ 'tsconfig.client.json: Project Reference "./packages/api/split" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead',
71
+ 'tsconfig.host.json: Project Reference "./packages/api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead',
72
+ ])
73
+ })
74
+
75
+ it('uses the referencing project face throughout the reachable graph', () => {
76
+ const root = workspaceFixture({
77
+ host: ['./packages/core/host-consumer'],
78
+ client: ['./packages/core/client-consumer'],
79
+ })
80
+ const hostConsumer = join(root, 'packages/core/host-consumer')
81
+ mkdirSync(hostConsumer, { recursive: true })
82
+ writeJson(join(hostConsumer, 'package.json'), { name: '@deepseek-ai/dsh-host-consumer' })
83
+ writeJson(join(hostConsumer, 'tsconfig.json'), {
84
+ extends: '../../../tsconfig.base.json',
85
+ references: [{ path: '../../api/split/tsconfig.client.json' }],
86
+ })
87
+ const clientConsumer = join(root, 'packages/core/client-consumer')
88
+ mkdirSync(clientConsumer, { recursive: true })
89
+ writeJson(join(clientConsumer, 'package.json'), { name: '@deepseek-ai/dsh-client-consumer' })
90
+ writeJson(join(clientConsumer, 'tsconfig.json'), {
91
+ extends: '../../../tsconfig.base.client.json',
92
+ references: [{ path: '../../api/split/tsconfig.host.json' }],
93
+ })
94
+
95
+ expect(collectProjectReferenceFaceViolations(root)).toEqual([
96
+ 'packages/core/client-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.host.json" enters split project packages/api/split from a Client config; reference "packages/api/split/tsconfig.client.json" instead',
97
+ 'packages/core/host-consumer/tsconfig.json: Project Reference "../../api/split/tsconfig.client.json" enters split project packages/api/split from a Host config; reference "packages/api/split/tsconfig.host.json" instead',
98
+ ])
99
+ })
100
+ })
scripts/publication-payload.spec.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ hasTypertRemoteNavigation,
4
+ isForbiddenPublicationFile,
5
+ validateTarballPayload,
6
+ } from './publication-payload.ts'
7
+
8
+ function validateFixtureTarball(files: readonly string[]): () => void {
9
+ return () => {
10
+ validateTarballPayload(files, 'fixture.tgz')
11
+ }
12
+ }
13
+
14
+ describe('publication payload policy', () => {
15
+ it.each([
16
+ 'lib/index.js',
17
+ 'lib/types/index.d.ts',
18
+ 'lib/styles/base.css',
19
+ ])('accepts %s', (file) => {
20
+ expect(isForbiddenPublicationFile(file)).toBe(false)
21
+ })
22
+
23
+ it.each([
24
+ 'src',
25
+ './src',
26
+ 'src/',
27
+ 'src/index.ts',
28
+ './src/index.ts',
29
+ String.raw`src\index.ts`,
30
+ 'lib/types/index.d.ts.map',
31
+ './lib/types/index.d.ts.map',
32
+ 'lib/typert.remote-client.d.ts.map',
33
+ 'lib/client.js.map',
34
+ './lib/client.js.map',
35
+ ])('rejects static manifest path %s', (file) => {
36
+ expect(isForbiddenPublicationFile(file)).toBe(true)
37
+ })
38
+
39
+ it('rejects source members in packed tarballs', () => {
40
+ expect(validateFixtureTarball([
41
+ 'package/package.json',
42
+ 'package/src/index.ts',
43
+ ])).toThrow('fixture.tgz publishes source file package/src/index.ts')
44
+ })
45
+
46
+ it('rejects source maps in packed tarballs', () => {
47
+ expect(validateFixtureTarball([
48
+ 'package/package.json',
49
+ 'package/lib/types/index.d.ts.map',
50
+ ])).toThrow('fixture.tgz publishes source map package/lib/types/index.d.ts.map')
51
+ expect(validateFixtureTarball([
52
+ 'package/package.json',
53
+ 'package/lib/typert.remote-client.d.ts.map',
54
+ ])).toThrow('fixture.tgz publishes source map package/lib/typert.remote-client.d.ts.map')
55
+ expect(validateFixtureTarball([
56
+ 'package/package.json',
57
+ 'package/lib/client.js.map',
58
+ ])).toThrow('fixture.tgz publishes source map package/lib/client.js.map')
59
+ })
60
+
61
+ it('accepts a clean packed tarball', () => {
62
+ expect(validateFixtureTarball([
63
+ 'package/package.json',
64
+ 'package/lib/index.js',
65
+ 'package/lib/types/index.d.ts',
66
+ 'package/lib/styles/base.css',
67
+ ])).not.toThrow()
68
+ })
69
+
70
+ it('recognizes only the canonical Host-for-Client export pair', () => {
71
+ expect(hasTypertRemoteNavigation({
72
+ exports: {
73
+ './remote': {
74
+ types: './lib/typert.remote-client.d.ts',
75
+ default: './lib/typert.remote-client.js',
76
+ },
77
+ },
78
+ })).toBe(true)
79
+ expect(hasTypertRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false)
80
+ })
81
+ })
scripts/publication-payload.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Publication payload policy shared by static manifests and packed tarballs. */
2
+
3
+ /**
4
+ * Whether a package manifest exports generated Host-for-Client metadata.
5
+ * @param manifest - parsed package manifest to inspect.
6
+ * @returns whether the canonical `./remote` export pair is present.
7
+ */
8
+ export function hasTypertRemoteNavigation(manifest: unknown): boolean {
9
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false
10
+ const exportsField = (manifest as Record<string, unknown>).exports
11
+ if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
12
+ const remote = (exportsField as Record<string, unknown>)['./remote']
13
+ if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false
14
+ const entry = remote as Record<string, unknown>
15
+ return entry.types === './lib/typert.remote-client.d.ts'
16
+ && entry.default === './lib/typert.remote-client.js'
17
+ }
18
+
19
+ /** Normalize a package manifest path or npm tarball member to its payload-relative path. */
20
+ function payloadPath(file: string): string {
21
+ const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '')
22
+ return normalized.startsWith('package/') ? normalized.slice('package/'.length) : normalized
23
+ }
24
+
25
+ /**
26
+ * Whether a package payload path exposes source or map intermediates. Maps
27
+ * serve editor navigation during development, where a workspace consumer
28
+ * resolves their source through the package link; a published map resolves
29
+ * nothing, so no payload publishes one.
30
+ * @param file - manifest path or tarball member to classify.
31
+ * @returns whether publishing this path is forbidden.
32
+ */
33
+ export function isForbiddenPublicationFile(file: string): boolean {
34
+ const normalized = payloadPath(file)
35
+ return normalized === 'src'
36
+ || normalized.startsWith('src/')
37
+ || normalized.endsWith('.d.ts.map')
38
+ || normalized.endsWith('.js.map')
39
+ }
40
+
41
+ /**
42
+ * Reject source and map members in a packed npm tarball.
43
+ * @param files - tarball members to validate.
44
+ * @param context - tarball identity named in the failure.
45
+ */
46
+ export function validateTarballPayload(files: readonly string[], context: string): void {
47
+ for (const file of files) {
48
+ if (!isForbiddenPublicationFile(file)) continue
49
+ const normalized = payloadPath(file)
50
+ if (normalized === 'src' || normalized.startsWith('src/')) {
51
+ throw new Error(`${context} publishes source file ${file}`)
52
+ }
53
+ throw new Error(`${context} publishes source map ${file}`)
54
+ }
55
+ }
scripts/publint-all.spec.ts ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { once } from 'node:events'
6
+ import { execa } from 'execa'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+
9
+ const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
10
+ const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
11
+ const roots: string[] = []
12
+ const children: Array<{ kill: () => void; closed: Promise<unknown> }> = []
13
+
14
+ afterEach(async () => {
15
+ // A test timeout can reach teardown before the test's pending await settles.
16
+ const ownedRoots = roots.splice(0)
17
+ await Promise.all(children.splice(0).map(async ({ kill, closed }) => {
18
+ kill()
19
+ await closed
20
+ }))
21
+ for (const root of ownedRoots) rmSync(root, { recursive: true, force: true })
22
+ })
23
+
24
+ function fixture(options: {
25
+ exportPath?: string
26
+ indexSource?: string
27
+ files?: Record<string, string>
28
+ } = {}): string {
29
+ const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
30
+ roots.push(root)
31
+ const packageDir = join(root, 'packages/core/probe')
32
+ mkdirSync(join(packageDir, 'lib'), { recursive: true })
33
+ writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
34
+ name: '@deepseek-ai/dsh-probe',
35
+ version: '0.0.1',
36
+ type: 'module',
37
+ license: 'MIT',
38
+ engines: { node: '>=22.19' },
39
+ sideEffects: false,
40
+ files: ['lib'],
41
+ exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
42
+ }, null, 2)}\n`)
43
+ writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
44
+ writeFileSync(join(packageDir, 'lib/index.js'), options.indexSource ?? 'export const probe = true\n')
45
+ for (const [path, source] of Object.entries(options.files ?? {})) {
46
+ mkdirSync(join(packageDir, path, '..'), { recursive: true })
47
+ writeFileSync(join(packageDir, path), source)
48
+ }
49
+ writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
50
+ return root
51
+ }
52
+
53
+ /** Own direct Node children until close; Vitest's signal supplies the lane deadline. */
54
+ function start(args: string[], signal: AbortSignal, cwd = repositoryRoot) {
55
+ const child = execa(process.execPath, args, {
56
+ cwd,
57
+ cancelSignal: signal,
58
+ killSignal: 'SIGKILL',
59
+ reject: false,
60
+ stdin: 'ignore',
61
+ stripFinalNewline: false,
62
+ })
63
+ // `error` is an outcome, not the completion edge for the process and its pipes.
64
+ const closed = new Promise<void>(resolve => child.nodeChildProcess.once('close', () => { resolve() }))
65
+ const result = Promise.all([child, closed]).then(([result]) => result)
66
+ children.push({ kill: () => { child.kill('SIGKILL') }, closed: result })
67
+ return { child, result }
68
+ }
69
+
70
+ function expectCompleted(result: Awaited<ReturnType<typeof start>['result']>) {
71
+ const diagnostics = [
72
+ `publint subprocess: error=${String(result.cause)}; signal=${String(result.signal)}; exitCode=${String(result.exitCode)}`,
73
+ `canceled=${result.isCanceled}; timedOut=${result.timedOut}`,
74
+ result.shortMessage ?? '',
75
+ `stdout:\n${result.stdout}`,
76
+ `stderr:\n${result.stderr}`,
77
+ ].join('\n')
78
+ expect(result.cause, diagnostics).toBeUndefined()
79
+ expect(result.isCanceled, diagnostics).toBe(false)
80
+ expect(result.timedOut, diagnostics).toBe(false)
81
+ expect(result.signal, diagnostics).toBeUndefined()
82
+ }
83
+
84
+ async function run(root: string, signal: AbortSignal) {
85
+ const { result } = start([
86
+ '--import', 'tsx', runner,
87
+ '--packages-root', root,
88
+ ], signal)
89
+ const completed = await result
90
+ expectCompleted(completed)
91
+ return completed
92
+ }
93
+
94
+ describe('publint package runner', () => {
95
+ it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
96
+ const deadline = new AbortController()
97
+ const active = [0, 1].map(() => start([
98
+ '-e', "process.stderr.write('probe stderr\\n'); process.stdout.write('ready\\n'); setInterval(() => {}, 1000)",
99
+ ], AbortSignal.any([signal, deadline.signal])))
100
+ const closed = active.map(() => false)
101
+ active.forEach(({ child }, index) => child.nodeChildProcess.once('close', () => { closed[index] = true }))
102
+ await Promise.all(active.map(({ child }) => once(child.stdout, 'data', { signal })))
103
+ expect(closed).toEqual([false, false])
104
+ // Start the deadline only after both children announce readiness; startup speed is not the oracle.
105
+ const timer = setTimeout(() => { deadline.abort(new DOMException('fixture deadline expired', 'TimeoutError')) }, 0)
106
+ try {
107
+ const results = await Promise.all(active.map(({ result }) => result))
108
+ expect(closed).toEqual([true, true])
109
+ for (const [index, result] of results.entries()) {
110
+ expect(result.isCanceled).toBe(true)
111
+ expect(() => { expectCompleted(result) }).toThrow(/publint subprocess: error=TimeoutError: fixture deadline expired; signal=/)
112
+ expect(() => { expectCompleted(result) }).toThrow(/canceled=true/)
113
+ expect(() => { expectCompleted(result) }).toThrow(/ready/)
114
+ expect(() => { expectCompleted(result) }).toThrow(/probe stderr/)
115
+ expect(() => process.kill(active[index]!.child.pid!, 0)).toThrow(/ESRCH/)
116
+ }
117
+ } finally {
118
+ clearTimeout(timer)
119
+ }
120
+ })
121
+
122
+ it('reports spawn errors before checking the expected exit code', async ({ signal }) => {
123
+ const { result } = start(['-e', ''], signal, join(fixture(), 'missing-cwd'))
124
+ const completed = await result
125
+ expect(completed.cause).toMatchObject({ code: 'ENOENT' })
126
+ expect(() => { expectCompleted(completed) }).toThrow(/publint subprocess: error=.*ENOENT.*; signal=undefined/)
127
+ })
128
+
129
+ it('lints recursively declared files from an in-memory publication view', async ({ signal }) => {
130
+ const result = await run(fixture(), signal)
131
+ expect(result.exitCode, result.stderr).toBe(0)
132
+ expect(result.stdout).toContain('linting 1 package(s)')
133
+ expect(result.stdout).toContain('All good!')
134
+ })
135
+
136
+ it('rejects an export that exists in the workspace but is not published', async ({ signal }) => {
137
+ const result = await run(fixture({ exportPath: './unpublished.js' }), signal)
138
+ expect(result.exitCode).toBe(1)
139
+ expect(result.stdout).toContain('unpublished.js')
140
+ })
141
+
142
+ it('rejects a public export whose built file is missing', async ({ signal }) => {
143
+ const result = await run(fixture({ exportPath: './lib/missing.js' }), signal)
144
+ expect(result.exitCode).toBe(1)
145
+ expect(result.stdout).toContain('missing.js')
146
+ })
147
+
148
+ it('accepts published relative JavaScript and CSS targets', async ({ signal }) => {
149
+ const result = await run(fixture({
150
+ indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
151
+ files: {
152
+ 'lib/helper.js': 'export const helper = true\n',
153
+ 'lib/theme.css': ':root {}\n',
154
+ },
155
+ }), signal)
156
+ expect(result.exitCode, result.stderr).toBe(0)
157
+ })
158
+
159
+ it('rejects unpublished relative JavaScript and CSS targets', async ({ signal }) => {
160
+ const result = await run(fixture({
161
+ indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
162
+ }), signal)
163
+ expect(result.exitCode).toBe(1)
164
+ expect(result.stderr).toContain('imports "./missing.js"')
165
+ expect(result.stderr).toContain('imports "./missing.css"')
166
+ })
167
+ })
scripts/publint-all.ts ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Run publint over the exact manifest-declared publication view of every package. */
2
+
3
+ import {
4
+ globSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ } from 'node:fs'
9
+ import { availableParallelism } from 'node:os'
10
+ import { dirname, posix, relative, resolve, sep } from 'node:path'
11
+ import { parseArgs } from 'node:util'
12
+ import { publint, type Message, type PackFile } from 'publint'
13
+ import { formatMessage } from 'publint/utils'
14
+ import ts from 'typescript'
15
+
16
+ const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
17
+ const repositoryRoot = resolve(import.meta.dirname, '..')
18
+ const { values: options } = parseArgs({
19
+ args: process.argv.slice(2),
20
+ options: { 'packages-root': { type: 'string' } },
21
+ })
22
+ const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
23
+
24
+ interface PackageTarget {
25
+ path: string
26
+ directory: string
27
+ manifest: PackageManifest
28
+ }
29
+
30
+ interface PackageManifest {
31
+ name?: string
32
+ files?: unknown
33
+ }
34
+
35
+ type PublintResult =
36
+ | {
37
+ path: string
38
+ status: 'passed'
39
+ messages: Message[]
40
+ closureViolations: string[]
41
+ manifest: Record<string, unknown>
42
+ }
43
+ | {
44
+ path: string
45
+ status: 'failed'
46
+ messages: Message[]
47
+ closureViolations: string[]
48
+ manifest: Record<string, unknown>
49
+ failure?: string
50
+ }
51
+
52
+ function workspacePackages(): PackageTarget[] {
53
+ return globSync('packages/*/*/package.json', { cwd: packagesRoot })
54
+ .sort()
55
+ .map((manifestPath) => {
56
+ const absoluteManifestPath = resolve(packagesRoot, manifestPath)
57
+ const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8')) as PackageManifest
58
+ return { path: dirname(manifestPath), directory: dirname(absoluteManifestPath), manifest }
59
+ })
60
+ }
61
+
62
+ function publintConcurrency(total: number): number {
63
+ if (total === 0) return 0
64
+
65
+ const raw = process.env[CONCURRENCY_ENV]
66
+ if (raw !== undefined && raw !== '') {
67
+ const parsed = Number.parseInt(raw, 10)
68
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
69
+ throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
70
+ }
71
+ return Math.min(total, parsed)
72
+ }
73
+
74
+ return Math.min(total, availableParallelism())
75
+ }
76
+
77
+ function publicationFiles(target: PackageTarget): PackFile[] {
78
+ const paths = new Set<string>()
79
+ addPath(resolve(target.directory, 'package.json'), paths)
80
+ const declared = Array.isArray(target.manifest.files)
81
+ ? target.manifest.files.filter((value): value is string => typeof value === 'string')
82
+ : []
83
+ for (const pattern of [
84
+ ...declared,
85
+ 'README*',
86
+ 'LICENSE*',
87
+ 'LICENCE*',
88
+ 'CHANGELOG*',
89
+ 'CHANGES*',
90
+ 'HISTORY*',
91
+ 'NOTICE*',
92
+ ]) {
93
+ for (const match of globSync(pattern, { cwd: target.directory })) {
94
+ addPath(resolve(target.directory, match), paths)
95
+ }
96
+ }
97
+
98
+ return [...paths]
99
+ .sort()
100
+ .map(path => ({
101
+ name: `package/${relative(target.directory, path).split(sep).join('/')}`,
102
+ data: readFileSync(path),
103
+ }))
104
+ }
105
+
106
+ function addPath(path: string, paths: Set<string>): void {
107
+ const stat = statSync(path)
108
+ if (stat.isDirectory()) {
109
+ // readdirSync, not globSync: `**/*` skips dot-prefixed segments, but npm
110
+ // pack publishes dotfiles inside included directories, and this view must
111
+ // match what npm publishes.
112
+ for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
113
+ if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name))
114
+ }
115
+ } else if (stat.isFile()) {
116
+ paths.add(path)
117
+ }
118
+ }
119
+
120
+ interface RelativeImport {
121
+ specifier: string
122
+ line: number
123
+ }
124
+
125
+ /** Return relative imports whose targets are absent from the publication view. */
126
+ function publicationClosureViolations(target: PackageTarget, files: readonly PackFile[]): string[] {
127
+ const published = new Set(files.map(file => file.name))
128
+ const violations: string[] = []
129
+ for (const file of files) {
130
+ if (!/\.(?:js|mjs|cjs)$/.test(file.name)) continue
131
+ const bytes = file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : file.data
132
+ const source = typeof bytes === 'string' ? bytes : Buffer.from(bytes).toString('utf8')
133
+ for (const imported of relativeImports(file.name, source)) {
134
+ const resolved = posix.normalize(posix.join(posix.dirname(file.name), imported.specifier))
135
+ if (resolutionCandidates(resolved).some(candidate => published.has(candidate))) continue
136
+ violations.push(
137
+ `${target.path}/${file.name.slice('package/'.length)}:${String(imported.line)}`
138
+ + ` imports ${JSON.stringify(imported.specifier)}, but ${target.manifest.name ?? target.path}`
139
+ + ` does not publish ${JSON.stringify(resolved.slice('package/'.length))}`,
140
+ )
141
+ }
142
+ }
143
+ return violations
144
+ }
145
+
146
+ /** Paths a relative JavaScript module request can resolve to in a published package. */
147
+ function resolutionCandidates(target: string): string[] {
148
+ const base = target.replace(/\/+$/, '')
149
+ return [
150
+ target,
151
+ ...['.js', '.mjs', '.cjs', '/index.js', '/index.mjs', '/index.cjs'].map(suffix => base + suffix),
152
+ ]
153
+ }
154
+
155
+ /** Extract relative static imports, re-exports, dynamic imports, and requires. */
156
+ function relativeImports(file: string, sourceText: string): RelativeImport[] {
157
+ const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
158
+ const imports: RelativeImport[] = []
159
+ const record = (node: ts.Node, literal: ts.Expression | undefined): void => {
160
+ if (literal === undefined || !ts.isStringLiteralLike(literal) || !literal.text.startsWith('.')) return
161
+ imports.push({
162
+ specifier: literal.text,
163
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
164
+ })
165
+ }
166
+ const visit = (node: ts.Node): void => {
167
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
168
+ record(node, node.moduleSpecifier)
169
+ } else if (ts.isCallExpression(node)
170
+ && (node.expression.kind === ts.SyntaxKind.ImportKeyword
171
+ || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
172
+ record(node, node.arguments[0])
173
+ }
174
+ ts.forEachChild(node, visit)
175
+ }
176
+ visit(source)
177
+ return imports
178
+ }
179
+
180
+ /**
181
+ * publint reads the CommonJS interop preamble inside the prebuilt browser
182
+ * bundles and reports CJS-written-as-ESM. Node never resolves those files:
183
+ * `lib/client.js` is evaluated by the page module system as a classic script,
184
+ * and `lib/worker.js` by `new Worker(url, { type: 'module' })` — both outside
185
+ * the Node resolution publint models. Exactly that verdict on exactly those
186
+ * files is suppressed; every other publint error stays fatal.
187
+ */
188
+ function isBrowserBundleFormatFalsePositive(message: Message): boolean {
189
+ if (message.code !== 'FILE_INVALID_FORMAT') return false
190
+ const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
191
+ const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
192
+ return /(^|\/)lib\/(client|worker)\.js$/.test(filePath)
193
+ || /(^|\/)\.\/(client|worker)$/.test(exportKey)
194
+ }
195
+
196
+ async function runPublint(target: PackageTarget): Promise<PublintResult> {
197
+ try {
198
+ const files = publicationFiles(target)
199
+ const closureViolations = publicationClosureViolations(target, files)
200
+ const result = await publint({
201
+ pkgDir: 'package',
202
+ pack: { files },
203
+ })
204
+ const manifest = result.pkg as Record<string, unknown>
205
+ const messages = result.messages.filter(message => !isBrowserBundleFormatFalsePositive(message))
206
+ return messages.some(message => message.type === 'error') || closureViolations.length > 0
207
+ ? { path: target.path, status: 'failed', messages, closureViolations, manifest }
208
+ : { path: target.path, status: 'passed', messages, closureViolations, manifest }
209
+ } catch (error: unknown) {
210
+ return {
211
+ path: target.path,
212
+ status: 'failed',
213
+ messages: [],
214
+ closureViolations: [],
215
+ manifest: target.manifest as Record<string, unknown>,
216
+ failure: error instanceof Error ? error.message : String(error),
217
+ }
218
+ }
219
+ }
220
+
221
+ async function runAll(targets: PackageTarget[], concurrency: number): Promise<PublintResult[]> {
222
+ let next = 0
223
+ const results: Array<PublintResult | undefined> = []
224
+ await Promise.all(Array.from({ length: concurrency }, async () => {
225
+ for (;;) {
226
+ const index = next
227
+ next += 1
228
+ const target = targets[index]
229
+ if (target === undefined) return
230
+ results[index] = await runPublint(target)
231
+ }
232
+ }))
233
+
234
+ return targets.map((target, index) => {
235
+ const result = results[index]
236
+ if (result === undefined) throw new Error(`publint-all: missing result for ${target.path}.`)
237
+ return result
238
+ })
239
+ }
240
+
241
+ function printResult(result: PublintResult): void {
242
+ console.log(`Running publint for ${result.path}...`)
243
+ if ('failure' in result) console.error(result.failure)
244
+ for (const message of result.messages) {
245
+ console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
246
+ }
247
+ for (const violation of result.closureViolations) console.error(violation)
248
+ if (result.status === 'passed' && result.messages.length === 0 && result.closureViolations.length === 0) {
249
+ console.log('All good!')
250
+ }
251
+ }
252
+
253
+ const packages = workspacePackages()
254
+ const concurrency = publintConcurrency(packages.length)
255
+ console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)
256
+
257
+ const results = await runAll(packages, concurrency)
258
+ for (const result of results) printResult(result)
259
+
260
+ if (results.some(result => result.status === 'failed')) process.exit(1)
scripts/render-persistence-schema.ts ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Readable, linked persistence schemas rendered from the fingerprint inventory. */
2
+
3
+ import { githubSlug } from './verify-md-links.ts'
4
+ import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
5
+ import {
6
+ canonicalizeSchema,
7
+ schemaChildren,
8
+ schemaDigest,
9
+ type CanonicalSchema,
10
+ type PersistenceSchemaInventory,
11
+ type PersistenceType,
12
+ type SchemaNode,
13
+ } from './persistence-schema-model.ts'
14
+
15
+ interface TypeDisplay {
16
+ readonly type: PersistenceType
17
+ readonly label: string
18
+ readonly anchor: string
19
+ }
20
+
21
+ function code(text: string): string {
22
+ return '`' + text.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
23
+ }
24
+
25
+ function sourcePath(source: string): string {
26
+ return source.replace(/:\d+(?::\d+)?$/u, '')
27
+ }
28
+
29
+ function nodeAt(schema: CanonicalSchema, index: number): SchemaNode {
30
+ const node = schema.nodes[index]
31
+ if (!node) throw new Error(`persistence catalog: missing schema node ${String(index)}`)
32
+ return node
33
+ }
34
+
35
+ function childPath(node: SchemaNode, position: number): string {
36
+ switch (node.kind) {
37
+ case 'object': return node.properties[position]?.name ?? `index-${String(position - node.properties.length)}`
38
+ case 'array': return 'item'
39
+ case 'tuple': return `item-${String(position)}`
40
+ case 'union': return `variant-${String(position)}`
41
+ case 'primitive':
42
+ case 'literal':
43
+ case 'opaque': return String(position)
44
+ default: return assertNever(node)
45
+ }
46
+ }
47
+
48
+ function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
49
+ const paths = new Map<string, string>()
50
+ for (const root of inventory.roots) {
51
+ const seen = new Set<number>()
52
+ const visit = (index: number, path: string): void => {
53
+ if (seen.has(index)) return
54
+ seen.add(index)
55
+ const digest = schemaDigest(canonicalizeSchema(root.schema.nodes, index))
56
+ if (!paths.has(digest)) paths.set(digest, path)
57
+ const node = nodeAt(root.schema, index)
58
+ schemaChildren(node).forEach((child, position) => { visit(child, `${path}.${childPath(node, position)}`) })
59
+ }
60
+ visit(0, root.key)
61
+ }
62
+ const labels = inventory.types.map((type) => {
63
+ const node = nodeAt(type.schema, 0)
64
+ const label = node.kind === 'primitive' ? node.type
65
+ : node.kind === 'literal' ? JSON.stringify(node.value)
66
+ : node.kind === 'opaque' ? node.reason
67
+ : type.names[0] ?? paths.get(type.digest) ?? node.kind
68
+ return { type, label }
69
+ })
70
+ const counts = new Map<string, number>()
71
+ for (const { label } of labels) counts.set(label, (counts.get(label) ?? 0) + 1)
72
+ const used = new Set<string>()
73
+ return new Map(labels.map(({ type, label: original }) => {
74
+ const label = (counts.get(original) ?? 0) > 1
75
+ ? `${original} (${type.sources[0] ? sourcePath(type.sources[0]) : paths.get(type.digest) ?? 'anonymous'})`
76
+ : original
77
+ const base = `persistence-type-${githubSlug(label)}`
78
+ let anchor = base
79
+ for (let suffix = 2; used.has(anchor); suffix += 1) anchor = `${base}-${String(suffix)}`
80
+ used.add(anchor)
81
+ return [type.digest, { type, label, anchor }]
82
+ }))
83
+ }
84
+
85
+ function reference(digest: string, entries: ReadonlyMap<string, TypeDisplay>): string {
86
+ const entry = entries.get(digest)
87
+ if (!entry) throw new Error(`persistence catalog: reachable type ${digest} is absent from the inventory`)
88
+ return `[${code(entry.label)}](#${entry.anchor})`
89
+ }
90
+
91
+ function typeExpression(
92
+ schema: CanonicalSchema,
93
+ index: number,
94
+ entries: ReadonlyMap<string, TypeDisplay>,
95
+ locale: PersistenceCatalogLocale,
96
+ ): string {
97
+ const node = nodeAt(schema, index)
98
+ if (node.kind === 'primitive') return code(node.type)
99
+ if (node.kind === 'literal') return code(JSON.stringify(node.value))
100
+ if (node.kind === 'opaque') return `${code(node.reason)}${persistenceCatalogText[locale].opaque}`
101
+ return reference(schemaDigest(canonicalizeSchema(schema.nodes, index)), entries)
102
+ }
103
+
104
+ function definition(
105
+ entry: TypeDisplay,
106
+ entries: ReadonlyMap<string, TypeDisplay>,
107
+ locale: PersistenceCatalogLocale,
108
+ sourceLink: (source: string) => string | undefined,
109
+ headingLevel: number,
110
+ ): string[] {
111
+ const text = persistenceCatalogText[locale]
112
+ const schema = entry.type.schema
113
+ const node = nodeAt(schema, 0)
114
+ const lines = [`<a id="${entry.anchor}"></a>`, '', `${'#'.repeat(headingLevel)} ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
115
+ if (entry.type.sources.length > 0) {
116
+ lines.push(`${text.sources}${entry.type.sources.map((source) => {
117
+ const href = sourceLink(sourcePath(source))
118
+ return href === undefined ? code(source) : `[${code(source)}](${href})`
119
+ }).join(' · ')}`, '')
120
+ }
121
+ const expression = (index: number): string => typeExpression(schema, index, entries, locale)
122
+ switch (node.kind) {
123
+ case 'object':
124
+ if (node.properties.length === 0 && node.indices.length === 0) lines.push(text.emptyObject, '')
125
+ else {
126
+ lines.push(text.propertyColumns, '|---|---|---|')
127
+ for (const property of node.properties) {
128
+ lines.push(`| ${code(property.name)} | ${property.optional ? text.optional : text.required} | ${expression(property.type)} |`)
129
+ }
130
+ for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
131
+ lines.push('')
132
+ }
133
+ break
134
+ case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
135
+ case 'tuple':
136
+ lines.push(text.positionColumns, '|---|---|---|')
137
+ node.elements.forEach((element, index) => {
138
+ const presence = element.rest ? text.rest : element.optional ? text.optional : text.required
139
+ lines.push(`| ${String(index)} | ${presence} | ${expression(element.type)} |`)
140
+ })
141
+ lines.push('')
142
+ break
143
+ case 'union':
144
+ lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
145
+ break
146
+ case 'primitive': lines.push(code(node.type), ''); break
147
+ case 'literal': lines.push(code(JSON.stringify(node.value)), ''); break
148
+ case 'opaque': lines.push(`${code(node.reason)}${text.opaqueExplanation}`, ''); break
149
+ default: assertNever(node)
150
+ }
151
+ return lines
152
+ }
153
+
154
+ /**
155
+ * Render every tracked root with its exact digest and resolved type reference.
156
+ * @param inventory - complete current-source schemas and declaration metadata.
157
+ * @param locale - generated document language.
158
+ * @param introduction - paragraphs before the root table; defaults to current-source links.
159
+ * @param headingLevel - section depth within the containing reference.
160
+ * @returns Markdown index including the history and contributor workflow links.
161
+ */
162
+ export function renderPersistenceSchemaIndex(
163
+ inventory: PersistenceSchemaInventory,
164
+ locale: PersistenceCatalogLocale = 'en',
165
+ introduction: readonly string[] = [persistenceCatalogText[locale].fingerprintsIntro, persistenceCatalogText[locale].historyIntro],
166
+ headingLevel: 2 | 3 = 2,
167
+ ): string {
168
+ const entries = displays(inventory)
169
+ const text = persistenceCatalogText[locale]
170
+ return [
171
+ `${'#'.repeat(headingLevel)} ${text.fingerprints}`, '', ...introduction.flatMap(paragraph => [paragraph, '']),
172
+ text.rootColumns, '|---|---|---|---|',
173
+ ...inventory.roots.map(root => `| ${code(root.key)} | ${root.kind} | ${code(root.digest)} | ${reference(root.digest, entries)} |`), '',
174
+ ].join('\n')
175
+ }
176
+
177
+ /**
178
+ * Render every reachable type once, with links for shared and recursive definitions.
179
+ * @param inventory - complete current-source schemas and declaration metadata.
180
+ * @param locale - generated document language.
181
+ * @param sourceLink - source path to URL; undefined keeps historical locations as text.
182
+ * @param headingLevel - section depth; individual definitions use the next heading level.
183
+ * @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
184
+ */
185
+ export function renderPersistenceSchemaDefinitions(
186
+ inventory: PersistenceSchemaInventory,
187
+ locale: PersistenceCatalogLocale = 'en',
188
+ sourceLink: (source: string) => string | undefined = source => `../${source}`,
189
+ headingLevel: 2 | 3 = 2,
190
+ ): string {
191
+ const entries = displays(inventory)
192
+ const text = persistenceCatalogText[locale]
193
+ const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
194
+ return [
195
+ `${'#'.repeat(headingLevel)} ${text.definitions}`, '', text.definitionsIntro, '',
196
+ ...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink, headingLevel + 1)),
197
+ ].join('\n')
198
+ }
199
+
200
+ function assertNever(value: never): never {
201
+ throw new Error(`persistence catalog: unsupported type ${JSON.stringify(value)}`)
202
+ }
scripts/repo-files.ts ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Shared repository file discovery and line-oriented reference scanning. */
2
+
3
+ import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
4
+ import { join, relative, resolve, sep } from 'node:path'
5
+
6
+ /** One authored path plus its canonical target for symlink deduplication. */
7
+ export interface RepoFile {
8
+ /** Absolute path matched by the caller's glob. */
9
+ abs: string
10
+ /** Absolute canonical path used only for deduplication. */
11
+ real: string
12
+ }
13
+
14
+ /** A rejected line-oriented repository reference. */
15
+ export interface ReferenceViolation {
16
+ /** Repo-relative file containing the reference. */
17
+ file: string
18
+ /** 1-based line containing the reference. */
19
+ line: number
20
+ /** Normalized reference text. */
21
+ ref: string
22
+ }
23
+
24
+ /** Whether a repository path is frozen Agent Note history, not evolving source prose. */
25
+ export function isArchivedAgentNotePath(path: string): boolean {
26
+ return path.replaceAll('\\', '/').startsWith('.agents/notes/archived/')
27
+ }
28
+
29
+ /**
30
+ * Whether a pattern segment matches a directory or file name. Supports `*` and
31
+ * `?` inside a segment and mirrors node's glob `dot: false`: a segment whose
32
+ * first character is a wildcard does not match dot names. `**` is handled as a
33
+ * whole segment by the walker, never here.
34
+ */
35
+ function segmentMatches(pattern: string, name: string): boolean {
36
+ if (name.startsWith('.') && (pattern.startsWith('*') || pattern.startsWith('?'))) return false
37
+ let expression = ''
38
+ for (const character of pattern) {
39
+ if (character === '*') expression += '.*'
40
+ else if (character === '?') expression += '.'
41
+ else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
42
+ }
43
+ return new RegExp(`^${expression}$`).test(name)
44
+ }
45
+
46
+ /**
47
+ * Reject glob syntax the walker does not model. Node's `fs.glob` understands
48
+ * character classes, brace alternation, and extglobs; expanding those
49
+ * silently as literals would match nothing and quietly shrink a gate's
50
+ * corpus, so a pattern segment using them fails loudly instead. Pure literal
51
+ * segments must therefore contain none of the rejected metacharacters
52
+ * either, even where node glob would read them literally.
53
+ */
54
+ const UNSUPPORTED_GLOB = /[[\]{}()!+@\\]/
55
+
56
+ function assertSupportedSegment(segment: string): void {
57
+ if (UNSUPPORTED_GLOB.test(segment)) {
58
+ throw new TypeError(`repo-files walker does not model glob syntax in segment: ${segment}`)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Walk `root` matching one repository-relative glob without node's `fs.glob`.
64
+ * The repository's own walker exists because node's internal glob, from some
65
+ * 24.x releases, lstat-probes `<matched>/<next segment>` for symlinked files
66
+ * while expanding `**` and throws ENOTDIR instead of skipping (observed on
67
+ * node 24.13.0 scanning `snapshots/acp/image-compaction`'s symlinked
68
+ * `system-prompt.expected.md`). The walker decides directoryhood from dirent
69
+ * types and stat results, never by probing a path under a file, so the same
70
+ * tree enumerates identically on every node version.
71
+ *
72
+ * Segment semantics: literal segments resolve through `stat`, so a literal
73
+ * naming a symlinked directory enters that directory exactly as node glob
74
+ * does — including a literal repeated across segments, which node glob
75
+ * resolves each time; wildcard segments and `**` resolve through dirent
76
+ * types, so they never enter symlinked directories and never match dot
77
+ * names. Node documents `follow: false` only for `**` expansion, so the
78
+ * wildcard-side behavior is this walker's own contract, pinned by
79
+ * repo-files.spec.ts, rather than a cross-version node guarantee; the four
80
+ * consuming gates' current patterns contain no wildcard segment over a
81
+ * symlinked directory, so the corpus is unchanged. `**` spans zero or more
82
+ * directories. The final segment matches files and symlinks; a broken or
83
+ * cyclic symlink then fails loudly in the caller's realpathSync rather than
84
+ * shrinking the scanned corpus, while a broken symlink under a literal
85
+ * non-final segment matches nothing, again as node glob does. Traversal
86
+ * terminates without a visited set: each literal segment consumes one pattern
87
+ * segment per recursion, and `**` recurses only into real directories, which
88
+ * form a finite tree because symlinked directories are never expanded by it.
89
+ * Pattern segments support `*`, `?`, and literals that contain none of
90
+ * `[]{}()!+@\`; other node-glob syntax, including a trailing `**`, `..`, and
91
+ * empty segments, is rejected loudly up front, while a `.` segment is folded
92
+ * away exactly as node glob normalizes it. Returns repository-relative slash
93
+ * paths, sorted.
94
+ */
95
+ function expandGlob(root: string, pattern: string): string[] {
96
+ const segments: string[] = []
97
+ for (const segment of pattern.split('/')) {
98
+ if (segment === '.') {
99
+ // Node glob normalizes a `.` segment away; dropping it here keeps
100
+ // `./README.md` and `a/./b` matching exactly as node glob does instead
101
+ // of silently matching nothing.
102
+ continue
103
+ }
104
+ if (segment === '..') {
105
+ // A `..` segment escapes the scanned root and interacts with `**` in
106
+ // ways node glob special-cases; no gate pattern uses one, so the walker
107
+ // rejects the form loudly instead of silently matching nothing.
108
+ throw new TypeError(`repo-files walker does not model .. segments in pattern: ${pattern}`)
109
+ }
110
+ if (segment === '') {
111
+ // A leading, doubled, or trailing slash yields an empty segment that
112
+ // can never match an entry; node glob would tolerate the form, so the
113
+ // walker must reject it loudly instead of silently returning nothing.
114
+ throw new TypeError(`repo-files walker does not model empty segments in pattern: ${pattern}`)
115
+ }
116
+ segments.push(segment)
117
+ }
118
+ if (segments.length === 0) {
119
+ throw new TypeError(`repo-files walker does not model a pattern with no segments: ${pattern}`)
120
+ }
121
+ for (const segment of segments) {
122
+ if (segment !== '**') assertSupportedSegment(segment)
123
+ }
124
+ // A trailing `**` would match files, directories, and symlinks below the
125
+ // prefix; the walker models `**` only as a directory-spanning segment, so
126
+ // it must reject the form loudly instead of silently returning nothing.
127
+ if (segments[segments.length - 1] === '**') {
128
+ throw new TypeError(`repo-files walker does not model a trailing ** segment in pattern: ${pattern}`)
129
+ }
130
+ const out: string[] = []
131
+
132
+ const visit = (dirAbs: string, dirRel: string, index: number): void => {
133
+ if (index >= segments.length) return
134
+ if (segments[index] !== '**') {
135
+ visitSegment(dirAbs, dirRel, index)
136
+ return
137
+ }
138
+ // `**` consumes zero directories here and one directory per recursion.
139
+ visitSegment(dirAbs, dirRel, index + 1)
140
+ for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
141
+ if (entry.name.startsWith('.') || !entry.isDirectory()) continue
142
+ visit(join(dirAbs, entry.name), dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`, index)
143
+ }
144
+ }
145
+
146
+ const visitSegment = (dirAbs: string, dirRel: string, index: number): void => {
147
+ if (index >= segments.length) return
148
+ const segment = segments[index]
149
+ if (segment === undefined) return
150
+ if (segment === '**') {
151
+ visit(dirAbs, dirRel, index)
152
+ return
153
+ }
154
+ const last = index === segments.length - 1
155
+ for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
156
+ if (!segmentMatches(segment, entry.name)) continue
157
+ const childAbs = join(dirAbs, entry.name)
158
+ const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`
159
+ if (last) {
160
+ // Files and symlinks match; a broken or cyclic symlink then fails
161
+ // loudly in the caller's realpathSync exactly as node glob did,
162
+ // rather than silently shrinking the scanned corpus.
163
+ if (entry.isFile() || entry.isSymbolicLink()) out.push(childRel)
164
+ continue
165
+ }
166
+ if (entry.isDirectory()) {
167
+ visitSegment(childAbs, childRel, index + 1)
168
+ continue
169
+ }
170
+ // A literal non-final segment resolves through stat like node glob's,
171
+ // so it enters a symlinked directory; wildcard segments never reach
172
+ // this branch because they are matched from dirent types above.
173
+ if (!entry.isSymbolicLink() || hasWildcard(segment)) continue
174
+ let target: ReturnType<typeof statSync>
175
+ try {
176
+ target = statSync(childAbs)
177
+ } catch {
178
+ // A broken symlink matches nothing under a literal segment, as node
179
+ // glob silently returns no match for it.
180
+ continue
181
+ }
182
+ if (!target.isDirectory()) continue
183
+ visitSegment(childAbs, childRel, index + 1)
184
+ }
185
+ }
186
+
187
+ visit(root, '.', 0)
188
+ return out.sort()
189
+ }
190
+
191
+ /** Whether a pattern segment contains `*` or `?` and is therefore wildcard. */
192
+ function hasWildcard(segment: string): boolean {
193
+ return segment.includes('*') || segment.includes('?')
194
+ }
195
+
196
+ /**
197
+ * Expand repository-relative globs and deduplicate symlinked files.
198
+ * @param root - absolute repository root.
199
+ * @param patterns - repository-relative glob patterns, processed in order.
200
+ * @param isExcluded - optional predicate over each matched relative path.
201
+ * @returns matched files in stable first-seen order.
202
+ */
203
+ export function uniqueRepoFiles(
204
+ root: string,
205
+ patterns: readonly string[],
206
+ isExcluded: (relativePath: string) => boolean = () => false,
207
+ ): RepoFile[] {
208
+ const seen = new Set<string>()
209
+ const files: RepoFile[] = []
210
+ for (const pattern of patterns) {
211
+ for (const repoPath of expandGlob(root, pattern)) {
212
+ if (isExcluded(repoPath)) continue
213
+ const abs = resolve(root, repoPath)
214
+ const real = realpathSync(abs)
215
+ if (seen.has(real)) continue
216
+ seen.add(real)
217
+ files.push({ abs, real })
218
+ }
219
+ }
220
+ return files
221
+ }
222
+
223
+ /**
224
+ * Scan regex matches line by line and return the normalized matches rejected by
225
+ * a caller predicate.
226
+ * @param root - absolute repository root used for violation paths.
227
+ * @param absPath - absolute text-file path to scan.
228
+ * @param pattern - global regex matched independently against each line.
229
+ * @param normalize - maps raw regex text to the reference the gate evaluates.
230
+ * @param isViolation - returns true when the normalized reference is invalid.
231
+ * @returns every rejected reference in source order.
232
+ */
233
+ export function findReferenceViolations(
234
+ root: string,
235
+ absPath: string,
236
+ pattern: RegExp,
237
+ normalize: (raw: string) => string,
238
+ isViolation: (ref: string) => boolean,
239
+ ): ReferenceViolation[] {
240
+ const file = relative(root, absPath).split(sep).join('/')
241
+ const out: ReferenceViolation[] = []
242
+ const lines = readFileSync(absPath, 'utf8').split('\n')
243
+ for (let i = 0; i < lines.length; i++) {
244
+ const line = lines[i]
245
+ if (line === undefined) continue
246
+ for (const match of line.matchAll(pattern)) {
247
+ const ref = normalize(match[0])
248
+ if (isViolation(ref)) out.push({ file, line: i + 1, ref })
249
+ }
250
+ }
251
+ return out
252
+ }
scripts/run-gates.spec.ts ADDED
@@ -0,0 +1,1029 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it, vi, type MockInstance } from 'vitest'
3
+ import {
4
+ cliGateOptions,
5
+ collectDescendants,
6
+ defaultConcurrency,
7
+ formatGateResultReason,
8
+ gatesForMode,
9
+ parsePidPpidLines,
10
+ runGate,
11
+ runGates,
12
+ taskkillArgs,
13
+ type Gate,
14
+ type GateResult,
15
+ } from './run-gates.ts'
16
+
17
+ /**
18
+ * Capture output a gate streams through runGate's streamOutput path.
19
+ * @returns the accumulated chunks and the stdout spy to restore in finally.
20
+ */
21
+ function captureStreamedOutput(): { writes: string[]; write: MockInstance } {
22
+ const writes: string[] = []
23
+ const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
24
+ writes.push(String(chunk))
25
+ return true
26
+ })
27
+ return { writes, write }
28
+ }
29
+
30
+ /**
31
+ * A process has stopped executing when its /proc entry is gone, or when it
32
+ * lingers as a zombie ('Z') — an un-reaped but dead entry still answers
33
+ * kill(pid, 0), so existence is not a liveness check. Non-Linux falls back to
34
+ * kill(pid, 0), whose ESRCH means the process is gone.
35
+ */
36
+ function procStopped(pid: number): boolean {
37
+ if (process.platform === 'linux') {
38
+ try {
39
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
40
+ return /\)\s+Z\s/.test(stat)
41
+ } catch {
42
+ return true
43
+ }
44
+ }
45
+ try {
46
+ process.kill(pid, 0)
47
+ return false
48
+ } catch {
49
+ return true
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Wait until the captured output contains `marker` and the grandchild pid the
55
+ * gate printed, then return that pid.
56
+ * @param writes - chunks captured from the gate's streamed stdout.
57
+ * @param marker - the output line that proves the gate reached the abort point.
58
+ * @param deadline - fail the wait when exceeded.
59
+ * @returns the grandchild pid printed by the gate script.
60
+ */
61
+ async function waitForGrandchildPid(writes: string[], marker: string, deadline: number): Promise<number> {
62
+ let pid: number | undefined
63
+ while ((pid === undefined || !writes.join('').includes(marker)) && Date.now() < deadline) {
64
+ const match = writes.join('').match(/grandchild:(\d+)/)
65
+ if (match !== null) pid = Number(match[1])
66
+ await new Promise(resolve => setTimeout(resolve, 50))
67
+ }
68
+ expect(pid ?? 0).toBeGreaterThan(0)
69
+ expect(writes.join('')).toContain(marker)
70
+ return pid!
71
+ }
72
+
73
+ /**
74
+ * Abort the run and assert it settles marked aborted with the grandchild no
75
+ * longer executing — the abort path must have signalled it from the captured
76
+ * descendant list rather than settling over a live orphan.
77
+ * @param promise - the pending `runGate` promise.
78
+ * @param controller - the signal source to abort.
79
+ * @param pid - the grandchild pid the gate script printed.
80
+ */
81
+ async function abortAndExpectTreeStopped(promise: Promise<GateResult>, controller: AbortController, pid: number): Promise<void> {
82
+ controller.abort()
83
+ const result = await promise
84
+ expect(result.aborted).toBe(true)
85
+ const stopDeadline = Date.now() + 8000
86
+ while (!procStopped(pid) && Date.now() < stopDeadline) {
87
+ await new Promise(resolve => setTimeout(resolve, 50))
88
+ }
89
+ expect(procStopped(pid)).toBe(true)
90
+ }
91
+
92
+
93
+ function gate(id: string, options: Partial<Gate> = {}): Gate {
94
+ return {
95
+ id,
96
+ label: id,
97
+ displayCommand: `run ${id}`,
98
+ command: process.execPath,
99
+ args: ['-e', ''],
100
+ ...options,
101
+ }
102
+ }
103
+
104
+ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult {
105
+ return {
106
+ gate: subject,
107
+ status,
108
+ durationMs: 10,
109
+ output: [],
110
+ exitCode: status === 'passed' ? 0 : 1,
111
+ signalCode: null,
112
+ }
113
+ }
114
+
115
+ function withPnpmEntrypoint<T>(action: () => T, entrypoint = '/private/pnpm.cjs'): T {
116
+ const previous = process.env.npm_execpath
117
+ process.env.npm_execpath = entrypoint
118
+ try {
119
+ return action()
120
+ } finally {
121
+ if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
122
+ else process.env.npm_execpath = previous
123
+ }
124
+ }
125
+
126
+ function withEnv<T>(name: string, value: string | undefined, action: () => T): T {
127
+ const previous = process.env[name]
128
+ if (value === undefined) Reflect.deleteProperty(process.env, name)
129
+ else process.env[name] = value
130
+ try {
131
+ return action()
132
+ } finally {
133
+ if (previous === undefined) Reflect.deleteProperty(process.env, name)
134
+ else process.env[name] = previous
135
+ }
136
+ }
137
+
138
+ describe('gate graph validation', () => {
139
+ it.each([
140
+ 'ci-primary',
141
+ 'ci-linux-primary',
142
+ 'ci-static',
143
+ 'ci-lint-contracts-ready',
144
+ 'ci-coverage',
145
+ 'ci-bench',
146
+ 'ci-snapshot',
147
+ 'ci-artifacts',
148
+ 'ci-consumers',
149
+ 'ci-windows-blocking',
150
+ 'ci-windows-complete',
151
+ 'ci-windows-observational',
152
+ 'node-compat',
153
+ 'check-all',
154
+ 'hygiene',
155
+ 'doc-sync',
156
+ 'doc-quick',
157
+ ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => {
158
+ const subject = withPnpmEntrypoint(() => gatesForMode(mode))
159
+ const execute = vi.fn(async (item: Gate) => resultFor(item))
160
+
161
+ await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
162
+ })
163
+
164
+ it('builds the native addon before benchmarks through the ci-bench script chain', () => {
165
+ const subject = withPnpmEntrypoint(() => gatesForMode('ci-bench'))
166
+ const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
167
+ scripts: Record<string, string>
168
+ }
169
+
170
+ expect(scripts['check:ci:bench']).toBe('tsx scripts/run-gates.ts ci-bench')
171
+ expect(subject).toHaveLength(1)
172
+ expect(subject[0]).toMatchObject({
173
+ id: 'bench',
174
+ displayCommand: 'pnpm run test:bench',
175
+ args: ['/private/pnpm.cjs', 'run', 'test:bench'],
176
+ })
177
+ expect(scripts['test:bench']).toBe('npm run build:bench && npm run build:web && npm run test:bench:built')
178
+ expect(scripts['build:bench']).toBe(
179
+ 'npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts',
180
+ )
181
+ expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
182
+ expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
183
+ })
184
+
185
+ it('checks all maintained repository references locally and in CI', () => {
186
+ const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
187
+ scripts: Record<string, string>
188
+ }
189
+ for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
190
+ const gates = withPnpmEntrypoint(() => gatesForMode(mode))
191
+ expect(gates).toContainEqual(expect.objectContaining({
192
+ id: 'repository-references',
193
+ displayCommand: 'pnpm run verify-repository-references',
194
+ }))
195
+ }
196
+ expect(scripts['verify-repository-references']).toBe('tsx scripts/verify-repository-references.ts')
197
+ })
198
+
199
+ it('keeps the public repository link policy in the documentation gate', () => {
200
+ const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
201
+
202
+ expect(ids).toContain('public-repository-links')
203
+ })
204
+
205
+ it('keeps the concrete terminology policy in the documentation gate', () => {
206
+ const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
207
+
208
+ expect(ids).toContain('concrete-terms')
209
+ })
210
+
211
+ it('checks retrospective releases alongside the current persistence history', () => {
212
+ for (const mode of ['doc-sync', 'ci-static'] as const) {
213
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
214
+ expect(ids).toEqual(expect.arrayContaining(['persistence-changes', 'persistence-releases']))
215
+ }
216
+ })
217
+
218
+ it('requires complete Session format references locally and in CI', () => {
219
+ const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
220
+ scripts: Record<string, string>
221
+ }
222
+ for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
223
+ expect(withPnpmEntrypoint(() => gatesForMode(mode))).toContainEqual(expect.objectContaining({
224
+ id: 'persistence-formats',
225
+ displayCommand: 'pnpm run verify-persistence-formats',
226
+ }))
227
+ }
228
+ expect(scripts['verify-persistence-formats']).toBe('tsx scripts/persistence-formats.ts')
229
+ })
230
+
231
+ it('keeps package-group subsystem ownership in the documentation gate', () => {
232
+ const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
233
+
234
+ expect(ids).toContain('subsystem-pages')
235
+ })
236
+
237
+ it('keeps the package README Summary limit in the documentation gate', () => {
238
+ const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
239
+
240
+ expect(ids).toContain('package-readme-summaries')
241
+ })
242
+
243
+ it('derives the quick documentation aggregate from marked doc-sync leaves', () => {
244
+ const full = withPnpmEntrypoint(() => gatesForMode('doc-sync'))
245
+ const quick = withPnpmEntrypoint(() => gatesForMode('doc-quick'))
246
+
247
+ expect(quick).toEqual(full.filter(gate => gate.quick === true))
248
+ })
249
+
250
+ it('checks the recorded npm dependency catalog in both documentation aggregates', () => {
251
+ for (const mode of ['doc-sync', 'doc-quick'] as const) {
252
+ expect(withPnpmEntrypoint(() => gatesForMode(mode).find(gate => gate.id === 'dependency-catalog')))
253
+ .toMatchObject({ args: ['/private/pnpm.cjs', 'run', 'verify-dependency-catalog'] })
254
+ }
255
+ })
256
+
257
+ it('keeps the hygiene aggregate aligned with the package script checks', () => {
258
+ const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
259
+
260
+ expect(ids).toEqual([
261
+ 'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
262
+ 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
263
+ 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config',
264
+ 'runtime-closure',
265
+ ])
266
+ expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
267
+ workers: 4,
268
+ source: '8 available CPU(s), hygiene cap 4',
269
+ })
270
+ })
271
+
272
+ it('schedules the longest documentation leaves before short checks', () => {
273
+ const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
274
+
275
+ expect(ids.slice(0, 11)).toEqual([
276
+ 'doc-typecheck', 'docs-site-build', 'doc-graphs', 'markdown-links', 'type-equivalence',
277
+ 'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'scoped-events', 'translation-pairing',
278
+ ])
279
+ })
280
+
281
+ it('launches a native pnpm entrypoint directly', () => {
282
+ const entrypoint = String.raw`C:\Program Files\pnpm\pnpm.exe`
283
+ const subject = withPnpmEntrypoint(() => gatesForMode('ci-windows-blocking')[0], entrypoint)
284
+
285
+ expect(subject).toMatchObject({
286
+ command: entrypoint,
287
+ args: ['run', 'build'],
288
+ })
289
+ })
290
+
291
+ it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
292
+ 'keeps the DSH package license policy in %s',
293
+ (mode) => {
294
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
295
+
296
+ expect(ids).toContain('dsh-package-licenses')
297
+ },
298
+ )
299
+
300
+ it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
301
+ 'keeps package dependency enforcement in %s',
302
+ (mode) => {
303
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
304
+
305
+ expect(ids).toContain('package-dependencies')
306
+ },
307
+ )
308
+
309
+ it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
310
+ 'executes default-product experimental isolation in %s',
311
+ (mode) => {
312
+ const gate = withPnpmEntrypoint(() => gatesForMode(mode)
313
+ .find(subject => subject.id === 'default-product-isolation'))
314
+
315
+ expect(gate?.args).toContain('verify-default-product-isolation')
316
+ expect(gate?.allowFailure).not.toBe(true)
317
+ },
318
+ )
319
+
320
+ it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
321
+ 'keeps the client dependency policy in %s',
322
+ (mode) => {
323
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
324
+
325
+ expect(ids).toContain('client-packages')
326
+ },
327
+ )
328
+
329
+ it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
330
+ 'keeps weighted approval policy tests in %s',
331
+ (mode) => {
332
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
333
+
334
+ expect(ids).toContain('approval-policy')
335
+ },
336
+ )
337
+
338
+ it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
339
+ 'keeps hard-coded Client UI copy enforcement in %s',
340
+ (mode) => {
341
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
342
+
343
+ expect(ids).toContain('client-ui-i18n')
344
+ },
345
+ )
346
+
347
+ it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
348
+ 'keeps application entrypoint enforcement in %s',
349
+ (mode) => {
350
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
351
+
352
+ expect(ids).toContain('application-entrypoints')
353
+ },
354
+ )
355
+
356
+ it('keeps native Windows coverage blocking and behind the complete build', () => {
357
+ const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
358
+ const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
359
+ .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
360
+ const byId = new Map(complete.map(subject => [subject.id, subject]))
361
+
362
+ expect(byId.get('coverage')?.allowFailure).not.toBe(true)
363
+ expect(byId.get('coverage')?.needs).toContain('build')
364
+ expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
365
+ expect(byId.get('coverage')?.needs).toContain('build')
366
+ expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build')
367
+ expect(byId.get('coverage-exempt-heavy')?.args).toContain(
368
+ 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
369
+ )
370
+ expect(observational).not.toHaveLength(0)
371
+ for (const gate of observational) {
372
+ const completeGate = byId.get(gate.id)
373
+ expect(completeGate?.allowFailure).toBe(true)
374
+ expect(completeGate?.after).toEqual(expect.arrayContaining([
375
+ 'coverage',
376
+ 'coverage-exempt-heavy',
377
+ ]))
378
+ expect(completeGate?.needs).toEqual(gate.needs)
379
+ }
380
+ })
381
+
382
+ it('runs the Windows built-bin smoke after other observational gates settle', () => {
383
+ const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
384
+ const builtBin = observational.find(gate => gate.id === 'built-bin-smoke')
385
+
386
+ expect(builtBin?.after).toEqual(
387
+ observational.filter(gate => gate.id !== 'built-bin-smoke').map(gate => gate.id),
388
+ )
389
+
390
+ const completeBuiltBin = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
391
+ .find(gate => gate.id === 'built-bin-smoke')
392
+ expect(completeBuiltBin?.after).toContain('windows-site')
393
+ expect(completeBuiltBin?.after).not.toContain('docs-site-build')
394
+ })
395
+
396
+ it('applies one configured test, polling, and hook timeout to both coverage gates', () => {
397
+ const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
398
+ withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
399
+
400
+ for (const id of ['coverage', 'coverage-exempt-heavy']) {
401
+ expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([
402
+ '--testTimeout=15000',
403
+ '--expect.poll.timeout=15000',
404
+ '--hookTimeout=15000',
405
+ ]))
406
+ }
407
+ })
408
+
409
+ it('keeps Vitest timeout defaults when the coverage override is absent', () => {
410
+ const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () =>
411
+ withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
412
+
413
+ for (const id of ['coverage', 'coverage-exempt-heavy']) {
414
+ expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([
415
+ expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
416
+ ]))
417
+ }
418
+ })
419
+
420
+ it('rejects an invalid coverage timeout before starting a gate', () => {
421
+ expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () =>
422
+ withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
423
+ .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer')
424
+ })
425
+
426
+ it('selects partitioned coverage only when explicitly configured', () => {
427
+ const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () =>
428
+ withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage')))
429
+
430
+ expect(coverage).toMatchObject({
431
+ displayCommand: 'DSH_COVERAGE_PARTITIONS=3 pnpm run test:coverage:partitioned',
432
+ args: ['/private/pnpm.cjs', 'run', 'test:coverage:partitioned'],
433
+ env: { DSH_COVERAGE_EXEMPT_HEAVY: '1' },
434
+ streamOutput: true,
435
+ })
436
+ })
437
+
438
+ it('rejects an invalid coverage partition count before starting a gate', () => {
439
+ expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () =>
440
+ withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
441
+ .toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1')
442
+ })
443
+
444
+ it.each([
445
+ ['empty', [], /gate graph has no gates/],
446
+ ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
447
+ ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
448
+ ['unknown ordering predecessors', [gate('subject', { after: ['missing'] })], /waits for unknown gate "missing"/],
449
+ ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
450
+ ['mixed cycles', [gate('first', { after: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
451
+ ] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
452
+ const execute = vi.fn(async (subject: Gate) => resultFor(subject))
453
+
454
+ await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message)
455
+ expect(execute).not.toHaveBeenCalled()
456
+ })
457
+
458
+ it('rejects an invalid worker count before starting a child', async () => {
459
+ const execute = vi.fn(async (subject: Gate) => resultFor(subject))
460
+
461
+ await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer')
462
+ expect(execute).not.toHaveBeenCalled()
463
+ })
464
+
465
+ it('skips dependents after their prerequisite fails', async () => {
466
+ const dependent = gate('dependent', { needs: ['root'] })
467
+ const root = gate('root')
468
+ const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed'))
469
+
470
+ const results = await runGates([dependent, root], 1, execute)
471
+
472
+ expect(execute).toHaveBeenCalledOnce()
473
+ expect(execute).toHaveBeenCalledWith(root, undefined)
474
+ expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
475
+ })
476
+
477
+ it('runs an ordered follower after its predecessor fails', async () => {
478
+ const follower = gate('follower', { after: ['root'] })
479
+ const root = gate('root')
480
+ const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
481
+
482
+ const results = await runGates([follower, root], 2, execute)
483
+
484
+ expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
485
+ expect(results.map(result => result.status)).toEqual(['passed', 'failed'])
486
+ })
487
+
488
+ it('runs an ordered follower after its predecessor is skipped', async () => {
489
+ const follower = gate('follower', { after: ['dependent'] })
490
+ const dependent = gate('dependent', { needs: ['root'] })
491
+ const root = gate('root')
492
+ const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
493
+
494
+ const results = await runGates([follower, dependent, root], 2, execute)
495
+
496
+ expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
497
+ expect(results.map(result => result.status)).toEqual(['passed', 'skipped', 'failed'])
498
+ })
499
+ })
500
+
501
+ describe('Oxlint gate', () => {
502
+ it('uses the package script when no worker bound is configured', () => {
503
+ const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
504
+ withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
505
+
506
+ expect(subject).toMatchObject({
507
+ id: 'lint',
508
+ displayCommand: 'pnpm run lint:contracts-ready',
509
+ command: process.execPath,
510
+ args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
511
+ })
512
+ })
513
+
514
+ it('surfaces the configured worker bound on the shared package script', () => {
515
+ const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
516
+ withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
517
+
518
+ expect(subject).toMatchObject({
519
+ id: 'lint',
520
+ displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint:contracts-ready',
521
+ command: process.execPath,
522
+ args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
523
+ })
524
+ })
525
+ })
526
+
527
+ describe('Typert contract preparation', () => {
528
+ it('prepares primary source consumers once before they run', () => {
529
+ const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
530
+ withPnpmEntrypoint(() => gatesForMode('ci-primary')))
531
+
532
+ expect(subject.find(item => item.id === 'typert-contracts')).toMatchObject({
533
+ displayCommand: 'pnpm run build:lib:host',
534
+ args: ['/private/pnpm.cjs', 'run', 'build:lib:host'],
535
+ })
536
+ for (const [id, script] of [
537
+ ['typecheck', 'typecheck:contracts-ready'],
538
+ ['lint', 'lint:contracts-ready'],
539
+ ['doc-typecheck', 'doc-typecheck:contracts-ready'],
540
+ ] as const) {
541
+ expect(subject.find(item => item.id === id)).toMatchObject({
542
+ displayCommand: `pnpm run ${script}`,
543
+ args: ['/private/pnpm.cjs', 'run', script],
544
+ needs: ['typert-contracts'],
545
+ })
546
+ }
547
+ expect(subject.find(item => item.id === 'build')?.needs).toEqual([
548
+ 'typecheck',
549
+ 'lint',
550
+ 'doc-typecheck',
551
+ ])
552
+ })
553
+
554
+ it('reuses contracts from the validated consumer build', () => {
555
+ const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
556
+
557
+ expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
558
+ displayCommand: 'pnpm run check:ci:lint:contracts-ready',
559
+ args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
560
+ })
561
+ expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
562
+ displayCommand: 'pnpm run doc-typecheck:contracts-ready',
563
+ args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
564
+ })
565
+ })
566
+
567
+ it('keeps standalone doc sync responsible for preparation', () => {
568
+ const docTypecheck = withPnpmEntrypoint(() =>
569
+ gatesForMode('doc-sync').find(item => item.id === 'doc-typecheck'))
570
+
571
+ expect(docTypecheck?.displayCommand).toBe('pnpm run doc-typecheck')
572
+ })
573
+ })
574
+
575
+ describe('Node compatibility graph', () => {
576
+ it('runs the jsdom environment smoke on every advertised Node line', () => {
577
+ const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
578
+
579
+ expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
580
+ label: 'Vitest jsdom smoke',
581
+ args: [
582
+ '/private/pnpm.cjs',
583
+ 'exec',
584
+ 'vitest',
585
+ 'run',
586
+ 'scripts/vitest-environment.compat.spec.ts',
587
+ ],
588
+ })
589
+ })
590
+ })
591
+
592
+ describe('Node 24 lane ownership', () => {
593
+ it('keeps the static lane source-only', () => {
594
+ const subject = withPnpmEntrypoint(() => gatesForMode('ci-static'))
595
+
596
+ expect(subject.map(item => item.id)).not.toContain('build')
597
+ expect(subject.map(item => item.id)).not.toContain('doc-typecheck')
598
+ })
599
+
600
+ it('owns the build and orders its artifact consumers', () => {
601
+ const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
602
+
603
+ expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
604
+ workers: 11,
605
+ source: 'ci-consumers gate count',
606
+ })
607
+ expect(subject.map(item => item.id)).toEqual([
608
+ 'build',
609
+ 'node-compat',
610
+ 'publint',
611
+ 'built-package-invariants',
612
+ 'lint-and-duplication',
613
+ 'snapshot',
614
+ 'expected-output',
615
+ 'web-snapshot',
616
+ 'doc-typecheck',
617
+ 'node-next-types',
618
+ 'built-bin-smoke',
619
+ ])
620
+ expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
621
+ expect(subject.find(item => item.id === 'build')?.env).toEqual({
622
+ DSH_BUILD_CLIENT_PROFILE: 'official',
623
+ })
624
+ expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
625
+ DSH_BUILD_CLIENT_PROFILE: 'official',
626
+ })
627
+ expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build'])
628
+ expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
629
+ for (const id of [
630
+ 'snapshot',
631
+ 'expected-output',
632
+ 'web-snapshot',
633
+ 'doc-typecheck',
634
+ 'node-next-types',
635
+ 'built-bin-smoke',
636
+ ]) {
637
+ expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
638
+ }
639
+ expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
640
+ expect(subject.find(item => item.id === 'expected-output')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
641
+ expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
642
+ DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
643
+ })
644
+ expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
645
+ expect.arrayContaining([
646
+ 'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
647
+ 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
648
+ 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
649
+ 'packages/experimental/agent-team/tests/built-lib.e2e.ts',
650
+ ]),
651
+ )
652
+ expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
653
+ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
654
+ env: { DSH_SNAPSHOT: 'replay' },
655
+ after: [
656
+ 'publint',
657
+ 'lint-and-duplication',
658
+ 'snapshot',
659
+ 'expected-output',
660
+ 'doc-typecheck',
661
+ 'node-next-types',
662
+ 'built-bin-smoke',
663
+ ],
664
+ })
665
+ })
666
+ })
667
+
668
+ describe('Linux primary graph', () => {
669
+ it('adds the same compare-only web gate after built client artifacts', () => {
670
+ const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary'))
671
+ const web = subject.find(item => item.id === 'web-snapshot')
672
+
673
+ expect(web).toMatchObject({
674
+ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
675
+ env: { DSH_SNAPSHOT: 'replay' },
676
+ needs: ['built-package-invariants'],
677
+ })
678
+ })
679
+ })
680
+
681
+ describe('gate process outcomes', () => {
682
+ it('streams selected gate output without retaining it', async () => {
683
+ const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
684
+ try {
685
+ const result = await runGate(gate('streamed', {
686
+ args: ['-e', "process.stdout.write('live output')"],
687
+ streamOutput: true,
688
+ }))
689
+
690
+ expect(result.status).toBe('passed')
691
+ expect(result.output).toEqual([])
692
+ expect(write).toHaveBeenCalledWith('live output')
693
+ } finally {
694
+ write.mockRestore()
695
+ }
696
+ })
697
+
698
+ it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
699
+ const result = await runGate(gate('terminated', {
700
+ args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
701
+ }))
702
+
703
+ expect(result.status).toBe('failed')
704
+ expect(result.exitCode).toBeNull()
705
+ expect(result.signalCode).toBe('SIGTERM')
706
+ expect(formatGateResultReason(result)).toBe('signal SIGTERM')
707
+ })
708
+ })
709
+
710
+ describe('fail-fast scheduling', () => {
711
+ it('aborts the aggregate at the first blocking failure', async () => {
712
+ const slow = gate('slow')
713
+ const fast = gate('fast')
714
+ const dependent = gate('dependent', { needs: ['slow'] })
715
+ const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
716
+ if (subject.id === 'fast') {
717
+ return new Promise<GateResult>((resolve) => {
718
+ signal?.addEventListener('abort', () => {
719
+ // The real runGate marks a gate the abort terminated; the drain
720
+ // must then record it skipped rather than keep the failure.
721
+ resolve({ ...resultFor(subject, 'failed'), aborted: true })
722
+ }, { once: true })
723
+ })
724
+ }
725
+ return resultFor(subject, subject.id === 'slow' ? 'failed' : 'passed')
726
+ })
727
+
728
+ const results = await runGates([slow, fast, dependent], 2, execute, () => {}, { failFast: true })
729
+
730
+ expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['slow', 'fast'])
731
+ expect(results.map(result => result.status)).toEqual(['failed', 'skipped', 'skipped'])
732
+ expect(results[1]).toMatchObject({
733
+ status: 'skipped',
734
+ error: 'aborted by fail-fast: slow failed',
735
+ })
736
+ expect(results[2]).toMatchObject({
737
+ status: 'skipped',
738
+ error: 'aborted by fail-fast: slow failed',
739
+ })
740
+ })
741
+
742
+ it('does not abort on a non-blocking gate failure', async () => {
743
+ const observational = gate('observational', { allowFailure: true })
744
+ const root = gate('root')
745
+ const execute = vi.fn(async (subject: Gate) => (
746
+ resultFor(subject, subject.id === 'observational' ? 'failed' : 'passed')
747
+ ))
748
+
749
+ const results = await runGates([observational, root], 2, execute, () => {}, { failFast: true })
750
+
751
+ expect(execute).toHaveBeenCalledTimes(2)
752
+ expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
753
+ })
754
+
755
+ it('runs independent gates to completion when fail-fast is disabled', async () => {
756
+ const root = gate('root')
757
+ const sibling = gate('sibling')
758
+ const execute = vi.fn(async (subject: Gate) => (
759
+ resultFor(subject, subject.id === 'root' ? 'failed' : 'passed')
760
+ ))
761
+
762
+ const results = await runGates([root, sibling], 2, execute, () => {}, { failFast: false })
763
+
764
+ expect(execute).toHaveBeenCalledTimes(2)
765
+ expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
766
+ })
767
+
768
+ it('kills the child when the abort signal fires', async () => {
769
+ const controller = new AbortController()
770
+ const promise = runGate(gate('killable', { args: ['-e', 'setInterval(() => {}, 1000)'] }), controller.signal)
771
+ controller.abort()
772
+ const result = await promise
773
+
774
+ expect(result.status).toBe('failed')
775
+ expect(result.aborted).toBe(true)
776
+ if (process.platform !== 'win32') expect(result.signalCode).toBe('SIGTERM')
777
+ })
778
+
779
+ it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
780
+ const { writes, write } = captureStreamedOutput()
781
+ try {
782
+ const controller = new AbortController()
783
+ const child = gate('traps-signal', {
784
+ args: ['-e', "process.stdout.write('ready\\n'); process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"],
785
+ streamOutput: true,
786
+ })
787
+ const promise = runGate(child, controller.signal)
788
+ // Wait for the child to register its SIGTERM trap before aborting, so
789
+ // the signal is caught and the child really exits zero.
790
+ const deadline = Date.now() + 5000
791
+ while (!writes.join('').includes('ready') && Date.now() < deadline) {
792
+ await new Promise(resolve => setTimeout(resolve, 10))
793
+ }
794
+ controller.abort()
795
+ const result = await promise
796
+
797
+ // The child trapped the signal and exited zero; the drain must not
798
+ // report this gate passed, so the raw outcome carries the abort mark.
799
+ expect(result.status).toBe('passed')
800
+ expect(result.aborted).toBe(true)
801
+ } finally {
802
+ write.mockRestore()
803
+ }
804
+ })
805
+
806
+ it.skipIf(process.platform === 'win32')('kills the whole gate process tree when the abort signal fires', async () => {
807
+ const { writes, write } = captureStreamedOutput()
808
+ const controller = new AbortController()
809
+ let promise: Promise<GateResult> | undefined
810
+ try {
811
+ const script = [
812
+ "const { spawn } = require('node:child_process')",
813
+ // Detached, so the grandchild leads its own process group: the gate
814
+ // group signal cannot reach it, and only the descendant enumeration in
815
+ // treeKill does — the shape of a nested run-gates' leaf gates.
816
+ "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true })",
817
+ "process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
818
+ 'setInterval(() => {}, 1000)',
819
+ ].join(';')
820
+ promise = runGate(gate('tree', { args: ['-e', script], streamOutput: true }), controller.signal)
821
+ const deadline = Date.now() + 5000
822
+ let pid: number | undefined
823
+ while (pid === undefined && Date.now() < deadline) {
824
+ const match = writes.join('').match(/grandchild:(\d+)/)
825
+ if (match !== null) pid = Number(match[1])
826
+ else await new Promise(resolve => setTimeout(resolve, 20))
827
+ }
828
+ expect(pid ?? 0).toBeGreaterThan(0)
829
+ controller.abort()
830
+ const result = await promise
831
+ expect(result.status).toBe('failed')
832
+ // The descendant enumeration signals the detached grandchild at the same
833
+ // time as the group signal reaches the direct child; the direct child's
834
+ // own death closes the gate pipes, so poll for the grandchild to stop
835
+ // executing rather than asserting on a fixed instant.
836
+ const stopDeadline = Date.now() + 5000
837
+ while (!procStopped(pid!) && Date.now() < stopDeadline) {
838
+ await new Promise(resolve => setTimeout(resolve, 20))
839
+ }
840
+ expect(procStopped(pid!)).toBe(true)
841
+ } finally {
842
+ // A failed wait or assertion must not leave the forever-looping detached
843
+ // grandchild behind on the host: abort the gate and wait for the
844
+ // process tree to settle before restoring the spy.
845
+ controller.abort()
846
+ await promise
847
+ write.mockRestore()
848
+ }
849
+ })
850
+
851
+ it('forwards host interruption signals to the abort path', async () => {
852
+ const slow = gate('slow')
853
+ const sibling = gate('sibling')
854
+ const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
855
+ if (subject.id === 'slow') {
856
+ return new Promise<GateResult>((resolve) => {
857
+ signal?.addEventListener('abort', () => {
858
+ // A child can trap the signal and exit zero; the drain must still
859
+ // record the gate skipped so the interrupted run fails.
860
+ resolve({ ...resultFor(subject, 'passed'), aborted: true })
861
+ }, { once: true })
862
+ })
863
+ }
864
+ return resultFor(subject)
865
+ })
866
+
867
+ const promise = runGates([slow, sibling], 1, execute, () => {}, { failFast: true, forwardProcessSignals: true })
868
+ // The first loop iteration starts `slow` synchronously, so its abort
869
+ // listener is registered before the signal is emitted.
870
+ process.emit('SIGTERM')
871
+ const results = await promise
872
+
873
+ expect(execute).toHaveBeenCalledOnce()
874
+ expect(results.map(result => result.status)).toEqual(['skipped', 'skipped'])
875
+ expect(results[0]).toMatchObject({
876
+ status: 'skipped',
877
+ error: 'aborted by fail-fast: host interruption',
878
+ })
879
+ })
880
+
881
+ it('pairs host signal forwarding with fail-fast at the CLI entrypoint', () => {
882
+ expect(cliGateOptions(true)).toEqual({ failFast: true, forwardProcessSignals: true })
883
+ expect(cliGateOptions(false)).toEqual({ failFast: false, forwardProcessSignals: false })
884
+ })
885
+
886
+ it('rejects host signal forwarding without fail-fast', async () => {
887
+ const execute = vi.fn(async (subject: Gate) => resultFor(subject))
888
+
889
+ await expect(runGates([gate('subject')], 1, execute, () => {}, { forwardProcessSignals: true }))
890
+ .rejects.toThrow('forwardProcessSignals requires failFast')
891
+ expect(execute).not.toHaveBeenCalled()
892
+ })
893
+
894
+ it('leaves an un-aborted child running to completion', async () => {
895
+ const result = await runGate(gate('settles', { args: ['-e', ''] }), new AbortController().signal)
896
+
897
+ expect(result.status).toBe('passed')
898
+ expect(result.aborted).toBe(false)
899
+ })
900
+
901
+ it.skipIf(process.platform === 'win32')('kills a detached descendant that outlived the child when the abort arrives later', async () => {
902
+ const writes: string[] = []
903
+ const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
904
+ writes.push(String(chunk))
905
+ return true
906
+ })
907
+ const controller = new AbortController()
908
+ let promise: Promise<GateResult> | undefined
909
+ try {
910
+ const script = [
911
+ "const { spawn } = require('node:child_process')",
912
+ // Detached with inherited stdio: the grandchild leads its own process
913
+ // group (the gate group signal misses it) and holds the gate's
914
+ // stdout write end (so `close` stays pending past the child exit).
915
+ "const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' })",
916
+ "process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
917
+ // Outlive the first descendant-sampler tick with margin so the cache
918
+ // holds the grandchild even on a loaded runner, then exit normally
919
+ // before the abort arrives.
920
+ "setTimeout(() => { process.stdout.write('child-exit\\n'); process.exit(0) }, 8000)",
921
+ ].join(';')
922
+ promise = runGate(gate('late-abort', { args: ['-e', script], streamOutput: true }), controller.signal)
923
+ const pid = await waitForGrandchildPid(writes, 'child-exit', Date.now() + 10000)
924
+ // terminate must not re-enumerate over the sampler cache now that the
925
+ // child is gone; the detached grandchild is killed from the cached list.
926
+ await abortAndExpectTreeStopped(promise, controller, pid)
927
+ } finally {
928
+ // A failed wait or assertion must not leave the forever-looping detached
929
+ // grandchild behind on the host: abort the gate and wait for the
930
+ // process tree to settle before restoring the spy.
931
+ controller.abort()
932
+ await promise
933
+ write.mockRestore()
934
+ }
935
+ }, 20000)
936
+
937
+ it.skipIf(process.platform === 'win32')('keeps a reparented detached descendant tracked across a sampler tick', async () => {
938
+ const { writes, write } = captureStreamedOutput()
939
+ const controller = new AbortController()
940
+ let promise: Promise<GateResult> | undefined
941
+ try {
942
+ const script = [
943
+ "const { spawn } = require('node:child_process')",
944
+ // Wrapper spawns a detached grandchild with inherited stdio (its own
945
+ // process group, holding the gate's stdout write end), prints the pid,
946
+ // then exits after 7 seconds — after the first sampler tick, before
947
+ // the second. From then on the grandchild is reparented and
948
+ // unreachable by parent id.
949
+ "const wrapper = spawn(process.execPath, ['-e', \"const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' }); process.stdout.write('grandchild:' + grandchild.pid + '\\\\n'); setTimeout(() => process.exit(0), 7000)\"], { stdio: 'inherit' })",
950
+ "wrapper.on('exit', () => process.stdout.write('wrapper-exited\\n'))",
951
+ // Keep the root child alive past the abort with a heartbeat so the
952
+ // test can abort while it is still running.
953
+ "setInterval(() => process.stdout.write('hb\\n'), 1000)",
954
+ ].join(';')
955
+ promise = runGate(gate('sampler-merge', { args: ['-e', script], streamOutput: true }), controller.signal)
956
+ const pid = await waitForGrandchildPid(writes, 'wrapper-exited', Date.now() + 15000)
957
+ // Wait past the second sampler tick (t=10) with margin: a replacing tick
958
+ // would drop the reparented grandchild from the cache, after which the
959
+ // abort cannot reach it. The root child keeps running throughout.
960
+ const tickDeadline = Date.now() + 10000
961
+ const wrapperExitedAt = Date.now()
962
+ while (Date.now() - wrapperExitedAt < 5000 && Date.now() < tickDeadline) {
963
+ await new Promise(resolve => setTimeout(resolve, 50))
964
+ }
965
+ expect(Date.now() - wrapperExitedAt).toBeGreaterThanOrEqual(5000)
966
+ await abortAndExpectTreeStopped(promise, controller, pid)
967
+ } finally {
968
+ // A failed wait or assertion must not leave the forever-looping detached
969
+ // grandchild behind on the host: abort the gate and wait for the
970
+ // process tree to settle before restoring the spy.
971
+ controller.abort()
972
+ await promise
973
+ write.mockRestore()
974
+ }
975
+ }, 30000)
976
+ })
977
+
978
+ describe('process-table parsing', () => {
979
+ it('excludes the root when a parent link returns to it', () => {
980
+ expect(collectDescendants(100, [[200, 100], [100, 200], [300, 200]]))
981
+ .toEqual([200, 300])
982
+ })
983
+
984
+ it('visits duplicate and cyclic descendant links only once', () => {
985
+ expect(collectDescendants(100, [
986
+ [200, 100], [200, 100], [300, 100], [200, 200], [400, 200], [200, 400], [500, 300], [900, 800],
987
+ ])).toEqual([200, 300, 400, 500])
988
+ })
989
+
990
+ it('returns no descendants for an isolated or self-parented root', () => {
991
+ expect(collectDescendants(100, [])).toEqual([])
992
+ expect(collectDescendants(100, [[100, 100]])).toEqual([])
993
+ })
994
+
995
+ it('walks a wide child set without spreading it into call arguments', () => {
996
+ const children = Array.from({ length: 150_000 }, (_, i): [number, number] => [i + 3, 2])
997
+ const descendants = collectDescendants(1, [[2, 1], ...children])
998
+ expect(descendants).toHaveLength(children.length + 1)
999
+ expect(descendants[0]).toBe(2)
1000
+ expect(descendants.at(-1)).toBe(150_002)
1001
+ })
1002
+
1003
+ it('parses `pid ppid` rows from a POSIX ps dump', () => {
1004
+ expect(parsePidPpidLines(' 123 1\n456 123\n 789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
1005
+ })
1006
+
1007
+ it('parses Windows PowerShell Get-CimInstance output of the same shape', () => {
1008
+ expect(parsePidPpidLines(' 123 1\r\n456 123\r\n')).toEqual([[123, 1], [456, 123]])
1009
+ })
1010
+
1011
+ it('drops blank and malformed lines', () => {
1012
+ expect(parsePidPpidLines(' 123 1\n\ncommand not found\n999 abc\n')).toEqual([[123, 1]])
1013
+ })
1014
+ })
1015
+
1016
+ describe('Windows tree termination', () => {
1017
+ it('targets the root first and each captured descendant after it', () => {
1018
+ expect(taskkillArgs(100, [201, 302, 403])).toEqual([
1019
+ ['/PID', '100', '/T', '/F'],
1020
+ ['/PID', '201', '/T', '/F'],
1021
+ ['/PID', '302', '/T', '/F'],
1022
+ ['/PID', '403', '/T', '/F'],
1023
+ ])
1024
+ })
1025
+
1026
+ it('terminates the root alone when no descendant was captured', () => {
1027
+ expect(taskkillArgs(100, [])).toEqual([['/PID', '100', '/T', '/F']])
1028
+ })
1029
+ })
scripts/run-web-snapshots.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Run serial browser owners before one bounded snapshot pool. */
2
+ import { spawn } from 'node:child_process'
3
+ import { pnpmInvocation } from './pnpm-invocation.ts'
4
+
5
+ const serialFiles = [
6
+ 'apps/web/tests/hmr-live.e2e.ts',
7
+ 'apps/web/tests/cordis-tool-round.e2e.ts',
8
+ ]
9
+ const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
10
+ const workers = Number.parseInt(workerRaw ?? '', 10)
11
+ if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
12
+ throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
13
+ }
14
+ const invocation = pnpmInvocation(['exec', 'vitest', 'run', '--config', 'vitest.web.config.ts'])
15
+ let serialStatus = 0
16
+ for (const file of serialFiles) {
17
+ serialStatus = await run(invocation.command, [...invocation.args, file])
18
+ if (serialStatus !== 0) break
19
+ }
20
+ if (serialStatus === 0) {
21
+ process.exitCode = await run(invocation.command, [
22
+ ...invocation.args,
23
+ ...serialFiles.map(file => `--exclude=${file}`),
24
+ '--fileParallelism',
25
+ `--maxWorkers=${String(workers)}`,
26
+ ])
27
+ } else {
28
+ process.exitCode = serialStatus
29
+ }
30
+
31
+ function run(command: string, args: string[]): Promise<number> {
32
+ return new Promise((resolveRun, reject) => {
33
+ const child = spawn(command, args, { stdio: 'inherit' })
34
+ child.once('error', reject)
35
+ child.once('exit', (exitCode, signalCode) => {
36
+ if (signalCode !== null) {
37
+ console.error(`web snapshots terminated by ${signalCode}`)
38
+ resolveRun(1)
39
+ return
40
+ }
41
+ resolveRun(exitCode ?? 1)
42
+ })
43
+ })
44
+ }
scripts/session-query-spill-command.spec.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Context } from '@deepseek-ai/cordis'
2
+ import { LocalSpillStore } from '@deepseek-ai/dsh-spill-local'
3
+ import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
4
+ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
5
+ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
6
+ import { SessionId } from '@deepseek-ai/dsh-session'
7
+ import { ToolCallId } from '@deepseek-ai/dsh-llm'
8
+ import { mkdtemp, rm } from 'node:fs/promises'
9
+ import { tmpdir } from 'node:os'
10
+ import { join, resolve } from 'node:path'
11
+ import { expect, it } from 'vitest'
12
+ import * as locators from './snapshot-spill-locators.ts'
13
+ import * as commands from '../snapshots/session/session-query-spill/resolve-spill-command.mjs'
14
+
15
+ it.skipIf(process.platform === 'win32')('resolves the exact spill verifier, retains real failures, and restores the shell', async () => {
16
+ const root = await mkdtemp(join(tmpdir(), "query-spill-'quoted-"))
17
+ const ctx = new Context()
18
+ const disposers: (() => Promise<void>)[] = []
19
+ try {
20
+ for (const fiber of [
21
+ ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 }),
22
+ ctx.plugin(LocalFileSystem, { cwd: root }),
23
+ ctx.plugin(LocalSubprocessRuntime),
24
+ ctx.plugin(LocalBashExecutor),
25
+ ]) {
26
+ disposers.push(() => fiber.dispose())
27
+ await fiber
28
+ }
29
+ const logical = resolve('/tmp/dsh-acp-snap-query-verifier')
30
+ const mapping = ctx.plugin(locators, { root, locatorRoot: logical })
31
+ disposers.push(() => mapping.dispose())
32
+ await mapping
33
+ const saved = await ctx.spillStore.saveText({
34
+ owner: { sessionId: SessionId('query-verifier') },
35
+ source: { kind: 'tool', toolName: 'session_event_read', callId: ToolCallId('query'), label: 'result' },
36
+ suggestedName: 'session_event_read.txt', content: 'request/header session_event_search',
37
+ })
38
+ const adapter = ctx.plugin(commands)
39
+ disposers.push(() => adapter.dispose())
40
+ await adapter
41
+ const command = 'file="' + saved.locator + '"' + "; grep -Fq request/header \"$file\" && grep -Fq session_event_search \"$file\" && printf 'SPILL_CANONICAL_OK\\n'"
42
+ const spec = ctx.shell.resolve({ command })
43
+ const output = await ctx.shell.run(spec)
44
+ expect(output.timedOut).toBe(false)
45
+ expect(output.exitCode).toBe(0)
46
+ expect(output.stdout.text).toBe('SPILL_CANONICAL_OK\n')
47
+ expect(spec.command).toBe(command)
48
+ const other = await ctx.shell.run(ctx.shell.resolve({ command: 'printf ordinary; exit 7' }))
49
+ expect(other.exitCode).toBe(7)
50
+ expect(other.stdout.text).toBe('ordinary')
51
+ const unmatched = await ctx.shell.run(ctx.shell.resolve({ command: command + '; exit 9' }))
52
+ expect(unmatched.exitCode).toBe(9)
53
+ expect(unmatched.stderr.text).toContain('No such file')
54
+ const physical = ctx.fs.processPath(await ctx.fs.resolve(saved.locator))
55
+ await rm(physical)
56
+ const missing = await ctx.shell.run(spec)
57
+ expect(missing.exitCode).not.toBe(0)
58
+ expect(missing.stdout.text).toBe('')
59
+ await adapter.dispose()
60
+ const restored = await ctx.shell.run(spec)
61
+ expect(restored.exitCode).not.toBe(0)
62
+ expect(restored.stderr.text).toContain(saved.locator)
63
+ } finally {
64
+ for (const dispose of disposers.reverse()) await dispose()
65
+ await rm(root, { recursive: true, force: true })
66
+ }
67
+ })
scripts/slot-walk.ts ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AST helpers for the client slot surface: the `SlotMap` declaration merges
3
+ * that type every slot, and the `slots.register` call sites that say who
4
+ * already occupies one. Both readings are lexical (no type-checker program):
5
+ * the client catalog generator consumes them, and the same scan doubles as its
6
+ * own exhaustiveness backstop because it reads every source file rather than a
7
+ * reachable-export closure.
8
+ */
9
+
10
+ import { globSync, readFileSync } from 'node:fs'
11
+ import { dirname, join, resolve, sep } from 'node:path'
12
+ import ts from 'typescript'
13
+
14
+ /** The module whose `SlotMap` / standard-kit interfaces every slot owner merges into. */
15
+ const SLOTS_MODULE = '@deepseek-ai/dsh-client-ui-slots'
16
+
17
+ /** Cheap textual prefilter for a slot-contract merge, quote-style agnostic. */
18
+ const MERGE_HEAD = /declare module ['"]@deepseek-ai\/dsh-client-ui-slots['"]/
19
+
20
+ /** Cheap textual prefilter for a registration call site. */
21
+ const REGISTER_HEAD = /\.register\(/
22
+
23
+ /** One `SlotMap` member: the slot's contract as its owning package declares it. */
24
+ export interface SlotDeclaration {
25
+ /** SlotMap key, e.g. `settings.section`. */
26
+ key: string
27
+ /** Cardinality literal (`single` / `list` / `keyed` / `chain`), or '' when not a literal. */
28
+ kind: string
29
+ /** Data-scope literal (`root` / `session` / `session-maybe`), or '' when not a literal. */
30
+ scope: string
31
+ /** Type name of the owner-supplied props share, absent when the slot declares none. */
32
+ ownerType?: string
33
+ /** Source text of the `keyProps` member (keyed slots), absent otherwise. */
34
+ keyProps?: string
35
+ /** Source text of the `hookContext` member, absent otherwise. */
36
+ hookContext?: string
37
+ /** Type name of the slot-level inject face, absent when the slot declares none. */
38
+ injectType?: string
39
+ /** The member's JSDoc with container indentation removed, '' when undocumented. */
40
+ jsDoc: string
41
+ /** Workspace package that declares the contract. */
42
+ package: string
43
+ /** Source pointer `packages/…/file.ts:line`. */
44
+ source: string
45
+ }
46
+
47
+ /** One `slots.register({ name, … }, Component)` call site. */
48
+ export interface SlotRegistration {
49
+ /** Target SlotMap key the entry contributes into. */
50
+ key: string
51
+ /** Workspace package that registers the entry. */
52
+ package: string
53
+ /** Component argument as written (identifier, or a trimmed expression). */
54
+ component: string
55
+ /** `id` literal of a list entry, absent otherwise. */
56
+ id?: string
57
+ /** `key` literal of a keyed entry, absent otherwise. */
58
+ entryKey?: string
59
+ /** SlotMap keys this registration declares as children (they exist while it is mounted). */
60
+ children: string[]
61
+ /** Source pointer `packages/…/file.ts:line`. */
62
+ source: string
63
+ }
64
+
65
+ /** One exported type declaration, retained with its JSDoc for catalog projection. */
66
+ export interface TypeDeclaration {
67
+ /** Declared name. */
68
+ name: string
69
+ /** Full declaration text INCLUDING its JSDoc (member docs are the teaching text). */
70
+ text: string
71
+ /** Source pointer `packages/…/file.ts:line`. */
72
+ source: string
73
+ }
74
+
75
+ /** One scanned source file with the artifacts the catalog reads from it. */
76
+ export interface ScannedFile {
77
+ /** Repo-relative, `/`-normalized path. */
78
+ rel: string
79
+ /** Workspace package name that owns the file. */
80
+ package: string
81
+ /** Parsed source file. */
82
+ sf: ts.SourceFile
83
+ }
84
+
85
+ /**
86
+ * Parse every file matching `patterns`, keeping the ones that carry a slot
87
+ * contract merge or a registration call. Files without either are skipped so
88
+ * the scan stays cheap over the whole workspace.
89
+ * @param scanRoot - repository root the patterns resolve against.
90
+ * @param patterns - glob(s) selecting the TypeScript/TSX files to scan.
91
+ * @returns one entry per interesting file, in path order.
92
+ */
93
+ export function scanSlotFiles(scanRoot: string, patterns: readonly string[]): ScannedFile[] {
94
+ const out: ScannedFile[] = []
95
+ const names = new Map<string, string>()
96
+ const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
97
+ .map(path => path.split(sep).join('/')))].sort()
98
+ for (const rel of rels) {
99
+ const abs = resolve(scanRoot, rel)
100
+ const text = readFileSync(abs, 'utf8')
101
+ if (!MERGE_HEAD.test(text) && !REGISTER_HEAD.test(text)) continue
102
+ out.push({
103
+ rel,
104
+ package: packageNameOf(scanRoot, rel, names),
105
+ sf: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, scriptKindOf(rel)),
106
+ })
107
+ }
108
+ return out
109
+ }
110
+
111
+ /**
112
+ * Index every exported type declaration of the scanned packages, keeping JSDoc.
113
+ * The catalog resolves owner-props and inject-face shapes through this index
114
+ * instead of a type-checker program: the declaration text with its member
115
+ * documentation IS the teaching material a registrant needs.
116
+ * @param scanRoot - repository root the patterns resolve against.
117
+ * @param patterns - glob(s) selecting the TypeScript/TSX files to index.
118
+ * @returns name → declaration, with names declared more than once dropped as ambiguous.
119
+ */
120
+ export function indexExportedTypes(scanRoot: string, patterns: readonly string[]): Map<string, TypeDeclaration> {
121
+ const index = new Map<string, TypeDeclaration>()
122
+ const ambiguous = new Set<string>()
123
+ const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
124
+ .map(path => path.split(sep).join('/')))].sort()
125
+ for (const rel of rels) {
126
+ const abs = resolve(scanRoot, rel)
127
+ const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, scriptKindOf(rel))
128
+ for (const statement of sf.statements) {
129
+ if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) continue
130
+ if (!statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
131
+ const name = statement.name.text
132
+ if (index.has(name)) {
133
+ ambiguous.add(name)
134
+ continue
135
+ }
136
+ index.set(name, {
137
+ name,
138
+ text: declarationText(statement, sf),
139
+ source: `${rel}:${String(lineOf(sf, statement))}`,
140
+ })
141
+ }
142
+ }
143
+ for (const name of ambiguous) index.delete(name)
144
+ return index
145
+ }
146
+
147
+ /**
148
+ * Read every `SlotMap` member declared in one scanned file.
149
+ * @param file - a file returned by {@link scanSlotFiles}.
150
+ * @returns the declared slots, in source order.
151
+ */
152
+ export function slotDeclarations(file: ScannedFile): SlotDeclaration[] {
153
+ const out: SlotDeclaration[] = []
154
+ for (const body of slotModuleBodies(file.sf)) {
155
+ for (const statement of body.statements) {
156
+ if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'SlotMap') continue
157
+ for (const member of statement.members) {
158
+ if (!ts.isPropertySignature(member) || member.type === undefined) continue
159
+ const key = ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
160
+ ? member.name.text
161
+ : member.name.getText(file.sf)
162
+ const entry = ts.isTypeLiteralNode(member.type) ? member.type : undefined
163
+ const ownerType = memberTypeText(entry, 'owner', file.sf)
164
+ const keyProps = memberTypeText(entry, 'keyProps', file.sf)
165
+ const hookContext = memberTypeText(entry, 'hookContext', file.sf)
166
+ const injectType = memberTypeText(entry, 'inject', file.sf)
167
+ out.push({
168
+ key,
169
+ kind: literalMember(entry, 'kind'),
170
+ scope: literalMember(entry, 'scope'),
171
+ ...ownerType === undefined ? {} : { ownerType },
172
+ ...keyProps === undefined ? {} : { keyProps },
173
+ ...hookContext === undefined ? {} : { hookContext },
174
+ ...injectType === undefined ? {} : { injectType },
175
+ jsDoc: jsDocOf(member, file.sf),
176
+ package: file.package,
177
+ source: `${file.rel}:${String(lineOf(file.sf, member))}`,
178
+ })
179
+ }
180
+ }
181
+ }
182
+ return out
183
+ }
184
+
185
+ /**
186
+ * Read every registration call site in one scanned file: which slot it
187
+ * occupies, with which component and cell identity, and which child slots it
188
+ * declares. A call whose `name` is not a string literal is skipped — the
189
+ * shipped composition always names its target literally, and a computed name
190
+ * carries no catalog fact.
191
+ * @param file - a file returned by {@link scanSlotFiles}.
192
+ * @returns the registrations, in source order.
193
+ */
194
+ export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
195
+ const out: SlotRegistration[] = []
196
+ const visit = (node: ts.Node): void => {
197
+ if (ts.isCallExpression(node)
198
+ && ts.isPropertyAccessExpression(node.expression)
199
+ && node.expression.name.text === 'register'
200
+ && isSlotsReceiver(node.expression.expression, file.sf)
201
+ && node.arguments.length >= 1) {
202
+ const options = node.arguments[0]
203
+ if (options !== undefined && ts.isObjectLiteralExpression(options)) {
204
+ const key = stringProperty(options, 'name')
205
+ if (key !== undefined) {
206
+ const id = stringProperty(options, 'id')
207
+ const entryKey = stringProperty(options, 'key')
208
+ out.push({
209
+ key,
210
+ package: file.package,
211
+ component: componentText(node.arguments[1], file.sf),
212
+ ...id === undefined ? {} : { id },
213
+ ...entryKey === undefined ? {} : { entryKey },
214
+ children: childKeys(options),
215
+ source: `${file.rel}:${String(lineOf(file.sf, node))}`,
216
+ })
217
+ }
218
+ }
219
+ }
220
+ ts.forEachChild(node, visit)
221
+ }
222
+ visit(file.sf)
223
+ return out
224
+ }
225
+
226
+ /**
227
+ * Read one standard-kit interface's members from the scanned files: the props
228
+ * a slot component receives for free from the framework at a given scope.
229
+ * @param files - scanned files to search.
230
+ * @param interfaceName - `GlobalStandardProps`, `SessionStandardProps`, or `SessionMaybeStandardProps`.
231
+ * @returns `member: type` texts in declaration order, merged across declaring files.
232
+ */
233
+ export function standardKitMembers(files: readonly ScannedFile[], interfaceName: string): string[] {
234
+ const out: string[] = []
235
+ for (const file of files) {
236
+ for (const body of slotModuleBodies(file.sf)) {
237
+ for (const statement of body.statements) {
238
+ if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) continue
239
+ for (const member of statement.members) {
240
+ if (!ts.isPropertySignature(member)) continue
241
+ const type = member.type === undefined ? 'unknown' : member.type.getText(file.sf)
242
+ out.push(`${member.name.getText(file.sf)}${member.questionToken === undefined ? '' : '?'}: ${collapse(type)}`)
243
+ }
244
+ }
245
+ }
246
+ }
247
+ return out
248
+ }
249
+
250
+ /**
251
+ * Names in the type index that seed texts mention, word-bounded — ONE level, not
252
+ * a transitive closure. The catalog expands an owner-props contract exactly one
253
+ * step: the owner interface carries the interaction protocol in its own member
254
+ * documentation, while the shapes its fields reference belong to the subsystems
255
+ * that own them and would otherwise drag the entire session model into a single
256
+ * slot's report.
257
+ * @param seeds - declaration or signature texts to search.
258
+ * @param index - the type index from {@link indexExportedTypes}.
259
+ * @returns the mentioned names, sorted.
260
+ */
261
+ export function referencedTypeNames(
262
+ seeds: readonly string[],
263
+ index: ReadonlyMap<string, TypeDeclaration>,
264
+ ): string[] {
265
+ const found: string[] = []
266
+ for (const name of index.keys()) {
267
+ const pattern = new RegExp(`\\b${name}\\b`)
268
+ if (seeds.some(text => pattern.test(text))) found.push(name)
269
+ }
270
+ return found.sort()
271
+ }
272
+
273
+ /**
274
+ * Resolve declarations by name, dropping names the index does not hold.
275
+ * @param names - type names to resolve.
276
+ * @param index - the type index from {@link indexExportedTypes}.
277
+ * @returns the resolved declarations, sorted by name.
278
+ */
279
+ export function declaredTypes(
280
+ names: readonly string[],
281
+ index: ReadonlyMap<string, TypeDeclaration>,
282
+ ): TypeDeclaration[] {
283
+ return [...names]
284
+ .flatMap(name => index.get(name) ?? [])
285
+ .sort((left, right) => left.name.localeCompare(right.name))
286
+ }
287
+
288
+ /** Every slot-contract module block in one file, in source order. */
289
+ function slotModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
290
+ const bodies: ts.ModuleBlock[] = []
291
+ for (const statement of sf.statements) {
292
+ if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name)) continue
293
+ if (statement.name.text !== SLOTS_MODULE) continue
294
+ if (statement.body !== undefined && ts.isModuleBlock(statement.body)) bodies.push(statement.body)
295
+ }
296
+ return bodies
297
+ }
298
+
299
+ /**
300
+ * Whether a `X.register(...)` receiver is the slots service. Every other
301
+ * registry in the repo (`ctx.tools`, `ctx.commands`, `ctx.settings`, …) also
302
+ * takes an options object with a `name`, so the receiver is what separates a
303
+ * slot occupancy fact from an unrelated registration.
304
+ */
305
+ function isSlotsReceiver(receiver: ts.Expression, sf: ts.SourceFile): boolean {
306
+ const text = receiver.getText(sf)
307
+ return text === 'slots' || text.endsWith('.slots')
308
+ }
309
+
310
+ /** The workspace package name owning a repo-relative file, memoized per package root. */
311
+ function packageNameOf(scanRoot: string, rel: string, cache: Map<string, string>): string {
312
+ let dir = dirname(resolve(scanRoot, rel))
313
+ while (dir.length > scanRoot.length) {
314
+ const cached = cache.get(dir)
315
+ if (cached !== undefined) return cached
316
+ try {
317
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown }
318
+ if (typeof manifest.name === 'string') {
319
+ cache.set(dir, manifest.name)
320
+ return manifest.name
321
+ }
322
+ } catch {
323
+ // No manifest at this level: keep walking up to the owning package root.
324
+ }
325
+ dir = dirname(dir)
326
+ }
327
+ return '(unknown package)'
328
+ }
329
+
330
+ /** TSX must parse as TSX; a `.ts` file with JSX-looking generics must not. */
331
+ function scriptKindOf(rel: string): ts.ScriptKind {
332
+ return rel.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
333
+ }
334
+
335
+ /** 1-based line of a node's first character. */
336
+ function lineOf(sf: ts.SourceFile, node: ts.Node): number {
337
+ return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
338
+ }
339
+
340
+ /** Declaration text including leading JSDoc, with container indentation removed. */
341
+ function declarationText(statement: ts.Node, sf: ts.SourceFile): string {
342
+ return dedent(sf.text.slice(statement.getStart(sf, true), statement.getEnd()))
343
+ }
344
+
345
+ /** One member's JSDoc comment text, '' when the member has none. */
346
+ function jsDocOf(member: ts.Node, sf: ts.SourceFile): string {
347
+ // getStart(includeJsDoc) brackets exactly the doc comment: with it the range
348
+ // opens at `/**`, without it at the member itself.
349
+ const withDoc = member.getStart(sf, true)
350
+ const withoutDoc = member.getStart(sf, false)
351
+ if (withDoc >= withoutDoc) return ''
352
+ return dedent(sf.text.slice(withDoc, withoutDoc).trimEnd())
353
+ }
354
+
355
+ /** Strip the shared leading indentation of a multi-line source slice. */
356
+ function dedent(text: string): string {
357
+ const lines = text.split('\n')
358
+ const indents = lines.slice(1).filter(line => line.trim() !== '')
359
+ .map(line => (/^\s*/.exec(line) as RegExpExecArray)[0].length)
360
+ const shared = indents.length === 0 ? 0 : Math.min(...indents)
361
+ return [lines[0] ?? '', ...lines.slice(1).map(line => line.slice(shared))].join('\n').trimEnd()
362
+ }
363
+
364
+ /** Collapse a type text to one line so catalog rows stay one row. */
365
+ function collapse(text: string): string {
366
+ return text.replace(/\s+/g, ' ').trim()
367
+ }
368
+
369
+ /** A type-literal member's string-literal type text, '' when absent or computed. */
370
+ function literalMember(entry: ts.TypeLiteralNode | undefined, name: string): string {
371
+ const member = namedMember(entry, name)
372
+ if (member?.type === undefined) return ''
373
+ return ts.isLiteralTypeNode(member.type) && ts.isStringLiteral(member.type.literal)
374
+ ? member.type.literal.text
375
+ : ''
376
+ }
377
+
378
+ /** A type-literal member's type text on one line, absent when the member is. */
379
+ function memberTypeText(
380
+ entry: ts.TypeLiteralNode | undefined,
381
+ name: string,
382
+ sf: ts.SourceFile,
383
+ ): string | undefined {
384
+ const member = namedMember(entry, name)
385
+ return member?.type === undefined ? undefined : collapse(member.type.getText(sf))
386
+ }
387
+
388
+ /** One named property signature of a type literal. */
389
+ function namedMember(entry: ts.TypeLiteralNode | undefined, name: string): ts.PropertySignature | undefined {
390
+ if (entry === undefined) return undefined
391
+ for (const member of entry.members) {
392
+ if (ts.isPropertySignature(member) && memberName(member.name) === name) return member
393
+ }
394
+ return undefined
395
+ }
396
+
397
+ /** A property name's text, quotes removed. */
398
+ function memberName(name: ts.PropertyName): string {
399
+ return ts.isStringLiteral(name) || ts.isIdentifier(name) ? name.text : name.getText()
400
+ }
401
+
402
+ /** One string-literal property of an options object literal. */
403
+ function stringProperty(options: ts.ObjectLiteralExpression, name: string): string | undefined {
404
+ for (const property of options.properties) {
405
+ if (!ts.isPropertyAssignment(property)) continue
406
+ if (memberName(property.name) !== name) continue
407
+ if (ts.isStringLiteral(property.initializer)) return property.initializer.text
408
+ }
409
+ return undefined
410
+ }
411
+
412
+ /** The SlotMap keys a registration's `children` table declares. */
413
+ function childKeys(options: ts.ObjectLiteralExpression): string[] {
414
+ for (const property of options.properties) {
415
+ if (!ts.isPropertyAssignment(property)) continue
416
+ if (memberName(property.name) !== 'children') continue
417
+ if (!ts.isObjectLiteralExpression(property.initializer)) return []
418
+ return property.initializer.properties
419
+ .flatMap(child => (child.name === undefined ? [] : [memberName(child.name)]))
420
+ }
421
+ return []
422
+ }
423
+
424
+ /** The component argument as written; a non-identifier expression is collapsed. */
425
+ function componentText(argument: ts.Expression | undefined, sf: ts.SourceFile): string {
426
+ if (argument === undefined) return '(none)'
427
+ const text = collapse(argument.getText(sf))
428
+ return text.length > 60 ? `${text.slice(0, 57)}…` : text
429
+ }
scripts/snapshot-workspace-parent.spec.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync } from 'node:fs'
2
+ import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { dirname, join, parse } from 'node:path'
5
+ import { Context } from '@deepseek-ai/cordis'
6
+ import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
7
+ import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
8
+ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
9
+ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
10
+ import { describe, expect, it } from 'vitest'
11
+ import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from './snapshot-workspace-parent.ts'
12
+
13
+ // Host disk exhaustion is not simulated: placement and the real write fence are the regression oracles.
14
+ describe('snapshot workspace parent', () => {
15
+ // Windows directory permissions and root bypass do not enforce POSIX write bits.
16
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('uses home when the temp parent is not writable', async () => {
17
+ const base = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-snapshot-readonly-'))
18
+ try {
19
+ const temporary = join(base, '_temp')
20
+ await mkdir(temporary)
21
+ await chmod(base, 0o500)
22
+ expect(outsideTempWorkspaceParent(temporary)).toBe(homedir())
23
+ } finally {
24
+ await chmod(base, 0o700)
25
+ await rm(base, { recursive: true, force: true })
26
+ }
27
+ })
28
+
29
+ it('uses home when a temp sibling would require a system directory or inherit its grant', () => {
30
+ expect(outsideTempWorkspaceParent('/tmp')).toBe(homedir())
31
+ expect(outsideTempWorkspaceParent(parse(tmpdir()).root)).toBe(homedir())
32
+ expect(outsideTempWorkspaceParent(join(canonicalPath('/tmp'), 'runner', '_temp'))).toBe(homedir())
33
+ })
34
+
35
+ it('rejects automatically writable temporary workspaces, including symlink aliases', async () => {
36
+ const root = await mkdtemp(join(tmpdir(), 'dsh-snapshot-parent-'))
37
+ try {
38
+ expect(() => { assertWorkspaceOutsideTemp(root) }).toThrow('must be outside temporary writable root')
39
+ const alias = join(root, 'alias')
40
+ await symlink(tmpdir(), alias, process.platform === 'win32' ? 'junction' : 'dir')
41
+ expect(() => { assertWorkspaceOutsideTemp(alias) }).toThrow('must be outside temporary writable root')
42
+ } finally {
43
+ await rm(root, { recursive: true, force: true })
44
+ }
45
+ })
46
+
47
+ it('allows the allocated workspace but denies sibling writes and cleans the complete tree', async () => {
48
+ const base = await mkdtemp(join(outsideTempWorkspaceParent(), 'dsh-snapshot-parent-'))
49
+ const ctx = new Context()
50
+ const fibers: Awaited<ReturnType<Context['plugin']>>[] = []
51
+ try {
52
+ const temporary = join(base, '_temp')
53
+ await mkdir(temporary)
54
+ expect(outsideTempWorkspaceParent(temporary)).toBe(canonicalPath(base))
55
+ const workspace = await mkdtemp(join(outsideTempWorkspaceParent(temporary), 'workspace-'))
56
+ const outside = join(base, 'outside.txt')
57
+ assertWorkspaceOutsideTemp(workspace)
58
+ expect(dirname(workspace)).toBe(canonicalPath(base))
59
+ expect((await stat(workspace)).dev).toBe((await stat(temporary)).dev)
60
+ fibers.push(await ctx.plugin(SessionProjectionRegistry))
61
+ fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }))
62
+ fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: workspace }))
63
+ const inside = join(workspace, 'inside.txt')
64
+ await ctx.fs.writeText(await ctx.fs.resolve(inside), 'inside')
65
+ expect(await readFile(inside, 'utf8')).toBe('inside')
66
+ await expect(ctx.fs.writeText(await ctx.fs.resolve(outside), 'outside'))
67
+ .rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
68
+ expect(existsSync(outside)).toBe(false)
69
+ } finally {
70
+ try {
71
+ for (const fiber of fibers.reverse()) await fiber.dispose()
72
+ } finally {
73
+ await rm(base, { recursive: true, force: true })
74
+ }
75
+ }
76
+ expect(existsSync(base)).toBe(false)
77
+ })
78
+ })
scripts/test-fixture-cleanup.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Junction-safe fixture cleanup for Windows. Test fixtures junction the REAL
3
+ * `scripts/`, `node_modules`, and tsx package directories so installer probes
4
+ * resolve through them; Windows recursive deletion — both Node's `rmSync` and
5
+ * Git's `worktree remove` — follows MOUNT_POINT junctions into their targets
6
+ * and would delete the repository's own directories. POSIX `unlink`/`rm`
7
+ * already remove symlinks without following them, so the walk is a no-op
8
+ * there.
9
+ */
10
+
11
+ import { lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ /**
15
+ * Recursively unlink every symbolic link (junction) under `path`.
16
+ * @param path - the fixture tree whose reparse points are unlinked.
17
+ */
18
+ export function unlinkFixtureLinks(path: string): void {
19
+ const visit = (entry: string): void => {
20
+ let stat: ReturnType<typeof lstatSync>
21
+ try {
22
+ stat = lstatSync(entry)
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
25
+ throw error
26
+ }
27
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
28
+ if (stat.isSymbolicLink()) unlinkSync(entry)
29
+ return
30
+ }
31
+ for (const child of readdirSync(entry)) visit(join(entry, child))
32
+ }
33
+ visit(path)
34
+ }
35
+
36
+ /**
37
+ * Remove one fixture tree after its junctions are unlinked (see
38
+ * {@link unlinkFixtureLinks}). Retries the removal: Windows releases child
39
+ * process and antivirus file handles asynchronously, and an unretried
40
+ * `rmSync` fails immediately with EPERM under load. A 10-second retry window
41
+ * (50 attempts × 200 ms) covers the failover pool's slow handle release;
42
+ * release is one-shot (a terminated child's handles drain, not reacquired),
43
+ * so a bounded window suffices and never pins afterEach cleanup.
44
+ * @param path - the fixture tree to remove.
45
+ */
46
+ export function removeFixtureSafely(path: string): void {
47
+ unlinkFixtureLinks(path)
48
+ rmSync(path, { recursive: true, force: true, maxRetries: 50, retryDelay: 200 })
49
+ }
scripts/test-proxy-environment.spec.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { PROXY_ENV_NAMES } from '../packages/util/http-proxy/src/policy.ts'
4
+ import { clearAmbientProxyEnv, TEST_PROXY_SETUP_FILE, vitestConfigFiles } from './test-proxy-environment.ts'
5
+
6
+ describe('ambient proxy environment', () => {
7
+ it('clears every name the policy resolver reads, in both casings', () => {
8
+ const env: NodeJS.ProcessEnv = {
9
+ HTTP_PROXY: 'http://p:1', http_proxy: 'http://p:1',
10
+ HTTPS_PROXY: 'http://p:1', https_proxy: 'http://p:1',
11
+ ALL_PROXY: 'http://p:1', all_proxy: 'http://p:1',
12
+ NO_PROXY: 'example.com', no_proxy: 'example.com',
13
+ NODE_USE_ENV_PROXY: '1',
14
+ PATH: '/usr/bin',
15
+ }
16
+ expect(clearAmbientProxyEnv(env)).toHaveLength(PROXY_ENV_NAMES.length + 1)
17
+ expect(env).toEqual({ PATH: '/usr/bin' })
18
+ })
19
+
20
+ it('reports only the names that were set, and touches nothing else', () => {
21
+ const env: NodeJS.ProcessEnv = { all_proxy: 'http://p:1', HOME: '/home/me' }
22
+ expect(clearAmbientProxyEnv(env)).toEqual(['all_proxy'])
23
+ expect(env).toEqual({ HOME: '/home/me' })
24
+ })
25
+
26
+ // A runtime assertion that this process is clear would pass either way: importing the module
27
+ // above already ran it. What can actually regress is the wiring — a new Vitest project, or a
28
+ // config that lists only the invariant host — so that is what this pins.
29
+ const declared = vitestConfigFiles()
30
+ .map(config => ({ config, slots: readFileSync(config, 'utf8').match(/setupFiles: \[[^\]]*\]/g) ?? [] }))
31
+ .filter(entry => entry.slots.length > 0)
32
+
33
+ it('finds the configurations that declare a setup at all', () => {
34
+ // Guards the discovery itself: a glob that stopped matching would make every case below vacuous.
35
+ expect(declared.map(entry => entry.config)).toEqual([
36
+ 'vitest.bench.config.ts', 'vitest.config.ts', 'vitest.e2e.config.ts', 'vitest.expected.config.ts',
37
+ 'vitest.snapshot.config.ts',
38
+ ])
39
+ })
40
+
41
+ it.each(declared)('$config runs the setup in every setupFiles it declares', ({ slots }) => {
42
+ for (const slot of slots) expect(slot).toContain(TEST_PROXY_SETUP_FILE)
43
+ })
44
+ })