@erclx/canon 5.63.0 → 5.65.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/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/context-fold/REQUIREMENT.md +2 -3
- package/claude/skills/context-fold/SKILL.md +1 -11
- package/claude/skills/context-fold/references/completion-report.md +8 -9
- package/claude/skills/create-skill/references/skill-practice.md +16 -1
- package/claude/skills/design-taste/references/adopted.md +17 -97
- package/claude/skills/design-taste/references/tells.md +2 -2
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +3 -14
- package/claude/skills/document-health/references/axes.md +14 -16
- package/claude/skills/test-craft/references/e2e.md +1 -1
- package/docs/agents/audits.md +2 -6
- package/docs/agents/context-audit.md +1 -1
- package/governance/ledger/skills/api-design/SKILL.md +62 -0
- package/governance/ledger/skills/code-craft/SKILL.md +53 -0
- package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
- package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
- package/governance/ledger/skills/codebase-layout/tests.md +26 -0
- package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
- package/governance/ledger/skills/design-taste/SKILL.md +64 -0
- package/governance/ledger/skills/design-taste/craft.md +66 -0
- package/governance/ledger/skills/design-taste/kinds.md +58 -0
- package/governance/ledger/skills/design-taste/systems.md +42 -0
- package/governance/ledger/skills/design-taste/tells.md +103 -0
- package/governance/ledger/skills/review-craft/SKILL.md +61 -0
- package/governance/ledger/skills/review-craft/security.md +34 -0
- package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
- package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
- package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
- package/governance/ledger/skills/test-craft/SKILL.md +67 -0
- package/governance/ledger/skills/test-craft/component.md +49 -0
- package/governance/ledger/skills/test-craft/e2e.md +55 -0
- package/governance/ledger/skills/test-craft/unit.md +54 -0
- package/package.json +3 -3
- package/src/audits/catalog.ts +0 -52
- package/src/claude/skills-audit.ts +124 -41
- package/src/cli/retired.ts +8 -0
- package/src/commands/claude.ts +11 -2
- package/src/commands/context.ts +0 -425
- package/src/commands/sandbox.ts +247 -2
- package/src/context/citations.ts +9 -3
- package/src/gate/measures.ts +4 -1
- package/src/gate/stages.ts +25 -0
- package/src/git/files.ts +8 -3
- package/src/markdown/gated.ts +1 -1
- package/src/markdown/scope.ts +0 -1
- package/src/migrate/records.ts +6 -0
- package/src/sandbox/replay/expected.ts +137 -0
- package/src/sandbox/replay/run.ts +323 -0
- package/src/sandbox/replay/verdict.ts +102 -0
- package/tooling/astro/configs/playwright.config.ts +2 -2
- package/tooling/nextjs/configs/playwright.config.ts +2 -2
- package/tooling/vite-react/configs/playwright.config.ts +2 -2
- package/claude/skills/context-fold/references/classify.md +0 -87
- package/src/context/classify/extract.ts +0 -443
- package/src/context/classify/ollama.ts +0 -172
- package/src/context/classify/patterns.ts +0 -75
- package/src/context/classify/prompts.ts +0 -74
- package/src/context/classify/run.ts +0 -348
- package/src/context/classify/settings.ts +0 -196
package/src/context/citations.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { resolve } from 'node:path'
|
|
3
|
+
import { relative, resolve } from 'node:path'
|
|
4
4
|
import { listRepositoryFiles } from '@/git/files'
|
|
5
5
|
import { RECORD_ROOTS } from '@/roots/record'
|
|
6
6
|
import { SURFACE_ROOTS } from '@/roots/surface'
|
|
7
|
+
import { stampPaths } from '@/sync/stamp'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Suppresses citation checking for the source line carrying it.
|
|
@@ -191,7 +192,11 @@ export type CitationReport =
|
|
|
191
192
|
| { readonly kind: 'unavailable' }
|
|
192
193
|
|
|
193
194
|
/**
|
|
194
|
-
* Resolves every cited path outside the fixture trees.
|
|
195
|
+
* Resolves every cited path outside the fixture trees and the install stamp.
|
|
196
|
+
*
|
|
197
|
+
* The stamp is written by the toolkit, and `canon tooling decline` records the
|
|
198
|
+
* declined path as a key in it, so scanning it reports a deliberate decline as
|
|
199
|
+
* a stale citation.
|
|
195
200
|
*
|
|
196
201
|
* This is the only check that gates the repository check, which makes it the
|
|
197
202
|
* only one whose false positives cost a contributor anything. It reports a
|
|
@@ -206,7 +211,8 @@ export async function auditCitations(
|
|
|
206
211
|
const listed = await listRepositoryFiles(root)
|
|
207
212
|
if (!listed) return { kind: 'unavailable' }
|
|
208
213
|
|
|
209
|
-
const
|
|
214
|
+
const stamps = new Set(stampPaths(root).map((path) => relative(root, path)))
|
|
215
|
+
const candidates = listed.filter((rel) => !isFixture(rel) && !stamps.has(rel))
|
|
210
216
|
const citations: Citation[] = []
|
|
211
217
|
|
|
212
218
|
for (const rel of candidates) {
|
package/src/gate/measures.ts
CHANGED
|
@@ -408,7 +408,7 @@ export const skillPracticeShape: Measure = async (ctx) => {
|
|
|
408
408
|
.join(', ')
|
|
409
409
|
return {
|
|
410
410
|
emissions: [],
|
|
411
|
-
failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named to a practice skill, or the ledger at ${PRACTICE_LEDGER} to any skill on the ledger list, citing one linked source or one House choice row. A reference on the per-bullet list takes one ledger row per bullet under the same H2 at ${SKILL_LEDGERS}.`,
|
|
411
|
+
failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named to a practice skill, or the ledger at ${PRACTICE_LEDGER} to any skill on the ledger list, citing one linked source or one House choice row. A reference of a skill on the per-bullet list, unless exempted, takes one ledger row per bullet under the same H2 at ${SKILL_LEDGERS}.`,
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
|
|
@@ -519,9 +519,12 @@ export const documentCeiling: Measure = async (ctx) => {
|
|
|
519
519
|
*
|
|
520
520
|
* `canon/context/context-model.md` is exempt, since "context model" is the
|
|
521
521
|
* domain's own name and the entry reads as nothing else without the prefix.
|
|
522
|
+
* So is a replay snapshot, which copies a consumer's paths verbatim, such as
|
|
523
|
+
* `canon/config/config.json`, and a renamed copy would sync as another file.
|
|
522
524
|
*/
|
|
523
525
|
export function isFolderEchoedName(path: string): boolean {
|
|
524
526
|
if (path === 'canon/context/context-model.md') return false
|
|
527
|
+
if (path.startsWith('sandbox/fixtures/replay/')) return false
|
|
525
528
|
const segments = path.split('/')
|
|
526
529
|
if (segments.length < 2) return false
|
|
527
530
|
const folder = segments[segments.length - 2] ?? ''
|
package/src/gate/stages.ts
CHANGED
|
@@ -304,6 +304,31 @@ export const STAGES: readonly Stage[] = [
|
|
|
304
304
|
label: 'Unreferenced rules',
|
|
305
305
|
checks: [{ kind: 'measure', measure: unreferencedRules }],
|
|
306
306
|
},
|
|
307
|
+
{
|
|
308
|
+
// A defect that only shows after a sync, such as a read crashing on a
|
|
309
|
+
// rule layout the sync just renamed, passes every unit test and every
|
|
310
|
+
// stage that reads this repository. Replaying the frozen consumer
|
|
311
|
+
// snapshots through this checkout's sync is what reaches it before a
|
|
312
|
+
// consumer does. Scoped to what a sync ships or reads, since a replay
|
|
313
|
+
// costs seconds a docs-only push should not pay. Governance is whole
|
|
314
|
+
// but for the ledger, since sync resolves stacks and renames as well as
|
|
315
|
+
// rules, and a rename is the class the stage was built against.
|
|
316
|
+
id: 'consumer-replay',
|
|
317
|
+
label: 'Consumer replay',
|
|
318
|
+
scope:
|
|
319
|
+
/^src\/|^governance\/(?!ledger\/)|^tooling\/|^sandbox\/fixtures\/replay\//,
|
|
320
|
+
skipped:
|
|
321
|
+
'No synced surface or snapshot changed, so no consumer was replayed',
|
|
322
|
+
checks: [
|
|
323
|
+
{
|
|
324
|
+
kind: 'cli',
|
|
325
|
+
argv: ['sandbox', 'replay'],
|
|
326
|
+
failure:
|
|
327
|
+
'A consumer replay crashed, printed unparsable JSON, left an unformatted write, or drifted from its recorded answer. Run bun src/cli.ts sandbox replay, and when a drift is one the change meant, such as a governance rename moving a sync count, run it with --record and commit the expectations.',
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
success: 'Consumer replays clean',
|
|
331
|
+
},
|
|
307
332
|
{
|
|
308
333
|
// The facts the audit can answer from the files gate: a cited path that
|
|
309
334
|
// does not resolve, a required section an entry does not declare, an H2
|
package/src/git/files.ts
CHANGED
|
@@ -140,7 +140,9 @@ export async function listIgnoreAdditions(
|
|
|
140
140
|
/**
|
|
141
141
|
* Lists the files under `root`: tracked, plus untracked files git does not
|
|
142
142
|
* ignore. The untracked half is what keeps a file added on this branch in scope
|
|
143
|
-
* rather than one push later.
|
|
143
|
+
* rather than one push later. A tracked path deleted in the working tree is
|
|
144
|
+
* left out, since a caller reading the listing would otherwise open a file that
|
|
145
|
+
* is gone.
|
|
144
146
|
*
|
|
145
147
|
* Returns undefined when git cannot answer, which every caller reports rather
|
|
146
148
|
* than smoothing into an empty list. An empty list passes each of its zero
|
|
@@ -161,9 +163,12 @@ export async function listRepositoryFiles(
|
|
|
161
163
|
|
|
162
164
|
const tracked = await run(['ls-files'])
|
|
163
165
|
const untracked = await run(['ls-files', '--others', '--exclude-standard'])
|
|
164
|
-
|
|
166
|
+
const deleted = await run(['ls-files', '--deleted'])
|
|
167
|
+
if (!tracked || !untracked || !deleted) return undefined
|
|
165
168
|
|
|
166
|
-
|
|
169
|
+
const gone = new Set(deleted)
|
|
170
|
+
const present = tracked.filter((path) => !gone.has(path))
|
|
171
|
+
return [...new Set([...present, ...untracked])].sort()
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
/**
|
package/src/markdown/gated.ts
CHANGED
|
@@ -120,7 +120,7 @@ export function appendGatedPrefix(
|
|
|
120
120
|
return 'appended'
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/** Written by hand rather than serialized, the
|
|
123
|
+
/** Written by hand rather than serialized, so the list keeps its one-entry-per-line form. */
|
|
124
124
|
function writeGatedList(root: string, prefixes: readonly string[]): void {
|
|
125
125
|
const path = join(root, GATED_CONFIG_REL)
|
|
126
126
|
const mode = existsSync(path) ? statSync(path).mode : undefined
|
package/src/markdown/scope.ts
CHANGED
|
@@ -154,7 +154,6 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
154
154
|
'claude/skills/codebase-layout/references/tests.md',
|
|
155
155
|
'claude/skills/context-fold/REQUIREMENT.md',
|
|
156
156
|
'claude/skills/context-fold/SKILL.md',
|
|
157
|
-
'claude/skills/context-fold/references/classify.md',
|
|
158
157
|
'claude/skills/context-fold/references/completion-report.md',
|
|
159
158
|
'claude/skills/context-fold/references/context-refresh.md',
|
|
160
159
|
'claude/skills/context-fold/references/context.md',
|
package/src/migrate/records.ts
CHANGED
|
@@ -103,6 +103,11 @@ function escape(value: string): string {
|
|
|
103
103
|
* never match. The guard then stops firing in a project the move has not
|
|
104
104
|
* reached, which is silent: the index goes stale while every save succeeds.
|
|
105
105
|
*
|
|
106
|
+
* A replay snapshot is a frozen copy of a consumer, its hooks included, and
|
|
107
|
+
* the replay tests the upgrade from exactly that state. Sweeping one rewrites
|
|
108
|
+
* the consumer's own old-root citations, so the snapshot stops matching the
|
|
109
|
+
* release it was cut at.
|
|
110
|
+
*
|
|
106
111
|
* A file outside this list can still couple to one inside it, naming an
|
|
107
112
|
* excluded path as plain text while it also carries a live citation of its
|
|
108
113
|
* own, which `referencesExcluded` reports separately below. Widening this list
|
|
@@ -115,6 +120,7 @@ const EXCLUDED_PREFIXES: readonly string[] = [
|
|
|
115
120
|
'scripts/eval/result-',
|
|
116
121
|
'.claude/hooks/',
|
|
117
122
|
'tooling/claude/seeds/.claude/hooks/',
|
|
123
|
+
'sandbox/fixtures/replay/',
|
|
118
124
|
]
|
|
119
125
|
|
|
120
126
|
const EXCLUDED_PATHS: readonly string[] = [
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { StepResult } from '@/sandbox/replay/run'
|
|
4
|
+
|
|
5
|
+
/** Sits in a snapshot's folder beside its `.fixture` files and never reaches the replay tree. */
|
|
6
|
+
export const EXPECTED_FILE = 'expected.json'
|
|
7
|
+
|
|
8
|
+
export interface StepExpectation {
|
|
9
|
+
readonly exit: number | null
|
|
10
|
+
readonly findings: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A snapshot's recorded answer, keyed by step name. It pins the exit and the
|
|
15
|
+
* finding count rather than the finding lines, so rewording a warning moves
|
|
16
|
+
* nothing while a seed sync newly calls missing still drifts.
|
|
17
|
+
*/
|
|
18
|
+
export interface ReplayExpectation {
|
|
19
|
+
readonly steps: Readonly<Record<string, StepExpectation>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const RECORD_HINT =
|
|
23
|
+
'run bun src/cli.ts sandbox replay --record if the change meant it'
|
|
24
|
+
|
|
25
|
+
export function expectationOf(steps: readonly StepResult[]): ReplayExpectation {
|
|
26
|
+
return {
|
|
27
|
+
steps: Object.fromEntries(
|
|
28
|
+
steps.map((s) => [s.step, { exit: s.exit, findings: s.findings.length }]),
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isStepExpectation(value: unknown): value is StepExpectation {
|
|
34
|
+
if (typeof value !== 'object' || value === null) return false
|
|
35
|
+
const { exit, findings } = value as Record<string, unknown>
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
(exit === null || typeof exit === 'number') && typeof findings === 'number'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The expectation recorded in `dir`, or undefined when none is. Throws naming
|
|
44
|
+
* the file and the step when the file holds anything but the recorded shape.
|
|
45
|
+
*/
|
|
46
|
+
export function readExpectation(dir: string): ReplayExpectation | undefined {
|
|
47
|
+
const path = join(dir, EXPECTED_FILE)
|
|
48
|
+
if (!existsSync(path)) return undefined
|
|
49
|
+
|
|
50
|
+
let parsed: unknown
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`${path} does not parse as JSON`)
|
|
55
|
+
}
|
|
56
|
+
const steps =
|
|
57
|
+
typeof parsed === 'object' && parsed !== null
|
|
58
|
+
? (parsed as Record<string, unknown>).steps
|
|
59
|
+
: undefined
|
|
60
|
+
if (typeof steps !== 'object' || steps === null)
|
|
61
|
+
throw new Error(`${path} carries no steps object`)
|
|
62
|
+
for (const [name, value] of Object.entries(steps))
|
|
63
|
+
if (!isStepExpectation(value))
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${path}: step ${name} needs an exit and a findings count`,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return { steps: steps as ReplayExpectation['steps'] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Written the way oxfmt leaves JSON, so a recorded file never fails the format stage. */
|
|
72
|
+
export function writeExpectation(
|
|
73
|
+
dir: string,
|
|
74
|
+
expectation: ReplayExpectation,
|
|
75
|
+
): void {
|
|
76
|
+
writeFileSync(
|
|
77
|
+
join(dir, EXPECTED_FILE),
|
|
78
|
+
`${JSON.stringify(expectation, null, 2)}\n`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function driftOf(
|
|
83
|
+
run: StepResult,
|
|
84
|
+
recorded: StepExpectation | undefined,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (recorded === undefined) return `no recorded answer, ${RECORD_HINT}`
|
|
87
|
+
if (run.exit !== recorded.exit)
|
|
88
|
+
return `exit ${run.exit}, recorded ${recorded.exit}`
|
|
89
|
+
if (run.findings.length !== recorded.findings)
|
|
90
|
+
return `${run.findings.length} findings, recorded ${recorded.findings}`
|
|
91
|
+
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Judges a snapshot's steps against its recorded answer. A step that already
|
|
97
|
+
* failed keeps its verdict, since a crash names more than a changed exit. A
|
|
98
|
+
* step either side lacks drifts, and with no expectation at all every step
|
|
99
|
+
* does, since a missing input read as a pass is what the replay exists to stop.
|
|
100
|
+
*/
|
|
101
|
+
export function judgeDrift(
|
|
102
|
+
steps: readonly StepResult[],
|
|
103
|
+
expectation: ReplayExpectation | undefined,
|
|
104
|
+
): StepResult[] {
|
|
105
|
+
if (expectation === undefined)
|
|
106
|
+
return steps.map((s) =>
|
|
107
|
+
s.verdict === 'ok'
|
|
108
|
+
? {
|
|
109
|
+
...s,
|
|
110
|
+
verdict: 'drifted',
|
|
111
|
+
detail: `no ${EXPECTED_FILE} for this snapshot, run bun src/cli.ts sandbox replay --record`,
|
|
112
|
+
}
|
|
113
|
+
: s,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const judged = steps.map((s) => {
|
|
117
|
+
if (s.verdict !== 'ok') return s
|
|
118
|
+
const detail = driftOf(s, expectation.steps[s.step])
|
|
119
|
+
|
|
120
|
+
return detail === undefined
|
|
121
|
+
? s
|
|
122
|
+
: { ...s, verdict: 'drifted' as const, detail }
|
|
123
|
+
})
|
|
124
|
+
const ran = new Set(steps.map((s) => s.step))
|
|
125
|
+
const dropped = Object.keys(expectation.steps)
|
|
126
|
+
.filter((name) => !ran.has(name))
|
|
127
|
+
.map((name): StepResult => ({
|
|
128
|
+
step: name,
|
|
129
|
+
argv: [],
|
|
130
|
+
exit: null,
|
|
131
|
+
verdict: 'drifted',
|
|
132
|
+
detail: `recorded but did not run, ${RECORD_HINT}`,
|
|
133
|
+
findings: [],
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
return [...judged, ...dropped]
|
|
137
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { dirname, join, relative } from 'node:path'
|
|
13
|
+
import { gitEnv } from '@/git/env'
|
|
14
|
+
import { EXPECTED_FILE } from '@/sandbox/replay/expected'
|
|
15
|
+
import {
|
|
16
|
+
findingsOf,
|
|
17
|
+
judgeFormat,
|
|
18
|
+
judgeStep,
|
|
19
|
+
type ReplayVerdict,
|
|
20
|
+
type StepOutput,
|
|
21
|
+
} from '@/sandbox/replay/verdict'
|
|
22
|
+
import { FIXTURE_SUFFIX, listFixtureFiles } from '@/sandbox/scenario'
|
|
23
|
+
|
|
24
|
+
export interface ReplaySource {
|
|
25
|
+
readonly name: string
|
|
26
|
+
readonly path: string
|
|
27
|
+
/**
|
|
28
|
+
* A snapshot is a folder of `.fixture` files with no history, committed
|
|
29
|
+
* fresh in the copy. A checkout is a consumer's real repository, copied
|
|
30
|
+
* whole with its history so its gitignored records come along.
|
|
31
|
+
*/
|
|
32
|
+
readonly kind: 'snapshot' | 'checkout'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReplayContext {
|
|
36
|
+
/** The canon checkout whose CLI, formatter, and golden config the replay runs. */
|
|
37
|
+
readonly checkoutRoot: string
|
|
38
|
+
/** The folder each replay's temp tree is made under, outside every repository. */
|
|
39
|
+
readonly tempRoot: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StepResult {
|
|
43
|
+
readonly step: string
|
|
44
|
+
readonly argv: readonly string[]
|
|
45
|
+
readonly exit: number | null
|
|
46
|
+
readonly verdict: ReplayVerdict
|
|
47
|
+
readonly detail?: string
|
|
48
|
+
/** The `!` lines the step printed, reported whatever the verdict. */
|
|
49
|
+
readonly findings: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ReplayResult {
|
|
53
|
+
readonly name: string
|
|
54
|
+
readonly kind: ReplaySource['kind']
|
|
55
|
+
readonly steps: readonly StepResult[]
|
|
56
|
+
/** Paths sync added or changed, relative to the tree, sorted. */
|
|
57
|
+
readonly written: readonly string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The sync, then the reads a consumer runs after one, each `--json` read a
|
|
62
|
+
* contract on its own stdout. The reads run on the unstaged result, which is
|
|
63
|
+
* the state a consumer's session meets before anyone commits the sync.
|
|
64
|
+
*/
|
|
65
|
+
const CLI_STEPS: readonly { step: string; args: readonly string[] }[] = [
|
|
66
|
+
{ step: 'sync', args: ['sync', '.'] },
|
|
67
|
+
{ step: 'sync --check', args: ['sync', '--check', '.', '--json'] },
|
|
68
|
+
{ step: 'context audit', args: ['context', 'audit', '--json'] },
|
|
69
|
+
{ step: 'markdown audit', args: ['markdown', 'audit', '--json'] },
|
|
70
|
+
{ step: 'migrate status', args: ['migrate', 'status', '--json'] },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/** Each step took seconds on both consumers in the spike, so five minutes is a hang. */
|
|
74
|
+
const STEP_TIMEOUT_MS = 300_000
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A checkout copy skips installs at any depth and the linked worktrees other
|
|
78
|
+
* sessions hold, neither of which a sync or a health read touches.
|
|
79
|
+
*/
|
|
80
|
+
function isCopied(rel: string): boolean {
|
|
81
|
+
return rel.split('/').at(-1) !== 'node_modules' && rel !== '.claude/worktrees'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Copies a source into `tree`. A snapshot's files lose their `.fixture`
|
|
86
|
+
* suffix and its recorded expectation stays behind, and a checkout copies whole rather than cloning, since a clone
|
|
87
|
+
* drops the gitignored `.canon/` records a consumer's health verbs read.
|
|
88
|
+
*/
|
|
89
|
+
export function copySource(source: ReplaySource, tree: string): void {
|
|
90
|
+
if (source.kind === 'checkout') {
|
|
91
|
+
cpSync(source.path, tree, {
|
|
92
|
+
recursive: true,
|
|
93
|
+
verbatimSymlinks: true,
|
|
94
|
+
filter: (from) => isCopied(relative(source.path, from)),
|
|
95
|
+
})
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const file of listFixtureFiles(source.path)) {
|
|
100
|
+
if (relative(source.path, file) === EXPECTED_FILE) continue
|
|
101
|
+
if (!file.endsWith(FIXTURE_SUFFIX))
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Snapshot file missing the ${FIXTURE_SUFFIX} suffix: ${file}`,
|
|
104
|
+
)
|
|
105
|
+
const target = join(
|
|
106
|
+
tree,
|
|
107
|
+
relative(source.path, file).slice(0, -FIXTURE_SUFFIX.length),
|
|
108
|
+
)
|
|
109
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
110
|
+
copyFileSync(file, target)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every child gets git's resolution variables stripped, since a pre-push hook
|
|
116
|
+
* exports `GIT_DIR` and a commit in the temp tree would otherwise land in the
|
|
117
|
+
* canon repository running the gate.
|
|
118
|
+
*/
|
|
119
|
+
function childEnv(): NodeJS.ProcessEnv {
|
|
120
|
+
return { ...gitEnv(), CANON_NON_INTERACTIVE: '1', NO_COLOR: '1' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function spawn(
|
|
124
|
+
command: string,
|
|
125
|
+
argv: readonly string[],
|
|
126
|
+
cwd: string,
|
|
127
|
+
json: boolean,
|
|
128
|
+
): StepOutput {
|
|
129
|
+
const result = spawnSync(command, argv, {
|
|
130
|
+
cwd,
|
|
131
|
+
env: childEnv(),
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
|
+
timeout: STEP_TIMEOUT_MS,
|
|
135
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: result.status,
|
|
140
|
+
signal: result.signal,
|
|
141
|
+
stdout: result.stdout ?? '',
|
|
142
|
+
stderr: result.stderr ?? result.error?.message ?? '',
|
|
143
|
+
json,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function git(tree: string, ...args: string[]): void {
|
|
148
|
+
const result = spawnSync(
|
|
149
|
+
'git',
|
|
150
|
+
[
|
|
151
|
+
'-c',
|
|
152
|
+
'user.name=canon-replay',
|
|
153
|
+
'-c',
|
|
154
|
+
'user.email=replay@localhost',
|
|
155
|
+
'-c',
|
|
156
|
+
'commit.gpgsign=false',
|
|
157
|
+
...args,
|
|
158
|
+
],
|
|
159
|
+
{ cwd: tree, env: childEnv(), encoding: 'utf8' },
|
|
160
|
+
)
|
|
161
|
+
if (result.status !== 0)
|
|
162
|
+
throw new Error(
|
|
163
|
+
`git ${args.join(' ')} failed in the replay tree: ${result.stderr.trim()}`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Every folder under the snapshot root, each one consumer's frozen managed paths. */
|
|
168
|
+
export function listSnapshots(dir: string): ReplaySource[] {
|
|
169
|
+
if (!existsSync(dir)) return []
|
|
170
|
+
|
|
171
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
172
|
+
.filter((entry) => entry.isDirectory())
|
|
173
|
+
.map((entry) => entry.name)
|
|
174
|
+
.sort()
|
|
175
|
+
.map((name) => ({ name, path: join(dir, name), kind: 'snapshot' }))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface SourceRefusal {
|
|
179
|
+
readonly reason: 'not-found' | 'not-a-repo' | 'dirty'
|
|
180
|
+
readonly message: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Why a named checkout cannot be replayed, or undefined when it can. A dirty
|
|
185
|
+
* tree is refused rather than copied, since sync refuses one and the replay
|
|
186
|
+
* would then read a tree nobody synced, reporting the refusal as a clean run.
|
|
187
|
+
*/
|
|
188
|
+
export function checkoutRefusal(path: string): SourceRefusal | undefined {
|
|
189
|
+
if (!existsSync(path))
|
|
190
|
+
return { reason: 'not-found', message: `No folder at ${path}` }
|
|
191
|
+
|
|
192
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
193
|
+
cwd: path,
|
|
194
|
+
env: childEnv(),
|
|
195
|
+
encoding: 'utf8',
|
|
196
|
+
})
|
|
197
|
+
if (top.status !== 0 || top.stdout.trim() !== realpathSync(path))
|
|
198
|
+
return {
|
|
199
|
+
reason: 'not-a-repo',
|
|
200
|
+
message: `${path} is not the root of a git repository`,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const status = spawnSync('git', ['status', '--porcelain'], {
|
|
204
|
+
cwd: path,
|
|
205
|
+
env: childEnv(),
|
|
206
|
+
encoding: 'utf8',
|
|
207
|
+
})
|
|
208
|
+
if (status.stdout.trim() !== '')
|
|
209
|
+
return {
|
|
210
|
+
reason: 'dirty',
|
|
211
|
+
message: `${path} has uncommitted changes. Commit or stash them, then replay it.`,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Added, modified, and untracked paths after the sync. A deletion has nothing to format. */
|
|
218
|
+
function writtenPaths(tree: string): string[] {
|
|
219
|
+
const result = spawnSync(
|
|
220
|
+
'git',
|
|
221
|
+
['status', '--porcelain', '-z', '--untracked-files=all'],
|
|
222
|
+
{
|
|
223
|
+
cwd: tree,
|
|
224
|
+
env: childEnv(),
|
|
225
|
+
encoding: 'utf8',
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return result.stdout
|
|
230
|
+
.split('\0')
|
|
231
|
+
.filter((entry) => entry.length > 3 && !entry.slice(0, 2).includes('D'))
|
|
232
|
+
.map((entry) => entry.slice(3))
|
|
233
|
+
.sort()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Copies one source into a fresh temp tree, syncs it with the checkout's own
|
|
238
|
+
* CLI, and runs the health reads and the format check on the unstaged
|
|
239
|
+
* result. The tree is removed whatever the steps conclude.
|
|
240
|
+
*/
|
|
241
|
+
export function replaySource(
|
|
242
|
+
source: ReplaySource,
|
|
243
|
+
context: ReplayContext,
|
|
244
|
+
): ReplayResult {
|
|
245
|
+
mkdirSync(context.tempRoot, { recursive: true })
|
|
246
|
+
const tree = mkdtempSync(join(context.tempRoot, `${source.name}-`))
|
|
247
|
+
const cli = join(context.checkoutRoot, 'src', 'cli.ts')
|
|
248
|
+
const steps: StepResult[] = []
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
copySource(source, tree)
|
|
252
|
+
if (source.kind === 'snapshot') {
|
|
253
|
+
git(tree, 'init', '-q', '-b', 'main')
|
|
254
|
+
git(tree, 'add', '-A')
|
|
255
|
+
git(tree, 'commit', '-q', '-m', 'snapshot')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const { step, args } of CLI_STEPS) {
|
|
259
|
+
const output = spawn('bun', [cli, ...args], tree, args.includes('--json'))
|
|
260
|
+
steps.push({
|
|
261
|
+
step,
|
|
262
|
+
argv: ['canon', ...args],
|
|
263
|
+
exit: output.status,
|
|
264
|
+
...judgeStep(output),
|
|
265
|
+
findings: findingsOf(output.stderr),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const written = writtenPaths(tree)
|
|
270
|
+
steps.push(formatStep(context.checkoutRoot, tree, written))
|
|
271
|
+
|
|
272
|
+
return { name: source.name, kind: source.kind, steps, written }
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(tree, { recursive: true, force: true })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Runs the checkout's oxfmt over what sync wrote, under the golden config canon
|
|
280
|
+
* ships to every target rather than oxfmt's defaults, which quote YAML
|
|
281
|
+
* frontmatter differently from every consumer and would fail clean files.
|
|
282
|
+
*/
|
|
283
|
+
function formatStep(
|
|
284
|
+
checkoutRoot: string,
|
|
285
|
+
tree: string,
|
|
286
|
+
written: readonly string[],
|
|
287
|
+
): StepResult {
|
|
288
|
+
const config = join(
|
|
289
|
+
checkoutRoot,
|
|
290
|
+
'tooling',
|
|
291
|
+
'base',
|
|
292
|
+
'configs',
|
|
293
|
+
'.oxfmtrc.json',
|
|
294
|
+
)
|
|
295
|
+
const args = [
|
|
296
|
+
'-c',
|
|
297
|
+
config,
|
|
298
|
+
'--check',
|
|
299
|
+
'--no-error-on-unmatched-pattern',
|
|
300
|
+
...written,
|
|
301
|
+
]
|
|
302
|
+
const argv = ['oxfmt', '--check', '<written>']
|
|
303
|
+
if (written.length === 0)
|
|
304
|
+
return { step: 'format', argv, exit: 0, verdict: 'ok', findings: [] }
|
|
305
|
+
|
|
306
|
+
const output = spawn(oxfmtBinary(checkoutRoot), args, tree, false)
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
step: 'format',
|
|
310
|
+
argv,
|
|
311
|
+
exit: output.status,
|
|
312
|
+
...judgeFormat(output),
|
|
313
|
+
findings: [],
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function oxfmtBinary(checkoutRoot: string): string {
|
|
318
|
+
return join(checkoutRoot, 'node_modules', '.bin', 'oxfmt')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function hasFormatter(checkoutRoot: string): boolean {
|
|
322
|
+
return existsSync(oxfmtBinary(checkoutRoot))
|
|
323
|
+
}
|