@erclx/canon 5.14.0 → 5.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/read-frames/SKILL.md +3 -2
  6. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  7. package/claude/skills/record-screencast/SKILL.md +28 -5
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/close-out.md +1 -1
  10. package/claude/skills/review-pr/references/post.md +1 -1
  11. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  12. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  13. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  14. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  15. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  16. package/docs/agents/commands.md +3 -0
  17. package/docs/agents/demo.md +34 -7
  18. package/docs/agents/migrate.md +1 -1
  19. package/docs/agents/pr-reads.md +1 -1
  20. package/docs/agents/sandbox.md +28 -3
  21. package/docs/agents/state-scoped-risk.md +2 -2
  22. package/docs/target/migrations.md +1 -1
  23. package/docs/workflow/ai-workflow.md +2 -3
  24. package/docs/workflow/skill-map.md +1 -1
  25. package/governance/renames.toml +7 -5
  26. package/governance/rules/canon/630-requirements.md +1 -1
  27. package/package.json +8 -6
  28. package/scripts/core/check-color-source.sh +11 -1
  29. package/scripts/core/install-check.sh +13 -4
  30. package/scripts/lib/sandbox-dispatch.sh +1 -1
  31. package/scripts/lib/sandbox-fixtures.sh +2 -2
  32. package/scripts/lib/sandbox-path.sh +1 -1
  33. package/scripts/manage-sandbox.sh +2 -2
  34. package/src/canvas/client/app.tsx +85 -0
  35. package/src/canvas/client/main.tsx +62 -0
  36. package/src/canvas/client/pages-panel.tsx +123 -0
  37. package/src/canvas/client/shell.css +319 -0
  38. package/src/canvas/client/state.ts +186 -0
  39. package/src/canvas/client/surface.tsx +213 -0
  40. package/src/canvas/content.ts +326 -0
  41. package/src/canvas/index.html +14 -0
  42. package/src/canvas/server.ts +302 -0
  43. package/src/canvas/tokens.ts +71 -0
  44. package/src/claude/seeds.ts +0 -1
  45. package/src/cli.ts +2 -0
  46. package/src/commands/canvas.ts +325 -0
  47. package/src/commands/claude.ts +9 -14
  48. package/src/commands/demo.ts +6 -6
  49. package/src/commands/sandbox.ts +110 -2
  50. package/src/context/citations.ts +1 -1
  51. package/src/context/classify/prompts.ts +1 -1
  52. package/src/context/folders.ts +0 -1
  53. package/src/demo/compile.ts +8 -4
  54. package/src/demo/container.ts +1 -1
  55. package/src/gov/renames.ts +3 -2
  56. package/src/help.ts +1 -0
  57. package/src/record-root.ts +1 -0
  58. package/src/sandbox/census.ts +1 -1
  59. package/src/sandbox/coverage.ts +1 -1
  60. package/src/sandbox/equivalence.ts +847 -0
  61. package/src/sandbox/expect.ts +0 -1
  62. package/src/serve/static.ts +25 -11
  63. package/src/shipped/references.ts +8 -10
  64. package/src/surface-root.ts +0 -1
  65. package/src/targets/pulls.ts +2 -2
  66. package/standards/architecture.md +1 -1
  67. package/standards/context.md +1 -1
  68. package/standards/design.md +1 -1
  69. package/standards/index.md +0 -1
  70. package/standards/requirements.md +4 -6
  71. package/standards/wireframes.md +2 -2
  72. package/tooling/claude/reference.md +0 -1
  73. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  74. package/tsconfig.json +1 -1
  75. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  76. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  77. package/governance/rules/canon/645-decisions.md +0 -12
  78. package/standards/decisions.md +0 -105
  79. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -0,0 +1,847 @@
