explico 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +112 -1
  2. package/dist/compiler/cli-main.d.ts +61 -4
  3. package/dist/compiler/cli-main.js +77 -36
  4. package/dist/compiler/cli-reference.d.ts +50 -0
  5. package/dist/compiler/cli-reference.js +213 -0
  6. package/dist/compiler/cli-verbs.d.ts +45 -0
  7. package/dist/compiler/cli-verbs.js +224 -0
  8. package/dist/compiler/cli-verify.d.ts +51 -0
  9. package/dist/compiler/cli-verify.js +151 -0
  10. package/dist/compiler/cli.d.ts +19 -50
  11. package/dist/compiler/cli.js +45 -6
  12. package/dist/compiler/content-release.d.ts +49 -0
  13. package/dist/compiler/content-release.js +63 -0
  14. package/dist/compiler/content-schemas.d.ts +2 -2
  15. package/dist/compiler/manifest.js +29 -24
  16. package/dist/compiler/page-versions.d.ts +25 -1
  17. package/dist/compiler/page-versions.js +19 -0
  18. package/dist/compiler/validate-content.js +2 -2
  19. package/dist/reference/consistency.js +7 -1
  20. package/dist/reference/diagram-fit.d.ts +5 -1
  21. package/dist/reference/diagram-fit.js +5 -1
  22. package/dist/rules/catalog.js +16 -0
  23. package/dist/rules/in-effect.d.ts +1 -1
  24. package/dist/rules/in-effect.js +5 -4
  25. package/dist/rules/rules.schema.json +21 -0
  26. package/docs/authoring/README.md +2 -0
  27. package/docs/authoring/agents.md +18 -11
  28. package/docs/authoring/cli-reference.json +2172 -0
  29. package/docs/authoring/cli.md +887 -0
  30. package/docs/authoring/deck.md +24 -0
  31. package/docs/authoring/migrating.md +168 -18
  32. package/docs/authoring/versioning.md +2 -1
  33. package/docs/course-agent/AGENTS.md +10 -7
  34. package/docs/course-agent/agents/rules-reviewer.md +2 -2
  35. package/docs/course-agent/skills/draft-lesson/SKILL.md +7 -6
  36. package/docs/course-agent/skills/migrate-course/SKILL.md +5 -4
  37. package/docs/course-agent/skills/release-content/SKILL.md +5 -3
  38. package/docs/diagram-scaling.md +2 -2
  39. package/package.json +1 -1
  40. package/src/compiler/cli-main.ts +135 -47
  41. package/src/compiler/cli-reference.ts +285 -0
  42. package/src/compiler/cli-verbs.ts +270 -0
  43. package/src/compiler/cli-verify.ts +241 -0
  44. package/src/compiler/cli.ts +77 -17
  45. package/src/compiler/content-release.ts +91 -0
  46. package/src/compiler/manifest.ts +34 -24
  47. package/src/compiler/page-versions.ts +42 -0
  48. package/src/compiler/validate-content.ts +5 -2
  49. package/src/reference/consistency.ts +13 -2
  50. package/src/reference/diagram-fit.ts +18 -4
  51. package/src/rules/catalog.ts +17 -0
  52. package/src/rules/in-effect.ts +5 -4
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Every `content` verb, as data: its arguments, what it is for, what it
3
+ * prints, what it writes, when it exits non-zero, and the exported type its
4
+ * `--json` prints. The one source for the usage text (`CONTENT_USAGE`),
5
+ * `content help <verb>`, `docs/authoring/cli.md` and
6
+ * `docs/authoring/cli-reference.json` — the last two generated by
7
+ * `scripts/agent/gen-cli.mjs`, which also turns each `json` type name into a
8
+ * JSON Schema from the TypeScript declaration (`--check` in verify and CI).
9
+ *
10
+ * A new verb is a row here, a `case` in `cli-main.ts`, and a regenerated
11
+ * reference; the `--json` contract test in the playground runs every
12
+ * read-only row against the course and validates its output.
13
+ */
14
+
15
+ /** One flag a verb takes. */
16
+ export interface ContentVerbFlag {
17
+ /** As typed: `--lesson <id>`, `--write`. */
18
+ readonly flag: string;
19
+ readonly description: string;
20
+ }
21
+
22
+ /** One verb's row. */
23
+ export interface ContentVerb {
24
+ /** The command word(s): `symbol`, `new lesson`. */
25
+ readonly verb: string;
26
+ /** What follows the verb on the usage line, `--json` left out (every verb takes it). */
27
+ readonly args: string;
28
+ /** What you want to know or do when you reach for it. */
29
+ readonly when: string;
30
+ /** What it prints as text. */
31
+ readonly prints: string;
32
+ /** The flags, each once; a positional argument is described in `prints` or `when`. */
33
+ readonly flags: readonly ContentVerbFlag[];
34
+ /** What it changes on disk. */
35
+ readonly writes: string;
36
+ /** When it exits 1, beyond a usage error. */
37
+ readonly exit?: string;
38
+ /** The type its `--json` prints, by its exported name in `explico/compiler/cli`, `cli-main` or `cli-verify`. */
39
+ readonly json: string;
40
+ }
41
+
42
+ const WRITE: ContentVerbFlag = { flag: '--write', description: 'apply the edits; without it the diff is printed and nothing changes' };
43
+ const READ_ONLY = 'nothing';
44
+
45
+ export const CONTENT_VERBS: readonly ContentVerb[] = [
46
+ {
47
+ verb: 'status',
48
+ args: '',
49
+ when: 'how the course stands',
50
+ prints:
51
+ "counts, diagnostic totals, the knowledge and page versions, the agent surfaces' sizes, whether the course's AGENTS.md points at the engine's guide and the procedures are installed",
52
+ flags: [],
53
+ writes: READ_ONLY,
54
+ json: 'StatusReport',
55
+ },
56
+ {
57
+ verb: 'diagnostics',
58
+ args: '[info|warning|error] [--all | --code <c> | --lesson <id> | --changed]',
59
+ when: 'what is wrong, where',
60
+ prints:
61
+ 'counts by code (with each code\'s rule) and by file, at the given severity or higher (info by default); a filter or --all lists the lines instead. --json always prints the lines',
62
+ flags: [
63
+ { flag: '--all', description: 'list every diagnostic instead of the counts' },
64
+ { flag: '--code <c>', description: 'only that diagnostic code' },
65
+ { flag: '--lesson <id>', description: "only that lesson's file" },
66
+ { flag: '--changed', description: 'only lessons whose .mdx or .checks.yml differs from git HEAD, tracked or not' },
67
+ ],
68
+ writes: READ_ONLY,
69
+ json: 'DiagnosticsReport',
70
+ },
71
+ {
72
+ verb: 'warnings',
73
+ args: '',
74
+ when: 'the warnings alone',
75
+ prints: 'every warning, one line each with its rule, then the count',
76
+ flags: [],
77
+ writes: READ_ONLY,
78
+ json: 'WarningsReport',
79
+ },
80
+ {
81
+ verb: 'context',
82
+ args: '<lesson>',
83
+ when: 'what a lesson draws on',
84
+ prints:
85
+ 'its outcomes, notation, sources, diagnostics and token counts; --json adds the body, the floats it can reference, its sections and domain signatures',
86
+ flags: [],
87
+ writes: READ_ONLY,
88
+ json: 'ContextReport',
89
+ },
90
+ {
91
+ verb: 'equation',
92
+ args: '"<latex>" [--lesson <id>]',
93
+ when: 'will this expression build',
94
+ prints: "each glyph's card, unresolved glyphs with where they are defined",
95
+ flags: [{ flag: '--lesson <id>', description: "resolve against that lesson's local notation as well as the shared cards" }],
96
+ writes: READ_ONLY,
97
+ exit: 'when the build would refuse the expression',
98
+ json: 'EquationReport',
99
+ },
100
+ {
101
+ verb: 'bindings',
102
+ args: '<lesson>',
103
+ when: "what a lesson's math binds",
104
+ prints: 'every math span: its bindings, unresolved glyphs, redundant \\explain wraps',
105
+ flags: [],
106
+ writes: READ_ONLY,
107
+ json: 'BindingsReport',
108
+ },
109
+ {
110
+ verb: 'symbol',
111
+ args: '<latex | key>',
112
+ when: 'is this symbol defined already',
113
+ prints: 'the shared card, each local entry with its lesson, each gloss with its card, near misses',
114
+ flags: [],
115
+ writes: READ_ONLY,
116
+ json: 'SymbolReport',
117
+ },
118
+ {
119
+ verb: 'check',
120
+ args: '<lesson | deck>… [--changed]',
121
+ when: 'does it render',
122
+ prints: 'ok or FAIL per lesson: schema, references, MDX → KaTeX; each error and warning with its rule, the info counted by code',
123
+ flags: [{ flag: '--changed', description: 'the lessons whose .mdx or .checks.yml differs from git HEAD, tracked or not' }],
124
+ writes: READ_ONLY,
125
+ exit: 'when a lesson has an error',
126
+ json: 'CheckReport',
127
+ },
128
+ {
129
+ verb: 'rule',
130
+ args: '<ID>',
131
+ when: 'one rule, whole',
132
+ prints: "the entry as this course has it (enforcement, codes, settings in effect) and the rule page's section, example included",
133
+ flags: [],
134
+ writes: READ_ONLY,
135
+ exit: 'for an ID the catalog and the course rules do not know',
136
+ json: 'RuleReport',
137
+ },
138
+ {
139
+ verb: 'migrate',
140
+ args: '[--from <version>] [--to <version>]',
141
+ when: 'what an upgrade needs',
142
+ prints: "the migration guide's sections between the version package.json pins at git HEAD and the one installed, oldest first",
143
+ flags: [
144
+ { flag: '--from <version>', description: 'start here instead of the version pinned at HEAD' },
145
+ { flag: '--to <version>', description: 'stop here instead of the installed version' },
146
+ ],
147
+ writes: READ_ONLY,
148
+ exit: 'when a section between the two versions is missing',
149
+ json: 'MigrateReport',
150
+ },
151
+ {
152
+ verb: 'rules',
153
+ args: '[--element <e>[,<e>]] [--enforcement script|partial|guide|locked] [--edition chapter|deck]',
154
+ when: 'which rules apply',
155
+ prints: 'the rules this course has on, by element, with enforcement, codes, settings in effect and a one-line summary',
156
+ flags: [
157
+ { flag: '--element <e>[,<e>]', description: 'only rules about these elements (prose, table, notation, …)' },
158
+ { flag: '--enforcement <kind>', description: 'only script, partial, guide or locked rules' },
159
+ { flag: '--edition <edition>', description: "chapter (the default) or deck: the rules for that edition's pages" },
160
+ ],
161
+ writes: READ_ONLY,
162
+ json: 'RulesJson',
163
+ },
164
+ {
165
+ verb: 'move',
166
+ args: '<lesson>:<key> [--to <key>] [--write]',
167
+ when: 'a local entry becomes shared',
168
+ prints: 'the new card and every copy and reference that changes, as a diff',
169
+ flags: [{ flag: '--to <key>', description: 'the shared card\'s key, when it differs from the local one' }, WRITE],
170
+ writes: 'only with --write',
171
+ json: 'EditPlanJson',
172
+ },
173
+ {
174
+ verb: 'rename',
175
+ args: '<old> <new> [--write]',
176
+ when: 'rename a shared card',
177
+ prints: 'the card file and every reference, as a diff',
178
+ flags: [WRITE],
179
+ writes: 'only with --write',
180
+ json: 'EditPlanJson',
181
+ },
182
+ {
183
+ verb: 'fix',
184
+ args: '<lesson>… | --all [--write]',
185
+ when: 'the mechanical repairs',
186
+ prints: 'redundant \\explain wraps unwrapped, as a diff',
187
+ flags: [{ flag: '--all', description: 'every lesson; the ones with nothing to fix are counted' }, WRITE],
188
+ writes: 'only with --write',
189
+ json: 'EditPlanJson',
190
+ },
191
+ {
192
+ verb: 'new lesson',
193
+ args: '<id>',
194
+ when: 'a chapter to start from',
195
+ prints: 'the files it created: the .mdx and its .checks.yml',
196
+ flags: [],
197
+ writes: 'new files; never overwrites',
198
+ json: 'NewJson',
199
+ },
200
+ {
201
+ verb: 'new deck',
202
+ args: '<lesson | part>',
203
+ when: 'slides to start from',
204
+ prints: "the deck it drafted from the chapter (or the part's chapters)",
205
+ flags: [],
206
+ writes: 'new files; never overwrites',
207
+ json: 'NewJson',
208
+ },
209
+ {
210
+ verb: 'new term',
211
+ args: '<key>',
212
+ when: 'a shared notation card to start from',
213
+ prints: 'the card file it created',
214
+ flags: [],
215
+ writes: 'new files; never overwrites',
216
+ json: 'NewJson',
217
+ },
218
+ {
219
+ verb: 'init-agent',
220
+ args: '[--write]',
221
+ when: 'install the agent procedures',
222
+ prints:
223
+ "the skills and the rules-reviewer agent it would copy into .claude/, and a baseline AGENTS.md when the course has none; rerun after an upgrade",
224
+ flags: [WRITE],
225
+ writes: 'only with --write',
226
+ json: 'EditPlanJson',
227
+ },
228
+ {
229
+ verb: 'release',
230
+ args: '[major|minor|patch] [--write]',
231
+ when: 'cut a content release (rule C3)',
232
+ prints:
233
+ "## [Unreleased] in content/CHANGELOG.md becoming a dated ## [x.y.z] heading and about.version becoming x.y.z, as a diff; the bump defaults to what the section's headings imply (Breaking or Removed → major, Added → minor, else patch)",
234
+ flags: [WRITE],
235
+ writes: 'only with --write',
236
+ json: 'EditPlanJson',
237
+ },
238
+ {
239
+ verb: 'verify',
240
+ args: '[--e2e] [--force]',
241
+ when: 'is it ready to hand back',
242
+ prints:
243
+ "one line per step — validate:content, check --changed, astro check, the course's tests, astro build, and with --e2e the browser tests — and the tail of each failed step's log; a rerun on an unchanged tree answers from the last pass",
244
+ flags: [
245
+ { flag: '--e2e', description: "also run the browser tests (the course's test:e2e script, or playwright test when it has a config)" },
246
+ { flag: '--force', description: 'run even when nothing changed since the last pass' },
247
+ ],
248
+ writes: 'what validate:content and astro build write; logs and the pass record under node_modules/.cache/explico/verify/',
249
+ exit: 'when a step fails',
250
+ json: 'VerifyReport',
251
+ },
252
+ {
253
+ verb: 'help',
254
+ args: '[<verb>]',
255
+ when: "a verb's flags and its --json shape",
256
+ prints: "the usage; with a verb, its flags, what it writes, its exit code and the shape of its --json",
257
+ flags: [],
258
+ writes: READ_ONLY,
259
+ exit: 'for an unknown verb',
260
+ json: 'HelpJson',
261
+ },
262
+ ];
263
+
264
+ /** One usage line: `pnpm content symbol <latex | key> [--json]`. */
265
+ export function usageLine(verb: ContentVerb): string {
266
+ return `pnpm content ${verb.verb}${verb.args === '' ? '' : ` ${verb.args}`} [--json]`;
267
+ }
268
+
269
+ /** The usage text `content help` prints; `scripts/agent/gen-map.mjs` lists the verbs from the generated reference. */
270
+ export const CONTENT_USAGE = `Usage:\n${CONTENT_VERBS.map((verb) => ` ${usageLine(verb)}`).join('\n')}`;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * `content verify` — the one gate before a course's work is handed back:
3
+ * `validate:content` → `content check --changed` → `astro check` → the
4
+ * course's tests → `astro build`, and with `--e2e` its browser tests. Each
5
+ * step runs as its own process (the course's own scripts, so whatever they
6
+ * do is what runs), its whole output goes to a log under
7
+ * `node_modules/.cache/explico/verify/`, and the run prints one line per step
8
+ * and the tail of each failed step's log.
9
+ *
10
+ * A pass records the tree it left (git HEAD, every change against it, every
11
+ * untracked file's content; ignored files are no input) and whether it ran
12
+ * the browser tests. A rerun on the same tree that asks for no more answers
13
+ * from the record without running anything, so "run it again to be sure"
14
+ * costs nothing. This repository's `scripts/agent/verify` is the same gate
15
+ * for the engine and the playground.
16
+ */
17
+ import { execFileSync, spawnSync } from 'node:child_process';
18
+ import { createHash } from 'node:crypto';
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join, relative } from 'node:path';
21
+
22
+ export type VerifyStepName = 'validate:content' | 'check --changed' | 'astro check' | 'tests' | 'astro build' | 'e2e';
23
+
24
+ export interface VerifyStep {
25
+ readonly name: VerifyStepName;
26
+ readonly status: 'pass' | 'fail' | 'skipped';
27
+ /** The command it ran, as it would be typed; absent for a skipped step. */
28
+ readonly command?: string;
29
+ /** Why it was skipped, or why it could not start. */
30
+ readonly reason?: string;
31
+ readonly seconds?: number;
32
+ /** Course-relative path of its whole output. */
33
+ readonly log?: string;
34
+ /** The last lines of the output of a failed step. */
35
+ readonly tail?: readonly string[];
36
+ }
37
+
38
+ export interface VerifyReport {
39
+ readonly ok: boolean;
40
+ /** True when the tree is unchanged since a pass that covered as much: nothing ran, `steps` are that pass's. */
41
+ readonly cached: boolean;
42
+ /** When the recorded pass ran, for a cached answer (ISO 8601). */
43
+ readonly since?: string;
44
+ /** The hash of the tree the run left, or `null` outside a git repository (then nothing is recorded). */
45
+ readonly tree: string | null;
46
+ /** Whether the browser tests were part of the run. */
47
+ readonly e2e: boolean;
48
+ readonly steps: readonly VerifyStep[];
49
+ }
50
+
51
+ export interface VerifyOptions {
52
+ readonly e2e?: boolean;
53
+ readonly force?: boolean;
54
+ /** The course root; the working directory by default. */
55
+ readonly cwd?: string;
56
+ /** Called as each step starts and ends, for a progress line. */
57
+ readonly onStep?: (event: { readonly phase: 'start'; readonly name: VerifyStepName } | { readonly phase: 'end'; readonly step: VerifyStep }) => void;
58
+ }
59
+
60
+ export const VERIFY_DIR = join('node_modules', '.cache', 'explico', 'verify');
61
+ const RECORD = 'last-pass.json';
62
+ const TAIL_LINES = 20;
63
+
64
+ interface PassRecord {
65
+ readonly tree: string;
66
+ readonly e2e: boolean;
67
+ readonly when: string;
68
+ readonly steps: readonly VerifyStep[];
69
+ }
70
+
71
+ /** The working tree as the gate sees it, in one hash; `null` outside git. */
72
+ export function verifyTreeHash(cwd: string): string | null {
73
+ try {
74
+ const git = (args: string[]) =>
75
+ execFileSync('git', args, { cwd, maxBuffer: 1 << 30, stdio: ['ignore', 'pipe', 'ignore'] });
76
+ const hash = createHash('sha1');
77
+ hash.update(git(['rev-parse', 'HEAD']));
78
+ hash.update(git(['diff', 'HEAD', '--binary']));
79
+ const untracked = git(['ls-files', '--others', '--exclude-standard', '-z']).toString('utf8').split('\0').filter(Boolean).sort();
80
+ for (const file of untracked) {
81
+ hash.update(`\0${file}\0`);
82
+ if (existsSync(join(cwd, file))) hash.update(readFileSync(join(cwd, file)));
83
+ }
84
+ return hash.digest('hex');
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** How to run a package script with the manager that started this process (`npm_execpath`), pnpm otherwise. */
91
+ function scriptCommand(script: string, args: readonly string[]): { file: string; argv: string[]; display: string } {
92
+ const execpath = process.env.npm_execpath ?? '';
93
+ const manager = /pnpm/.test(execpath) ? 'pnpm' : /yarn/.test(execpath) ? 'yarn' : /npm/.test(execpath) ? 'npm' : 'pnpm';
94
+ // npm hands a script its arguments only after `--`; pnpm and yarn pass them as they are.
95
+ const tail = manager === 'npm' && args.length > 0 ? ['run', script, '--', ...args] : ['run', script, ...args];
96
+ const display = `${manager} ${tail.join(' ')}`;
97
+ if (execpath === '' || !execpath.includes(manager)) return { file: manager, argv: tail, display };
98
+ return /\.[cm]?js$/.test(execpath)
99
+ ? { file: process.execPath, argv: [execpath, ...tail], display }
100
+ : { file: execpath, argv: tail, display };
101
+ }
102
+
103
+ function binCommand(cwd: string, bin: string, args: readonly string[]): { file: string; argv: string[]; display: string } {
104
+ return { file: join(cwd, 'node_modules', '.bin', process.platform === 'win32' ? `${bin}.cmd` : bin), argv: [...args], display: [bin, ...args].join(' ') };
105
+ }
106
+
107
+ function slug(name: VerifyStepName): string {
108
+ return name.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
109
+ }
110
+
111
+ function tailOf(file: string): string[] {
112
+ const lines = readFileSync(file, 'utf8').split('\n').map((line) => line.trimEnd()).filter((line) => line !== '');
113
+ return lines.slice(-TAIL_LINES);
114
+ }
115
+
116
+ /** Run every step, record a pass, and say what happened; never throws for a failing step. */
117
+ export function runVerify(options: VerifyOptions = {}): VerifyReport {
118
+ const cwd = options.cwd ?? process.cwd();
119
+ const e2e = options.e2e ?? false;
120
+ const dir = join(cwd, VERIFY_DIR);
121
+ const recordFile = join(dir, RECORD);
122
+
123
+ if (!options.force && existsSync(recordFile)) {
124
+ try {
125
+ const record = JSON.parse(readFileSync(recordFile, 'utf8')) as PassRecord;
126
+ const tree = verifyTreeHash(cwd);
127
+ if (tree !== null && record.tree === tree && (record.e2e || !e2e)) {
128
+ return { ok: true, cached: true, since: record.when, tree, e2e: record.e2e, steps: record.steps };
129
+ }
130
+ } catch {
131
+ // an unreadable record is no record
132
+ }
133
+ }
134
+
135
+ mkdirSync(dir, { recursive: true });
136
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as { scripts?: Record<string, string> };
137
+ const scripts = pkg.scripts ?? {};
138
+ const has = (path: string) => existsSync(join(cwd, path));
139
+ const playwrightConfig = ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js', 'playwright.config.mjs'].some(has);
140
+
141
+ type Plan = { name: VerifyStepName; command?: ReturnType<typeof scriptCommand>; reason?: string; fail?: boolean };
142
+ const plans: Plan[] = [
143
+ scripts['validate:content'] === undefined
144
+ ? { name: 'validate:content', reason: 'package.json has no validate:content script', fail: true }
145
+ : { name: 'validate:content', command: scriptCommand('validate:content', []) },
146
+ scripts.content === undefined
147
+ ? { name: 'check --changed', reason: 'package.json has no content script', fail: true }
148
+ : { name: 'check --changed', command: scriptCommand('content', ['check', '--changed']) },
149
+ // Without @astrojs/check, `astro check` stops to ask whether to install it.
150
+ has(join('node_modules', '@astrojs', 'check', 'package.json'))
151
+ ? { name: 'astro check', command: binCommand(cwd, 'astro', ['check']) }
152
+ : { name: 'astro check', reason: '@astrojs/check is not installed (pnpm add -D @astrojs/check typescript)' },
153
+ scripts.test === undefined
154
+ ? { name: 'tests', reason: 'package.json has no test script' }
155
+ : { name: 'tests', command: scriptCommand('test', []) },
156
+ { name: 'astro build', command: binCommand(cwd, 'astro', ['build']) },
157
+ ];
158
+ if (e2e) {
159
+ plans.push(
160
+ scripts['test:e2e'] !== undefined
161
+ ? { name: 'e2e', command: scriptCommand('test:e2e', []) }
162
+ : playwrightConfig
163
+ ? { name: 'e2e', command: binCommand(cwd, 'playwright', ['test']) }
164
+ : { name: 'e2e', reason: 'no test:e2e script and no playwright.config' },
165
+ );
166
+ }
167
+
168
+ const steps: VerifyStep[] = [];
169
+ for (const plan of plans) {
170
+ options.onStep?.({ phase: 'start', name: plan.name });
171
+ let step: VerifyStep;
172
+ if (plan.command === undefined) {
173
+ step = { name: plan.name, status: plan.fail ? 'fail' : 'skipped', reason: plan.reason! };
174
+ } else {
175
+ const log = join(dir, `${slug(plan.name)}.log`);
176
+ const fd = openSync(log, 'w');
177
+ const started = Date.now();
178
+ let result;
179
+ try {
180
+ result = spawnSync(plan.command.file, plan.command.argv, {
181
+ cwd,
182
+ stdio: ['ignore', fd, fd],
183
+ shell: process.platform === 'win32',
184
+ env: process.env,
185
+ });
186
+ } finally {
187
+ closeSync(fd);
188
+ }
189
+ const seconds = Math.round((Date.now() - started) / 100) / 10;
190
+ const passed = result.error === undefined && result.status === 0;
191
+ step = {
192
+ name: plan.name,
193
+ status: passed ? 'pass' : 'fail',
194
+ command: plan.command.display,
195
+ seconds,
196
+ log: relative(cwd, log),
197
+ ...(result.error === undefined ? {} : { reason: result.error.message }),
198
+ ...(passed ? {} : { tail: tailOf(log) }),
199
+ };
200
+ }
201
+ steps.push(step);
202
+ options.onStep?.({ phase: 'end', step });
203
+ }
204
+
205
+ const ok = steps.every((step) => step.status !== 'fail');
206
+ // The tree the steps left: validate:content may rewrite tracked files (content/versions.json).
207
+ const tree = verifyTreeHash(cwd);
208
+ if (ok && tree !== null) {
209
+ const record: PassRecord = { tree, e2e, when: new Date().toISOString(), steps };
210
+ writeFileSync(recordFile, `${JSON.stringify(record, null, 2)}\n`);
211
+ }
212
+ return { ok, cached: false, tree, e2e, steps };
213
+ }
214
+
215
+ /** The digest after a run: failed steps' tails, then one verdict line. */
216
+ export function formatVerifyReport(report: VerifyReport): string {
217
+ if (report.cached) {
218
+ return `verify: PASS, unchanged since ${report.since} (${report.e2e ? 'with' : 'without'} e2e); --force reruns\n`;
219
+ }
220
+ const out: string[] = [];
221
+ for (const step of report.steps) {
222
+ if (step.status !== 'fail') continue;
223
+ out.push('', `${step.name} failed${step.reason === undefined ? '' : `: ${step.reason}`}${step.log === undefined ? '' : ` — last lines of ${step.log}:`}`);
224
+ for (const line of step.tail ?? []) out.push(` ${line}`);
225
+ }
226
+ const failed = report.steps.filter((step) => step.status === 'fail').map((step) => step.name);
227
+ out.push(
228
+ '',
229
+ failed.length === 0
230
+ ? `verify: PASS${report.tree === null ? ' (not a git repository: nothing recorded)' : ''}`
231
+ : `verify: FAIL — ${failed.join(', ')}; whole output under ${VERIFY_DIR}/`,
232
+ );
233
+ return `${out.join('\n').replace(/^\n/, '')}\n`;
234
+ }
235
+
236
+ /** One progress line per step: ` astro build ok 41.2s`. */
237
+ export function formatVerifyStep(step: VerifyStep): string {
238
+ const verdict = step.status === 'pass' ? 'ok' : step.status === 'fail' ? 'FAIL' : 'skipped';
239
+ const detail = step.seconds !== undefined ? `${step.seconds.toFixed(1)}s` : (step.reason ?? '');
240
+ return ` ${step.name.padEnd(18)} ${verdict.padEnd(7)} ${detail}\n`;
241
+ }