@erclx/canon 5.76.0 → 5.78.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 (90) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  4. package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
  5. package/claude/skills/ci-workflow/SKILL.md +3 -0
  6. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  7. package/claude/skills/code-craft/SKILL.md +7 -0
  8. package/claude/skills/code-craft/references/adopted.md +8 -0
  9. package/claude/skills/pr-evidence/SKILL.md +6 -1
  10. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  11. package/claude/skills/pr-evidence/references/reel.md +9 -1
  12. package/claude/skills/review-craft/references/ui.md +13 -1
  13. package/claude/skills/review-pr/references/body-variants.md +2 -0
  14. package/claude/skills/review-pr/references/close-out.md +2 -0
  15. package/claude/skills/review-pr/references/post.md +1 -0
  16. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  17. package/claude/skills/review-ui/SKILL.md +2 -2
  18. package/claude/skills/review-ui/references/driving.md +32 -4
  19. package/claude/skills/review-ui/references/post.md +6 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  21. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  22. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  24. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  25. package/claude/skills/role-worker/SKILL.md +1 -0
  26. package/claude/skills/test-craft/SKILL.md +1 -0
  27. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  28. package/claude/skills/ui-checklist/SKILL.md +2 -1
  29. package/claude/skills/ui-craft/references/capture.md +16 -0
  30. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  31. package/claude/skills/ui-film/SKILL.md +6 -1
  32. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  33. package/claude/skills/ui-film/references/config.md +2 -2
  34. package/claude/skills/ui-film/references/handoff.md +1 -1
  35. package/claude/skills/ui-film/references/harness.md +71 -0
  36. package/claude/skills/ui-film/references/take-craft.md +42 -17
  37. package/docs/agents/targets.md +3 -1
  38. package/docs/workflow/operating-model.md +6 -5
  39. package/docs/workflow/visual-design-workflow.md +2 -1
  40. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  41. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  42. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  43. package/package.json +5 -4
  44. package/src/commands/pr.ts +117 -31
  45. package/src/commands/take.ts +12 -1
  46. package/src/commands/targets.ts +11 -1
  47. package/src/design/components.ts +5 -107
  48. package/src/design/favicon.ts +21 -17
  49. package/src/design/render.ts +9 -27
  50. package/src/design/template.ts +42 -0
  51. package/src/design/templates/favicon.svg +17 -0
  52. package/src/design/templates/hand-drawn-figure.css +54 -0
  53. package/src/design/templates/preview.html +27 -0
  54. package/src/design/templates/scrollbar.css +29 -0
  55. package/src/design/templates/status.css +24 -0
  56. package/src/gate/embedded.ts +400 -0
  57. package/src/gate/stages.ts +8 -0
  58. package/src/pr/evidence.ts +43 -4
  59. package/src/pr/frames.ts +1 -6
  60. package/src/pr/lift.ts +77 -9
  61. package/src/pr/rest.ts +2 -0
  62. package/src/pr/review-scope.ts +28 -0
  63. package/src/pr/review-state.ts +5 -5
  64. package/src/pr/waiver.ts +45 -0
  65. package/src/take/camera.ts +337 -208
  66. package/src/take/film.ts +7 -1
  67. package/src/take/log.ts +43 -2
  68. package/src/take/perform.ts +85 -13
  69. package/src/take/plan.ts +26 -5
  70. package/src/take/reel.ts +30 -5
  71. package/src/take/screencast.ts +110 -3
  72. package/src/take/stepper.ts +24 -4
  73. package/src/targets/registry.ts +99 -14
  74. package/src/targets/resolve.ts +3 -1
  75. package/tooling/astro/configs/card.config.mjs +4 -0
  76. package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
  77. package/tooling/astro/reference.md +2 -1
  78. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
  79. package/tooling/web/configs/.github/workflows/verify.yml +5 -2
  80. package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
  81. package/tooling/web/reference.md +5 -0
  82. package/src/commands/fixtures/gh.sh +0 -64
  83. package/src/take/fixtures/busy.html +0 -107
  84. package/src/take/fixtures/fade.html +0 -49
  85. package/src/take/fixtures/fetch.html +0 -38
  86. package/src/take/fixtures/input.html +0 -138
  87. package/src/take/zoom/NOTICE.md +0 -45
  88. package/src/take/zoom/math-utils.ts +0 -69
  89. package/src/take/zoom/region-utils.ts +0 -55
  90. package/src/take/zoom/spring.ts +0 -133