1
+ import { execFileSync, spawn } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import {
4
+ closeSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ openSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ readlinkSync,
13
+ rmSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { collectCoverage, DEFAULT_ARM } from '@/sandbox/coverage'
20
+
21
+ /** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
22
+ const FIXTURES_DIR = 'fixtures'
23
+
24
+ /** One arm provisions inside this budget on each side, or reads as a failed run. */
25
+ const PROVISION_TIMEOUT_MS = 120_000
26
+
27
+ /** Arms run this many at a time, each in its own directory. */
28
+ const CONCURRENCY = 6
29
+
30
+ /** Pinned across both sides of every arm, so the run id cannot read as a difference. */
31
+ const PINNED_RUN_ID = 'equivalence'
32
+
33
+ /** Written for a process that timed out or was killed, the status `timeout(1)` uses. */
34
+ const TIMEOUT_STATUS = 124
35
+
36
+ const ROOT_PLACEHOLDER = '<root>'
37
+
38
+ export interface Arm {
39
+ readonly category: string
40
+ readonly command: string
41
+ /** Undefined for a scenario that routes no arm, which carries one provision. */
42
+ readonly arm?: string
43
+ readonly anchor: boolean
44
+ }
45
+
46
+ export type ArmState =
47
+ | 'identical'
48
+ | 'differs'
49
+ | 'red-on-base'
50
+ | 'skipped-anchor'
51
+
52
+ export interface Mask {
53
+ /** Scopes the entry to one arm key, or every arm when absent. */
54
+ readonly arm?: string
55
+ readonly key: RegExp
56
+ /** When set, matches are rewritten before the comparison and the key stays compared. */
57
+ readonly replace?: RegExp
58
+ readonly reason: string
59
+ }
60
+
61
+ /**
62
+ * Every difference two provisions of one commit are known to produce and that
63
+ * says nothing about the change under test. A mask outside this list does not
64
+ * exist, and each entry carries the measurement that put it here, so one that
65
+ * stops matching can be deleted rather than left to hide something later.
66
+ */
67
+ export const MASKS: readonly Mask[] = [
68
+ {
69
+ key: /^file:canon\/config\/config\.json$/,
70
+ reason:
71
+ 'the install stamp carries a `syncedAt` wall-clock time, so two provisions of one commit differ',
72
+ },
73
+ {
74
+ arm: 'claude:read-frames/resolved',
75
+ key: /^file:demos\/cold-open\.webm$/,
76
+ reason: 'ffmpeg output carries an encoder timestamp, so two runs differ',
77
+ },
78
+ {
79
+ key: /^log$/,
80
+ replace: /sandbox-occupant-\d+|\(pid \d+\)/g,
81
+ reason:
82
+ 'the narration names the fake occupant session by its process id, which differs per run',
83
+ },
84
+ {
85
+ key: /^log$/,
86
+ replace: /Resolved, downloaded and extracted \[\d+\]/g,
87
+ reason:
88
+ 'a package fetch reports how many packages it downloaded, and the base side runs first and warms the cache the head side then reads',
89
+ },
90
+ {
91
+ key: /^git:.*:index$/,
92
+ replace: /^160000 [0-9a-f]+/gm,
93
+ reason:
94
+ 'a submodule entry holds the nested commit id, which a commit timestamp changes, while the nested log is compared on its own',
95
+ },
96
+ ]
97
+
98
+ const MASKED_TOKEN = '<masked>'
99
+
100
+ /** A key a mask touched, with the reason of the mask that did, so two masks on one key stay apart. */
101
+ export interface MaskedKey {
102
+ readonly key: string
103
+ readonly reason: string
104
+ }
105
+
106
+ export type Manifest = ReadonlyMap<string, string>
107
+
108
+ export interface ArmRecord {
109
+ readonly arm: string
110
+ readonly state: ArmState
111
+ readonly differs: readonly string[]
112
+ readonly masked: readonly MaskedKey[]
113
+ readonly baseExit?: number
114
+ readonly headExit?: number
115
+ readonly check?: CheckRecord
116
+ }
117
+
118
+ export interface CheckRecord {
119
+ readonly asserted?: number
120
+ readonly state?: string
121
+ readonly error?: string
122
+ }
123
+
124
+ export interface EquivalenceRecord {
125
+ readonly base: string
126
+ readonly baseCommit: string
127
+ readonly canonVersion: string
128
+ readonly masksApplied: readonly string[]
129
+ readonly arms: readonly ArmRecord[]
130
+ readonly errors: readonly string[]
131
+ readonly counts: Readonly<Record<ArmState, number>>
132
+ }
133
+
134
+ export interface EquivalenceOptions {
135
+ readonly root: string
136
+ readonly targets: readonly string[]
137
+ readonly base: string
138
+ readonly includeAnchor: boolean
139
+ readonly useMasks: boolean
140
+ readonly out?: string
141
+ }
142
+
143
+ export function armKey(arm: Arm): string {
144
+ const scenario = `${arm.category}:${arm.command}`
145
+
146
+ return arm.arm === undefined ? scenario : `${scenario}/${arm.arm}`
147
+ }
148
+
149
+ function directories(path: string): string[] {
150
+ return readdirSync(path, { withFileTypes: true })
151
+ .filter((entry) => entry.isDirectory())
152
+ .map((entry) => entry.name)
153
+ .sort()
154
+ }
155
+
156
+ /**
157
+ * Reads the quoted arguments of `select_or_route_scenario`, joining a call that
158
+ * continues across backslash-newline pairs. The first argument is the prompt and
159
+ * the rest are arm names. A call this cannot see returns no arms, which the
160
+ * coverage cross-check in `enumerateArms` turns into an error rather than a
161
+ * smaller list.
162
+ */
163
+ export function parseArmNames(source: string): string[] {
164
+ const start = source.search(/select_or_route_scenario\b/)
165
+ if (start === -1) return []
166
+
167
+ const call = source
168
+ .slice(start)
169
+ .replace(/\\\n/g, ' ')
170
+ .split('\n')[0]
171
+ ?.replace(/^select_or_route_scenario/, '')
172
+
173
+ return [...(call ?? '').matchAll(/"([^"]*)"/g)]
174
+ .map((match) => match[1] ?? '')
175
+ .slice(1)
176
+ }
177
+
178
+ function matchesTarget(
179
+ category: string,
180
+ command: string,
181
+ targets: readonly string[],
182
+ ): boolean {
183
+ if (targets.length === 0) return true
184
+
185
+ return targets.some((t) => {
186
+ const scenario = t.split('/')[0]
187
+
188
+ return scenario === category || scenario === `${category}:${command}`
189
+ })
190
+ }
191
+
192
+ function matchesArm(arm: Arm, targets: readonly string[]): boolean {
193
+ const named = targets.filter((t) => t.includes('/'))
194
+ const scenario = `${arm.category}:${arm.command}`
195
+ const scoped = named.filter((t) => t.startsWith(`${scenario}/`))
196
+ if (scoped.length === 0) return true
197
+
198
+ return scoped.includes(armKey(arm))
199
+ }
200
+
201
+ /**
202
+ * Lists every provision a target names, from the scenario source. An armed arm
203
+ * that `canon sandbox coverage` reports and this list lacks is returned as an
204
+ * error, so a scenario whose call this reader cannot parse cannot shrink the
205
+ * check unannounced.
206
+ */
207
+ export function enumerateArms(
208
+ root: string,
209
+ targets: readonly string[],
210
+ ): { arms: Arm[]; errors: string[] } {
211
+ const sandboxDir = join(root, 'sandbox')
212
+ const arms: Arm[] = []
213
+ const errors: string[] = []
214
+ const coverage = collectCoverage(root)
215
+
216
+ for (const category of directories(sandboxDir)) {
217
+ if (category === FIXTURES_DIR) continue
218
+
219
+ const commands = readdirSync(join(sandboxDir, category), {
220
+ withFileTypes: true,
221
+ })
222
+ .filter((f) => f.isFile() && f.name.endsWith('.sh'))
223
+ .map((f) => f.name.replace(/\.sh$/, ''))
224
+ .sort()
225
+
226
+ for (const command of commands) {
227
+ if (!matchesTarget(category, command, targets)) continue
228
+
229
+ const source = readFileSync(
230
+ join(sandboxDir, category, `${command}.sh`),
231
+ 'utf8',
232
+ )
233
+ const anchor = /^use_anchor\s*\(\)/m.test(source)
234
+ const names = parseArmNames(source)
235
+
236
+ const found: Arm[] =
237
+ names.length === 0
238
+ ? [{ category, command, anchor }]
239
+ : names.map((name) => ({ category, command, arm: name, anchor }))
240
+ arms.push(...found.filter((a) => matchesArm(a, targets)))
241
+
242
+ const armed = coverage.scenarios.find(
243
+ (s) => s.category === category && s.command === command,
244
+ )?.armed
245
+ for (const declared of armed ?? []) {
246
+ if (declared === DEFAULT_ARM || names.includes(declared)) continue
247
+ errors.push(
248
+ `${category}:${command} declares arm "${declared}" that enumeration lacks`,
249
+ )
250
+ }
251
+ }
252
+ }
253
+
254
+ return { arms, errors }
255
+ }
256
+
257
+ function sha256(path: string): string {
258
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
259
+ }
260
+
261
+ function git(cwd: string, args: string[]): string {
262
+ try {
263
+ return execFileSync('git', args, {
264
+ cwd,
265
+ encoding: 'utf8',
266
+ stdio: ['ignore', 'pipe', 'pipe'],
267
+ }).trimEnd()
268
+ } catch (error) {
269
+ return `error: ${error instanceof Error ? error.message : String(error)}`
270
+ }
271
+ }
272
+
273
+ function isBare(dir: string): boolean {
274
+ return (
275
+ existsSync(join(dir, 'HEAD')) &&
276
+ existsSync(join(dir, 'objects')) &&
277
+ !existsSync(join(dir, '.git'))
278
+ )
279
+ }
280
+
281
+ function isRepository(dir: string): boolean {
282
+ return existsSync(join(dir, '.git'))
283
+ }
284
+
285
+ /**
286
+ * Walks a provisioned tree into flat `key -> value` entries. A file is its mode
287
+ * and sha256, a symlink its target. The outer repository and every nested one
288
+ * contribute commit subjects, the staged index, and the branch, and never their
289
+ * object files, which differ between two runs of one commit.
290
+ */
291
+ export function buildManifest(dir: string): Manifest {
292
+ const manifest = new Map<string, string>()
293
+
294
+ function walk(relative: string): void {
295
+ const absolute = join(dir, relative)
296
+
297
+ // A bare repository holds objects whose bytes differ between two runs of one
298
+ // commit, so it contributes its commit subjects and head and its files stay out.
299
+ if (isBare(absolute)) {
300
+ manifest.set(
301
+ `git:${relative}:log`,
302
+ git(absolute, ['log', '--format=%s', '--reverse', '--all']),
303
+ )
304
+ manifest.set(
305
+ `git:${relative}:head`,
306
+ git(absolute, ['symbolic-ref', 'HEAD']),
307
+ )
308
+
309
+ return
310
+ }
311
+
312
+ if (isRepository(absolute)) {
313
+ const label = relative === '' ? '.' : relative
314
+ manifest.set(
315
+ `git:${label}:log`,
316
+ git(absolute, ['log', '--format=%s', '--reverse']),
317
+ )
318
+ manifest.set(`git:${label}:index`, git(absolute, ['ls-files', '-s']))
319
+ manifest.set(
320
+ `git:${label}:branch`,
321
+ git(absolute, ['branch', '--show-current']),
322
+ )
323
+ }
324
+
325
+ for (const entry of readdirSync(absolute, { withFileTypes: true })) {
326
+ if (entry.name === '.git') continue
327
+
328
+ const child = relative === '' ? entry.name : join(relative, entry.name)
329
+ const stat = lstatSync(join(dir, child))
330
+
331
+ if (stat.isSymbolicLink()) {
332
+ manifest.set(`link:${child}`, readlinkSync(join(dir, child)))
333
+ } else if (stat.isDirectory()) {
334
+ walk(child)
335
+ } else if (stat.isFile()) {
336
+ const mode = (stat.mode & 0o777).toString(8)
337
+ manifest.set(`file:${child}`, `${mode} ${sha256(join(dir, child))}`)
338
+ }
339
+ }
340
+ }
341
+
342
+ if (existsSync(dir)) walk('')
343
+
344
+ return manifest
345
+ }
346
+
347
+ /**
348
+ * Compares two manifests and names every key that differs, with a key present
349
+ * on one side only counting as a difference. Keys matching a mask for this arm
350
+ * drop out of both sides first and come back as `masked`, so the caller can
351
+ * report each mask that fired.
352
+ */
353
+ export function diffManifests(
354
+ arm: string,
355
+ a: Manifest,
356
+ b: Manifest,
357
+ masks: readonly Mask[],
358
+ ): { differs: string[]; masked: MaskedKey[] } {
359
+ const differs: string[] = []
360
+ const masked: MaskedKey[] = []
361
+
362
+ const applies = (m: Mask, key: string): boolean =>
363
+ (m.arm === undefined || m.arm === arm) && m.key.test(key)
364
+ const hidingMask = (key: string): Mask | undefined =>
365
+ masks.find((m) => m.replace === undefined && applies(m, key))
366
+ const isMasked = (key: string): boolean => hidingMask(key) !== undefined
367
+ const note = (key: string, reason: string): void => {
368
+ if (!masked.some((m) => m.key === key && m.reason === reason))
369
+ masked.push({ key, reason })
370
+ }
371
+
372
+ // A rewriting mask changes the value before the comparison. It counts as having
373
+ // fired only where the text it rewrites differs between the sides, since a
374
+ // match both sides share hides nothing and would otherwise take the credit for
375
+ // a difference another mask on the same key absorbed.
376
+ const rewrite = (
377
+ key: string,
378
+ value: string | undefined,
379
+ ): string | undefined => {
380
+ let next = value
381
+ for (const m of masks) {
382
+ if (m.replace === undefined || !applies(m, key) || next === undefined)
383
+ continue
384
+ next = next.replace(m.replace, MASKED_TOKEN)
385
+ }
386
+
387
+ return next
388
+ }
389
+ const noteRewrites = (key: string): void => {
390
+ for (const m of masks) {
391
+ if (m.replace === undefined || !applies(m, key)) continue
392
+ const matches = (v: string | undefined): string =>
393
+ (v?.match(m.replace as RegExp) ?? []).join('\n')
394
+ if (matches(a.get(key)) !== matches(b.get(key))) note(key, m.reason)
395
+ }
396
+ }
397
+
398
+ const keys = [...new Set([...a.keys(), ...b.keys()])].sort()
399
+ const maskedPaths = new Set(
400
+ keys
401
+ .filter((k) => k.startsWith('file:') && isMasked(k))
402
+ .map((k) => k.slice(5)),
403
+ )
404
+
405
+ // A staged blob's hash is the masked file's content again, so its index line
406
+ // leaves the comparison with the file itself.
407
+ const comparable = (
408
+ key: string,
409
+ value: string | undefined,
410
+ ): string | undefined => {
411
+ const rewritten = rewrite(key, value)
412
+ const repo = /^git:(.*):index$/.exec(key)?.[1]
413
+ if (repo === undefined || rewritten === undefined) return rewritten
414
+
415
+ return rewritten
416
+ .split('\n')
417
+ .filter((line) => {
418
+ const path = line.split('\t')[1] ?? ''
419
+
420
+ return !maskedPaths.has(repo === '.' ? path : `${repo}/${path}`)
421
+ })
422
+ .join('\n')
423
+ }
424
+
425
+ for (const key of keys) {
426
+ const hiding = hidingMask(key)
427
+ if (hiding !== undefined) {
428
+ note(key, hiding.reason)
429
+ continue
430
+ }
431
+ noteRewrites(key)
432
+ if (comparable(key, a.get(key)) !== comparable(key, b.get(key)))
433
+ differs.push(key)
434
+ }
435
+
436
+ return { differs, masked }
437
+ }
438
+
439
+ /**
440
+ * Classifies one arm from its two exits and its manifest difference. A pair of
441
+ * exits that disagree is a difference whatever the trees hold, since a side that
442
+ * fails and a side that succeeds are not the same behavior.
443
+ */
444
+ export function classifyArm(
445
+ baseExit: number,
446
+ headExit: number,
447
+ differs: readonly string[],
448
+ ): { state: ArmState; differs: string[] } {
449
+ const keys = [...differs]
450
+ if (baseExit !== headExit) keys.push('exit')
451
+ if (keys.length > 0) return { state: 'differs', differs: keys }
452
+ if (baseExit !== 0) return { state: 'red-on-base', differs: [] }
453
+
454
+ return { state: 'identical', differs: [] }
455
+ }
456
+
457
+ interface Provisioned {
458
+ readonly exit: number
459
+ readonly log: string
460
+ }
461
+
462
+ /**
463
+ * Provisions run in their own process group so an interrupted run can end each
464
+ * one with its scenario scripts. A child left alive would recreate a tree inside
465
+ * a folder this run just removed.
466
+ */
467
+ const running = new Set<number>()
468
+
469
+ function killGroup(pid: number): void {
470
+ try {
471
+ process.kill(-pid, 'SIGKILL')
472
+ } catch {
473
+ // The group already exited.
474
+ }
475
+ }
476
+
477
+ function killRunning(): void {
478
+ for (const pid of running) killGroup(pid)
479
+ running.clear()
480
+ }
481
+
482
+ function runProcess(
483
+ command: string,
484
+ args: string[],
485
+ options: { cwd: string; env: NodeJS.ProcessEnv; logPath: string },
486
+ ): Promise<number> {
487
+ return new Promise((resolve) => {
488
+ const fd = openSync(options.logPath, 'w')
489
+ const child = spawn(command, args, {
490
+ cwd: options.cwd,
491
+ env: options.env,
492
+ stdio: ['ignore', fd, fd],
493
+ timeout: PROVISION_TIMEOUT_MS,
494
+ detached: true,
495
+ })
496
+ if (child.pid !== undefined) running.add(child.pid)
497
+ const finish = (status: number): void => {
498
+ // A scenario can leave a background process in the group, such as the fake
499
+ // occupant `git-worktree` starts, and nothing else ends it when the tree goes.
500
+ if (child.pid !== undefined) killGroup(child.pid)
501
+ if (child.pid !== undefined) running.delete(child.pid)
502
+ closeSync(fd)
503
+ resolve(status)
504
+ }
505
+ child.on('error', () => finish(TIMEOUT_STATUS))
506
+ child.on('close', (code) => finish(code ?? TIMEOUT_STATUS))
507
+ })
508
+ }
509
+
510
+ /**
511
+ * A scratch Claude config folder beside each arm's tree. A scenario can register
512
+ * a fake session record under the config folder, which would otherwise be the
513
+ * operator's own, and nothing removes it once the process it names is gone.
514
+ */
515
+ function configDirFor(dir: string): string {
516
+ return `${dir}.config`
517
+ }
518
+
519
+ function provisionEnv(arm: Arm, dir: string): NodeJS.ProcessEnv {
520
+ const env: NodeJS.ProcessEnv = {
521
+ ...process.env,
522
+ CANON_NON_INTERACTIVE: '1',
523
+ CANON_SANDBOX_DIR: dir,
524
+ CANON_SANDBOX_RUN_ID: PINNED_RUN_ID,
525
+ CLAUDE_CONFIG_DIR: configDirFor(dir),
526
+ }
527
+ if (arm.arm === undefined) delete env.SANDBOX_SCENARIO
528
+ else env.SANDBOX_SCENARIO = arm.arm
529
+
530
+ return env
531
+ }
532
+
533
+ /**
534
+ * Provisions through the public `canon sandbox` entry on one side, treating the
535
+ * harness as a black box so the check survives the harness being replaced. The
536
+ * log carries the checkout root as a placeholder, since the two sides differ in
537
+ * exactly that.
538
+ */
539
+ async function provision(
540
+ root: string,
541
+ arm: Arm,
542
+ dir: string,
543
+ logPath: string,
544
+ ): Promise<Provisioned> {
545
+ rmSync(dir, { recursive: true, force: true })
546
+ rmSync(configDirFor(dir), { recursive: true, force: true })
547
+ const exit = await runProcess(
548
+ 'bun',
549
+ [join(root, 'src', 'cli.ts'), 'sandbox', `${arm.category}:${arm.command}`],
550
+ { cwd: root, env: provisionEnv(arm, dir), logPath },
551
+ )
552
+ const log = readFileSync(logPath, 'utf8').split(root).join(ROOT_PLACEHOLDER)
553
+
554
+ return { exit, log }
555
+ }
556
+
557
+ async function checkHead(
558
+ root: string,
559
+ arm: Arm,
560
+ dir: string,
561
+ logPath: string,
562
+ ): Promise<CheckRecord> {
563
+ const args = [join(root, 'src', 'cli.ts'), 'sandbox', 'check']
564
+ args.push(`${arm.category}:${arm.command}`)
565
+ if (arm.arm !== undefined) args.push(arm.arm)
566
+ args.push('--json')
567
+
568
+ await runProcess('bun', args, {
569
+ cwd: root,
570
+ env: provisionEnv(arm, dir),
571
+ logPath,
572
+ })
573
+
574
+ const line = readFileSync(logPath, 'utf8')
575
+ .split('\n')
576
+ .reverse()
577
+ .find((l) => l.startsWith('{'))
578
+ if (line === undefined) return { error: 'check printed no record' }
579
+
580
+ try {
581
+ const verdict = JSON.parse(line) as {
582
+ asserted?: number
583
+ state?: string
584
+ note?: string
585
+ }
586
+ if (typeof verdict.asserted !== 'number')
587
+ return { error: verdict.note ?? 'record carries no asserted count' }
588
+
589
+ return { asserted: verdict.asserted, state: verdict.state }
590
+ } catch {
591
+ return { error: 'check record did not parse' }
592
+ }
593
+ }
594
+
595
+ function withExit(manifest: Manifest, run: Provisioned): Manifest {
596
+ const next = new Map(manifest)
597
+ next.set('exit', String(run.exit))
598
+ next.set('log', run.log)
599
+
600
+ return next
601
+ }
602
+
603
+ async function runArm(
604
+ arm: Arm,
605
+ workDir: string,
606
+ index: number,
607
+ baseRoot: string,
608
+ headRoot: string,
609
+ masks: readonly Mask[],
610
+ declared: boolean,
611
+ ): Promise<ArmRecord> {
612
+ const key = armKey(arm)
613
+ const dir = join(workDir, 'arms', `arm-${index}`)
614
+ const logPath = join(workDir, 'logs', `arm-${index}.log`)
615
+
616
+ const base = await provision(baseRoot, arm, dir, logPath)
617
+ const baseManifest = withExit(buildManifest(dir), base)
618
+
619
+ const head = await provision(headRoot, arm, dir, logPath)
620
+ const headManifest = withExit(buildManifest(dir), head)
621
+
622
+ // Kept beside the logs, so a reader of an `--out` folder can see what a
623
+ // difference was rather than only that one exists.
624
+ for (const [side, manifest] of [
625
+ ['base', baseManifest],
626
+ ['head', headManifest],
627
+ ] as const)
628
+ writeFileSync(
629
+ join(workDir, 'manifests', `arm-${index}.${side}.json`),
630
+ JSON.stringify(Object.fromEntries(manifest), null, 2),
631
+ )
632
+
633
+ const diff = diffManifests(key, baseManifest, headManifest, masks)
634
+ const classified = classifyArm(base.exit, head.exit, diff.differs)
635
+
636
+ const check = declared
637
+ ? await checkHead(
638
+ headRoot,
639
+ arm,
640
+ dir,
641
+ join(workDir, 'logs', `check-${index}.log`),
642
+ )
643
+ : undefined
644
+
645
+ rmSync(configDirFor(dir), { recursive: true, force: true })
646
+
647
+ return {
648
+ arm: key,
649
+ state: classified.state,
650
+ differs: classified.differs,
651
+ masked: diff.masked,
652
+ baseExit: base.exit,
653
+ headExit: head.exit,
654
+ check,
655
+ }
656
+ }
657
+
658
+ async function pool<T, R>(
659
+ items: readonly T[],
660
+ size: number,
661
+ work: (item: T, index: number) => Promise<R>,
662
+ ): Promise<R[]> {
663
+ const results: R[] = new Array(items.length)
664
+ let next = 0
665
+
666
+ async function lane(): Promise<void> {
667
+ while (next < items.length) {
668
+ const index = next++
669
+ results[index] = await work(items[index] as T, index)
670
+ }
671
+ }
672
+
673
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, lane))
674
+
675
+ return results
676
+ }
677
+
678
+ function canonVersion(): string {
679
+ try {
680
+ return execFileSync('canon', ['--version'], {
681
+ encoding: 'utf8',
682
+ stdio: ['ignore', 'pipe', 'ignore'],
683
+ }).trim()
684
+ } catch {
685
+ return 'unavailable'
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Adds the baseline as a detached worktree of the base ref, so it carries full
691
+ * history for the scenarios that read it, and returns a remover that is safe to
692
+ * call twice. `node_modules` is linked from the working checkout, since the
693
+ * baseline runs the same CLI and carries no install of its own.
694
+ */
695
+ function addBaseline(root: string, workDir: string, ref: string): () => void {
696
+ const path = join(workDir, 'base')
697
+ execFileSync('git', ['worktree', 'add', '--detach', path, ref], {
698
+ cwd: root,
699
+ stdio: ['ignore', 'ignore', 'pipe'],
700
+ })
701
+
702
+ const modules = join(root, 'node_modules')
703
+ if (existsSync(modules)) symlinkSync(modules, join(path, 'node_modules'))
704
+
705
+ let removed = false
706
+
707
+ return () => {
708
+ if (removed) return
709
+ removed = true
710
+ try {
711
+ execFileSync('git', ['worktree', 'remove', '--force', path], {
712
+ cwd: root,
713
+ stdio: 'ignore',
714
+ })
715
+ } catch {
716
+ rmSync(path, { recursive: true, force: true })
717
+ }
718
+ try {
719
+ execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'ignore' })
720
+ } catch {
721
+ // The entry is already gone, or the repository is.
722
+ }
723
+ }
724
+ }
725
+
726
+ export async function runEquivalence(
727
+ options: EquivalenceOptions,
728
+ ): Promise<EquivalenceRecord> {
729
+ const { arms, errors } = enumerateArms(options.root, options.targets)
730
+ const masks = options.useMasks ? MASKS : []
731
+ const coverage = collectCoverage(options.root)
732
+ const declaredKeys = new Set(
733
+ coverage.scenarios.flatMap((s) =>
734
+ s.armed.map((a) =>
735
+ a === DEFAULT_ARM
736
+ ? `${s.category}:${s.command}`
737
+ : `${s.category}:${s.command}/${a}`,
738
+ ),
739
+ ),
740
+ )
741
+
742
+ const workDir =
743
+ options.out ?? mkdtempSync(join(tmpdir(), 'canon-equivalence-'))
744
+ mkdirSync(join(workDir, 'arms'), { recursive: true })
745
+ mkdirSync(join(workDir, 'logs'), { recursive: true })
746
+ mkdirSync(join(workDir, 'manifests'), { recursive: true })
747
+
748
+ let removeBaseline: () => void
749
+ try {
750
+ removeBaseline = addBaseline(options.root, workDir, options.base)
751
+ } catch (error) {
752
+ // An unknown ref must not leave an empty scratch folder behind.
753
+ if (options.out === undefined)
754
+ rmSync(workDir, { recursive: true, force: true })
755
+ throw error
756
+ }
757
+ const cleanup = (): void => {
758
+ killRunning()
759
+ removeBaseline()
760
+ if (options.out === undefined)
761
+ rmSync(workDir, { recursive: true, force: true })
762
+ }
763
+ const onSignal = (signal: NodeJS.Signals): void => {
764
+ cleanup()
765
+ process.exit(signal === 'SIGINT' ? 130 : 143)
766
+ }
767
+ process.on('SIGINT', onSignal)
768
+ process.on('SIGTERM', onSignal)
769
+
770
+ try {
771
+ const baseRoot = join(workDir, 'base')
772
+ const baseCommit = git(options.root, ['rev-parse', options.base])
773
+ const runnable = arms.filter((a) => !a.anchor || options.includeAnchor)
774
+ const skipped = arms.filter((a) => a.anchor && !options.includeAnchor)
775
+
776
+ const indexed = new Map(arms.map((a, i) => [armKey(a), i]))
777
+ const run = (arm: Arm): Promise<ArmRecord> =>
778
+ runArm(
779
+ arm,
780
+ workDir,
781
+ indexed.get(armKey(arm)) ?? 0,
782
+ baseRoot,
783
+ options.root,
784
+ masks,
785
+ declaredKeys.has(armKey(arm)),
786
+ )
787
+
788
+ // Anchor arms force-push `main` and recreate a pull request on one shared
789
+ // remote, so they run strictly one at a time.
790
+ const offline = await pool(
791
+ runnable.filter((a) => !a.anchor),
792
+ CONCURRENCY,
793
+ (arm) => run(arm),
794
+ )
795
+ const anchored: ArmRecord[] = []
796
+ for (const arm of runnable.filter((a) => a.anchor))
797
+ anchored.push(await run(arm))
798
+
799
+ const records: ArmRecord[] = [
800
+ ...offline,
801
+ ...anchored,
802
+ ...skipped.map(
803
+ (arm): ArmRecord => ({
804
+ arm: armKey(arm),
805
+ state: 'skipped-anchor',
806
+ differs: [],
807
+ masked: [],
808
+ }),
809
+ ),
810
+ ].sort((x, y) => x.arm.localeCompare(y.arm))
811
+
812
+ const counts: Record<ArmState, number> = {
813
+ identical: 0,
814
+ differs: 0,
815
+ 'red-on-base': 0,
816
+ 'skipped-anchor': 0,
817
+ }
818
+ for (const record of records) counts[record.state] += 1
819
+
820
+ const masksApplied = [
821
+ ...new Set(
822
+ records.flatMap((r) =>
823
+ r.masked.map((m) => `${r.arm} ${m.key}: ${m.reason}`),
824
+ ),
825
+ ),
826
+ ]
827
+
828
+ return {
829
+ base: options.base,
830
+ baseCommit,
831
+ canonVersion: canonVersion(),
832
+ masksApplied,
833
+ arms: records,
834
+ errors,
835
+ counts,
836
+ }
837
+ } finally {
838
+ process.off('SIGINT', onSignal)
839
+ process.off('SIGTERM', onSignal)
840
+ cleanup()
841
+ }
842
+ }
843
+
844
+ /** Exit 1 on any difference or enumeration error. A red-on-both arm is reported only. */
845
+ export function equivalenceExitCode(record: EquivalenceRecord): number {
846
+ return record.counts.differs > 0 || record.errors.length > 0 ? 1 : 0
847
+ }