@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +2 -3
  5. package/claude/skills/context-fold/SKILL.md +1 -11
  6. package/claude/skills/context-fold/references/completion-report.md +8 -9
  7. package/claude/skills/create-skill/references/skill-practice.md +16 -1
  8. package/claude/skills/design-taste/references/adopted.md +17 -97
  9. package/claude/skills/design-taste/references/tells.md +2 -2
  10. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  11. package/claude/skills/document-health/SKILL.md +3 -14
  12. package/claude/skills/document-health/references/axes.md +14 -16
  13. package/claude/skills/test-craft/references/e2e.md +1 -1
  14. package/docs/agents/audits.md +2 -6
  15. package/docs/agents/context-audit.md +1 -1
  16. package/governance/ledger/skills/api-design/SKILL.md +62 -0
  17. package/governance/ledger/skills/code-craft/SKILL.md +53 -0
  18. package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
  19. package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
  20. package/governance/ledger/skills/codebase-layout/tests.md +26 -0
  21. package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
  22. package/governance/ledger/skills/design-taste/SKILL.md +64 -0
  23. package/governance/ledger/skills/design-taste/craft.md +66 -0
  24. package/governance/ledger/skills/design-taste/kinds.md +58 -0
  25. package/governance/ledger/skills/design-taste/systems.md +42 -0
  26. package/governance/ledger/skills/design-taste/tells.md +103 -0
  27. package/governance/ledger/skills/review-craft/SKILL.md +61 -0
  28. package/governance/ledger/skills/review-craft/security.md +34 -0
  29. package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
  30. package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
  31. package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
  32. package/governance/ledger/skills/test-craft/SKILL.md +67 -0
  33. package/governance/ledger/skills/test-craft/component.md +49 -0
  34. package/governance/ledger/skills/test-craft/e2e.md +55 -0
  35. package/governance/ledger/skills/test-craft/unit.md +54 -0
  36. package/package.json +3 -3
  37. package/src/audits/catalog.ts +0 -52
  38. package/src/claude/skills-audit.ts +124 -41
  39. package/src/cli/retired.ts +8 -0
  40. package/src/commands/claude.ts +11 -2
  41. package/src/commands/context.ts +0 -425
  42. package/src/commands/sandbox.ts +247 -2
  43. package/src/context/citations.ts +9 -3
  44. package/src/gate/measures.ts +4 -1
  45. package/src/gate/stages.ts +25 -0
  46. package/src/git/files.ts +8 -3
  47. package/src/markdown/gated.ts +1 -1
  48. package/src/markdown/scope.ts +0 -1
  49. package/src/migrate/records.ts +6 -0
  50. package/src/sandbox/replay/expected.ts +137 -0
  51. package/src/sandbox/replay/run.ts +323 -0
  52. package/src/sandbox/replay/verdict.ts +102 -0
  53. package/tooling/astro/configs/playwright.config.ts +2 -2
  54. package/tooling/nextjs/configs/playwright.config.ts +2 -2
  55. package/tooling/vite-react/configs/playwright.config.ts +2 -2
  56. package/claude/skills/context-fold/references/classify.md +0 -87
  57. package/src/context/classify/extract.ts +0 -443
  58. package/src/context/classify/ollama.ts +0 -172
  59. package/src/context/classify/patterns.ts +0 -75
  60. package/src/context/classify/prompts.ts +0 -74
  61. package/src/context/classify/run.ts +0 -348
  62. package/src/context/classify/settings.ts +0 -196
@@ -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 candidates = listed.filter((rel) => !isFixture(rel))
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) {
@@ -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] ?? ''
@@ -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
- if (!tracked || !untracked) return undefined
166
+ const deleted = await run(['ls-files', '--deleted'])
167
+ if (!tracked || !untracked || !deleted) return undefined
165
168
 
166
- return [...new Set([...tracked, ...untracked])].sort()
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
  /**
@@ -120,7 +120,7 @@ export function appendGatedPrefix(
120
120
  return 'appended'
121
121
  }
122
122
 
123
- /** Written by hand rather than serialized, the way `classifier.toml` is. */
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
@@ -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',
@@ -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
+ }