@erclx/canon 5.64.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 +10 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/test-craft/references/e2e.md +1 -1
- 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/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/claude/skills-audit.ts +7 -0
- package/src/commands/sandbox.ts +247 -2
- package/src/gate/measures.ts +3 -0
- package/src/gate/stages.ts +25 -0
- 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/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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one step of a replay concluded. A finding or a refusal is the verb
|
|
3
|
+
* working, so it reads `ok`. Only a run that broke rather than answered fails:
|
|
4
|
+
* a `crash` threw or died, an `unreadable` run answered in a shape a caller
|
|
5
|
+
* cannot parse, and an `unformatted` tree holds a file sync wrote that the
|
|
6
|
+
* formatter canon ships rejects. A `drifted` read answered a frozen snapshot
|
|
7
|
+
* differently from the answer recorded beside it, which `judgeDrift` decides.
|
|
8
|
+
*/
|
|
9
|
+
export type ReplayVerdict =
|
|
10
|
+
| 'ok'
|
|
11
|
+
| 'crash'
|
|
12
|
+
| 'unreadable'
|
|
13
|
+
| 'unformatted'
|
|
14
|
+
| 'drifted'
|
|
15
|
+
|
|
16
|
+
export interface StepOutput {
|
|
17
|
+
/** Null when the process ended on a signal, a timeout included. */
|
|
18
|
+
readonly status: number | null
|
|
19
|
+
readonly signal: string | null
|
|
20
|
+
readonly stdout: string
|
|
21
|
+
readonly stderr: string
|
|
22
|
+
/** Whether the step asked for `--json`, which makes stdout a contract. */
|
|
23
|
+
readonly json: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Judgement {
|
|
27
|
+
readonly verdict: ReplayVerdict
|
|
28
|
+
readonly detail?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A JavaScript stack frame, `at fn (file:line:col)` or a bare `at file:line:col`. */
|
|
32
|
+
const STACK_FRAME = /^\s+at .*:\d+:\d+\)?$/m
|
|
33
|
+
|
|
34
|
+
/** The line naming what was thrown, `TypeError: ...` or a system code such as `ENOENT: ...`. */
|
|
35
|
+
const THROWN_LINE = /^(?:error: |[A-Z][A-Za-z]*Error: |E[A-Z]+: ).*$/m
|
|
36
|
+
|
|
37
|
+
function crashOf(output: StepOutput): Judgement | undefined {
|
|
38
|
+
if (output.signal !== null)
|
|
39
|
+
return { verdict: 'crash', detail: `killed by ${output.signal}` }
|
|
40
|
+
if (!STACK_FRAME.test(output.stderr)) return undefined
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
verdict: 'crash',
|
|
44
|
+
detail: THROWN_LINE.exec(output.stderr)?.[0] ?? 'stack trace on stderr',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function judgeStep(output: StepOutput): Judgement {
|
|
49
|
+
const crash = crashOf(output)
|
|
50
|
+
if (crash !== undefined) return crash
|
|
51
|
+
if (!output.json) return { verdict: 'ok' }
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
JSON.parse(output.stdout)
|
|
55
|
+
} catch {
|
|
56
|
+
const first = output.stdout.trim().split('\n')[0]
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
verdict: 'unreadable',
|
|
60
|
+
detail:
|
|
61
|
+
first === undefined || first === ''
|
|
62
|
+
? 'empty stdout'
|
|
63
|
+
: `stdout does not parse: ${first.slice(0, 120)}`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { verdict: 'ok' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The warnings a step printed, each line the CLI marks `!` on a stream that is
|
|
72
|
+
* not a terminal. They never fail the replay, and carrying them is what lets a
|
|
73
|
+
* reader see a finding the verdict passed, such as a seed sync calls missing.
|
|
74
|
+
*/
|
|
75
|
+
export function findingsOf(stderr: string): string[] {
|
|
76
|
+
return stderr
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter((line) => line.startsWith('! '))
|
|
79
|
+
.map((line) => line.slice(2).trim())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** oxfmt lists each file it would change as `<path> (<n>ms)` under `--check`. */
|
|
83
|
+
const OXFMT_FILE = /^(\S.*) \(\d+m?s\)$/
|
|
84
|
+
|
|
85
|
+
export function judgeFormat(output: StepOutput): Judgement {
|
|
86
|
+
const crash = crashOf(output)
|
|
87
|
+
if (crash !== undefined) return crash
|
|
88
|
+
if (output.status === 0) return { verdict: 'ok' }
|
|
89
|
+
|
|
90
|
+
const files = output.stdout
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((line) => OXFMT_FILE.exec(line.trim())?.[1])
|
|
93
|
+
.filter((file): file is string => file !== undefined)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
verdict: 'unformatted',
|
|
97
|
+
detail:
|
|
98
|
+
files.length > 0
|
|
99
|
+
? files.join(', ')
|
|
100
|
+
: output.stderr.trim().split('\n')[0] || `exit ${output.status}`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -14,8 +14,8 @@ export default defineConfig({
|
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
16
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
-
//
|
|
18
|
-
// `
|
|
17
|
+
// A top-level `use.reducedMotion` works from Playwright 1.50.
|
|
18
|
+
// `contextOptions` is the spelling that works on every version.
|
|
19
19
|
contextOptions: { reducedMotion: 'reduce' },
|
|
20
20
|
},
|
|
21
21
|
projects: [
|
|
@@ -12,8 +12,8 @@ export default defineConfig({
|
|
|
12
12
|
trace: 'on-first-retry',
|
|
13
13
|
baseURL,
|
|
14
14
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
15
|
-
//
|
|
16
|
-
// `
|
|
15
|
+
// A top-level `use.reducedMotion` works from Playwright 1.50.
|
|
16
|
+
// `contextOptions` is the spelling that works on every version.
|
|
17
17
|
contextOptions: { reducedMotion: 'reduce' },
|
|
18
18
|
},
|
|
19
19
|
projects: [
|
|
@@ -14,8 +14,8 @@ export default defineConfig({
|
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
16
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
-
//
|
|
18
|
-
// `
|
|
17
|
+
// A top-level `use.reducedMotion` works from Playwright 1.50.
|
|
18
|
+
// `contextOptions` is the spelling that works on every version.
|
|
19
19
|
contextOptions: { reducedMotion: 'reduce' },
|
|
20
20
|
},
|
|
21
21
|
projects: [
|