@@ -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
 
@@ -59,8 +62,11 @@ const BOX_LINE = /^(\s*[-*+] \[)([ xX])(\] )(.*)$/
59
62
  /** 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
63
  const STAMP = / · passed at `([0-9a-f]{7,40})`$/
61
64
 
62
- /** What ends the text of a box that a person has to judge by eye. */
63
- const TASTE_SUFFIX = '(taste)'
65
+ /** The marker a box a person has to judge by eye carries among its trailing markers. */
66
+ const TASTE_MARKER = '(taste)'
67
+
68
+ /** The run of parenthesized markers that ends a box, such as `(taste) (local only)`, in any order. */
69
+ const TRAILING_MARKERS = /(?:\s*\([^()]*\))+$/
64
70
 
65
71
  /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
66
72
  const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
@@ -476,6 +482,22 @@ function skipLines(prefix: string, reason: string | undefined): string[] {
476
482
  return text === '' ? [] : [`${prefix} ${escapeCell(text)}`, '']
477
483
  }
478
484
 
485
+ /**
486
+ * The uncaptured line, or nothing once the list is empty or a reel filmed at
487
+ * this head shows the change. A reel at an earlier head leaves it, since a
488
+ * later push may have added the path.
489
+ */
490
+ function uncapturedLines(
491
+ uncaptured: readonly string[],
492
+ head: string,
493
+ reel: EvidenceReel | undefined,
494
+ ): string[] {
495
+ if (uncaptured.length === 0) return []
496
+ if (reel !== undefined && head.startsWith(reel.head)) return []
497
+ const paths = uncaptured.map((path) => `\`${path}\``).join(', ')
498
+ return [`${UNCAPTURED_PREFIX} ${paths}`, '']
499
+ }
500
+
479
501
  export function evidenceMarker(head: string): string {
480
502
  return `${MARKER_PREFIX} head=${head} -->`
481
503
  }
@@ -511,6 +533,10 @@ export function evidenceMarker(head: string): string {
511
533
  * A skip names why the local address or the reel is missing, on its own line
512
534
  * where that address or reel would sit. Each is dropped whenever its address
513
535
  * or reel renders, carried or passed, so a skip never outlives its cause.
536
+ *
537
+ * The uncaptured line names each painted path changed with no capture, under
538
+ * the reel, and is computed fresh on every render rather than carried, so it
539
+ * drops on the first render after a capture or a reel at the head lands.
514
540
  */
515
541
  export function renderEvidenceBody(
516
542
  states: readonly EvidenceState[],
@@ -522,6 +548,7 @@ export function renderEvidenceBody(
522
548
  local?: string,
523
549
  reel?: EvidenceReel,
524
550
  skips: EvidenceSkips = {},
551
+ uncaptured: readonly string[] = [],
525
552
  ): string {
526
553
  const segments = [
527
554
  ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
@@ -535,6 +562,7 @@ export function renderEvidenceBody(
535
562
  ...(reel === undefined
536
563
  ? skipLines(REEL_SKIP_PREFIX, skips.reel)
537
564
  : [reelLine(reel), '']),
565
+ ...uncapturedLines(uncaptured, head, reel),
538
566
  ]
539
567
  const closing =
540
568
  checklist === undefined
@@ -859,7 +887,7 @@ export interface ChecklistBox {
859
887
  readonly isTicked: boolean
860
888
  /** The short sha a tick names, absent on an empty box or a tick nothing stamped. */
861
889
  readonly stamp?: string
862
- /** Whether the box ends in `(taste)`, which no driver ticks. */
890
+ /** Whether the box's trailing marker run holds `(taste)`, which no driver ticks. */
863
891
  readonly isTaste: boolean
864
892
  }
865
893
 
@@ -906,12 +934,23 @@ export function readChecklistBoxes(checklist: string): ChecklistBox[] {
906
934
  text: parsed.text,
907
935
  isTicked: parsed.isTicked,
908
936
  ...(parsed.stamp !== undefined && { stamp: parsed.stamp }),
909
- isTaste: parsed.text.endsWith(TASTE_SUFFIX),
937
+ isTaste: hasTasteMarker(parsed.text),
910
938
  })
911
939
  })
912
940
  return boxes
913
941
  }
