@erclx/canon 5.73.0 → 5.75.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 (87) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +5 -3
  4. package/claude/skills/build-in-slices/references/adopted.md +4 -2
  5. package/claude/skills/code-craft/references/adopted.md +9 -7
  6. package/claude/skills/codebase-layout/references/adopted.md +8 -6
  7. package/claude/skills/create-rule/SKILL.md +5 -1
  8. package/claude/skills/create-rule/references/ledger.md +73 -0
  9. package/claude/skills/create-rule/references/rule.md +1 -1
  10. package/claude/skills/create-skill/references/skill-practice.md +6 -1
  11. package/claude/skills/deprecation-migration/references/adopted.md +5 -3
  12. package/claude/skills/design-pick/references/board.md +2 -2
  13. package/claude/skills/plan-feature/SKILL.md +2 -1
  14. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  15. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
  16. package/claude/skills/pr-evidence/SKILL.md +2 -0
  17. package/claude/skills/pr-evidence/references/evidence.md +11 -4
  18. package/claude/skills/pr-evidence/references/reel.md +108 -0
  19. package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
  20. package/claude/skills/proposal-critique/SKILL.md +126 -0
  21. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  22. package/claude/skills/review-craft/references/adopted.md +4 -4
  23. package/claude/skills/search-craft/references/adopted.md +9 -7
  24. package/claude/skills/systematic-debugging/references/adopted.md +4 -2
  25. package/claude/skills/teach-craft/references/adopted.md +8 -6
  26. package/claude/skills/test-craft/references/adopted.md +14 -12
  27. package/claude/skills/test-first/SKILL.md +1 -1
  28. package/claude/skills/test-first/references/adopted.md +9 -5
  29. package/claude/skills/ui-film/REQUIREMENT.md +4 -5
  30. package/claude/skills/ui-film/SKILL.md +21 -19
  31. package/claude/skills/ui-film/references/compose-craft.md +2 -2
  32. package/claude/skills/ui-film/references/handoff.md +2 -7
  33. package/claude/skills/write-human/references/adopted.md +8 -3
  34. package/docs/agents/audits.md +24 -24
  35. package/docs/agents/migrate.md +10 -10
  36. package/docs/migrations/by-verb.md +2 -0
  37. package/docs/target/index.md +1 -0
  38. package/docs/target/outside-tools.md +39 -0
  39. package/docs/workflow/skill-map.md +1 -0
  40. package/governance/detect.toml +4 -0
  41. package/governance/ledger/claude/rule.md +16 -0
  42. package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
  43. package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
  44. package/governance/ledger/skills/search-craft/SKILL.md +54 -0
  45. package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
  46. package/governance/ledger/skills/test-first/SKILL.md +28 -0
  47. package/governance/ledger/skills/write-human/SKILL.md +62 -0
  48. package/governance/ledger/skills/write-human/density.md +36 -0
  49. package/governance/ledger/skills/write-human/machine-tells.md +30 -0
  50. package/governance/rules/claude/rule.md +13 -0
  51. package/package.json +1 -1
  52. package/scripts/ci/docs-only.sh +24 -0
  53. package/scripts/ci/refresh-frames-pr.sh +11 -12
  54. package/src/audits/catalog.ts +7 -2
  55. package/src/capture/stamp.ts +3 -5
  56. package/src/claude/cases/misc.ts +14 -0
  57. package/src/claude/skills-audit.ts +154 -8
  58. package/src/cli/help.ts +1 -1
  59. package/src/commands/claude.ts +6 -2
  60. package/src/commands/gov.ts +12 -7
  61. package/src/commands/migrate.ts +6 -2
  62. package/src/commands/pr.ts +110 -13
  63. package/src/commands/take.ts +36 -7
  64. package/src/counts/catalogs.ts +3 -5
  65. package/src/gate/measures.ts +2 -145
  66. package/src/gate/sequencer.ts +7 -1
  67. package/src/gate/stages.ts +36 -47
  68. package/src/gov/audit.ts +59 -13
  69. package/src/markdown/scope.ts +4 -0
  70. package/src/migrate/session-defaults.ts +114 -4
  71. package/src/pr/checks.ts +75 -3
  72. package/src/pr/evidence.ts +62 -1
  73. package/src/pr/lift.ts +23 -2
  74. package/src/pr/merge.ts +13 -3
  75. package/src/take/encode.ts +9 -1
  76. package/src/take/film.ts +17 -2
  77. package/src/take/fixtures/busy.html +107 -0
  78. package/src/take/perform.ts +99 -25
  79. package/src/take/screencast.ts +4 -3
  80. package/src/take/stepper.ts +33 -20
  81. package/src/targets/pulls.ts +42 -0
  82. package/tooling/claude/reference.md +4 -2
  83. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  84. package/tooling/claude/seeds/.claude/settings.json +8 -0
  85. package/claude/skills/ui-film/references/pr-clip.md +0 -108
  86. package/scripts/core/regen-hero.ts +0 -113
  87. package/src/capture/frame-values.ts +0 -326
