@erclx/canon 5.67.0 → 5.69.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 +31 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +14 -4
- package/claude/skills/app-drive/references/drive-record.md +12 -1
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +4 -4
- package/claude/skills/design-identity/SKILL.md +7 -7
- package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
- package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +18 -10
- package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
- package/claude/skills/design-pick/references/design-config.md +33 -0
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/design-taste/SKILL.md +3 -3
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +21 -6
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-worker/SKILL.md +5 -6
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
- package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-craft/SKILL.md +1 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +7 -5
- package/claude/skills/{ui-record → ui-film}/SKILL.md +23 -12
- package/claude/skills/{ui-record → ui-film}/references/config.md +3 -3
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +28 -9
- package/claude/skills/ui-film/references/pr-clip.md +84 -0
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +8 -1
- package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
- package/docs/agents/audits.md +1 -1
- package/docs/agents/migrate.md +13 -13
- package/docs/agents/records.md +1 -0
- package/docs/target/migrations.md +12 -10
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -2
- package/docs/workflow/skill-map.md +9 -10
- package/docs/workflow/visual-design-workflow.md +6 -1
- package/governance/ledger/claude/background.md +26 -0
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +8 -5
- package/scripts/ci/install-playwright-deps.sh +52 -0
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +0 -5
- package/src/claude/cases/workflow.ts +6 -6
- package/src/commands/fixtures/gh.sh +64 -0
- package/src/commands/gov.ts +33 -3
- package/src/commands/migrate.ts +12 -2
- package/src/commands/pr.ts +237 -79
- package/src/commands/take.ts +44 -8
- package/src/commands/tasks.ts +183 -0
- package/src/gate/stages.ts +2 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/markdown/scope.ts +9 -14
- package/src/migrate/record-layout.ts +5 -3
- package/src/migrate/skill-names.ts +21 -4
- package/src/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- package/src/records/validate.ts +1 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/stub-gh.sh +26 -0
- package/src/sandbox/stub-remote.ts +8 -29
- package/src/sync/engine.ts +1 -1
- package/src/take/camera.ts +2 -2
- package/src/take/encode.ts +38 -1
- package/src/take/film.ts +26 -3
- package/src/take/pointer.ts +169 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +5 -2
- package/src/take/stepper.ts +23 -6
- package/src/take/zoom/NOTICE.md +1 -1
- package/src/tasks/launch-wait.ts +160 -0
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
- package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
- package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
- package/claude/skills/plan-ready/SKILL.md +0 -136
- package/claude/skills/plan-ready/references/assembly.md +0 -75
- package/claude/skills/plan-ready/references/ready.md +0 -128
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
package/src/commands/pr.ts
CHANGED
|
@@ -32,17 +32,22 @@ import {
|
|
|
32
32
|
findEvidenceHead,
|
|
33
33
|
findEvidenceLocal,
|
|
34
34
|
findEvidencePreview,
|
|
35
|
+
findEvidenceReel,
|
|
35
36
|
type EvidenceItem,
|
|
36
37
|
type EvidenceReading,
|
|
37
38
|
groupEvidence,
|
|
38
39
|
hasMarkedEvidenceComment,
|
|
40
|
+
isEvidencePath,
|
|
39
41
|
PNG_HEADER_BYTES,
|
|
40
42
|
readChecklistBoxes,
|
|
41
43
|
readOwed,
|
|
42
44
|
readPngWidth,
|
|
45
|
+
readUncaptured,
|
|
46
|
+
nextEvidenceReel,
|
|
43
47
|
renderEvidenceBody,
|
|
44
48
|
settleChecklist,
|
|
45
49
|
} from '@/pr/evidence'
|
|
50
|
+
import { isWithinTolerance } from '@/pr/pixels'
|
|
46
51
|
import {
|
|
47
52
|
dropFramesWithLegacy,
|
|
48
53
|
FRAMES_BRANCH,
|
|
@@ -56,7 +61,9 @@ import {
|
|
|
56
61
|
LEGACY_FRAMES_BRANCH,
|
|
57
62
|
pruneFramesWithLegacy,
|
|
58
63
|
pushFrame,
|
|
64
|
+
REEL_MAX_BYTES,
|
|
59
65
|
readFrame,
|
|
66
|
+
readReel,
|
|
60
67
|
isDesignReference,
|
|
61
68
|
referenceSlug,
|
|
62
69
|
type TreeEntry,
|
|
@@ -162,6 +169,7 @@ interface EvidenceOptions extends ReadOptions {
|
|
|
162
169
|
readonly preview?: string
|
|
163
170
|
readonly checklist?: string
|
|
164
171
|
readonly local?: string
|
|
172
|
+
readonly reel?: string
|
|
165
173
|
readonly check?: boolean
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -272,6 +280,7 @@ interface FramesOptions extends ReadOptions {
|
|
|
272
280
|
readonly head?: string
|
|
273
281
|
readonly pass?: string
|
|
274
282
|
readonly theme?: string
|
|
283
|
+
readonly reel?: string
|
|
275
284
|
readonly drop?: boolean
|
|
276
285
|
readonly prune?: string
|
|
277
286
|
}
|
|
@@ -287,6 +296,8 @@ type FramesCommandRefusal =
|
|
|
287
296
|
| 'bad-theme'
|
|
288
297
|
| 'bad-days'
|
|
289
298
|
| 'unreadable-frame'
|
|
299
|
+
| 'unreadable-reel'
|
|
300
|
+
| 'reel-too-large'
|
|
290
301
|
| 'read-only'
|
|
291
302
|
| 'gh-missing'
|
|
292
303
|
| 'gh-failed'
|
|
@@ -294,22 +305,25 @@ type FramesCommandRefusal =
|
|
|
294
305
|
|
|
295
306
|
const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
|
|
296
307
|
'bad-mode':
|
|
297
|
-
'Pass exactly one of --add, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
|
|
308
|
+
'Pass exactly one of --add, --reel, --drop, or --prune. --prune reads every pull request on the branch, so it takes no number.',
|
|
298
309
|
'no-number':
|
|
299
|
-
'--add and --drop write for one pull request, so name its number.',
|
|
310
|
+
'--add, --reel, and --drop write for one pull request, so name its number.',
|
|
300
311
|
'bad-box':
|
|
301
|
-
'--add takes --box <n>, a positive whole number naming the checklist box, or --reference, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, never both.',
|
|
312
|
+
'--add takes --box <n>, a positive whole number naming the checklist box, or --reference, as <page>/<frame> or a tracked canon/design/<surface>/<name> path, never both. --reel takes neither, since a pass has one reel.',
|
|
302
313
|
'bad-reference':
|
|
303
314
|
'--reference takes <page>/<frame> or a tracked canon/design/<surface>/<name> path, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
|
|
304
315
|
'bad-head':
|
|
305
316
|
'--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
|
|
306
317
|
'bad-pass':
|
|
307
|
-
'--add
|
|
318
|
+
'--add and --reel take --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
|
|
308
319
|
'bad-theme':
|
|
309
320
|
'--theme takes dark or light and goes with --add --box, so a second shot of one box never replaces the first.',
|
|
310
321
|
'bad-days': '--prune takes a positive whole number of days.',
|
|
311
322
|
'unreadable-frame':
|
|
312
323
|
'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
|
|
324
|
+
'unreadable-reel':
|
|
325
|
+
'The file named by --reel is missing, empty, or not a WebP, so it would not play in the comment. Nothing is pushed.',
|
|
326
|
+
'reel-too-large': `The file named by --reel is past ${REEL_MAX_BYTES / 1_000_000} MB, the most GitHub renders as an image in a comment. Film fewer or shorter beats. Nothing is pushed.`,
|
|
313
327
|
'read-only': `GitHub refused the write to ${FRAMES_BRANCH}. The token cannot write this repository, which is what a fork pull request's workflow token gets, so describe the frame in words instead.`,
|
|
314
328
|
'gh-missing':
|
|
315
329
|
'gh is not on the path, so the branch could not be read or written.',
|
|
@@ -415,7 +429,7 @@ type EvidenceRefusal =
|
|
|
415
429
|
|
|
416
430
|
const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
|
|
417
431
|
'check-writes':
|
|
418
|
-
'--check is a read and renders no body, so it takes none of --preview, --local, or --
|
|
432
|
+
'--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.',
|
|
419
433
|
'gh-failed':
|
|
420
434
|
'gh could not answer for this repository. Name the pull request number.',
|
|
421
435
|
'unreadable-checklist':
|
|
@@ -752,6 +766,10 @@ export function register(program: Command): void {
|
|
|
752
766
|
'--local <url>',
|
|
753
767
|
"Add this worktree's running server to the address line, from canon pr local",
|
|
754
768
|
)
|
|
769
|
+
.option(
|
|
770
|
+
'--reel <link>',
|
|
771
|
+
'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
|
|
772
|
+
)
|
|
755
773
|
.option(
|
|
756
774
|
'--check',
|
|
757
775
|
'Report which post-pull-request steps the thread is still owed, rendering nothing',
|
|
@@ -782,6 +800,19 @@ export function register(program: Command): void {
|
|
|
782
800
|
'the hosted and local previews with " · ", carrying whichever apply,',
|
|
783
801
|
'then the Base and Head line when there are screenshots.',
|
|
784
802
|
'',
|
|
803
|
+
'Each case shows its head image at the comment width under a bold',
|
|
804
|
+
'label, with its base folded beneath in a <details> block, and an',
|
|
805
|
+
'added case shows its head alone. Every state opens while the comment',
|
|
806
|
+
'carries at most six head images and closes past that.',
|
|
807
|
+
'',
|
|
808
|
+
'Each modified PNG is compared with its base pixel by pixel. A case',
|
|
809
|
+
'whose densest 16px square holds 6 or fewer differing pixels moves to',
|
|
810
|
+
'one closed "Within tolerance" block after the states, still named and',
|
|
811
|
+
'embedded, and stops counting toward the six. Its item on the record',
|
|
812
|
+
'carries `withinTolerance`, which is absent for an added case, a',
|
|
813
|
+
'format other than PNG, or a pair with a failed read. None of those',
|
|
814
|
+
'folds, and neither does a pair that differs in size or will not decode.',
|
|
815
|
+
'',
|
|
785
816
|
'--preview puts the hosted address on that line. With no evidence in',
|
|
786
817
|
'the diff, the body is the heading, that line, and the marker alone,',
|
|
787
818
|
'reported as ok, so the preview still lands in one comment a later',
|
|
@@ -811,6 +842,13 @@ export function register(program: Command): void {
|
|
|
811
842
|
'comment. Together with --checklist the link and the checklist land in',
|
|
812
843
|
'one comment.',
|
|
813
844
|
'',
|
|
845
|
+
'--reel embeds an animated reel on its own line under the addresses,',
|
|
846
|
+
'labeled with the head this render describes as the one it was filmed',
|
|
847
|
+
'at. Without --reel, a reel the marked comment already embeds is carried',
|
|
848
|
+
'forward under the head it names, so a stale reel never reads as',
|
|
849
|
+
'current, and a passed one replaces it. Like --local it rides on a',
|
|
850
|
+
'checklist or a comparison and never opens a comment alone.',
|
|
851
|
+
'',
|
|
814
852
|
"A render at a new head also resets the pull request body's UI pass",
|
|
815
853
|
'line, the one `canon pr tick` writes under ## Testing, to unchecked',
|
|
816
854
|
'naming that head. It touches no other body text, leaves a line already',
|
|
@@ -820,6 +858,7 @@ export function register(program: Command): void {
|
|
|
820
858
|
'present only when that comment holds it:',
|
|
821
859
|
' preview the hosted **Preview:** address the comment carries',
|
|
822
860
|
' local the **Local preview:** address beside it',
|
|
861
|
+
' reel the embedded reel, as `link` and the `head` it was filmed at',
|
|
823
862
|
' checklist the checklist between its delimiters, ticks included',
|
|
824
863
|
' boxes each checklist box with its number, tick, stamp, and taste',
|
|
825
864
|
' mark, the numbering `canon pr tick` takes',
|
|
@@ -829,6 +868,16 @@ export function register(program: Command): void {
|
|
|
829
868
|
'unread thread refuses as gh-failed on both reasons rather than',
|
|
830
869
|
'reporting the fields absent.',
|
|
831
870
|
'',
|
|
871
|
+
'The ok, no-evidence, and --check records carry `uncaptured`, the',
|
|
872
|
+
'painted paths the pull request changed while no evidence image did, in',
|
|
873
|
+
"a project whose base branch's tree holds one. Painted is the set",
|
|
874
|
+
'`canon/config/painted.json` tunes, minus any path under an evidence/',
|
|
875
|
+
'segment or canon/design/, and any changed evidence image empties the',
|
|
876
|
+
'list. The field is absent when the base tree holds no capture or could',
|
|
877
|
+
'not be read, a cut-short listing showing none included, so absent means',
|
|
878
|
+
'unchecked rather than clean. It never adds to `owed`, since no step of',
|
|
879
|
+
'the ship chain can take a capture.',
|
|
880
|
+
'',
|
|
832
881
|
'--check reads the same changed set and thread, renders no body, and',
|
|
833
882
|
'reports what the ship chain still owes the pull request:',
|
|
834
883
|
' settled nothing is owed',
|
|
@@ -839,7 +888,7 @@ export function register(program: Command): void {
|
|
|
839
888
|
'request changed, and the marked comment carries no **Preview:**',
|
|
840
889
|
'address. A failed deploy reads the same as a skipped one.',
|
|
841
890
|
'The record carries `owed` and the same marked fields. --check refuses',
|
|
842
|
-
'with --preview, --local, or --
|
|
891
|
+
'with --preview, --local, --checklist, or --reel as check-writes.',
|
|
843
892
|
'',
|
|
844
893
|
'Refusals, read off `reason`:',
|
|
845
894
|
' unreadable-checklist --checklist named a path that could not be read,',
|
|
@@ -862,6 +911,7 @@ export function register(program: Command): void {
|
|
|
862
911
|
' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
|
|
863
912
|
' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
|
|
864
913
|
' canon pr evidence 1341 --local http://localhost:5173 --json',
|
|
914
|
+
' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
|
|
865
915
|
' canon pr evidence 1341 --check --json',
|
|
866
916
|
'',
|
|
867
917
|
].join('\n'),
|
|
@@ -1396,6 +1446,10 @@ export function register(program: Command): void {
|
|
|
1396
1446
|
'--theme <theme>',
|
|
1397
1447
|
'The theme the frame was shot at, dark or light, with --add, keeping two shots of one box apart',
|
|
1398
1448
|
)
|
|
1449
|
+
.option(
|
|
1450
|
+
'--reel <file>',
|
|
1451
|
+
"Push this animated WebP as the pass's reel, in place of --add",
|
|
1452
|
+
)
|
|
1399
1453
|
.option('--drop', "Rewrite the branch without this pull request's frames")
|
|
1400
1454
|
.option(
|
|
1401
1455
|
'--prune <days>',
|
|
@@ -1417,6 +1471,11 @@ export function register(program: Command): void {
|
|
|
1417
1471
|
'segment keeps a second pass at one head and box from replacing an image',
|
|
1418
1472
|
'an earlier comment shows. The first --add creates the branch.',
|
|
1419
1473
|
'',
|
|
1474
|
+
`--reel <file> pushes one animated WebP of at most ${REEL_MAX_BYTES / 1_000_000} MB to`,
|
|
1475
|
+
'pr-<number>/<short-head>/<pass>/reel.webp beside the box frames, taking',
|
|
1476
|
+
'--pass and --head the way --add does, and carries `reel: true` in place of',
|
|
1477
|
+
'a box. A drop or a prune clears it with the rest of the folder.',
|
|
1478
|
+
'',
|
|
1420
1479
|
'--drop and --prune write one root commit and force-move the ref,',
|
|
1421
1480
|
'since a delete commit would keep every image reachable through history.',
|
|
1422
1481
|
'A rewrite that empties the branch deletes it. --prune keeps a pull',
|
|
@@ -1430,15 +1489,17 @@ export function register(program: Command): void {
|
|
|
1430
1489
|
'',
|
|
1431
1490
|
'Read `reason` on the JSON record:',
|
|
1432
1491
|
' ok an add pushed, or a drop or prune finished',
|
|
1433
|
-
' bad-mode not exactly one of --add, --drop, --prune',
|
|
1434
|
-
' no-number --add or --drop named no pull request',
|
|
1435
|
-
' bad-box --add with both --box and --reference, or neither',
|
|
1492
|
+
' bad-mode not exactly one of --add, --reel, --drop, --prune',
|
|
1493
|
+
' no-number --add, --reel, or --drop named no pull request',
|
|
1494
|
+
' bad-box --add with both --box and --reference, or neither, or --reel with either',
|
|
1436
1495
|
' bad-reference --reference is not <page>/<frame> or canon/design/<surface>/<name> in the safe name form, or the tracked path is no file',
|
|
1437
1496
|
' bad-head --head is not 7 to 40 lowercase hex characters',
|
|
1438
|
-
' bad-pass --add without a compact UTC --pass stamp',
|
|
1497
|
+
' bad-pass --add or --reel without a compact UTC --pass stamp',
|
|
1439
1498
|
' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
|
|
1440
1499
|
' bad-days --prune without a positive number of days',
|
|
1441
1500
|
' unreadable-frame the --add file is missing, empty, or not a PNG',
|
|
1501
|
+
' unreadable-reel the --reel file is missing, empty, or not a WebP',
|
|
1502
|
+
` reel-too-large the --reel file is past ${REEL_MAX_BYTES / 1_000_000} MB`,
|
|
1442
1503
|
' read-only GitHub refused the write, as on a fork token',
|
|
1443
1504
|
' unreadable-tip the branch could not be read',
|
|
1444
1505
|
' push-failed a write failed and the ref did not move',
|
|
@@ -1453,6 +1514,7 @@ export function register(program: Command): void {
|
|
|
1453
1514
|
'',
|
|
1454
1515
|
'Examples:',
|
|
1455
1516
|
' canon pr frames 1341 --add frames/3.png --box 3 --pass 20261002T154450Z --json',
|
|
1517
|
+
' canon pr frames 1341 --reel reel.webp --pass 20261002T154450Z --json',
|
|
1456
1518
|
' canon pr frames 1341 --drop --json',
|
|
1457
1519
|
' canon pr frames --prune 30 --json',
|
|
1458
1520
|
'',
|
|
@@ -2366,6 +2428,31 @@ function everyChangedPath(files: PullFiles): string[] {
|
|
|
2366
2428
|
return [...files.present.keys(), ...files.removed]
|
|
2367
2429
|
}
|
|
2368
2430
|
|
|
2431
|
+
/**
|
|
2432
|
+
* Whether the base branch's tree holds an evidence image, read recursively
|
|
2433
|
+
* over the API so the answer is the same from any checkout. GitHub cuts a
|
|
2434
|
+
* large listing short, and a cut listing proves captures only when one
|
|
2435
|
+
* arrived before the cut, so it reads as unread otherwise. Undefined on any
|
|
2436
|
+
* failed read.
|
|
2437
|
+
*/
|
|
2438
|
+
async function readBaseKeepsCaptures(
|
|
2439
|
+
cwd: string,
|
|
2440
|
+
baseRef: string | undefined,
|
|
2441
|
+
): Promise<boolean | undefined> {
|
|
2442
|
+
if (baseRef === undefined || baseRef === '') return undefined
|
|
2443
|
+
const stdout = await gh(cwd, [
|
|
2444
|
+
'api',
|
|
2445
|
+
`repos/{owner}/{repo}/git/trees/${baseRef}?recursive=1`,
|
|
2446
|
+
'--jq',
|
|
2447
|
+
'.truncated, (.tree[] | select(.type == "blob") | .path)',
|
|
2448
|
+
])
|
|
2449
|
+
if (stdout === null) return undefined
|
|
2450
|
+
const [truncated, ...paths] = stdout.split('\n').filter(Boolean)
|
|
2451
|
+
if (truncated !== 'true' && truncated !== 'false') return undefined
|
|
2452
|
+
if (paths.some(isEvidencePath)) return true
|
|
2453
|
+
return truncated === 'true' ? undefined : false
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2369
2456
|
/**
|
|
2370
2457
|
* The commit the pull request diverged from its base at, from the compare
|
|
2371
2458
|
* endpoint, since a checkout on another branch resolves its own trunk tip
|
|
@@ -2386,25 +2473,30 @@ async function readPullMergeBase(
|
|
|
2386
2473
|
return sha === null || sha.trim() === '' ? undefined : sha.trim()
|
|
2387
2474
|
}
|
|
2388
2475
|
|
|
2389
|
-
/** How many
|
|
2390
|
-
const
|
|
2476
|
+
/** How many cases are read at once. Each reads its images one after another, so this bounds both the downloads in flight and the pairs held for comparison. */
|
|
2477
|
+
const IMAGE_READ_CONCURRENCY = 4
|
|
2391
2478
|
|
|
2392
|
-
type
|
|
2393
|
-
| {
|
|
2479
|
+
type ImageRead =
|
|
2480
|
+
| {
|
|
2481
|
+
readonly kind: 'read'
|
|
2482
|
+
readonly width?: number
|
|
2483
|
+
readonly bytes?: Uint8Array
|
|
2484
|
+
}
|
|
2394
2485
|
| { readonly kind: 'failed' }
|
|
2395
2486
|
|
|
2396
2487
|
/**
|
|
2397
|
-
*
|
|
2398
|
-
*
|
|
2399
|
-
* file arrives and
|
|
2400
|
-
* reads
|
|
2401
|
-
* the caller can keep the ceiling width and still report
|
|
2488
|
+
* A PNG at `sha`, read over the contents API like every other evidence read,
|
|
2489
|
+
* with its pixel width from the header. The endpoint ignores a `Range` header,
|
|
2490
|
+
* so the whole file arrives, and its bytes are kept for the pixel comparison.
|
|
2491
|
+
* A format other than PNG reads with neither, and a refused or timed-out call
|
|
2492
|
+
* reads as failed, so the caller can keep the ceiling width and still report
|
|
2493
|
+
* the cause.
|
|
2402
2494
|
*/
|
|
2403
|
-
async function
|
|
2495
|
+
async function readImage(
|
|
2404
2496
|
cwd: string,
|
|
2405
2497
|
path: string,
|
|
2406
2498
|
sha: string,
|
|
2407
|
-
): Promise<
|
|
2499
|
+
): Promise<ImageRead> {
|
|
2408
2500
|
if (!/\.png$/i.test(path)) return { kind: 'read' }
|
|
2409
2501
|
try {
|
|
2410
2502
|
const result = await execa(
|
|
@@ -2423,8 +2515,11 @@ async function readImageWidth(
|
|
|
2423
2515
|
encoding: 'buffer',
|
|
2424
2516
|
},
|
|
2425
2517
|
)
|
|
2426
|
-
const
|
|
2427
|
-
|
|
2518
|
+
const bytes = result.stdout
|
|
2519
|
+
const width = readPngWidth(bytes.subarray(0, PNG_HEADER_BYTES))
|
|
2520
|
+
return width === undefined
|
|
2521
|
+
? { kind: 'read', bytes }
|
|
2522
|
+
: { kind: 'read', width, bytes }
|
|
2428
2523
|
} catch {
|
|
2429
2524
|
return { kind: 'failed' }
|
|
2430
2525
|
}
|
|
@@ -2452,66 +2547,79 @@ async function mapPool<T, R>(
|
|
|
2452
2547
|
|
|
2453
2548
|
interface SizedEvidence {
|
|
2454
2549
|
readonly reading: EvidenceReading
|
|
2455
|
-
/** Each image whose
|
|
2550
|
+
/** Each image whose read failed, as `head:<path>` or `base:<path>`, which rendered at the ceiling and left its case uncompared. */
|
|
2551
|
+
readonly unread: readonly string[]
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
interface MeasuredItem {
|
|
2555
|
+
readonly item: EvidenceItem
|
|
2456
2556
|
readonly unread: readonly string[]
|
|
2457
2557
|
}
|
|
2458
2558
|
|
|
2459
|
-
/**
|
|
2460
|
-
|
|
2559
|
+
/**
|
|
2560
|
+
* One case with the pixel width of its head image and, when it has one, its
|
|
2561
|
+
* base image, and whether the two match within tolerance. The pair is compared
|
|
2562
|
+
* as soon as both reads land, so its bytes are dropped before the pool takes
|
|
2563
|
+
* the next case. A case missing either PNG carries no verdict.
|
|
2564
|
+
*/
|
|
2565
|
+
async function measureItem(
|
|
2566
|
+
cwd: string,
|
|
2567
|
+
item: EvidenceItem,
|
|
2568
|
+
base: string | undefined,
|
|
2569
|
+
head: string,
|
|
2570
|
+
): Promise<MeasuredItem> {
|
|
2571
|
+
const headRead = await readImage(cwd, item.path, head)
|
|
2572
|
+
const baseRead =
|
|
2573
|
+
item.added || base === undefined
|
|
2574
|
+
? undefined
|
|
2575
|
+
: await readImage(cwd, item.path, base)
|
|
2576
|
+
const unread = [
|
|
2577
|
+
...(headRead.kind === 'failed' ? [`head:${item.path}`] : []),
|
|
2578
|
+
...(baseRead?.kind === 'failed' ? [`base:${item.path}`] : []),
|
|
2579
|
+
]
|
|
2580
|
+
const headBytes = headRead.kind === 'read' ? headRead.bytes : undefined
|
|
2581
|
+
const baseBytes = baseRead?.kind === 'read' ? baseRead.bytes : undefined
|
|
2582
|
+
return {
|
|
2583
|
+
item: {
|
|
2584
|
+
...item,
|
|
2585
|
+
...(headRead.kind === 'read' &&
|
|
2586
|
+
headRead.width !== undefined && { width: headRead.width }),
|
|
2587
|
+
...(baseRead?.kind === 'read' &&
|
|
2588
|
+
baseRead.width !== undefined && { baseWidth: baseRead.width }),
|
|
2589
|
+
...(headBytes !== undefined &&
|
|
2590
|
+
baseBytes !== undefined && {
|
|
2591
|
+
withinTolerance: isWithinTolerance(baseBytes, headBytes),
|
|
2592
|
+
}),
|
|
2593
|
+
},
|
|
2594
|
+
unread,
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
/** Each item measured by `measureItem`, in the reading's order. */
|
|
2599
|
+
async function withImageReads(
|
|
2461
2600
|
cwd: string,
|
|
2462
2601
|
reading: EvidenceReading,
|
|
2463
2602
|
base: string | undefined,
|
|
2464
2603
|
head: string,
|
|
2465
2604
|
): Promise<SizedEvidence> {
|
|
2466
2605
|
if (reading.kind === 'refused') return { reading, unread: [] }
|
|
2467
|
-
const
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
itemIndex,
|
|
2472
|
-
side: 'head' as const,
|
|
2473
|
-
path: item.path,
|
|
2474
|
-
sha: head,
|
|
2475
|
-
},
|
|
2476
|
-
...(item.added || base === undefined
|
|
2477
|
-
? []
|
|
2478
|
-
: [
|
|
2479
|
-
{
|
|
2480
|
-
stateIndex,
|
|
2481
|
-
itemIndex,
|
|
2482
|
-
side: 'base' as const,
|
|
2483
|
-
path: item.path,
|
|
2484
|
-
sha: base,
|
|
2485
|
-
},
|
|
2486
|
-
]),
|
|
2487
|
-
]),
|
|
2606
|
+
const measured = await mapPool(
|
|
2607
|
+
reading.states.flatMap((entry) => entry.items),
|
|
2608
|
+
IMAGE_READ_CONCURRENCY,
|
|
2609
|
+
(item) => measureItem(cwd, item, base, head),
|
|
2488
2610
|
)
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
job.itemIndex !== itemIndex ||
|
|
2502
|
-
read === undefined
|
|
2503
|
-
) {
|
|
2504
|
-
return
|
|
2505
|
-
}
|
|
2506
|
-
if (read.kind === 'failed') unread.push(`${job.side}:${job.path}`)
|
|
2507
|
-
else if (read.width !== undefined) {
|
|
2508
|
-
widths[job.side === 'head' ? 'width' : 'baseWidth'] = read.width
|
|
2509
|
-
}
|
|
2510
|
-
})
|
|
2511
|
-
return { ...item, ...widths }
|
|
2512
|
-
}),
|
|
2513
|
-
}))
|
|
2514
|
-
return { reading: { kind: 'read', states }, unread }
|
|
2611
|
+
let offset = 0
|
|
2612
|
+
const states = reading.states.map((entry) => {
|
|
2613
|
+
const items = measured
|
|
2614
|
+
.slice(offset, offset + entry.items.length)
|
|
2615
|
+
.map((read) => read.item)
|
|
2616
|
+
offset += entry.items.length
|
|
2617
|
+
return { ...entry, items }
|
|
2618
|
+
})
|
|
2619
|
+
return {
|
|
2620
|
+
reading: { kind: 'read', states },
|
|
2621
|
+
unread: measured.flatMap((entry) => entry.unread),
|
|
2622
|
+
}
|
|
2515
2623
|
}
|
|
2516
2624
|
|
|
2517
2625
|
async function runEvidence(
|
|
@@ -2528,7 +2636,8 @@ async function runEvidence(
|
|
|
2528
2636
|
isCheck &&
|
|
2529
2637
|
(opts.preview !== undefined ||
|
|
2530
2638
|
opts.local !== undefined ||
|
|
2531
|
-
opts.checklist !== undefined
|
|
2639
|
+
opts.checklist !== undefined ||
|
|
2640
|
+
opts.reel !== undefined)
|
|
2532
2641
|
) {
|
|
2533
2642
|
return refuseWith(
|
|
2534
2643
|
'check-writes',
|
|
@@ -2581,9 +2690,10 @@ async function runEvidence(
|
|
|
2581
2690
|
|
|
2582
2691
|
// The check never reads the merge base, so it skips the compare call and
|
|
2583
2692
|
// a failed one cannot hold a draft lift for a reason the check never had.
|
|
2584
|
-
const [pullFiles, base] = await Promise.all([
|
|
2693
|
+
const [pullFiles, base, keepsCaptures] = await Promise.all([
|
|
2585
2694
|
listPullFiles(root, identity.number),
|
|
2586
2695
|
isCheck ? undefined : readPullMergeBase(root, identity.base, identity.head),
|
|
2696
|
+
readBaseKeepsCaptures(root, identity.base),
|
|
2587
2697
|
])
|
|
2588
2698
|
if (pullFiles === undefined) {
|
|
2589
2699
|
return refuseWith(
|
|
@@ -2598,6 +2708,11 @@ async function runEvidence(
|
|
|
2598
2708
|
[...pullFiles.present.keys()],
|
|
2599
2709
|
async (path) => pullFiles.present.get(path) === false,
|
|
2600
2710
|
)
|
|
2711
|
+
const uncaptured = readUncaptured(
|
|
2712
|
+
everyChangedPath(pullFiles),
|
|
2713
|
+
loadPaintedPatterns(root),
|
|
2714
|
+
keepsCaptures,
|
|
2715
|
+
)
|
|
2601
2716
|
|
|
2602
2717
|
let existingCases = 0
|
|
2603
2718
|
let commentId: number | undefined
|
|
@@ -2623,12 +2738,14 @@ async function runEvidence(
|
|
|
2623
2738
|
carriedPreview = findEvidencePreview(comments)
|
|
2624
2739
|
carriedChecklist = findEvidenceChecklist(comments)
|
|
2625
2740
|
carriedLocal = findEvidenceLocal(comments)
|
|
2741
|
+
const carriedReel = findEvidenceReel(comments)
|
|
2626
2742
|
|
|
2627
2743
|
// What the marked comment already shows a reviewer, kept apart from the
|
|
2628
2744
|
// flags this call passed so a reader learns what is posted.
|
|
2629
2745
|
const carried = {
|
|
2630
2746
|
...(carriedPreview !== undefined && { preview: carriedPreview }),
|
|
2631
2747
|
...(carriedLocal !== undefined && { local: carriedLocal }),
|
|
2748
|
+
...(carriedReel !== undefined && { reel: carriedReel }),
|
|
2632
2749
|
...(carriedChecklist !== undefined && {
|
|
2633
2750
|
checklist: carriedChecklist,
|
|
2634
2751
|
boxes: readChecklistBoxes(carriedChecklist),
|
|
@@ -2643,6 +2760,16 @@ async function runEvidence(
|
|
|
2643
2760
|
servesChange(deploy.filter, everyChangedPath(pullFiles))
|
|
2644
2761
|
const isUnserved = deploy.kind === 'found' && !deployServesChange
|
|
2645
2762
|
|
|
2763
|
+
// Reported on every record that read the changed set, and never owed,
|
|
2764
|
+
// since no step of the ship chain can take the capture a skip left out.
|
|
2765
|
+
const uncapturedField = uncaptured !== undefined && { uncaptured }
|
|
2766
|
+
const warnUncaptured = (): void => {
|
|
2767
|
+
if (uncaptured === undefined || uncaptured.length === 0) return
|
|
2768
|
+
logWarn(
|
|
2769
|
+
`${plural(uncaptured.length, 'painted path')} changed with no evidence capture: ${uncaptured.join(', ')}`,
|
|
2770
|
+
)
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2646
2773
|
if (isCheck) {
|
|
2647
2774
|
const owed = readOwed({
|
|
2648
2775
|
hasEvidenceChange: grouped.kind === 'read',
|
|
@@ -2656,6 +2783,7 @@ async function runEvidence(
|
|
|
2656
2783
|
? 'Nothing the ship chain posts after the pull request is missing.'
|
|
2657
2784
|
: `missing ${owed.join(', ')}`,
|
|
2658
2785
|
)
|
|
2786
|
+
warnUncaptured()
|
|
2659
2787
|
outro()
|
|
2660
2788
|
if (emitJson) {
|
|
2661
2789
|
process.stdout.write(
|
|
@@ -2664,6 +2792,7 @@ async function runEvidence(
|
|
|
2664
2792
|
number: identity.number,
|
|
2665
2793
|
reason: owed.length === 0 ? 'settled' : 'owed',
|
|
2666
2794
|
owed,
|
|
2795
|
+
...uncapturedField,
|
|
2667
2796
|
...(commentId !== undefined && { commentId }),
|
|
2668
2797
|
...carried,
|
|
2669
2798
|
})}\n`,
|
|
@@ -2691,6 +2820,7 @@ async function runEvidence(
|
|
|
2691
2820
|
logInfo(
|
|
2692
2821
|
'No changed path carries an evidence/ segment, so there is nothing to post.',
|
|
2693
2822
|
)
|
|
2823
|
+
warnUncaptured()
|
|
2694
2824
|
outro()
|
|
2695
2825
|
if (emitJson) {
|
|
2696
2826
|
process.stdout.write(
|
|
@@ -2698,6 +2828,7 @@ async function runEvidence(
|
|
|
2698
2828
|
root,
|
|
2699
2829
|
...(identity.number !== undefined && { number: identity.number }),
|
|
2700
2830
|
reason: 'no-evidence',
|
|
2831
|
+
...uncapturedField,
|
|
2701
2832
|
...carried,
|
|
2702
2833
|
})}\n`,
|
|
2703
2834
|
)
|
|
@@ -2717,12 +2848,13 @@ async function runEvidence(
|
|
|
2717
2848
|
|
|
2718
2849
|
const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
|
|
2719
2850
|
const local = opts.local ?? carriedLocal
|
|
2851
|
+
const reel = nextEvidenceReel(opts.reel, identity.head, comments)
|
|
2720
2852
|
const unsettled = suppliedChecklist ?? carriedChecklist
|
|
2721
2853
|
const checklist =
|
|
2722
2854
|
unsettled === undefined
|
|
2723
2855
|
? undefined
|
|
2724
2856
|
: settleChecklist(unsettled, identity.head, findEvidenceHead(comments))
|
|
2725
|
-
const sized = await
|
|
2857
|
+
const sized = await withImageReads(root, grouped, base, identity.head)
|
|
2726
2858
|
const states = sized.reading.kind === 'read' ? sized.reading.states : []
|
|
2727
2859
|
const body = renderEvidenceBody(
|
|
2728
2860
|
states,
|
|
@@ -2732,6 +2864,7 @@ async function runEvidence(
|
|
|
2732
2864
|
preview,
|
|
2733
2865
|
checklist,
|
|
2734
2866
|
local,
|
|
2867
|
+
reel,
|
|
2735
2868
|
)
|
|
2736
2869
|
|
|
2737
2870
|
const caseCount = states.reduce((n, s) => n + s.items.length, 0)
|
|
@@ -2789,6 +2922,13 @@ async function runEvidence(
|
|
|
2789
2922
|
}
|
|
2790
2923
|
if (preview !== undefined) logInfo(`preview ${preview}`)
|
|
2791
2924
|
if (local !== undefined) logInfo(`local preview ${local}`)
|
|
2925
|
+
if (reel !== undefined) {
|
|
2926
|
+
logInfo(
|
|
2927
|
+
opts.reel === undefined
|
|
2928
|
+
? `reel carried forward, filmed at ${reel.head.slice(0, 7)}`
|
|
2929
|
+
: 'reel embedded under the addresses',
|
|
2930
|
+
)
|
|
2931
|
+
}
|
|
2792
2932
|
if (checklist !== undefined) {
|
|
2793
2933
|
logInfo(
|
|
2794
2934
|
suppliedChecklist === undefined
|
|
@@ -2796,6 +2936,7 @@ async function runEvidence(
|
|
|
2796
2936
|
: 'checklist folded in below the comparison',
|
|
2797
2937
|
)
|
|
2798
2938
|
}
|
|
2939
|
+
warnUncaptured()
|
|
2799
2940
|
|
|
2800
2941
|
outro()
|
|
2801
2942
|
|
|
@@ -2811,6 +2952,7 @@ async function runEvidence(
|
|
|
2811
2952
|
states,
|
|
2812
2953
|
...(sized.unread.length > 0 && { widthUnread: sized.unread }),
|
|
2813
2954
|
...(uiPass !== undefined && { uiPass }),
|
|
2955
|
+
...uncapturedField,
|
|
2814
2956
|
...(commentId !== undefined && { commentId }),
|
|
2815
2957
|
...carried,
|
|
2816
2958
|
})}\n`,
|
|
@@ -3672,6 +3814,7 @@ function framesArgumentRefusal(
|
|
|
3672
3814
|
): FramesCommandRefusal | undefined {
|
|
3673
3815
|
const modes = [
|
|
3674
3816
|
opts.add !== undefined,
|
|
3817
|
+
opts.reel !== undefined,
|
|
3675
3818
|
opts.drop === true,
|
|
3676
3819
|
opts.prune !== undefined,
|
|
3677
3820
|
]
|
|
@@ -3681,6 +3824,12 @@ function framesArgumentRefusal(
|
|
|
3681
3824
|
return POSITIVE_WHOLE.test(opts.prune) ? undefined : 'bad-days'
|
|
3682
3825
|
}
|
|
3683
3826
|
if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
|
|
3827
|
+
if (
|
|
3828
|
+
opts.reel !== undefined &&
|
|
3829
|
+
(opts.box !== undefined || opts.reference !== undefined)
|
|
3830
|
+
) {
|
|
3831
|
+
return 'bad-box'
|
|
3832
|
+
}
|
|
3684
3833
|
if (
|
|
3685
3834
|
opts.add !== undefined &&
|
|
3686
3835
|
(opts.box === undefined) === (opts.reference === undefined)
|
|
@@ -3697,7 +3846,7 @@ function framesArgumentRefusal(
|
|
|
3697
3846
|
}
|
|
3698
3847
|
if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
|
|
3699
3848
|
if (
|
|
3700
|
-
opts.add !== undefined &&
|
|
3849
|
+
(opts.add !== undefined || opts.reel !== undefined) &&
|
|
3701
3850
|
(opts.pass === undefined || !isFramePass(opts.pass))
|
|
3702
3851
|
) {
|
|
3703
3852
|
return 'bad-pass'
|
|
@@ -3765,6 +3914,10 @@ async function runFrames(
|
|
|
3765
3914
|
if (opts.add !== undefined && bytes === undefined) {
|
|
3766
3915
|
return refuseFrames('unreadable-frame')
|
|
3767
3916
|
}
|
|
3917
|
+
const reel =
|
|
3918
|
+
opts.reel === undefined ? undefined : await readReel(resolve(opts.reel))
|
|
3919
|
+
if (reel?.kind === 'unreadable') return refuseFrames('unreadable-reel')
|
|
3920
|
+
if (reel?.kind === 'too-large') return refuseFrames('reel-too-large')
|
|
3768
3921
|
|
|
3769
3922
|
if (Bun.which('gh') === null) return refuseFrames('gh-missing')
|
|
3770
3923
|
const runner = ghApiRunner(root)
|
|
@@ -3840,14 +3993,19 @@ async function runFrames(
|
|
|
3840
3993
|
box,
|
|
3841
3994
|
reference,
|
|
3842
3995
|
theme,
|
|
3843
|
-
|
|
3996
|
+
reel: reel !== undefined,
|
|
3997
|
+
bytes: reel?.bytes ?? bytes ?? new Uint8Array(),
|
|
3844
3998
|
})
|
|
3845
3999
|
if (result.kind === 'refused') return refuseWrite(result.reason)
|
|
3846
4000
|
const link = frameLink(repo, result.path)
|
|
3847
4001
|
return finish(
|
|
3848
4002
|
{
|
|
3849
4003
|
number: pull,
|
|
3850
|
-
...(
|
|
4004
|
+
...(reel !== undefined
|
|
4005
|
+
? { reel: true }
|
|
4006
|
+
: box === undefined
|
|
4007
|
+
? { reference }
|
|
4008
|
+
: { box }),
|
|
3851
4009
|
...(theme === undefined ? {} : { theme }),
|
|
3852
4010
|
head,
|
|
3853
4011
|
pass,
|