@erclx/aitk 3.52.0 → 3.53.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.
@@ -0,0 +1,682 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ export interface CommandResult {
6
+ readonly exitCode: number
7
+ readonly stdout: string
8
+ readonly stderr: string
9
+ /**
10
+ * The two streams interleaved, which is what `2>&1` handed the frame in the
11
+ * script this replaces. A stage borrowing a command's own output pipes this
12
+ * rather than either stream alone.
13
+ */
14
+ readonly all: string
15
+ /**
16
+ * Set when the binary could not be started at all, which is a different state
17
+ * from a binary that ran and refused. An absent tool on a contributor's
18
+ * machine is somebody mid-setup, and an absent tool under CI is a broken
19
+ * workflow step, so the two have to stay distinguishable.
20
+ */
21
+ readonly spawnError?: string
22
+ }
23
+
24
+ export type RunCommand = (argv: readonly string[]) => Promise<CommandResult>
25
+
26
+ export interface MeasureContext {
27
+ readonly root: string
28
+ /**
29
+ * True where this run is the merge gate rather than a contributor's machine.
30
+ * It decides nothing about what a stage measures and everything about what an
31
+ * absent input means, which is why it reaches a measure at all.
32
+ */
33
+ readonly ci: boolean
34
+ /** Any binary, run from the project root. */
35
+ readonly run: RunCommand
36
+ /**
37
+ * This checkout's own CLI rather than whatever `aitk` resolves to on PATH. A
38
+ * globally installed binary resolves to the main checkout no matter which
39
+ * worktree is running, so a gate reading through it would measure the wrong
40
+ * tree and report a pass over a branch it never opened.
41
+ */
42
+ readonly cli: RunCommand
43
+ }
44
+
45
+ export type Emission =
46
+ | { readonly kind: 'info'; readonly text: string }
47
+ | { readonly kind: 'warn'; readonly text: string }
48
+ /** Borrowed output, indented under the stage the way `pipe_output` did. */
49
+ | { readonly kind: 'output'; readonly text: string }
50
+
51
+ export interface MeasureReport {
52
+ readonly emissions: readonly Emission[]
53
+ /** The stage read its input and found a fact, so the run stops here. */
54
+ readonly failure?: string
55
+ /**
56
+ * The stage could not read its input at all, so it has no verdict to give.
57
+ *
58
+ * Distinct from a pass with nothing to report, which is what six places in
59
+ * the script this replaces used to print. A skip rendered as a passing line
60
+ * reports the pass the stage exists to withhold, so the sequencer renders
61
+ * this as a warning on a contributor's machine and refuses on it under CI.
62
+ */
63
+ readonly unmeasured?: string
64
+ }
65
+
66
+ export type Measure = (ctx: MeasureContext) => Promise<MeasureReport>
67
+
68
+ const info = (text: string): Emission => ({ kind: 'info', text })
69
+ const warn = (text: string): Emission => ({ kind: 'warn', text })
70
+ const output = (text: string): Emission => ({ kind: 'output', text })
71
+
72
+ /**
73
+ * Rules no stack reaches, sorted the way `aitk gov list` emits them.
74
+ * `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
75
+ * want. `505-at-references` used to sit here too, shipping with no stack on
76
+ * purpose since a rule under `claude/` would reach every base consumer through
77
+ * the folder-whole entry there. Its own install channel, `aitk snippets
78
+ * install`, retired with nothing left to deliver it, so `base` now carries
79
+ * `snippets` as a folder-whole entry of its own and the rule reaches every base
80
+ * consumer through that instead.
81
+ *
82
+ * Both are recorded here rather than in a config file: the list is what a
83
+ * reader compares a new arrival against, and a config file would absorb the
84
+ * arrival silently.
85
+ */
86
+ export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
87
+
88
+ /**
89
+ * Scenarios declaring no expectation, taken from `aitk sandbox coverage`
90
+ * against a clean tree. Raising it is a deliberate edit that says which
91
+ * scenario shipped unarmed and why.
92
+ */
93
+ export const SANDBOX_UNDECLARED_CEILING = 47
94
+
95
+ /**
96
+ * The retained counts the audit stage compares each run against. Spelled here
97
+ * rather than derived, because this stage only ever names the file in a remedy
98
+ * a reader has to be able to open, and `aitk audits run` owns writing it.
99
+ */
100
+ export const AUDITS_BASELINE = '.claude/audits/baseline.json'
101
+
102
+ export const HERO_STAMP_FAILURE =
103
+ 'The hero set disagrees with the stamp written when the image was captured. Run aitk capture assets/hero.html and commit all three files together.'
104
+
105
+ function parseJson(payload: string): unknown {
106
+ try {
107
+ return JSON.parse(payload)
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+
113
+ /**
114
+ * A rule no stack names is a report rather than a gate. All three standing
115
+ * findings ship that way on purpose, so failing would fail every push over the
116
+ * deliberate case and teach a reader to route around the stage.
117
+ *
118
+ * The catalog is parsed here rather than piped through `bun --eval`, which the
119
+ * script this replaces had to do and had to guard: that interpreter exits 0
120
+ * when its script throws while stdin is a pipe, so a payload that is not JSON
121
+ * printed nothing and exited clean, and empty already means every rule is
122
+ * reached. A sentinel string carried success past that. Parsing in process
123
+ * leaves the two states distinguishable with nothing to carry.
124
+ */
125
+ export const unreferencedRules: Measure = async (ctx) => {
126
+ const { exitCode, stdout } = await ctx.cli(['gov', 'list', '--json'])
127
+ if (exitCode !== 0 || stdout.trim() === '') {
128
+ return {
129
+ emissions: [],
130
+ unmeasured: 'The governance catalog did not report.',
131
+ }
132
+ }
133
+
134
+ const record = parseJson(stdout) as { unreferenced?: unknown } | undefined
135
+ if (!Array.isArray(record?.unreferenced)) {
136
+ return {
137
+ emissions: [],
138
+ unmeasured:
139
+ 'The governance catalog carried no readable unreferenced list.',
140
+ }
141
+ }
142
+
143
+ const unreferenced = record.unreferenced.map(String)
144
+ if (unreferenced.length === 0) {
145
+ return { emissions: [info('Every rule is reached by a stack')] }
146
+ }
147
+
148
+ const listed = unreferenced.join(' ')
149
+ if (listed === GOV_EXPECTED_UNREFERENCED.join(' ')) {
150
+ return {
151
+ emissions: [
152
+ info(`Reached by no stack: ${listed} (each recorded above with why)`),
153
+ ],
154
+ }
155
+ }
156
+
157
+ return {
158
+ emissions: [
159
+ warn(`Reached by no stack: ${listed}`),
160
+ warn(
161
+ `Expected: ${GOV_EXPECTED_UNREFERENCED.join(' ')}. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in src/gate/measures.ts and say why it reaches no stack.`,
162
+ ),
163
+ ],
164
+ }
165
+ }
166
+
167
+ /**
168
+ * A banned character, word, or spelling is a fact rather than a threshold, so
169
+ * it fails the push while bullet, paragraph, and depth weight stay advisory.
170
+ *
171
+ * The whole corpus is measured rather than the changed files, because a
172
+ * `Do not use` bullet added to a standard bans a token retroactively and no
173
+ * file in the push that adds it was edited.
174
+ *
175
+ * `--json` sends the record to stdout and the frame to stderr, so a passing run
176
+ * stays silent and a failing one is re-run for its frame rather than parsed out
177
+ * of a stream this stage would have to strip.
178
+ */
179
+ export const markdownBans: Measure = async (ctx) => {
180
+ const { exitCode } = await ctx.cli(['markdown', 'audit', '--json'])
181
+
182
+ if (exitCode === 0) {
183
+ return { emissions: [info('No banned character, word, or spelling')] }
184
+ }
185
+
186
+ if (exitCode === 1) {
187
+ return {
188
+ emissions: [],
189
+ unmeasured: 'The markdown audit refused and measured nothing.',
190
+ }
191
+ }
192
+
193
+ if (exitCode === 3) {
194
+ return {
195
+ emissions: [],
196
+ failure:
197
+ 'The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for. Check src/markdown/bans.ts.',
198
+ }
199
+ }
200
+
201
+ if (exitCode === 2) {
202
+ const frame = await ctx.cli(['markdown', 'audit'])
203
+ return {
204
+ emissions: [output(frame.all)],
205
+ failure:
206
+ 'Markdown prose carries a banned character, word, or spelling. Rewrite the sentence, and reach for a code span only where the token is genuinely an identifier under discussion.',
207
+ }
208
+ }
209
+
210
+ return {
211
+ emissions: [],
212
+ failure: `The markdown audit exited ${exitCode}, which is neither a pass nor a finding.`,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * The markdown stage audits this repository. The seed tree ships into every
218
+ * scaffolded project, so a seed breaking the standard it seeds propagates
219
+ * instead of sitting still, and no rule path reaches the tree to report it.
220
+ *
221
+ * `--gate` fails on the two findings beside citations that are facts, a missing
222
+ * required section and index drift, and leaves the thresholds advisory for the
223
+ * reason the stage above leaves its own so.
224
+ *
225
+ * The roots are discovered rather than listed, through the one bash definition
226
+ * `check-seed-independence.sh` already reads, so a stack seeding `.claude/`
227
+ * later is covered with no edit here and the two stages cannot disagree about
228
+ * which roots exist.
229
+ */
230
+ export const seedStandards: Measure = async (ctx) => {
231
+ const roots = await ctx.run([
232
+ 'bash',
233
+ join(ctx.root, 'scripts/core/list-seed-roots.sh'),
234
+ ])
235
+
236
+ if (roots.exitCode !== 0) {
237
+ return {
238
+ emissions: [],
239
+ unmeasured: 'The seed roots could not be listed, so no seed was read.',
240
+ }
241
+ }
242
+
243
+ const seedRoots = roots.stdout.split('\n').filter((line) => line !== '')
244
+ if (seedRoots.length === 0) {
245
+ return {
246
+ emissions: [],
247
+ unmeasured: 'No seed root carries .claude/, so nothing was measured.',
248
+ }
249
+ }
250
+
251
+ const emissions: Emission[] = []
252
+ let measured = 0
253
+
254
+ for (const seedRoot of seedRoots) {
255
+ const run = await ctx.cli([
256
+ 'context',
257
+ 'audit',
258
+ seedRoot,
259
+ '--gate',
260
+ '--json',
261
+ ])
262
+
263
+ // The audit separates 1 from 2 and they mean opposite things. 2 is a seed
264
+ // breaking the standard it seeds. 1 is the audit refusing, which a seed
265
+ // root carrying a `.claude/` but no audited folder produces, and reporting
266
+ // that as a violation sends a reader hunting one that does not exist.
267
+ if (run.exitCode === 1) {
268
+ emissions.push(
269
+ warn(`${seedRoot}: no audited folder under .claude/, nothing measured`),
270
+ )
271
+ continue
272
+ }
273
+
274
+ if (run.exitCode !== 0) {
275
+ const frame = await ctx.cli(['context', 'audit', seedRoot, '--gate'])
276
+ emissions.push(output(frame.all))
277
+ return {
278
+ emissions,
279
+ failure:
280
+ run.exitCode === 2
281
+ ? `A seed breaks the standard governing the folder it seeds: ${seedRoot}`
282
+ : `The seed audit exited ${run.exitCode} against ${seedRoot}, which is neither a pass nor a finding.`,
283
+ }
284
+ }
285
+
286
+ const entries = seedEntryCount(run.stdout)
287
+ measured += entries
288
+ emissions.push(
289
+ entries === 0
290
+ ? warn(
291
+ `${seedRoot}: no entry under an audited folder, nothing measured`,
292
+ )
293
+ : info(`${seedRoot}: ${entries} entries measured`),
294
+ )
295
+ }
296
+
297
+ if (measured === 0) {
298
+ return {
299
+ emissions,
300
+ unmeasured: 'No seed entry was measured, so the stage covered nothing.',
301
+ }
302
+ }
303
+
304
+ return { emissions }
305
+ }
306
+
307
+ /**
308
+ * Entries the audit actually measured, summed across the folders it resolved.
309
+ *
310
+ * A root can resolve a folder and measure nothing in it, which is a passing
311
+ * gate over an empty set. The caller states this per root rather than reporting
312
+ * one verdict for every root, or a tree nobody measured reads as a tree that
313
+ * passed.
314
+ */
315
+ export function seedEntryCount(payload: string): number {
316
+ const record = parseJson(payload) as
317
+ | { folders?: { entries?: unknown }[] }
318
+ | undefined
319
+ const folders = Array.isArray(record?.folders) ? record.folders : []
320
+ return folders.reduce(
321
+ (total, folder) =>
322
+ total + (typeof folder.entries === 'number' ? folder.entries : 0),
323
+ 0,
324
+ )
325
+ }
326
+
327
+ /**
328
+ * Scoped to arrival rather than the corpus, since `standards/standard.md`
329
+ * forbids writing a criterion into an existing standard outside the change that
330
+ * exercises it. Gating the known gaps would fail every push until someone
331
+ * closed them all, which is the sweep that rule exists to prevent.
332
+ */
333
+ export const standardCriteria: Measure = async (ctx) => {
334
+ const run = await ctx.cli(['standards', 'audit', '--arrivals-only'])
335
+
336
+ if (run.exitCode === 0) {
337
+ return { emissions: [info('Arriving standards carry a success criterion')] }
338
+ }
339
+
340
+ return {
341
+ emissions: [output(run.all)],
342
+ failure:
343
+ run.exitCode === 2
344
+ ? 'A standard new to this branch carries no ## Success criterion section. Run bun src/cli.ts standards audit.'
345
+ : 'aitk standards audit could not read which standards arrived on this branch. Run bun src/cli.ts standards audit --json to see why.',
346
+ }
347
+ }
348
+
349
+ /**
350
+ * `aitk sandbox coverage` moves only when a person runs it, so a scenario added
351
+ * with no expectation ships unnoticed.
352
+ *
353
+ * The gate is an absolute count of undeclared scenarios rather than a ratio or
354
+ * a floor under the declared count. A floor passes the case this exists to
355
+ * catch, since adding an unarmed scenario leaves that number where it was. A
356
+ * ratio moves when a scenario is legitimately deleted, and this ceiling does
357
+ * not: deleting an unarmed scenario lowers it and deleting an armed one leaves
358
+ * it alone.
359
+ */
360
+ export const sandboxCoverage: Measure = async (ctx) => {
361
+ const run = await ctx.cli(['sandbox', 'coverage', '--json'])
362
+
363
+ if (run.exitCode !== 0) {
364
+ return {
365
+ emissions: [],
366
+ unmeasured: `The scenario tree did not report (exit ${run.exitCode}). It ships in the checkout, so a run that does not report is a broken command rather than an absent tree.`,
367
+ }
368
+ }
369
+
370
+ const record = parseJson(run.stdout) as
371
+ | { totalScenarios?: unknown; armedScenarios?: unknown }
372
+ | undefined
373
+ const total = record?.totalScenarios
374
+ const armed = record?.armedScenarios
375
+
376
+ if (typeof total !== 'number' || typeof armed !== 'number') {
377
+ return {
378
+ emissions: [],
379
+ failure:
380
+ 'The coverage report carried no scenario totals, so the stage measured nothing. Run bun src/cli.ts sandbox coverage --json.',
381
+ }
382
+ }
383
+
384
+ const undeclared = total - armed
385
+ if (undeclared > SANDBOX_UNDECLARED_CEILING) {
386
+ return {
387
+ emissions: [],
388
+ failure: `${undeclared} of ${total} scenarios declare no expectation, over the ceiling of ${SANDBOX_UNDECLARED_CEILING}. Declare expectations on the new scenario, or raise SANDBOX_UNDECLARED_CEILING in src/gate/measures.ts and say which scenario shipped unarmed.`,
389
+ }
390
+ }
391
+
392
+ return {
393
+ emissions: [
394
+ info(
395
+ `${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
396
+ ),
397
+ ],
398
+ }
399
+ }
400
+
401
+ /** The flat scalars `aitk audits run --json` publishes for a caller to read. */
402
+ interface AuditSummary {
403
+ readonly grown?: number
404
+ readonly shrunk?: number
405
+ readonly facts?: number
406
+ readonly unmeasured?: number
407
+ readonly absent?: number
408
+ readonly unrecorded?: number
409
+ }
410
+
411
+ /**
412
+ * The three stages gating on the three findings here that are facts sit above,
413
+ * and this stage reports the rest. It runs the whole set anyway rather than
414
+ * only what those stages skip, because the aggregate's own value is one verdict
415
+ * over every audit, and a stage measuring a subset would report a health this
416
+ * repository never took.
417
+ *
418
+ * This reports and never fails. Growth in a judgment count is the thing the
419
+ * baseline exists to make visible, and failing a push on one would teach a
420
+ * contributor to route around the stage. A fact still fails the push, at the
421
+ * specific stage above that names its own remedy.
422
+ */
423
+ export const auditSet: Measure = async (ctx) => {
424
+ const run = await ctx.cli(['audits', 'run', '--json'])
425
+
426
+ if (run.stdout.trim() === '') {
427
+ return {
428
+ emissions: [],
429
+ unmeasured: `The audit set did not report (exit ${run.exitCode}).`,
430
+ }
431
+ }
432
+
433
+ const record = parseJson(run.stdout) as { summary?: AuditSummary } | undefined
434
+ const summary = record?.summary
435
+
436
+ // An absent field is a record this stage cannot read, which is not the same
437
+ // as a run with nothing to report. Reading it as zero would print a clean
438
+ // line over a summary nobody parsed.
439
+ if (
440
+ typeof summary?.grown !== 'number' ||
441
+ typeof summary.facts !== 'number' ||
442
+ typeof summary.unmeasured !== 'number'
443
+ ) {
444
+ return {
445
+ emissions: [],
446
+ unmeasured:
447
+ 'The audit record carried no summary, so this stage measured nothing. Run bun src/cli.ts audits run.',
448
+ }
449
+ }
450
+
451
+ const emissions: Emission[] = []
452
+
453
+ // An absent per-machine folder is the ordinary state here rather than a
454
+ // finding, since every one of them is gitignored and CI carries none. It is
455
+ // still stated, because a stage naming only what it measured claims a
456
+ // coverage it does not have.
457
+ if (typeof summary.absent === 'number' && summary.absent > 0) {
458
+ emissions.push(
459
+ info(
460
+ `${summary.absent} per-machine corpus/corpora absent, so unmeasured here by design`,
461
+ ),
462
+ )
463
+ }
464
+ if (summary.unmeasured > 0) {
465
+ emissions.push(
466
+ warn(
467
+ `${summary.unmeasured} audit(s) did not report, so the set is incomplete. Run bun src/cli.ts audits run.`,
468
+ ),
469
+ )
470
+ }
471
+ if (summary.facts > 0) {
472
+ emissions.push(
473
+ warn(
474
+ `${summary.facts} audit(s) carry a finding that is a fact. The stage above names the remedy.`,
475
+ ),
476
+ )
477
+ }
478
+ if (typeof summary.unrecorded === 'number' && summary.unrecorded > 0) {
479
+ emissions.push(
480
+ warn(
481
+ `${summary.unrecorded} tracked audit(s) have no recorded floor. Take one with bun src/cli.ts audits run --record.`,
482
+ ),
483
+ )
484
+ }
485
+ emissions.push(
486
+ summary.grown > 0
487
+ ? warn(
488
+ `${summary.grown} measure(s) grew against ${AUDITS_BASELINE}. Run bun src/cli.ts audits run to see which, then fix them or re-record and say why.`,
489
+ )
490
+ : info(`No measure grew against ${AUDITS_BASELINE}`),
491
+ )
492
+ if (typeof summary.shrunk === 'number' && summary.shrunk > 0) {
493
+ emissions.push(
494
+ info(`${summary.shrunk} measure(s) fell against ${AUDITS_BASELINE}`),
495
+ )
496
+ }
497
+
498
+ return { emissions }
499
+ }
500
+
501
+ /**
502
+ * The plugin is the second delivery path and this is the only stage gating it,
503
+ * so an absent binary is a contributor's machine rather than a clean tree. A
504
+ * runner installs the CLI as a workflow step, which makes an absent binary
505
+ * there a broken workflow, and passing would report a verdict for every
506
+ * manifest on the way to a marketplace install.
507
+ *
508
+ * A global install can also land the wrapper and no platform-native binary,
509
+ * which resolves on PATH and cannot run, so the two states are separated by
510
+ * whether the spawn started at all rather than by a second lookup.
511
+ */
512
+ export const pluginManifests: Measure = async (ctx) => {
513
+ const version = await ctx.run(['claude', '--version'])
514
+
515
+ if (version.spawnError !== undefined) {
516
+ return {
517
+ emissions: [],
518
+ unmeasured:
519
+ 'claude is not installed, so no manifest was read. CI installs it before this stage, so read the Install Plugin CLI step in .github/workflows/verify.yml.',
520
+ }
521
+ }
522
+
523
+ if (version.exitCode !== 0) {
524
+ return {
525
+ emissions: [],
526
+ unmeasured:
527
+ 'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in .claude/context/ci.md.',
528
+ }
529
+ }
530
+
531
+ const manifests = await collectPluginManifests(ctx)
532
+ if (manifests.length === 0) {
533
+ return {
534
+ emissions: [],
535
+ unmeasured:
536
+ 'No plugin or marketplace manifest is present, so none was validated.',
537
+ }
538
+ }
539
+
540
+ for (const manifest of manifests) {
541
+ const run = await ctx.run([
542
+ 'claude',
543
+ 'plugin',
544
+ 'validate',
545
+ '--strict',
546
+ manifest,
547
+ ])
548
+ if (run.exitCode !== 0) {
549
+ return {
550
+ emissions: [output(run.all)],
551
+ failure: `Manifest validation failed: ${manifest}`,
552
+ }
553
+ }
554
+ }
555
+
556
+ return { emissions: [info('Manifests valid')] }
557
+ }
558
+
559
+ /**
560
+ * Whatever plugin and marketplace manifests the repository currently carries,
561
+ * so the stage picks up a new one without an edit here. Both listings honor
562
+ * `.gitignore`, which keeps linked worktrees and dependency copies out.
563
+ */
564
+ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
565
+ const patterns = [
566
+ '*.claude-plugin/plugin.json',
567
+ '*.claude-plugin/marketplace.json',
568
+ ]
569
+ const tracked = await ctx.run(['git', 'ls-files', '--', ...patterns])
570
+ const untracked = await ctx.run([
571
+ 'git',
572
+ 'ls-files',
573
+ '--others',
574
+ '--exclude-standard',
575
+ '--',
576
+ ...patterns,
577
+ ])
578
+
579
+ const seen = new Set(
580
+ `${tracked.stdout}\n${untracked.stdout}`
581
+ .split('\n')
582
+ .map((line) => line.trim())
583
+ .filter((line) => line !== ''),
584
+ )
585
+ return [...seen].sort()
586
+ }
587
+
588
+ /**
589
+ * The drift assert on the Hero stage covers the markup because the image beside
590
+ * it is a chromium render whose bytes move with the browser. That leaves the
591
+ * artifact a visitor actually sees asserted nowhere, so a branch regenerating
592
+ * the markup and never running the capture would pass every stage while
593
+ * shipping an image with the old counts.
594
+ *
595
+ * `aitk capture` records a digest of the markup it rendered and one of the image
596
+ * it wrote, so this reads provenance rather than timing. Comparing the commit
597
+ * that last touched each file passes any pair that moved together whatever the
598
+ * two files hold, which is what a binary conflict resolved by taking either
599
+ * side produces.
600
+ *
601
+ * Both digests are checked because either file can move alone. The markup side
602
+ * catches an edit committed with no capture, and the image side catches an
603
+ * image replaced under markup that never changed. All three absent passes,
604
+ * which is correct for a tree that carries none of them.
605
+ */
606
+ export const heroStamp: Measure = async (ctx) => {
607
+ const set = [
608
+ ['assets/hero.html', join(ctx.root, 'assets/hero.html')],
609
+ ['assets/hero.png', join(ctx.root, 'assets/hero.png')],
610
+ ['assets/hero.stamp', join(ctx.root, 'assets/hero.stamp')],
611
+ ] as const
612
+
613
+ if (set.every(([, path]) => !existsSync(path))) return { emissions: [] }
614
+
615
+ const missing = set
616
+ .filter(([, path]) => !existsSync(path))
617
+ .map(([label]) => label)
618
+ if (missing.length > 0) {
619
+ return {
620
+ emissions: [output(`Missing from the hero set: ${missing.join(' ')}`)],
621
+ failure: HERO_STAMP_FAILURE,
622
+ }
623
+ }
624
+
625
+ const [[, html], [, png], [, stamp]] = set
626
+ const lines = [
627
+ ...assertStampField(ctx.root, stamp, 'source-sha256', html),
628
+ ...assertStampField(ctx.root, stamp, 'image-sha256', png),
629
+ ]
630
+ if (lines.length === 0) return { emissions: [] }
631
+
632
+ return { emissions: [output(lines.join('\n'))], failure: HERO_STAMP_FAILURE }
633
+ }
634
+
635
+ /**
636
+ * One digest the stamp recorded against the file it was taken over, returning
637
+ * the lines to report and nothing when the two agree.
638
+ *
639
+ * An absent field reports itself rather than comparing against an empty string,
640
+ * so a stamp predating the current format is distinguishable from a file that
641
+ * moved. Both names in the message come off the paths rather than from
642
+ * arguments, which is what keeps a name from disagreeing with the file it
643
+ * labels once a second capture source calls this.
644
+ */
645
+ export function assertStampField(
646
+ root: string,
647
+ stamp: string,
648
+ field: string,
649
+ file: string,
650
+ ): string[] {
651
+ const stampLabel = relativeTo(root, stamp)
652
+ const fileLabel = relativeTo(root, file)
653
+
654
+ const recorded = readStampField(stamp, field)
655
+ if (recorded === undefined) {
656
+ return [
657
+ `${stampLabel} carries no ${field} line, so it predates the capture that writes one.`,
658
+ ]
659
+ }
660
+
661
+ const actual = createHash('sha256').update(readFileSync(file)).digest('hex')
662
+ if (recorded !== actual) {
663
+ return [
664
+ `${stampLabel} records ${field} ${recorded}`,
665
+ `${fileLabel} hashes to ${actual}`,
666
+ ]
667
+ }
668
+
669
+ return []
670
+ }
671
+
672
+ function readStampField(stamp: string, field: string): string | undefined {
673
+ for (const line of readFileSync(stamp, 'utf8').split('\n')) {
674
+ const [key, value] = line.trim().split(/\s+/)
675
+ if (key === `${field}:` && value !== undefined) return value
676
+ }
677
+ return undefined
678
+ }
679
+
680
+ function relativeTo(root: string, path: string): string {
681
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
682
+ }