@@ -1489,9 +1489,13 @@ export function register(program: Command): void {
1489
1489
  'false only when the key is absent. A deny entry is never removed and',
1490
1490
  'an existing autoMemoryEnabled is never changed.',
1491
1491
  '',
1492
+ 'Where .claude/hooks/plan-guard.sh exists, also wires it into',
1493
+ 'hooks.PreToolUse for Bash and Write|Edit, skipping a tool whose',
1494
+ 'matcher already runs it, so a hand wiring is never doubled.',
1495
+ '',
1492
1496
  'A missing settings file is nothing to do. A file that is not valid',
1493
- 'JSON, or whose permissions or deny has an unexpected shape, is',
1494
- 'refused by name and never partly written.',
1497
+ 'JSON, or whose permissions, deny, hooks, or PreToolUse has an',
1498
+ 'unexpected shape, is refused by name and never partly written.',
1495
1499
  '',
1496
1500
  'Exit codes:',
1497
1501
  ' 0 nothing to add, or --write applied the plan',
@@ -23,6 +23,8 @@ import {
23
23
  import {
24
24
  type CheckRunListing,
25
25
  collapseChecks,
26
+ type RawStatus,
27
+ requiredChecks,
26
28
  withMergeState,
27
29
  } from '@/pr/checks'
28
30
  import {
@@ -33,6 +35,7 @@ import {
33
35
  findEvidenceLocal,
34
36
  findEvidencePreview,
35
37
  findEvidenceReel,
38
+ findEvidenceSkips,
36
39
  type EvidenceItem,
37
40
  type EvidenceReading,
38
41
  groupEvidence,
@@ -171,6 +174,8 @@ interface EvidenceOptions extends ReadOptions {
171
174
  readonly checklist?: string
172
175
  readonly local?: string
173
176
  readonly reel?: string
177
+ readonly localSkip?: string
178
+ readonly reelSkip?: string
174
179
  readonly check?: boolean
175
180
  }
176
181
 
@@ -433,7 +438,7 @@ type EvidenceRefusal =
433
438
 
434
439
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
435
440
  'check-writes':
436
- '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, or --reel. Run the check and the render as two calls.',
441
+ '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
437
442
  'gh-failed':
438
443
  'gh could not answer for this repository. Name the pull request number.',
439
444
  'unreadable-checklist':
@@ -775,6 +780,14 @@ export function register(program: Command): void {
775
780
  '--reel <link>',
776
781
  'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
777
782
  )
783
+ .option(
784
+ '--local-skip <reason>',
785
+ 'Name why no local address is posted, from the reason pr-evidence holds for canon pr local',
786
+ )
787
+ .option(
788
+ '--reel-skip <reason>',
789
+ 'Name why no reel is embedded, from the reason pr-evidence holds for canon pr frames --reel',
790
+ )
778
791
  .option(
779
792
  '--check',
780
793
  'Report which post-pull-request steps the thread is still owed, rendering nothing',
@@ -854,6 +867,17 @@ export function register(program: Command): void {
854
867
  'current, and a passed one replaces it. Like --local it rides on a',
855
868
  'checklist or a comparison and never opens a comment alone.',
856
869
  '',
870
+ '--local-skip and --reel-skip each put one line under the addresses,',
871
+ '**No local preview:** or **No reel:** followed by the text passed, a',
872
+ 'reason code and any one-line message joined to it, folded onto one',
873
+ 'line. Without the flag, a skip line the marked comment already carries',
874
+ 'is carried forward. A skip line is dropped whenever its address or',
875
+ 'reel renders, passed or carried, so a later run that posts the link or',
876
+ 'films the reel replaces it. A passed --local-skip drops a carried',
877
+ 'local address, since that line names no head and would read as live,',
878
+ 'while a carried reel names its head and stays beside --reel-skip. Like',
879
+ '--local, neither opens a comment alone.',
880
+ '',
857
881
  "A render at a new head also resets the pull request body's UI pass",
858
882
  'line, the one `canon pr tick` writes under ## Testing, to unchecked',
859
883
  'naming that head. It touches no other body text, leaves a line already',
@@ -893,7 +917,8 @@ export function register(program: Command): void {
893
917
  'request changed, and the marked comment carries no **Preview:**',
894
918
  'address. A failed deploy reads the same as a skipped one.',
895
919
  'The record carries `owed` and the same marked fields. --check refuses',
896
- 'with --preview, --local, --checklist, or --reel as check-writes.',
920
+ 'with --preview, --local, --checklist, --reel, --local-skip, or',
921
+ '--reel-skip as check-writes.',
897
922
  '',
898
923
  'Refusals, read off `reason`:',
899
924
  ' unreadable-checklist --checklist named a path that could not be read,',
@@ -917,6 +942,7 @@ export function register(program: Command): void {
917
942
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
918
943
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
919
944
  ' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
945
+ " canon pr evidence 1341 --reel-skip 'no-server: <message>' --json",
920
946
  ' canon pr evidence 1341 --check --json',
921
947
  '',
922
948
  ].join('\n'),
@@ -960,7 +986,12 @@ export function register(program: Command): void {
960
986
  ' or needing eyes refuses `ui-undriven`, carried or not',
961
987
  ' evidence the evidence check reads settled',
962
988
  ' standing the head is fresh against the base, not stale or conflicted',
963
- ' ci every check run on the tip passed, waited on up to --wait',
989
+ ' ci every required check run on the tip passed, waited on up to --wait',
990
+ '',
991
+ "The required checks are the ones the base branch's rules list. A",
992
+ 'check outside them is waited on by nobody, and one that failed is',
993
+ 'named in the record as `advisory` without blocking. Rules that do not',
994
+ 'read, or that list no check, gate by every check run on the tip.',
964
995
  '',
965
996
  'Review state, UI state, evidence, and standing are read before the CI',
966
997
  'wait, so a reviewer whose partner is still driving refuses at once. The',
@@ -1039,6 +1070,10 @@ export function register(program: Command): void {
1039
1070
  'request already passed `canon pr lift`. It does not wait on checks or',
1040
1071
  'delete the branch.',
1041
1072
  '',
1073
+ "The checks gate by the ones the base branch's rules require, the way",
1074
+ '`canon pr lift` reads them. A failing check outside that set is named',
1075
+ 'in the record as `advisory` and does not refuse the merge.',
1076
+ '',
1042
1077
  'Reasons:',
1043
1078
  ' merged, dry-run (--dry-run)',
1044
1079
  ' pull-closed, pull-draft, missing-summary, missing-key-changes,',
@@ -2658,7 +2693,9 @@ async function runEvidence(
2658
2693
  (opts.preview !== undefined ||
2659
2694
  opts.local !== undefined ||
2660
2695
  opts.checklist !== undefined ||
2661
- opts.reel !== undefined)
2696
+ opts.reel !== undefined ||
2697
+ opts.localSkip !== undefined ||
2698
+ opts.reelSkip !== undefined)
2662
2699
  ) {
2663
2700
  return refuseWith(
2664
2701
  'check-writes',
@@ -2868,8 +2905,17 @@ async function runEvidence(
2868
2905
  }
2869
2906
 
2870
2907
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
2871
- const local = opts.local ?? carriedLocal
2908
+ // A carried local line names no head and reads as live, so a passed skip
2909
+ // drops it the way a passed address replaces it. A carried reel names the
2910
+ // head it was filmed at, so it stays beside a passed skip.
2911
+ const local =
2912
+ opts.local ?? (opts.localSkip === undefined ? carriedLocal : undefined)
2872
2913
  const reel = nextEvidenceReel(opts.reel, identity.head, comments)
2914
+ const carriedSkips = findEvidenceSkips(comments)
2915
+ const skips = {
2916
+ local: opts.localSkip ?? carriedSkips.local,
2917
+ reel: opts.reelSkip ?? carriedSkips.reel,
2918
+ }
2873
2919
  const unsettled = suppliedChecklist ?? carriedChecklist
2874
2920
  const checklist =
2875
2921
  unsettled === undefined
@@ -2886,6 +2932,7 @@ async function runEvidence(
2886
2932
  checklist,
2887
2933
  local,
2888
2934
  reel,
2935
+ skips,
2889
2936
  )
2890
2937
 
2891
2938
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -4209,24 +4256,64 @@ async function readChecksGate(
4209
4256
  number: string,
4210
4257
  tip: string,
4211
4258
  ): Promise<ChecksGate | undefined> {
4212
- const [listed, pulled] = await Promise.all([
4259
+ // A status read that fails leaves a required status context unseen, which
4260
+ // reads pending rather than passing, so it needs no refusal of its own.
4261
+ const [listed, pulled, combined] = await Promise.all([
4213
4262
  gh(root, [
4214
4263
  'api',
4215
4264
  `repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
4216
4265
  ]),
4217
4266
  gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
4267
+ gh(root, [
4268
+ 'api',
4269
+ `repos/{owner}/{repo}/commits/${tip}/status?per_page=100`,
4270
+ ]),
4218
4271
  ])
4219
4272
  if (listed === null) return undefined
4220
4273
  try {
4221
- const mergeState =
4222
- pulled === null
4223
- ? undefined
4224
- : mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
4274
+ const row = pulled === null ? undefined : (JSON.parse(pulled) as RestPull)
4275
+ const required = await readRequiredChecks(root, row?.base?.ref)
4276
+ const statuses =
4277
+ combined === null
4278
+ ? []
4279
+ : ((JSON.parse(combined) as { statuses?: readonly RawStatus[] })
4280
+ .statuses ?? [])
4225
4281
  const reading = withMergeState(
4226
- mergeState,
4227
- collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
4282
+ mergeStateOf(row?.mergeable_state),
4283
+ collapseChecks(
4284
+ tip,
4285
+ JSON.parse(listed) as CheckRunListing,
4286
+ required,
4287
+ statuses,
4288
+ ),
4228
4289
  )
4229
- return { state: reading.state, isConflicted: reading.conflicted }
4290
+ return {
4291
+ state: reading.state,
4292
+ isConflicted: reading.conflicted,
4293
+ ...(reading.advisory !== undefined && { advisory: reading.advisory }),
4294
+ }
4295
+ } catch {
4296
+ return undefined
4297
+ }
4298
+ }
4299
+
4300
+ /**
4301
+ * The checks the base branch's rules require, so a check they leave out runs
4302
+ * without holding the gate. Undefined on any read that fails, which gates by
4303
+ * every check as the verbs did before the rules were read.
4304
+ */
4305
+ async function readRequiredChecks(
4306
+ root: string,
4307
+ base: string | undefined,
4308
+ ): Promise<ReadonlySet<string> | undefined> {
4309
+ if (base === undefined || base === '') return undefined
4310
+ const rules = await gh(root, [
4311
+ 'api',
4312
+ `repos/{owner}/{repo}/rules/branches/${base}`,
4313
+ ])
4314
+ if (rules === null) return undefined
4315
+ try {
4316
+ return requiredChecks(JSON.parse(rules))
4230
4317
  } catch {
4231
4318
  return undefined
4232
4319
  }
@@ -4360,6 +4447,7 @@ async function runLift(
4360
4447
  for (const gate of record.gates) {
4361
4448
  if (!gate.isOk) logWarn(`${gate.gate}: ${gate.reason ?? 'not clear'}`)
4362
4449
  }
4450
+ warnAdvisory(record.advisory)
4363
4451
  outro()
4364
4452
 
4365
4453
  if (emitJson) {
@@ -4371,6 +4459,14 @@ async function runLift(
4371
4459
  return record.isOk ? 0 : 1
4372
4460
  }
4373
4461
 
4462
+ function warnAdvisory(advisory: readonly string[] | undefined): void {
4463
+ for (const name of advisory ?? []) {
4464
+ logWarn(
4465
+ `advisory: ${name} failed, which the base's rules do not require, so it did not block`,
4466
+ )
4467
+ }
4468
+ }
4469
+
4374
4470
  const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
4375
4471
  'gh-missing': PULL_REFUSALS['gh-missing'],
4376
4472
  'pull-closed': 'The pull request is closed, so there is nothing to merge.',
@@ -4516,6 +4612,7 @@ async function runMerge(
4516
4612
  ? `#${record.number} would merge with the message below`
4517
4613
  : MERGE_REFUSALS[record.reason],
4518
4614
  )
4615
+ warnAdvisory(record.advisory)
4519
4616
  outro()
4520
4617
 
4521
4618
  if (emitJson) {
@@ -28,6 +28,7 @@ type Take = typeof import('@/take/film')
28
28
  interface RunOptions {
29
29
  readonly out?: string
30
30
  readonly reel?: string
31
+ readonly maxSeconds?: string
31
32
  readonly json?: boolean
32
33
  }
33
34
 
@@ -58,6 +59,10 @@ const TAKE_REFUSALS: Record<TakeRefusal, string> = {
58
59
  'browser-missing': 'The headless shell is not installed in this project.',
59
60
  'server-unreachable': 'Nothing answered at the URL, so no beat was filmed.',
60
61
  'target-missing': 'A step names a target the page does not carry.',
62
+ 'target-offscreen':
63
+ 'A step names a target outside the window, so a setup step must bring it into view first.',
64
+ 'over-cap':
65
+ 'The filmed beats ran past --max-seconds, so the take stopped at the beat that crossed it.',
61
66
  'take-failed': 'The take failed against a reachable page.',
62
67
  }
63
68
 
@@ -80,6 +85,10 @@ export function register(program: Command): void {
80
85
  '--reel <file>',
81
86
  'Also join the beats into one animated WebP with the pointer drawn',
82
87
  )
88
+ .option(
89
+ '--max-seconds <n>',
90
+ 'Refuse once the filmed beats run past n seconds in total',
91
+ )
83
92
  .option('--json', 'Add a machine-readable record on stdout')
84
93
  .addHelpText(
85
94
  'after',
@@ -153,6 +162,7 @@ export function register(program: Command): void {
153
162
  ` ${INSTALL_BROWSER}`,
154
163
  '',
155
164
  'Reasons:',
165
+ ' bad-max-seconds --max-seconds is not a positive number',
156
166
  ' no-plan-file nothing readable at the plan path',
157
167
  ' unreadable-plan the plan file is not valid JSON',
158
168
  ' no-beats the plan names no beat',
@@ -166,15 +176,18 @@ export function register(program: Command): void {
166
176
  ' browser-missing the headless shell or browser binary is not installed',
167
177
  ' server-unreachable nothing answered at the URL',
168
178
  ' target-missing a step names a target the page does not carry',
179
+ ' target-offscreen a step names a target outside the window, so a setup step must bring it into view first',
180
+ ' over-cap the filmed beats ran past --max-seconds, naming the',
181
+ ' beat that crossed it',
169
182
  ' take-failed the take failed against a reachable page',
170
183
  ' reel-failed every beat was filmed and the reel did not encode',
171
184
  '',
172
- 'A refusal in a beat carries beat. target-missing carries still when the',
173
- "page answered a capture, the beat folder's still.jpg as the step met it. A",
174
- 'beat that fell back to the screencast before refusing carries fallback,',
175
- 'the reason it fell back. The screencast opens the address a second time,',
176
- 'so a single-use link is spent by then: check the access before the',
177
- 'selector.',
185
+ 'A refusal in a beat carries beat. target-missing and target-offscreen',
186
+ "carry still when the page answered a capture, the beat folder's",
187
+ 'still.jpg as the step met it. A beat that fell back to the screencast',
188
+ 'before refusing carries fallback, the reason it fell back. The',
189
+ 'screencast opens the address a second time, so a single-use link is',
190
+ 'spent by then: check the access before the selector.',
178
191
  '',
179
192
  'Exit codes:',
180
193
  ' 0 every beat was filmed, and the reel written when asked for',
@@ -300,6 +313,17 @@ async function runTake(
300
313
 
301
314
  intro(`canon take ${url}`)
302
315
 
316
+ const maxSeconds =
317
+ opts.maxSeconds === undefined ? undefined : readRate(opts.maxSeconds)
318
+ if (opts.maxSeconds !== undefined && maxSeconds === undefined) {
319
+ return refuse(
320
+ emitJson,
321
+ url,
322
+ 'bad-max-seconds',
323
+ `--max-seconds ${opts.maxSeconds} is not a positive number, such as 12 or 7.5.`,
324
+ )
325
+ }
326
+
303
327
  let source: string
304
328
  try {
305
329
  source = readFileSync(path, 'utf8')
@@ -344,7 +368,12 @@ async function runTake(
344
368
  }
345
369
 
346
370
  logStep('Filming')
347
- const result = await taker.take({ url, plan, out: opts.out })
371
+ const result = await taker.take({
372
+ url,
373
+ plan,
374
+ out: opts.out,
375
+ ...(maxSeconds === undefined ? {} : { maxSeconds }),
376
+ })
348
377
 
349
378
  if (result.status === 'failed') {
350
379
  logStep('Refused')
@@ -24,12 +24,10 @@ const REGISTER_IMPORT = /^import \{ register as /gm
24
24
  /**
25
25
  * Every top-level `canon` command, read off the CLI entry point rather than a
26
26
  * catalog verb, because a command carries no `--json` listing of its own
27
- * siblings. `regen-hero.ts` imports this reader for its `COMMAND_COUNT`
28
- * rather than a second regex on `src/cli.ts`, so this is the one place that
29
- * pattern is written.
27
+ * siblings. Callers import this reader rather than a second regex on
28
+ * `src/cli.ts`, so this is the one place that pattern is written.
30
29
  *
31
- * This is the one catalog with no meaning outside this repository, the way
32
- * `regen-hero.ts` documents itself as clone-only for the same reason.
30
+ * This is the one catalog with no meaning outside this repository.
33
31
  */
34
32
  export function countCommands(root: string): number | undefined {
35
33
  const cli = join(root, 'src', 'cli.ts')
@@ -1,5 +1,4 @@
1
- import { createHash } from 'node:crypto'
2
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
3
2
  import { join, relative } from 'node:path'
4
3
  import {
5
4
  CLIENT_COMMAND_MARKER,
@@ -144,9 +143,6 @@ export function auditsBaselineRel(root: string): string {
144
143
  return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
145
144
  }
146
145
 
147
- export const CAPTURE_STAMP_FAILURE =
148
- 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/frames and commit each frame with its image and its stamp.'
149
-
150
146
  function parseJson(payload: string): unknown {
151
147
  try {
152
148
  return JSON.parse(payload)
@@ -1111,7 +1107,7 @@ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
1111
1107
 
1112
1108
  /**
1113
1109
  * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
1114
- * copy of the eight path globs a landing-page change should trigger on, plus
1110
+ * copy of the five path globs a landing-page change should trigger on, plus
1115
1111
  * the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
1116
1112
  * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
1117
1113
  * anchor cannot cross files here, so the two copies are read live and compared
@@ -1478,142 +1474,3 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
1478
1474
  )
1479
1475
  return [...seen].sort()
1480
1476
  }
1481
-
1482
- /**
1483
- * A capture set spans two folders. The markup and the template beside it are
1484
- * the authored half and sit under `assets/captures/`, while the image a
1485
- * document points at and the stamp answering for it sit under
1486
- * `assets/frames/`, which is where `--out assets/frames` sends them. They sit
1487
- * outside `assets/evidence/` so a changed marketing image is not read as dev
1488
- * evidence.
1489
- */
1490
- const CAPTURE_MARKUP_DIR = 'assets/captures'
1491
- const CAPTURE_OUTPUT_DIR = 'assets/frames'
1492
-
1493
- /**
1494
- * Every capture under `assets/captures/`, named by the base its three files
1495
- * share.
1496
- *
1497
- * Read off the folder rather than listed, and off the markup specifically,
1498
- * because that is how `resolveCaptureSources` decides what `canon capture
1499
- * assets/captures` renders. A list would fail open on the frame somebody adds
1500
- * next, which is the one nobody thinks to add here, and driving off the PNGs
1501
- * instead would report a missing set for any image in `assets/` that is not a
1502
- * capture.
1503
- */
1504
- function captureBases(root: string): string[] {
1505
- const dir = join(root, CAPTURE_MARKUP_DIR)
1506
- if (!existsSync(dir)) return []
1507
-
1508
- return readdirSync(dir)
1509
- .filter((name) => name.endsWith('.html'))
1510
- .map((name) => name.slice(0, -'.html'.length))
1511
- .sort()
1512
- }
1513
-
1514
- /**
1515
- * The drift assert on the Hero stage covers the markup because the image beside
1516
- * it is a chromium render whose bytes move with the browser. That leaves the
1517
- * artifact a visitor actually sees asserted nowhere, so a branch regenerating
1518
- * the markup and never running the capture would pass every stage while
1519
- * shipping an image with the old counts.
1520
- *
1521
- * `canon capture` records a digest of the markup it rendered and one of the image
1522
- * it wrote, so this reads provenance rather than timing. Comparing the commit
1523
- * that last touched each file passes any pair that moved together whatever the
1524
- * two files hold, which is what a binary conflict resolved by taking either
1525
- * side produces.
1526
- *
1527
- * Both digests are checked because either file can move alone. The markup side
1528
- * catches an edit committed with no capture, and the image side catches an
1529
- * image replaced under markup that never changed. A tree carrying no markup
1530
- * under `assets/captures/` has no set to read and passes, which is correct.
1531
- */
1532
- export const captureStamps: Measure = async (ctx) => {
1533
- const lines = captureBases(ctx.root).flatMap((base) =>
1534
- readCaptureSet(ctx.root, base),
1535
- )
1536
- if (lines.length === 0) return { emissions: [] }
1537
-
1538
- return {
1539
- emissions: [output(lines.join('\n'))],
1540
- failure: CAPTURE_STAMP_FAILURE,
1541
- }
1542
- }
1543
-
1544
- /**
1545
- * One capture set, as the lines it has to report and none where it agrees.
1546
- *
1547
- * The markup resolves against the source folder and the two files a render
1548
- * writes resolve against the output folder, so the set spans both rather than
1549
- * hanging off one base directory. Sending any of the three to the wrong folder
1550
- * reports it as missing rather than failing loudly, since a missing file reads
1551
- * the same way whether the path is wrong or the file is.
1552
- */
1553
- function readCaptureSet(root: string, base: string): string[] {
1554
- const set = [
1555
- `${CAPTURE_MARKUP_DIR}/${base}.html`,
1556
- `${CAPTURE_OUTPUT_DIR}/${base}.png`,
1557
- `${CAPTURE_OUTPUT_DIR}/${base}.stamp`,
1558
- ]
1559
-
1560
- const missing = set.filter((rel) => !existsSync(join(root, rel)))
1561
- if (missing.length > 0) {
1562
- return [`Missing from the ${base} set: ${missing.join(' ')}`]
1563
- }
1564
-
1565
- const [html, png, stamp] = set.map((rel) => join(root, rel))
1566
- return [
1567
- ...assertStampField(root, stamp, 'source-sha256', html),
1568
- ...assertStampField(root, stamp, 'image-sha256', png),
1569
- ]
1570
- }
1571
-
1572
- /**
1573
- * One digest the stamp recorded against the file it was taken over, returning
1574
- * the lines to report and nothing when the two agree.
1575
- *
1576
- * An absent field reports itself rather than comparing against an empty string,
1577
- * so a stamp predating the current format is distinguishable from a file that
1578
- * moved. Both names in the message come off the paths rather than from
1579
- * arguments, which is what keeps a name from disagreeing with the file it
1580
- * labels once a second capture source calls this.
1581
- */
1582
- export function assertStampField(
1583
- root: string,
1584
- stamp: string,
1585
- field: string,
1586
- file: string,
1587
- ): string[] {
1588
- const stampLabel = relativeTo(root, stamp)
1589
- const fileLabel = relativeTo(root, file)
1590
-
1591
- const recorded = readStampField(stamp, field)
1592
- if (recorded === undefined) {
1593
- return [
1594
- `${stampLabel} carries no ${field} line, so it predates the capture that writes one.`,
1595
- ]
1596
- }
1597
-
1598
- const actual = createHash('sha256').update(readFileSync(file)).digest('hex')
1599
- if (recorded !== actual) {
1600
- return [
1601
- `${stampLabel} records ${field} ${recorded}`,
1602
- `${fileLabel} hashes to ${actual}`,
1603
- ]
1604
- }
1605
-
1606
- return []
1607
- }
1608
-
1609
- function readStampField(stamp: string, field: string): string | undefined {
1610
- for (const line of readFileSync(stamp, 'utf8').split('\n')) {
1611
- const [key, value] = line.trim().split(/\s+/)
1612
- if (key === `${field}:` && value !== undefined) return value
1613
- }
1614
- return undefined
1615
- }
1616
-
1617
- function relativeTo(root: string, path: string): string {
1618
- return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
1619
- }
@@ -386,7 +386,13 @@ async function runCheck(
386
386
  }
387
387
 
388
388
  const run = check.kind === 'cli' ? ctx.cli : ctx.run
389
- const result = await run(check.argv, env)
389
+ const argv =
390
+ check.kind === 'command' &&
391
+ check.changedArgv !== undefined &&
392
+ ctx.changed !== undefined
393
+ ? check.changedArgv(ctx.changed)
394
+ : check.argv
395
+ const result = await run(argv, env)
390
396
  return {
391
397
  emissions: [{ kind: 'output', text: result.all }],
392
398
  failure: result.exitCode === 0 ? undefined : check.failure,