914
942
 
943
+ function hasTasteMarker(text: string): boolean {
944
+ const run = TRAILING_MARKERS.exec(text)?.[0] ?? ''
945
+ const markers: readonly string[] = run.match(/\([^()]*\)/g) ?? []
946
+ return markers.includes(TASTE_MARKER)
947
+ }
948
+
949
+ /** The boxes a driver can run, being every box but a taste one. */
950
+ export function countDrivable(boxes: readonly ChecklistBox[]): number {
951
+ return boxes.filter((box) => !box.isTaste).length
952
+ }
953
+
915
954
  /** The head the marked comment's trailing marker names. */
916
955
  export function findEvidenceHead(
917
956
  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/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)
package/src/pr/rest.ts CHANGED
@@ -39,6 +39,7 @@ export interface RestReview {
39
39
  readonly body?: string | null
40
40
  readonly commit_id?: string | null
41
41
  readonly submitted_at?: string | null
42
+ readonly state?: string | null
42
43
  }
43
44
 
44
45
  /** One row of `repos/{owner}/{repo}/issues/<n>/comments`. */
@@ -117,6 +118,7 @@ export function reviewRowOf(review: RestReview): ReviewRow {
117
118
  ? null
118
119
  : { oid: review.commit_id },
119
120
  submittedAt: review.submitted_at ?? null,
121
+ ...(typeof review.state === 'string' && { state: review.state }),
120
122
  }
121
123
  }
122
124
 
@@ -47,6 +47,8 @@ export interface ReviewRow {
47
47
  readonly body?: string
48
48
  readonly commit?: { readonly oid?: string } | null
49
49
  readonly submittedAt?: string | null
50
+ /** GitHub's review state, `DISMISSED` once the operator withdrew the pass. */
51
+ readonly state?: string | null
50
52
  }
51
53
 
52
54
  /**
@@ -238,6 +240,32 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
238
240
  }
239
241
  }
240
242
 
243
+ /**
244
+ * The newest code pass and the newest UI pass, each only when its own marker
245
+ * closes it, skipping any pass the operator dismissed. A body with no pass
246
+ * heading never stands, and neither does an older pass a newer one replaced,
247
+ * so a pass speaks only for the head it read.
248
+ */
249
+ export function standingPasses(reviews: readonly ReviewRow[]): ReviewRow[] {
250
+ let code: ReviewRow | undefined
251
+ let ui: ReviewRow | undefined
252
+ for (const row of reviews) {
253
+ if (row.state === 'DISMISSED') continue
254
+ const body = row.body ?? ''
255
+ if (headingOf(body) !== undefined) code = row
256
+ else if (resolveUiScope({ reviews: [row] }).state !== 'none') ui = row
257
+ }
258
+
259
+ const passes: ReviewRow[] = []
260
+ if (code !== undefined && markerOf(code.body ?? '') !== undefined) {
261
+ passes.push(code)
262
+ }
263
+ if (ui !== undefined && UI_MARKER.test(lastLine(ui.body ?? '') ?? '')) {
264
+ passes.push(ui)
265
+ }
266
+ return passes
267
+ }
268
+
241
269
  /**
242
270
  * Whether a covered commit names the head. A marker may carry a short sha, so
243
271
  * coverage is a prefix match on the head.
@@ -94,7 +94,7 @@ function familyOf(gate: GateResult, commit: string | undefined, tip: string) {
94
94
  */
