@erclx/canon 5.77.0 → 5.79.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 +43 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +7 -0
- package/claude/skills/code-craft/references/adopted.md +8 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/pr-evidence/SKILL.md +6 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -1
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-craft/references/ui.md +13 -1
- package/claude/skills/review-pr/references/body-variants.md +2 -0
- package/claude/skills/review-pr/references/close-out.md +2 -0
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-pr/references/post.md +1 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +2 -2
- package/claude/skills/review-ui/references/driving.md +32 -4
- package/claude/skills/review-ui/references/post.md +6 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/test-craft/SKILL.md +1 -0
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +2 -1
- package/claude/skills/ui-craft/references/capture.md +16 -0
- package/claude/skills/ui-film/REQUIREMENT.md +2 -2
- package/claude/skills/ui-film/SKILL.md +6 -1
- package/claude/skills/ui-film/references/compose-craft.md +68 -11
- package/claude/skills/ui-film/references/config.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +1 -1
- package/claude/skills/ui-film/references/harness.md +71 -0
- package/claude/skills/ui-film/references/take-craft.md +42 -17
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/operating-model.md +6 -5
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/skills/code-craft/SKILL.md +9 -0
- package/governance/ledger/skills/test-craft/SKILL.md +1 -0
- package/governance/ledger/skills/ui-craft/capture.md +18 -5
- package/package.json +4 -3
- package/src/commands/pr.ts +255 -46
- package/src/commands/sessions.ts +88 -6
- package/src/commands/take.ts +12 -1
- package/src/commands/tasks.ts +41 -2
- package/src/design/components.ts +5 -107
- package/src/design/favicon.ts +21 -17
- package/src/design/render.ts +9 -27
- package/src/design/template.ts +42 -0
- package/src/design/templates/favicon.svg +17 -0
- package/src/design/templates/hand-drawn-figure.css +54 -0
- package/src/design/templates/preview.html +27 -0
- package/src/design/templates/scrollbar.css +29 -0
- package/src/design/templates/status.css +24 -0
- package/src/gate/embedded.ts +400 -0
- package/src/gate/stages.ts +8 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +81 -13
- package/src/pr/frames.ts +1 -6
- package/src/pr/health.ts +63 -15
- package/src/pr/lift.ts +77 -9
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/pr/rest.ts +2 -0
- package/src/pr/review-scope.ts +28 -0
- package/src/pr/review-state.ts +5 -5
- package/src/pr/waiver.ts +45 -0
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/camera.ts +337 -208
- package/src/take/encode.ts +16 -42
- package/src/take/film.ts +7 -1
- package/src/take/frame-clock.ts +46 -0
- package/src/take/log.ts +43 -2
- package/src/take/perform.ts +85 -13
- package/src/take/plan.ts +26 -5
- package/src/take/reel.ts +30 -5
- package/src/take/screencast.ts +223 -13
- package/src/take/stepper.ts +24 -4
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/commands/fixtures/gh.sh +0 -64
- package/src/take/fixtures/busy.html +0 -107
- package/src/take/fixtures/fade.html +0 -49
- package/src/take/fixtures/fetch.html +0 -38
- package/src/take/fixtures/input.html +0 -138
- package/src/take/zoom/NOTICE.md +0 -45
- package/src/take/zoom/math-utils.ts +0 -69
- package/src/take/zoom/region-utils.ts +0 -55
- package/src/take/zoom/spring.ts +0 -133
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/pr/bijection.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RenamePair } from '@/git/files'
|
|
2
|
+
import { findMislinked, type Mislinked } from '@/pr/links'
|
|
2
3
|
import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -62,6 +63,12 @@ export interface BijectionReport {
|
|
|
62
63
|
* comparison has no way to tell a stale claim from one it could not check.
|
|
63
64
|
*/
|
|
64
65
|
readonly evidenceUnread: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Links in the section whose anchor hashes a path other than the one their
|
|
68
|
+
* label names, which open the wrong file's diff. Empty for a body that links
|
|
69
|
+
* nothing, since an unlinked path is the ordinary form.
|
|
70
|
+
*/
|
|
71
|
+
readonly mislinked: readonly Mislinked[]
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
export type Bijection =
|
|
@@ -231,6 +238,7 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
231
238
|
unnamed: reached.filter((path) => !owesNoBullet(path)),
|
|
232
239
|
incidental: reached.filter(owesNoBullet),
|
|
233
240
|
evidenceUnread,
|
|
241
|
+
mislinked: findMislinked(input.body, input.changed),
|
|
234
242
|
}
|
|
235
243
|
}
|
|
236
244
|
|
package/src/pr/evidence.ts
CHANGED
|
@@ -31,6 +31,9 @@ const LOCAL_SKIP_PREFIX = '**No local preview:**'
|
|
|
31
31
|
/** How the line naming why no reel was filmed opens. */
|
|
32
32
|
const REEL_SKIP_PREFIX = '**No reel:**'
|
|
33
33
|
|
|
34
|
+
/** How the line naming the painted paths changed with no capture opens. */
|
|
35
|
+
const UNCAPTURED_PREFIX = '**Uncaptured:**'
|
|
36
|
+
|
|
34
37
|
/** The width a reel is filmed at, which it asks for in the comment. */
|
|
35
38
|
const REEL_WIDTH = 960
|
|
36
39
|
|
|
@@ -43,6 +46,9 @@ const FOLDED_SUMMARY = 'Within tolerance'
|
|
|
43
46
|
/** The heading every body opens with. */
|
|
44
47
|
const EVIDENCE_HEADING = '## Evidence'
|
|
45
48
|
|
|
49
|
+
/** How the line counting renamed images whose pixels did not change opens. */
|
|
50
|
+
const MOVED_PREFIX = '**Moved unchanged:**'
|
|
51
|
+
|
|
46
52
|
/** How the visible commit line opens. */
|
|
47
53
|
const BASE_PREFIX = '**Base:**'
|
|
48
54
|
|
|
@@ -59,8 +65,11 @@ const BOX_LINE = /^(\s*[-*+] \[)([ xX])(\] )(.*)$/
|
|
|
59
65
|
/** The trailing segment a tick carries, naming the commit the box passed at. Matched only at the end of a line, so a box whose own text says `passed at` is left alone. */
|
|
60
66
|
const STAMP = / · passed at `([0-9a-f]{7,40})`$/
|
|
61
67
|
|
|
62
|
-
/**
|
|
63
|
-
const
|
|
68
|
+
/** The marker a box a person has to judge by eye carries among its trailing markers. */
|
|
69
|
+
const TASTE_MARKER = '(taste)'
|
|
70
|
+
|
|
71
|
+
/** The run of parenthesized markers that ends a box, such as `(taste) (local only)`, in any order. */
|
|
72
|
+
const TRAILING_MARKERS = /(?:\s*\([^()]*\))+$/
|
|
64
73
|
|
|
65
74
|
/** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
|
|
66
75
|
const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
|
|
@@ -76,14 +85,18 @@ const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg)$/i
|
|
|
76
85
|
export interface EvidenceItem {
|
|
77
86
|
readonly path: string
|
|
78
87
|
readonly stem: string
|
|
79
|
-
/** True when `path` did not exist at the comparison's base commit. */
|
|
88
|
+
/** True when `path` did not exist at the comparison's base commit and was not renamed from a path that did. */
|
|
80
89
|
readonly added: boolean
|
|
90
|
+
/** Where a renamed image sat at the base, which its before image reads from. Absent for any image that was not renamed. */
|
|
91
|
+
readonly basePath?: string
|
|
81
92
|
/** Pixel width of the head image, read from its PNG header. Absent when unread. */
|
|
82
93
|
readonly width?: number
|
|
83
94
|
/** Pixel width of the base image, absent for an added case or an unread header. */
|
|
84
95
|
readonly baseWidth?: number
|
|
85
96
|
/** Whether the head differs from its base by no more than sub-visual noise. Absent when the pair was not compared. */
|
|
86
97
|
readonly withinTolerance?: boolean
|
|
98
|
+
/** Whether the head and base files hold the same bytes. Read only for a renamed image, where it alone lets the listing drop the pair, since a small edit also passes the tolerance. */
|
|
99
|
+
readonly identical?: boolean
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
export interface EvidenceState {
|
|
@@ -125,11 +138,13 @@ function splitEvidencePath(path: string): {
|
|
|
125
138
|
/**
|
|
126
139
|
* Groups every evidence path in `changed` by its state, keying each entry
|
|
127
140
|
* inside a state by filename stem so a before/after pair sharing a name
|
|
128
|
-
* across two states can be read as one case.
|
|
141
|
+
* across two states can be read as one case. A path in `renamedFrom` was moved
|
|
142
|
+
* from the path it maps to, so it is not added and its base reads from there.
|
|
129
143
|
*/
|
|
130
144
|
export async function groupEvidence(
|
|
131
145
|
changed: readonly string[],
|
|
132
146
|
existsAtBase: ExistsAtBase,
|
|
147
|
+
renamedFrom: ReadonlyMap<string, string> = new Map(),
|
|
133
148
|
): Promise<EvidenceReading> {
|
|
134
149
|
const paths = changed.filter(isEvidencePath)
|
|
135
150
|
if (paths.length === 0) return { kind: 'refused', reason: 'no-evidence' }
|
|
@@ -137,14 +152,20 @@ export async function groupEvidence(
|
|
|
137
152
|
const resolved = await Promise.all(
|
|
138
153
|
paths.map(async (path) => {
|
|
139
154
|
const { state, stem } = splitEvidencePath(path)
|
|
140
|
-
const
|
|
141
|
-
|
|
155
|
+
const basePath = renamedFrom.get(path)
|
|
156
|
+
const added = basePath === undefined && !(await existsAtBase(path))
|
|
157
|
+
return { path, state, stem, added, basePath }
|
|
142
158
|
}),
|
|
143
159
|
)
|
|
144
160
|
|
|
145
161
|
const byState = new Map<string, EvidenceItem[]>()
|
|
146
|
-
for (const { path, state, stem, added } of resolved) {
|
|
147
|
-
const item: EvidenceItem = {
|
|
162
|
+
for (const { path, state, stem, added, basePath } of resolved) {
|
|
163
|
+
const item: EvidenceItem = {
|
|
164
|
+
path,
|
|
165
|
+
stem,
|
|
166
|
+
added,
|
|
167
|
+
...(basePath !== undefined && { basePath }),
|
|
168
|
+
}
|
|
148
169
|
const items = byState.get(state)
|
|
149
170
|
if (items === undefined) byState.set(state, [item])
|
|
150
171
|
else items.push(item)
|
|
@@ -277,7 +298,7 @@ function baseFold(item: EvidenceItem, repo: string, base: string): string[] {
|
|
|
277
298
|
'<summary>Base</summary>',
|
|
278
299
|
'',
|
|
279
300
|
evidenceImage(
|
|
280
|
-
rawUrl(repo, base, item.path),
|
|
301
|
+
rawUrl(repo, base, item.basePath ?? item.path),
|
|
281
302
|
`${escapeCell(item.stem)} base`,
|
|
282
303
|
item.baseWidth,
|
|
283
304
|
),
|
|
@@ -476,6 +497,22 @@ function skipLines(prefix: string, reason: string | undefined): string[] {
|
|
|
476
497
|
return text === '' ? [] : [`${prefix} ${escapeCell(text)}`, '']
|
|
477
498
|
}
|
|
478
499
|
|
|
500
|
+
/**
|
|
501
|
+
* The uncaptured line, or nothing once the list is empty or a reel filmed at
|
|
502
|
+
* this head shows the change. A reel at an earlier head leaves it, since a
|
|
503
|
+
* later push may have added the path.
|
|
504
|
+
*/
|
|
505
|
+
function uncapturedLines(
|
|
506
|
+
uncaptured: readonly string[],
|
|
507
|
+
head: string,
|
|
508
|
+
reel: EvidenceReel | undefined,
|
|
509
|
+
): string[] {
|
|
510
|
+
if (uncaptured.length === 0) return []
|
|
511
|
+
if (reel !== undefined && head.startsWith(reel.head)) return []
|
|
512
|
+
const paths = uncaptured.map((path) => `\`${path}\``).join(', ')
|
|
513
|
+
return [`${UNCAPTURED_PREFIX} ${paths}`, '']
|
|
514
|
+
}
|
|
515
|
+
|
|
479
516
|
export function evidenceMarker(head: string): string {
|
|
480
517
|
return `${MARKER_PREFIX} head=${head} -->`
|
|
481
518
|
}
|
|
@@ -511,6 +548,10 @@ export function evidenceMarker(head: string): string {
|
|
|
511
548
|
* A skip names why the local address or the reel is missing, on its own line
|
|
512
549
|
* where that address or reel would sit. Each is dropped whenever its address
|
|
513
550
|
* or reel renders, carried or passed, so a skip never outlives its cause.
|
|
551
|
+
*
|
|
552
|
+
* The uncaptured line names each painted path changed with no capture, under
|
|
553
|
+
* the reel, and is computed fresh on every render rather than carried, so it
|
|
554
|
+
* drops on the first render after a capture or a reel at the head lands.
|
|
514
555
|
*/
|
|
515
556
|
export function renderEvidenceBody(
|
|
516
557
|
states: readonly EvidenceState[],
|
|
@@ -522,6 +563,7 @@ export function renderEvidenceBody(
|
|
|
522
563
|
local?: string,
|
|
523
564
|
reel?: EvidenceReel,
|
|
524
565
|
skips: EvidenceSkips = {},
|
|
566
|
+
uncaptured: readonly string[] = [],
|
|
525
567
|
): string {
|
|
526
568
|
const segments = [
|
|
527
569
|
...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
|
|
@@ -535,6 +577,7 @@ export function renderEvidenceBody(
|
|
|
535
577
|
...(reel === undefined
|
|
536
578
|
? skipLines(REEL_SKIP_PREFIX, skips.reel)
|
|
537
579
|
: [reelLine(reel), '']),
|
|
580
|
+
...uncapturedLines(uncaptured, head, reel),
|
|
538
581
|
]
|
|
539
582
|
const closing =
|
|
540
583
|
checklist === undefined
|
|
@@ -548,8 +591,16 @@ export function renderEvidenceBody(
|
|
|
548
591
|
states
|
|
549
592
|
.map((entry) => ({ ...entry, items: entry.items.filter(keep) }))
|
|
550
593
|
.filter((entry) => entry.items.length > 0)
|
|
551
|
-
const
|
|
552
|
-
|
|
594
|
+
const isMoved = (item: EvidenceItem): boolean =>
|
|
595
|
+
item.basePath !== undefined && item.identical === true
|
|
596
|
+
const open = split((item) => !isMoved(item) && item.withinTolerance !== true)
|
|
597
|
+
const folded = split(
|
|
598
|
+
(item) => !isMoved(item) && item.withinTolerance === true,
|
|
599
|
+
)
|
|
600
|
+
const movedCount = states.reduce(
|
|
601
|
+
(total, entry) => total + entry.items.filter(isMoved).length,
|
|
602
|
+
0,
|
|
603
|
+
)
|
|
553
604
|
|
|
554
605
|
const headCount = open.reduce((total, entry) => total + entry.items.length, 0)
|
|
555
606
|
const opener =
|
|
@@ -564,6 +615,12 @@ export function renderEvidenceBody(
|
|
|
564
615
|
...opening,
|
|
565
616
|
`${BASE_PREFIX} ${commitLink(repo, base)} · **Head:** ${commitLink(repo, head)}`,
|
|
566
617
|
'',
|
|
618
|
+
...(movedCount === 0
|
|
619
|
+
? []
|
|
620
|
+
: [
|
|
621
|
+
`${MOVED_PREFIX} ${movedCount} ${movedCount === 1 ? 'image' : 'images'}`,
|
|
622
|
+
'',
|
|
623
|
+
]),
|
|
567
624
|
...sections,
|
|
568
625
|
'',
|
|
569
626
|
...closing,
|
|
@@ -859,7 +916,7 @@ export interface ChecklistBox {
|
|
|
859
916
|
readonly isTicked: boolean
|
|
860
917
|
/** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
|
|
861
918
|
readonly stamp?: string
|
|
862
|
-
/** Whether the box
|
|
919
|
+
/** Whether the box's trailing marker run holds `(taste)`, which no driver ticks. */
|
|
863
920
|
readonly isTaste: boolean
|
|
864
921
|
}
|
|
865
922
|
|
|
@@ -906,12 +963,23 @@ export function readChecklistBoxes(checklist: string): ChecklistBox[] {
|
|
|
906
963
|
text: parsed.text,
|
|
907
964
|
isTicked: parsed.isTicked,
|
|
908
965
|
...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
|
|
909
|
-
isTaste: parsed.text
|
|
966
|
+
isTaste: hasTasteMarker(parsed.text),
|
|
910
967
|
})
|
|
911
968
|
})
|
|
912
969
|
return boxes
|
|
913
970
|
}
|
|
914
971
|
|
|
972
|
+
function hasTasteMarker(text: string): boolean {
|
|
973
|
+
const run = TRAILING_MARKERS.exec(text)?.[0] ?? ''
|
|
974
|
+
const markers: readonly string[] = run.match(/\([^()]*\)/g) ?? []
|
|
975
|
+
return markers.includes(TASTE_MARKER)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** The boxes a driver can run, being every box but a taste one. */
|
|
979
|
+
export function countDrivable(boxes: readonly ChecklistBox[]): number {
|
|
980
|
+
return boxes.filter((box) => !box.isTaste).length
|
|
981
|
+
}
|
|
982
|
+
|
|
915
983
|
/** The head the marked comment's trailing marker names. */
|
|
916
984
|
export function findEvidenceHead(
|
|
917
985
|
comments: readonly EvidenceComment[],
|
package/src/pr/frames.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { REEL_MAX_BYTES } from '@/take/reel'
|
|
2
3
|
|
|
3
4
|
/** The branch holding review frames. It never merges into a branch anyone checks out. */
|
|
4
5
|
export const FRAMES_BRANCH = 'canon/frames'
|
|
@@ -13,12 +14,6 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
|
13
14
|
const RIFF = [0x52, 0x49, 0x46, 0x46]
|
|
14
15
|
const WEBP = [0x57, 0x45, 0x42, 0x50]
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
* The largest reel an add takes, GitHub's cap on an image in a comment, so a
|
|
18
|
-
* reel that would not render is refused before it reaches the branch.
|
|
19
|
-
*/
|
|
20
|
-
export const REEL_MAX_BYTES = 10_000_000
|
|
21
|
-
|
|
22
17
|
const SHORT_HEAD_LENGTH = 7
|
|
23
18
|
|
|
24
19
|
const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
|
package/src/pr/health.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
+
/** The key listing folders whose listeners skip the health paths. */
|
|
5
|
+
export const PAGE_ONLY_KEY = 'page-only'
|
|
6
|
+
|
|
4
7
|
/** Where a project declares what `canon pr local` checks beyond the page. */
|
|
5
8
|
export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
|
-
* The health paths a project declared
|
|
9
|
-
* `health = []`. `invalid` is a
|
|
10
|
-
*
|
|
11
|
-
* fallback is the failure the
|
|
11
|
+
* The health paths a project declared, with the folders whose listeners skip
|
|
12
|
+
* them. `none` is an absent file or an empty `health = []`. `invalid` is a
|
|
13
|
+
* declaration that exists and cannot be used, which refuses rather than
|
|
14
|
+
* falling back to the page-only probe, since that fallback is the failure the
|
|
15
|
+
* declaration exists to catch.
|
|
12
16
|
*/
|
|
13
17
|
export type HealthDeclaration =
|
|
14
|
-
| {
|
|
15
|
-
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'paths'
|
|
20
|
+
readonly paths: readonly string[]
|
|
21
|
+
readonly pageOnly: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
| { readonly kind: 'none'; readonly pageOnly: readonly string[] }
|
|
16
24
|
| { readonly kind: 'invalid'; readonly reason: string }
|
|
17
25
|
|
|
18
26
|
/**
|
|
@@ -28,9 +36,24 @@ function isLocalPath(entry: unknown): entry is string {
|
|
|
28
36
|
)
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A repository-relative folder as whole segments, or undefined when the entry
|
|
41
|
+
* is not one. Absolute paths, drive letters, backslashes, and `..` refuse,
|
|
42
|
+
* since each would name a place outside the repository. A leading `./` and
|
|
43
|
+
* trailing slashes are dropped so the match compares segments alone.
|
|
44
|
+
*/
|
|
45
|
+
function toFolder(entry: unknown): string | undefined {
|
|
46
|
+
if (typeof entry !== 'string') return undefined
|
|
47
|
+
if (entry.startsWith('/') || entry.includes('\\')) return undefined
|
|
48
|
+
if (/^[A-Za-z]:/.test(entry)) return undefined
|
|
49
|
+
const segments = entry.split('/').filter((s) => s !== '' && s !== '.')
|
|
50
|
+
if (segments.length === 0 || segments.includes('..')) return undefined
|
|
51
|
+
return segments.join('/')
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
export function readHealthPaths(root: string): HealthDeclaration {
|
|
32
55
|
const path = join(root, LOCAL_CONFIG_REL)
|
|
33
|
-
if (!existsSync(path)) return { kind: 'none' }
|
|
56
|
+
if (!existsSync(path)) return { kind: 'none', pageOnly: [] }
|
|
34
57
|
|
|
35
58
|
let parsed: Record<string, unknown>
|
|
36
59
|
try {
|
|
@@ -42,15 +65,40 @@ export function readHealthPaths(root: string): HealthDeclaration {
|
|
|
42
65
|
return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const hasHealth = 'health' in parsed
|
|
69
|
+
const hasPageOnly = PAGE_ONLY_KEY in parsed
|
|
70
|
+
if (!hasHealth && !hasPageOnly) return invalidHealth()
|
|
71
|
+
|
|
72
|
+
let paths: readonly string[] = []
|
|
73
|
+
if (hasHealth) {
|
|
74
|
+
const declared = parsed.health
|
|
75
|
+
if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
|
|
76
|
+
return invalidHealth()
|
|
50
77
|
}
|
|
78
|
+
paths = declared
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
81
|
+
let pageOnly: readonly string[] = []
|
|
82
|
+
if (hasPageOnly) {
|
|
83
|
+
const declared = parsed[PAGE_ONLY_KEY]
|
|
84
|
+
const folders = Array.isArray(declared) ? declared.map(toFolder) : []
|
|
85
|
+
if (!Array.isArray(declared) || folders.includes(undefined)) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'invalid',
|
|
88
|
+
reason: `${LOCAL_CONFIG_REL} needs ${PAGE_ONLY_KEY} as a list of folders relative to the repository, with no leading / and no ..`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
pageOnly = folders as string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return paths.length === 0
|
|
95
|
+
? { kind: 'none', pageOnly }
|
|
96
|
+
: { kind: 'paths', paths, pageOnly }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function invalidHealth(): HealthDeclaration {
|
|
100
|
+
return {
|
|
101
|
+
kind: 'invalid',
|
|
102
|
+
reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
|
|
103
|
+
}
|
|
56
104
|
}
|
package/src/pr/lift.ts
CHANGED
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
type ReviewRow,
|
|
5
5
|
resolveReviewScope,
|
|
6
6
|
resolveUiScope,
|
|
7
|
+
standingPasses,
|
|
7
8
|
type UiCounts,
|
|
8
9
|
} from '@/pr/review-scope'
|
|
9
10
|
import { readCarry } from '@/pr/carry'
|
|
11
|
+
import { readWaivers } from '@/pr/waiver'
|
|
10
12
|
import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
|
|
11
13
|
|
|
12
14
|
/** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
|
|
@@ -28,6 +30,7 @@ export type LiftRefusal =
|
|
|
28
30
|
| 'ui-behind'
|
|
29
31
|
| 'ui-undriven'
|
|
30
32
|
| 'evidence-owed'
|
|
33
|
+
| 'painted-uncaptured'
|
|
31
34
|
| 'standing-stale'
|
|
32
35
|
| 'standing-conflict'
|
|
33
36
|
| 'standing-unknown'
|
|
@@ -52,6 +55,10 @@ export interface GateResult {
|
|
|
52
55
|
readonly paintedPaths?: readonly string[]
|
|
53
56
|
/** Every path a box can observe that kept a UI close from carrying forward. */
|
|
54
57
|
readonly drivenPaths?: readonly string[]
|
|
58
|
+
/** The painted paths changed with no capture, reel at the head, or waiver, sorted. */
|
|
59
|
+
readonly uncaptured?: readonly string[]
|
|
60
|
+
/** The uncaptured paths a review waived, sorted. */
|
|
61
|
+
readonly waived?: readonly string[]
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/** How an earlier commit sits against the head, and what changed since. */
|
|
@@ -78,8 +85,35 @@ export interface PullState {
|
|
|
78
85
|
export interface EvidenceGate {
|
|
79
86
|
readonly isSettled: boolean
|
|
80
87
|
readonly owed: readonly string[]
|
|
81
|
-
/** Whether the marked comment
|
|
82
|
-
readonly
|
|
88
|
+
/** Whether the marked comment's checklist holds a box a driver can run, which binds the UI gate. */
|
|
89
|
+
readonly hasDrivableBox: boolean
|
|
90
|
+
/**
|
|
91
|
+
* The painted paths changed while no evidence image did, absent where the
|
|
92
|
+
* base keeps no captures, which reads as unchecked and passes.
|
|
93
|
+
*/
|
|
94
|
+
readonly uncaptured?: readonly string[]
|
|
95
|
+
/** The head the marked comment's reel was filmed at, when it embeds one. */
|
|
96
|
+
readonly reelHead?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether an evidence record binds the UI family. A checklist binds unless
|
|
101
|
+
* the record counts zero drivable boxes, so a missing or unreadable count
|
|
102
|
+
* fails closed rather than reading as unbound.
|
|
103
|
+
*/
|
|
104
|
+
export function readsDrivableBox(record: {
|
|
105
|
+
readonly checklist?: unknown
|
|
106
|
+
readonly drivable?: unknown
|
|
107
|
+
}): boolean {
|
|
108
|
+
if (typeof record.checklist !== 'string' || record.checklist === '') {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
const { drivable } = record
|
|
112
|
+
return !(
|
|
113
|
+
typeof drivable === 'number' &&
|
|
114
|
+
Number.isInteger(drivable) &&
|
|
115
|
+
drivable === 0
|
|
116
|
+
)
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
export interface ChecksGate {
|
|
@@ -219,13 +253,14 @@ export async function uiGate(
|
|
|
219
253
|
painted: PaintedPatterns,
|
|
220
254
|
reviews: readonly ReviewRow[],
|
|
221
255
|
head: string,
|
|
222
|
-
|
|
256
|
+
hasDrivableBox: boolean,
|
|
223
257
|
): Promise<GateResult> {
|
|
224
|
-
if (!hasChecklist) return pass('ui')
|
|
225
|
-
|
|
226
258
|
const scope = resolveUiScope({ reviews })
|
|
227
|
-
|
|
259
|
+
// An open verdict holds the gate even once no drivable box remains, so
|
|
260
|
+
// dropping the checklist or marking a failed box taste never clears it.
|
|
228
261
|
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
262
|
+
if (!hasDrivableBox) return pass('ui')
|
|
263
|
+
if (scope.state === 'none') return fail('ui', 'ui-missing')
|
|
229
264
|
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
230
265
|
if (scope.commit === undefined) return fail('ui', 'ui-behind')
|
|
231
266
|
// Ahead of the head test so a close carried across commits is refused too.
|
|
@@ -254,6 +289,39 @@ export async function uiGate(
|
|
|
254
289
|
}
|
|
255
290
|
}
|
|
256
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The evidence gate. An owed step refuses first. Then every painted path the
|
|
294
|
+
* pull request changed with no capture refuses, unless a reel filmed at the
|
|
295
|
+
* head shows it or a standing pass waived it by the path as the list spells it.
|
|
296
|
+
* A reel at an earlier head clears nothing, since a later push may add the
|
|
297
|
+
* path, and for the same reason a waiver counts only while the newest pass of
|
|
298
|
+
* its family restates it.
|
|
299
|
+
*/
|
|
300
|
+
export function evidenceGate(
|
|
301
|
+
evidence: EvidenceGate,
|
|
302
|
+
reviews: readonly ReviewRow[],
|
|
303
|
+
head: string,
|
|
304
|
+
): GateResult {
|
|
305
|
+
if (!evidence.isSettled) return fail('evidence', 'evidence-owed')
|
|
306
|
+
|
|
307
|
+
const isFilmed =
|
|
308
|
+
evidence.reelHead !== undefined && coversHead(evidence.reelHead, head)
|
|
309
|
+
const listed = isFilmed ? [] : (evidence.uncaptured ?? [])
|
|
310
|
+
const named = new Set(
|
|
311
|
+
readWaivers(standingPasses(reviews)).map((waiver) => waiver.path),
|
|
312
|
+
)
|
|
313
|
+
const waived = listed.filter((path) => named.has(path)).toSorted()
|
|
314
|
+
const uncaptured = listed.filter((path) => !named.has(path)).toSorted()
|
|
315
|
+
|
|
316
|
+
const lists = {
|
|
317
|
+
...(uncaptured.length > 0 && { uncaptured }),
|
|
318
|
+
...(waived.length > 0 && { waived }),
|
|
319
|
+
}
|
|
320
|
+
return uncaptured.length > 0
|
|
321
|
+
? { ...fail('evidence', 'painted-uncaptured'), ...lists }
|
|
322
|
+
: { ...pass('evidence'), ...lists }
|
|
323
|
+
}
|
|
324
|
+
|
|
257
325
|
function standingGate(standing: Standing): GateResult {
|
|
258
326
|
if (standing === 'fresh') return pass('standing')
|
|
259
327
|
return fail('standing', `standing-${standing}`)
|
|
@@ -409,8 +477,8 @@ export async function lift(
|
|
|
409
477
|
|
|
410
478
|
const gates: GateResult[] = [
|
|
411
479
|
reviewGate(reviews, tip),
|
|
412
|
-
await uiGate(api, painted, reviews, tip, evidence.
|
|
413
|
-
|
|
480
|
+
await uiGate(api, painted, reviews, tip, evidence.hasDrivableBox),
|
|
481
|
+
evidenceGate(evidence, reviews, tip),
|
|
414
482
|
standingGate(standing),
|
|
415
483
|
]
|
|
416
484
|
if (!pull.isDraft) return settleReady(api, opts, pull, tip, gates)
|
|
@@ -481,7 +549,7 @@ export async function lift(
|
|
|
481
549
|
|
|
482
550
|
const reread = [
|
|
483
551
|
reviewGate(nowReviews, nowTip),
|
|
484
|
-
await uiGate(api, painted, nowReviews, nowTip, evidence.
|
|
552
|
+
await uiGate(api, painted, nowReviews, nowTip, evidence.hasDrivableBox),
|
|
485
553
|
].find((gate) => !gate.isOk)
|
|
486
554
|
if (reread?.reason !== undefined) {
|
|
487
555
|
return refused(reread.reason, cleared, nowTip, ci.waited)
|
package/src/pr/links.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { findBacktickSpans } from '@/markdown/backticks'
|
|
3
|
+
import { KEY_CHANGES, LINE_SUFFIX, readSectionAt } from '@/pr/paths'
|
|
4
|
+
|
|
5
|
+
/** A Key changes link whose anchor hashes a path other than its label's. */
|
|
6
|
+
export interface Mislinked {
|
|
7
|
+
readonly path: string
|
|
8
|
+
readonly anchor: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The fragment GitHub gives a file's diff in a pull request's Files tab, being
|
|
13
|
+
* `diff-` and the SHA-256 of the raw path. Read off a merged pull request's
|
|
14
|
+
* rendered page, so a path holding a space or a bracket hashes as written.
|
|
15
|
+
*/
|
|
16
|
+
export function diffAnchor(path: string): string {
|
|
17
|
+
return `diff-${createHash('sha256').update(path).digest('hex')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Located {
|
|
21
|
+
readonly text: string
|
|
22
|
+
readonly offset: number
|
|
23
|
+
readonly whole: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The section's text and where it starts, in the body with line endings normalized. */
|
|
27
|
+
function locateSection(body: string, title: string): Located | undefined {
|
|
28
|
+
const whole = body.replace(/\r\n/g, '\n')
|
|
29
|
+
const found = readSectionAt(whole, title)
|
|
30
|
+
return found === undefined ? undefined : { ...found, whole }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Blanks every code span, keeping offsets, so a bracket inside a path never reads as link syntax. */
|
|
34
|
+
function maskSpans(text: string): string {
|
|
35
|
+
let out = text
|
|
36
|
+
for (const span of findBacktickSpans(text)) {
|
|
37
|
+
out =
|
|
38
|
+
out.slice(0, span.start) +
|
|
39
|
+
' '.repeat(span.end - span.start) +
|
|
40
|
+
out.slice(span.end)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An inline link: a destination bare or in angle brackets, then an optional
|
|
47
|
+
* quoted title. A form this misses reads as no link, and a span inside it then
|
|
48
|
+
* gets a second link nested in the first.
|
|
49
|
+
*/
|
|
50
|
+
const LINK =
|
|
51
|
+
/\[[^\]]*\]\(\s*(<[^>]*>|[^)\s]*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/dg
|
|
52
|
+
const DIFF_DESTINATION = /#diff-[0-9a-f]{64}$/
|
|
53
|
+
|
|
54
|
+
interface Edit {
|
|
55
|
+
readonly start: number
|
|
56
|
+
readonly end: number
|
|
57
|
+
readonly text: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Writes each code span in `## Key changes` that names a changed file as a link
|
|
62
|
+
* to that file's diff, keeping the span as the label.
|
|
63
|
+
*
|
|
64
|
+
* Only an exact changed path is linked. A partial spelling or a folder names no
|
|
65
|
+
* single row in the Files tab, and a link to a guessed row is worse than none.
|
|
66
|
+
*
|
|
67
|
+
* A span that is the whole label of an existing diff link has its destination
|
|
68
|
+
* rewritten, which is how a wrong anchor clears. A span anywhere else inside a
|
|
69
|
+
* link's brackets is left alone, since a link nested in a link breaks the
|
|
70
|
+
* bullet, and the rewrite stays idempotent.
|
|
71
|
+
*
|
|
72
|
+
* `filesUrl` is the pull request's `/files` address, absolute because the body
|
|
73
|
+
* becomes the squash commit message, where a relative link resolves nowhere.
|
|
74
|
+
*/
|
|
75
|
+
export function linkKeyChanges(
|
|
76
|
+
body: string,
|
|
77
|
+
changed: readonly string[],
|
|
78
|
+
filesUrl: string,
|
|
79
|
+
): string {
|
|
80
|
+
const located = locateSection(body, KEY_CHANGES)
|
|
81
|
+
if (located === undefined) return body
|
|
82
|
+
|
|
83
|
+
const { text } = located
|
|
84
|
+
const known = new Set(changed)
|
|
85
|
+
const links = [...maskSpans(text).matchAll(LINK)].map((match) => ({
|
|
86
|
+
start: match.index ?? 0,
|
|
87
|
+
end: (match.index ?? 0) + match[0].length,
|
|
88
|
+
destination: match[1] ?? '',
|
|
89
|
+
destinationStart: match.indices?.[1]?.[0] ?? 0,
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
const edits: Edit[] = []
|
|
93
|
+
for (const span of findBacktickSpans(text)) {
|
|
94
|
+
const path = span.content.replace(LINE_SUFFIX, '')
|
|
95
|
+
if (!known.has(path)) continue
|
|
96
|
+
const target = `${filesUrl}#${diffAnchor(path)}`
|
|
97
|
+
|
|
98
|
+
const link = links.find((l) => l.start < span.start && span.end <= l.end)
|
|
99
|
+
if (link === undefined) {
|
|
100
|
+
edits.push({
|
|
101
|
+
start: span.start,
|
|
102
|
+
end: span.end,
|
|
103
|
+
text: `[${text.slice(span.start, span.end)}](${target})`,
|
|
104
|
+
})
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const wholeLabel =
|
|
109
|
+
span.start === link.start + 1 && text.startsWith('](', span.end)
|
|
110
|
+
if (!wholeLabel || !DIFF_DESTINATION.test(link.destination)) continue
|
|
111
|
+
edits.push({
|
|
112
|
+
start: link.destinationStart,
|
|
113
|
+
end: link.destinationStart + link.destination.length,
|
|
114
|
+
text: target,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let out = ''
|
|
119
|
+
let cursor = 0
|
|
120
|
+
for (const edit of edits) {
|
|
121
|
+
out += text.slice(cursor, edit.start) + edit.text
|
|
122
|
+
cursor = edit.end
|
|
123
|
+
}
|
|
124
|
+
out += text.slice(cursor)
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
located.whole.slice(0, located.offset) +
|
|
128
|
+
out +
|
|
129
|
+
located.whole.slice(located.offset + text.length)
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DIFF_LINK = /\[(`+)([^`]+)\1\]\([^)\s]*#(diff-[0-9a-f]{64})\)/g
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every Key changes link whose label is a changed path and whose anchor hashes
|
|
137
|
+
* another path.
|
|
138
|
+
*
|
|
139
|
+
* A label that is no changed path, such as a bare file name or a function, is
|
|
140
|
+
* left unjudged: it can open the right file under a spelling this cannot map
|
|
141
|
+
* back to a row, and an accusation needs the row.
|
|
142
|
+
*/
|
|
143
|
+
export function findMislinked(
|
|
144
|
+
body: string,
|
|
145
|
+
changed: readonly string[],
|
|
146
|
+
): Mislinked[] {
|
|
147
|
+
const located = locateSection(body, KEY_CHANGES)
|
|
148
|
+
if (located === undefined) return []
|
|
149
|
+
|
|
150
|
+
const known = new Set(changed)
|
|
151
|
+
const out: Mislinked[] = []
|
|
152
|
+
for (const match of located.text.matchAll(DIFF_LINK)) {
|
|
153
|
+
const path = (match[2] ?? '').trim().replace(LINE_SUFFIX, '')
|
|
154
|
+
const anchor = match[3] ?? ''
|
|
155
|
+
if (known.has(path) && diffAnchor(path) !== anchor) {
|
|
156
|
+
out.push({ path, anchor })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|