@erclx/canon 5.14.1 → 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.
- package/README.md +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +1 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +5 -2
- package/src/canvas/client/app.tsx +85 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +123 -0
- package/src/canvas/client/shell.css +319 -0
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +213 -0
- package/src/canvas/content.ts +326 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +302 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +325 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/sandbox.ts +108 -0
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/record-root.ts +1 -0
- package/src/sandbox/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- package/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- 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
|
+
}
|