95
95
  async function readAt(
96
96
  api: ReviewStateApi,
97
- hasChecklist: boolean,
97
+ hasDrivableBox: boolean,
98
98
  painted: PaintedPatterns,
99
99
  ): Promise<ReviewStateRead> {
100
100
  const [tip, reviews] = await Promise.all([api.readTip(), api.readReviews()])
@@ -105,7 +105,7 @@ async function readAt(
105
105
  const listing: { reviews: readonly ReviewRow[] } = { reviews }
106
106
  const scope = resolveReviewScope(listing)
107
107
  const uiScope = resolveUiScope(listing)
108
- const ui = await uiGate(api, painted, reviews, tip, hasChecklist)
108
+ const ui = await uiGate(api, painted, reviews, tip, hasDrivableBox)
109
109
  if (ui.reason === 'gh-failed') return { kind: 'refused', reason: 'gh-failed' }
110
110
 
111
111
  return {
@@ -117,7 +117,7 @@ async function readAt(
117
117
  ui: {
118
118
  ...uiScope,
119
119
  ...familyOf(ui, uiScope.commit, tip),
120
- isBound: hasChecklist,
120
+ isBound: hasDrivableBox || uiScope.state === 'open',
121
121
  ...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
122
122
  ...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
123
123
  ...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
@@ -132,7 +132,7 @@ export async function readReviewState(
132
132
  ): Promise<ReviewStateRead> {
133
133
  const evidence = await api.readEvidence()
134
134
  if (evidence === undefined) return { kind: 'refused', reason: 'gh-failed' }
135
- return readAt(api, evidence.hasChecklist, painted)
135
+ return readAt(api, evidence.hasDrivableBox, painted)
136
136
  }
137
137
 
138
138
  /**
@@ -158,7 +158,7 @@ export async function waitForReviews(
158
158
  }
159
159
 
160
160
  for (;;) {
161
- const read = await readAt(api, evidence.hasChecklist, painted)
161
+ const read = await readAt(api, evidence.hasDrivableBox, painted)
162
162
  if (read.kind === 'refused') {
163
163
  return { ...read, waitedSeconds: waited() }
164
164
  }
@@ -0,0 +1,45 @@
1
+ import type { ReviewRow } from '@/pr/review-scope'
2
+
3
+ /** A painted path a review accepted as owing no capture, and why. */
4
+ export interface Waiver {
5
+ readonly path: string
6
+ readonly reason: string
7
+ }
8
+
9
+ /**
10
+ * The line `review-pr` writes in a pass body to waive one path. The path is
11
+ * kept exactly as spelled, so a waiver matches an uncaptured path only when
12
+ * both spell it alike.
13
+ */
14
+ const WAIVER_LINE = /^- \*\*Waived capture:\*\* `([^`]+)`:\s*(\S.*)$/
15
+
16
+ const FENCE = /^\s*(```|~~~)/
17
+
18
+ /**
19
+ * Every waiver in the review bodies, in order. A line inside a fenced block is
20
+ * a quoted sample rather than a waiver, so it never counts.
21
+ *
22
+ * Review bodies alone are read, never the pull request body or an issue
23
+ * comment, since every session posts as one account and a review is the one
24
+ * surface a worker never writes.
25
+ */
26
+ export function readWaivers(reviews: readonly ReviewRow[]): Waiver[] {
27
+ const waivers: Waiver[] = []
28
+ for (const review of reviews) {
29
+ let fence: string | undefined
30
+ for (const line of (review.body ?? '').split('\n')) {
31
+ const opener = FENCE.exec(line)?.[1]
32
+ if (opener !== undefined) {
33
+ if (fence === undefined) fence = opener
34
+ else if (opener === fence) fence = undefined
35
+ continue
36
+ }
37
+ if (fence !== undefined) continue
38
+ const match = WAIVER_LINE.exec(line.trimEnd())
39
+ if (match?.[1] !== undefined && match[2] !== undefined) {
40
+ waivers.push({ path: match[1], reason: match[2] })
41
+ }
42
+ }
43
+ }
44
+ return waivers
45
+ }