@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.
- package/CHANGELOG.md +41 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +5 -3
- package/claude/skills/build-in-slices/references/adopted.md +4 -2
- package/claude/skills/code-craft/references/adopted.md +9 -7
- package/claude/skills/codebase-layout/references/adopted.md +8 -6
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +6 -1
- package/claude/skills/deprecation-migration/references/adopted.md +5 -3
- package/claude/skills/design-pick/references/board.md +2 -2
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -0
- package/claude/skills/pr-evidence/references/evidence.md +11 -4
- package/claude/skills/pr-evidence/references/reel.md +108 -0
- package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
- package/claude/skills/proposal-critique/SKILL.md +126 -0
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/review-craft/references/adopted.md +4 -4
- package/claude/skills/search-craft/references/adopted.md +9 -7
- package/claude/skills/systematic-debugging/references/adopted.md +4 -2
- package/claude/skills/teach-craft/references/adopted.md +8 -6
- package/claude/skills/test-craft/references/adopted.md +14 -12
- package/claude/skills/test-first/SKILL.md +1 -1
- package/claude/skills/test-first/references/adopted.md +9 -5
- package/claude/skills/ui-film/REQUIREMENT.md +4 -5
- package/claude/skills/ui-film/SKILL.md +21 -19
- package/claude/skills/ui-film/references/compose-craft.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +2 -7
- package/claude/skills/write-human/references/adopted.md +8 -3
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +2 -0
- package/docs/target/index.md +1 -0
- package/docs/target/outside-tools.md +39 -0
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
- package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
- package/governance/ledger/skills/search-craft/SKILL.md +54 -0
- package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
- package/governance/ledger/skills/test-first/SKILL.md +28 -0
- package/governance/ledger/skills/write-human/SKILL.md +62 -0
- package/governance/ledger/skills/write-human/density.md +36 -0
- package/governance/ledger/skills/write-human/machine-tells.md +30 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/docs-only.sh +24 -0
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/misc.ts +14 -0
- package/src/claude/skills-audit.ts +154 -8
- package/src/cli/help.ts +1 -1
- package/src/commands/claude.ts +6 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/pr.ts +110 -13
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +2 -145
- package/src/gate/sequencer.ts +7 -1
- package/src/gate/stages.ts +36 -47
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +114 -4
- package/src/pr/checks.ts +75 -3
- package/src/pr/evidence.ts +62 -1
- package/src/pr/lift.ts +23 -2
- package/src/pr/merge.ts +13 -3
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/src/targets/pulls.ts +42 -0
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +8 -0
- package/claude/skills/ui-film/references/pr-clip.md +0 -108
- package/scripts/core/regen-hero.ts +0 -113
- package/src/capture/frame-values.ts +0 -326
package/src/commands/migrate.ts
CHANGED
|
@@ -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
|
|
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',
|
package/src/commands/pr.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
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
|
-
|
|
4227
|
-
collapseChecks(
|
|
4282
|
+
mergeStateOf(row?.mergeable_state),
|
|
4283
|
+
collapseChecks(
|
|
4284
|
+
tip,
|
|
4285
|
+
JSON.parse(listed) as CheckRunListing,
|
|
4286
|
+
required,
|
|
4287
|
+
statuses,
|
|
4288
|
+
),
|
|
4228
4289
|
)
|
|
4229
|
-
return {
|
|
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) {
|
package/src/commands/take.ts
CHANGED
|
@@ -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
|
|
173
|
-
"page answered a capture, the beat folder's
|
|
174
|
-
'beat that fell back to the screencast
|
|
175
|
-
'the reason it fell back. The
|
|
176
|
-
'so a single-use link is
|
|
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({
|
|
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')
|
package/src/counts/catalogs.ts
CHANGED
|
@@ -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.
|
|
28
|
-
*
|
|
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
|
|
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')
|
package/src/gate/measures.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
}
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -386,7 +386,13 @@ async function runCheck(
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
const run = check.kind === 'cli' ? ctx.cli : ctx.run
|
|
389
|
-
const
|
|
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,
|