@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -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
 
@@ -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
- /** What ends the text of a box that a person has to judge by eye. */
63
- const TASTE_SUFFIX = '(taste)'
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 added = !(await existsAtBase(path))
141
- return { path, state, stem, added }
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 = { path, stem, added }
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 open = split((item) => item.withinTolerance !== true)
552
- const folded = split((item) => item.withinTolerance === true)
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 ends in `(taste)`, which no driver ticks. */
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.endsWith(TASTE_SUFFIX),
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. `none` is an absent file or an empty
9
- * `health = []`. `invalid` is a declaration that exists and cannot be used,
10
- * which refuses rather than falling back to the page-only probe, since that
11
- * fallback is the failure the declaration exists to catch.
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
- | { readonly kind: 'paths'; readonly paths: readonly string[] }
15
- | { readonly kind: 'none' }
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 declared = parsed.health
46
- if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
47
- return {
48
- kind: 'invalid',
49
- reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
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
- return declared.length === 0
54
- ? { kind: 'none' }
55
- : { kind: 'paths', paths: declared }
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 carries a visual checklist, which binds the UI gate. */
82
- readonly hasChecklist: boolean
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
- hasChecklist: boolean,
256
+ hasDrivableBox: boolean,
223
257
  ): Promise<GateResult> {
224
- if (!hasChecklist) return pass('ui')
225
-
226
258
  const scope = resolveUiScope({ reviews })
227
- if (scope.state === 'none') return fail('ui', 'ui-missing')
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.hasChecklist),
413
- evidence.isSettled ? pass('evidence') : fail('evidence', 'evidence-owed'),
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.hasChecklist),
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)
@@ -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
+ }