@erclx/canon 5.73.0 → 5.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +5 -3
  4. package/claude/skills/build-in-slices/references/adopted.md +4 -2
  5. package/claude/skills/code-craft/references/adopted.md +9 -7
  6. package/claude/skills/codebase-layout/references/adopted.md +8 -6
  7. package/claude/skills/create-rule/SKILL.md +5 -1
  8. package/claude/skills/create-rule/references/ledger.md +73 -0
  9. package/claude/skills/create-rule/references/rule.md +1 -1
  10. package/claude/skills/create-skill/references/skill-practice.md +6 -1
  11. package/claude/skills/deprecation-migration/references/adopted.md +5 -3
  12. package/claude/skills/design-pick/references/board.md +2 -2
  13. package/claude/skills/plan-feature/SKILL.md +2 -1
  14. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  15. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
  16. package/claude/skills/pr-evidence/SKILL.md +2 -0
  17. package/claude/skills/pr-evidence/references/evidence.md +11 -4
  18. package/claude/skills/pr-evidence/references/reel.md +108 -0
  19. package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
  20. package/claude/skills/proposal-critique/SKILL.md +126 -0
  21. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  22. package/claude/skills/review-craft/references/adopted.md +4 -4
  23. package/claude/skills/search-craft/references/adopted.md +9 -7
  24. package/claude/skills/systematic-debugging/references/adopted.md +4 -2
  25. package/claude/skills/teach-craft/references/adopted.md +8 -6
  26. package/claude/skills/test-craft/references/adopted.md +14 -12
  27. package/claude/skills/test-first/SKILL.md +1 -1
  28. package/claude/skills/test-first/references/adopted.md +9 -5
  29. package/claude/skills/ui-film/REQUIREMENT.md +4 -5
  30. package/claude/skills/ui-film/SKILL.md +21 -19
  31. package/claude/skills/ui-film/references/compose-craft.md +2 -2
  32. package/claude/skills/ui-film/references/handoff.md +2 -7
  33. package/claude/skills/write-human/references/adopted.md +8 -3
  34. package/docs/agents/audits.md +24 -24
  35. package/docs/agents/migrate.md +10 -10
  36. package/docs/migrations/by-verb.md +2 -0
  37. package/docs/target/index.md +1 -0
  38. package/docs/target/outside-tools.md +39 -0
  39. package/docs/workflow/skill-map.md +1 -0
  40. package/governance/detect.toml +4 -0
  41. package/governance/ledger/claude/rule.md +16 -0
  42. package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
  43. package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
  44. package/governance/ledger/skills/search-craft/SKILL.md +54 -0
  45. package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
  46. package/governance/ledger/skills/test-first/SKILL.md +28 -0
  47. package/governance/ledger/skills/write-human/SKILL.md +62 -0
  48. package/governance/ledger/skills/write-human/density.md +36 -0
  49. package/governance/ledger/skills/write-human/machine-tells.md +30 -0
  50. package/governance/rules/claude/rule.md +13 -0
  51. package/package.json +1 -1
  52. package/scripts/ci/docs-only.sh +24 -0
  53. package/scripts/ci/refresh-frames-pr.sh +11 -12
  54. package/src/audits/catalog.ts +7 -2
  55. package/src/capture/stamp.ts +3 -5
  56. package/src/claude/cases/misc.ts +14 -0
  57. package/src/claude/skills-audit.ts +154 -8
  58. package/src/cli/help.ts +1 -1
  59. package/src/commands/claude.ts +6 -2
  60. package/src/commands/gov.ts +12 -7
  61. package/src/commands/migrate.ts +6 -2
  62. package/src/commands/pr.ts +110 -13
  63. package/src/commands/take.ts +36 -7
  64. package/src/counts/catalogs.ts +3 -5
  65. package/src/gate/measures.ts +2 -145
  66. package/src/gate/sequencer.ts +7 -1
  67. package/src/gate/stages.ts +36 -47
  68. package/src/gov/audit.ts +59 -13
  69. package/src/markdown/scope.ts +4 -0
  70. package/src/migrate/session-defaults.ts +114 -4
  71. package/src/pr/checks.ts +75 -3
  72. package/src/pr/evidence.ts +62 -1
  73. package/src/pr/lift.ts +23 -2
  74. package/src/pr/merge.ts +13 -3
  75. package/src/take/encode.ts +9 -1
  76. package/src/take/film.ts +17 -2
  77. package/src/take/fixtures/busy.html +107 -0
  78. package/src/take/perform.ts +99 -25
  79. package/src/take/screencast.ts +4 -3
  80. package/src/take/stepper.ts +33 -20
  81. package/src/targets/pulls.ts +42 -0
  82. package/tooling/claude/reference.md +4 -2
  83. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  84. package/tooling/claude/seeds/.claude/settings.json +8 -0
  85. package/claude/skills/ui-film/references/pr-clip.md +0 -108
  86. package/scripts/core/regen-hero.ts +0 -113
  87. package/src/capture/frame-values.ts +0 -326
package/src/pr/lift.ts CHANGED
@@ -83,8 +83,19 @@ export interface EvidenceGate {
83
83
  }
84
84
 
85
85
  export interface ChecksGate {
86
+ /** The state over the checks the base requires, or every check when none were read. */
86
87
  readonly state: ChecksState
87
88
  readonly isConflicted: boolean
89
+ /** Failing checks outside the required set, which are reported and never gate. */
90
+ readonly advisory?: readonly string[]
91
+ }
92
+
93
+ /** The advisory field a record carries, absent when no advisory check failed. */
94
+ export function advisoryOf(reading: ChecksGate): {
95
+ advisory?: readonly string[]
96
+ } {
97
+ const advisory = reading.advisory ?? []
98
+ return advisory.length > 0 ? { advisory } : {}
88
99
  }
89
100
 
90
101
  /**
@@ -129,6 +140,8 @@ export interface LiftRecord {
129
140
  readonly carriedFrom?: string
130
141
  readonly paintedPaths?: readonly string[]
131
142
  readonly drivenPaths?: readonly string[]
143
+ /** Failing checks the base does not require, named so a reader sees them before merging. */
144
+ readonly advisory?: readonly string[]
132
145
  readonly isLifted: boolean
133
146
  /** Whether the caller can treat the pull request as ready afterwards. */
134
147
  readonly isOk: boolean
@@ -247,7 +260,11 @@ function standingGate(standing: Standing): GateResult {
247
260
  }
248
261
 
249
262
  type CiOutcome =
250
- | { readonly kind: 'passing'; readonly waited: number }
263
+ | {
264
+ readonly kind: 'passing'
265
+ readonly waited: number
266
+ readonly reading: ChecksGate
267
+ }
251
268
  | {
252
269
  readonly kind: 'refused'
253
270
  readonly reason: LiftRefusal
@@ -274,7 +291,9 @@ async function waitForChecks(
274
291
  if (reading === undefined) {
275
292
  return { kind: 'refused', reason: 'gh-failed', waited }
276
293
  }
277
- if (reading.state === 'passing') return { kind: 'passing', waited }
294
+ if (reading.state === 'passing') {
295
+ return { kind: 'passing', waited, reading }
296
+ }
278
297
  if (reading.state === 'failing') {
279
298
  return { kind: 'refused', reason: 'ci-failing', waited }
280
299
  }
@@ -422,6 +441,7 @@ export async function lift(
422
441
  gates: all,
423
442
  waitedSeconds: 0,
424
443
  ...carried(all),
444
+ ...advisoryOf(reading),
425
445
  }
426
446
  }
427
447
 
@@ -485,6 +505,7 @@ export async function lift(
485
505
  gates: cleared,
486
506
  waitedSeconds: ci.waited,
487
507
  ...carried(cleared),
508
+ ...advisoryOf(ci.reading),
488
509
  }
489
510
  }
490
511
 
package/src/pr/merge.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChecksGate } from '@/pr/lift'
1
+ import { advisoryOf, type ChecksGate } from '@/pr/lift'
2
2
  import { readSection } from '@/pr/paths'
3
3
  import { UI_PASS_MARKER } from '@/pr/tick'
4
4
 
@@ -67,6 +67,8 @@ export interface MergeRecord {
67
67
  readonly description?: string
68
68
  /** The squash commit, present only on a merge. */
69
69
  readonly sha?: string
70
+ /** Failing checks the base does not require, which the merge went past. */
71
+ readonly advisory?: readonly string[]
70
72
  }
71
73
 
72
74
  export type Composed =
@@ -230,8 +232,15 @@ export async function merge(
230
232
  )
231
233
  }
232
234
 
233
- if (opts.isDryRun)
234
- return { reason: 'dry-run', isOk: true, number, ...message }
235
+ if (opts.isDryRun) {
236
+ return {
237
+ reason: 'dry-run',
238
+ isOk: true,
239
+ number,
240
+ ...message,
241
+ ...advisoryOf(checks),
242
+ }
243
+ }
235
244
 
236
245
  const outcome = await api.putMerge({ ...message, sha: pull.head })
237
246
  if (outcome.kind === 'merged') {
@@ -241,6 +250,7 @@ export async function merge(
241
250
  number,
242
251
  ...message,
243
252
  sha: outcome.sha,
253
+ ...advisoryOf(checks),
244
254
  }
245
255
  }
246
256
  return refused(
@@ -97,11 +97,17 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
97
97
  }
98
98
  }
99
99
 
100
- /** Re-encodes a recorded clip to the same H.264 output at a constant `fps`. */
100
+ /**
101
+ * Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
102
+ * at `ms`. Playwright's recorder pads its webm past the last frame by at least
103
+ * a second, so the cut is what ends the clip where the take's clock stopped.
104
+ * A shorter input stays as it is, since `-t` never extends one.
105
+ */
101
106
  export async function transcode(
102
107
  input: string,
103
108
  out: string,
104
109
  fps: number,
110
+ ms: number,
105
111
  ): Promise<void> {
106
112
  const proc = spawn(
107
113
  'ffmpeg',
@@ -111,6 +117,8 @@ export async function transcode(
111
117
  'error',
112
118
  '-i',
113
119
  input,
120
+ '-t',
121
+ (ms / 1000).toFixed(3),
114
122
  '-r',
115
123
  String(fps),
116
124
  ...OUTPUT,
package/src/take/film.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type TakeLog,
12
12
  VIEWPORT,
13
13
  } from '@/take/log'
14
- import { TargetMissing } from '@/take/perform'
14
+ import { TargetMissing, TargetOffscreen, TargetRefused } from '@/take/perform'
15
15
  import type { Beat, Route, TakePlan } from '@/take/plan'
16
16
  import { filmScreencast } from '@/take/screencast'
17
17
  import { ShellMissing, findShell } from '@/take/shell'
@@ -30,6 +30,8 @@ export type TakeRefusal =
30
30
  | 'browser-missing'
31
31
  | 'server-unreachable'
32
32
  | 'target-missing'
33
+ | 'target-offscreen'
34
+ | 'over-cap'
33
35
  | 'take-failed'
34
36
 
35
37
  export interface FilmedBeat {
@@ -74,6 +76,8 @@ export interface TakeRequest {
74
76
  readonly env?: NodeJS.ProcessEnv
75
77
  /** How long one stepped frame may take before its beat falls back to a screencast. */
76
78
  readonly stallMs?: number
79
+ /** The filmed seconds the beats may sum to, past which the take refuses. */
80
+ readonly maxSeconds?: number
77
81
  }
78
82
 
79
83
  const REACH_TIMEOUT_MS = 5000
@@ -127,7 +131,7 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
127
131
  )
128
132
  } catch (error) {
129
133
  const cause = error instanceof FellBack ? error.error : error
130
- const shot = cause instanceof TargetMissing ? cause.still : undefined
134
+ const shot = cause instanceof TargetRefused ? cause.still : undefined
131
135
  const still = shot ? join(dir, 'still.jpg') : undefined
132
136
  if (shot && still) writeFileSync(join(out, still), shot)
133
137
  return {
@@ -136,6 +140,16 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
136
140
  ...(still ? { still } : {}),
137
141
  }
138
142
  }
143
+ // Checked after each beat rather than once at the end, so a take refused
144
+ // on its first beat films nothing past it.
145
+ const frames = beats.reduce((sum, filmed) => sum + filmed.frames, 0)
146
+ if (request.maxSeconds !== undefined && frames > request.maxSeconds * FPS) {
147
+ return failed(
148
+ 'over-cap',
149
+ `the beats through ${beat.name} run ${(frames / FPS).toFixed(2)} s, past the ${request.maxSeconds} s cap`,
150
+ beat.name,
151
+ )
152
+ }
139
153
  }
140
154
 
141
155
  writeFileSync(
@@ -231,6 +245,7 @@ class FellBack extends Error {
231
245
 
232
246
  function classify(error: unknown): TakeRefusal {
233
247
  if (error instanceof TargetMissing) return 'target-missing'
248
+ if (error instanceof TargetOffscreen) return 'target-offscreen'
234
249
  if (error instanceof ShellMissing || isBrowserMissing(error)) {
235
250
  return 'browser-missing'
236
251
  }
@@ -0,0 +1,107 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Busy</title>
6
+ <!--
7
+ A hero canvas that redraws every frame and spends busy=<ms> of main
8
+ thread work on each, so a frame takes about that long to render. A card
9
+ sits about 2900px down, below the window, and the rail link scrolls to it.
10
+ With timed=1 each frame spins on performance.now() instead, as the
11
+ groundwork page did, which never ends once the stepper pauses virtual
12
+ time.
13
+ -->
14
+ <style>
15
+ html {
16
+ scroll-behavior: smooth;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ font-family: sans-serif;
22
+ }
23
+
24
+ nav {
25
+ position: fixed;
26
+ top: 10px;
27
+ right: 10px;
28
+ z-index: 2;
29
+ }
30
+
31
+ nav a {
32
+ display: block;
33
+ padding: 12px 20px;
34
+ background: #fff;
35
+ }
36
+
37
+ canvas {
38
+ display: block;
39
+ width: 100vw;
40
+ height: 100vh;
41
+ }
42
+
43
+ section {
44
+ height: 900px;
45
+ border-top: 1px solid #ccc;
46
+ padding: 40px;
47
+ }
48
+
49
+ .card {
50
+ width: 400px;
51
+ height: 240px;
52
+ background: #ddd;
53
+ }
54
+
55
+ .card:hover {
56
+ background: #f60;
57
+ }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <nav><a id="rail" href="#cards">Cards</a></nav>
62
+ <canvas id="hero"></canvas>
63
+ <section>one</section>
64
+ <section>two</section>
65
+ <section id="cards"><div class="card" id="card">card</div></section>
66
+ <section>four</section>
67
+ <script>
68
+ const params = new URLSearchParams(location.search)
69
+ const busy = Number(params.get('busy') || 0)
70
+ const timed = params.get('timed') === '1'
71
+ const hero = document.getElementById('hero')
72
+ hero.width = innerWidth
73
+ hero.height = innerHeight
74
+ const context = hero.getContext('2d')
75
+ // The work is a count calibrated in real time on load, since a loop
76
+ // timed by performance.now() never ends under a paused virtual clock.
77
+ let sink = 0
78
+ const spin = (count) => {
79
+ for (let i = 0; i < count; i += 1) sink += Math.sqrt(i)
80
+ }
81
+ const measure = (ms) => {
82
+ let count = 0
83
+ const start = performance.now()
84
+ while (performance.now() - start < ms) {
85
+ spin(10000)
86
+ count += 10000
87
+ }
88
+ return count / ms
89
+ }
90
+ // Warmed first, so the count matches the optimized loop a frame runs.
91
+ measure(100)
92
+ const work = Math.round(busy * measure(200))
93
+ const draw = (time) => {
94
+ if (timed) {
95
+ const start = performance.now()
96
+ while (performance.now() - start < busy) {}
97
+ } else {
98
+ spin(work)
99
+ }
100
+ context.fillStyle = `hsl(${(time / 10) % 360}, 60%, 50%)`
101
+ context.fillRect(0, 0, hero.width, hero.height)
102
+ requestAnimationFrame(draw)
103
+ }
104
+ requestAnimationFrame(draw)
105
+ </script>
106
+ </body>
107
+ </html>
@@ -3,6 +3,7 @@ import {
3
3
  FPS,
4
4
  type LogEvent,
5
5
  type Point,
6
+ VIEWPORT,
6
7
  along,
7
8
  travelMs,
8
9
  } from '@/take/log'
@@ -74,13 +75,40 @@ export interface Stage {
74
75
  evaluate<T>(expression: string): Promise<T>
75
76
  }
76
77
 
77
- /** A target that resolved to nothing, which refuses rather than films a guess. */
78
- export class TargetMissing extends Error {
78
+ /** A target the pointer cannot reach, which refuses rather than films a guess. */
79
+ export class TargetRefused extends Error {
79
80
  /** The page as it stood when the lookup failed, as a JPEG, when the route caught one. */
80
81
  still?: Buffer
81
82
 
82
- constructor(readonly target: string) {
83
- super(`nothing on the page matches ${target}`)
83
+ constructor(
84
+ readonly target: string,
85
+ message: string,
86
+ ) {
87
+ super(message)
88
+ }
89
+ }
90
+
91
+ /** A target that resolved to nothing. */
92
+ export class TargetMissing extends TargetRefused {
93
+ constructor(target: string) {
94
+ super(target, `nothing on the page matches ${target}`)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * A target whose center lies outside the window, so the pointer would aim at
100
+ * a point the clip never shows, or for a scroll, a target with no part inside
101
+ * it. A setup step brings it into view first.
102
+ */
103
+ export class TargetOffscreen extends TargetRefused {
104
+ constructor(
105
+ target: string,
106
+ readonly at: Point,
107
+ ) {
108
+ super(
109
+ target,
110
+ `${target} is centered at (${Math.round(at.x)}, ${Math.round(at.y)}), outside the ${VIEWPORT.width}x${VIEWPORT.height} window, so a setup step must bring it into view first`,
111
+ )
84
112
  }
85
113
  }
86
114
 
@@ -155,19 +183,12 @@ export async function performSteps(
155
183
  break
156
184
  }
157
185
  case 'scroll': {
158
- const at = center(await boxOf(stage, step.target))
186
+ const at = await scrollPoint(stage, step.target)
159
187
  await moveTo(stage, state, at)
160
188
  const frames = Math.round(SCROLL_MS / FRAME_MS)
161
- state.log?.push({
162
- t: stage.now(),
163
- kind: 'scroll',
164
- at,
165
- dx: 0,
166
- dy: step.dy,
167
- ms: frames * FRAME_MS,
168
- })
189
+ const start = stage.now()
169
190
  let sent = 0
170
- for (let frame = 1; frame <= frames; frame += 1) {
191
+ await paced(stage, frames, async (frame) => {
171
192
  const want = along(
172
193
  { x: 0, y: 0 },
173
194
  { x: 0, y: step.dy },
@@ -175,7 +196,15 @@ export async function performSteps(
175
196
  ).y
176
197
  await stage.pointer({ type: 'wheel', ...at, dy: want - sent })
177
198
  sent = want
178
- }
199
+ })
200
+ state.log?.push({
201
+ t: start,
202
+ kind: 'scroll',
203
+ at,
204
+ dx: 0,
205
+ dy: step.dy,
206
+ ms: stage.now() - start,
207
+ })
179
208
  break
180
209
  }
181
210
  case 'wait':
@@ -194,21 +223,35 @@ async function moveTo(
194
223
  const from = state.pointer
195
224
  if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
196
225
  const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
197
- state.log?.push({
198
- t: stage.now(),
199
- kind: 'move',
200
- from,
201
- to,
202
- ms: frames * FRAME_MS,
203
- })
204
226
  const pressed = held
205
227
  ? { held: true as const, ...withModifiers(held.modifiers) }
206
228
  : {}
207
- for (let frame = 1; frame <= frames; frame += 1) {
229
+ const start = stage.now()
230
+ await paced(stage, frames, async (frame) => {
208
231
  state.pointer = along(from, to, frame / frames)
209
232
  await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
210
- }
233
+ })
211
234
  state.pointer = to
235
+ state.log?.push({ t: start, kind: 'move', from, to, ms: stage.now() - start })
236
+ }
237
+
238
+ /**
239
+ * Runs a motion planned as `frames` inputs, sending each at the frame the
240
+ * stage's clock has reached, so a page slower than a frame per input skips the
241
+ * points it is behind on and the motion still ends near its planned length. A
242
+ * clock advancing one frame per input, as the stepper's does, gets every frame.
243
+ */
244
+ async function paced(
245
+ stage: Stage,
246
+ frames: number,
247
+ input: (frame: number) => Promise<void>,
248
+ ): Promise<void> {
249
+ const start = stage.now()
250
+ for (let frame = 0; frame < frames;) {
251
+ const reached = Math.round((stage.now() - start) / FRAME_MS)
252
+ frame = Math.min(frames, Math.max(frame + 1, reached + 1))
253
+ await input(frame)
254
+ }
212
255
  }
213
256
 
214
257
  async function click(
@@ -315,12 +358,43 @@ function center(box: Box): Point {
315
358
  return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
316
359
  }
317
360
 
318
- async function boxOf(stage: Stage, target: string): Promise<Box> {
361
+ function inWindow(at: Point): boolean {
362
+ return (
363
+ at.x >= 0 && at.x <= VIEWPORT.width && at.y >= 0 && at.y <= VIEWPORT.height
364
+ )
365
+ }
366
+
367
+ async function foundBox(stage: Stage, target: string): Promise<Box> {
319
368
  const box = await stage.evaluate<Box | null>(boxExpression(target))
320
369
  if (!box) throw new TargetMissing(target)
321
370
  return box
322
371
  }
323
372
 
373
+ async function boxOf(stage: Stage, target: string): Promise<Box> {
374
+ const box = await foundBox(stage, target)
375
+ const at = center(box)
376
+ if (!inWindow(at)) throw new TargetOffscreen(target, at)
377
+ return box
378
+ }
379
+
380
+ /**
381
+ * Aims a wheel at the target's center, or, when that lies outside the window,
382
+ * at the center of the part the window shows. A wheel scrolls whatever sits
383
+ * under it, so any visible point of the target serves, and a page-tall element
384
+ * such as `body` has a center no setup step can bring into view.
385
+ */
386
+ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
387
+ const box = await foundBox(stage, target)
388
+ const at = center(box)
389
+ if (inWindow(at)) return at
390
+ const left = Math.max(box.x, 0)
391
+ const right = Math.min(box.x + box.width, VIEWPORT.width)
392
+ const top = Math.max(box.y, 0)
393
+ const bottom = Math.min(box.y + box.height, VIEWPORT.height)
394
+ if (right <= left || bottom <= top) throw new TargetOffscreen(target, at)
395
+ return { x: (left + right) / 2, y: (top + bottom) / 2 }
396
+ }
397
+
324
398
  /**
325
399
  * Finds a target's box in CSS pixels, reading `text=<words>` as the first
326
400
  * innermost element with a size whose whole text is those words, so a hidden
@@ -7,7 +7,7 @@ import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
7
  import {
8
8
  type PointerInput,
9
9
  type Stage,
10
- TargetMissing,
10
+ TargetRefused,
11
11
  performSteps,
12
12
  } from '@/take/perform'
13
13
  import type { Beat } from '@/take/plan'
@@ -92,13 +92,14 @@ export async function filmScreencast(request: {
92
92
  log: events,
93
93
  })
94
94
  await recording.hold(HOLD_MS)
95
+ const filmedMs = recording.now()
95
96
  await page.screencast.stop()
96
97
 
97
- await transcode(webm, request.clip, FPS)
98
+ await transcode(webm, request.clip, FPS, filmedMs)
98
99
  const frames = await probeFrames(request.clip)
99
100
  return { frames, events, still: await lastFrame(request.clip, frames) }
100
101
  } catch (error) {
101
- if (error instanceof TargetMissing && still) error.still = await still()
102
+ if (error instanceof TargetRefused && still) error.still = await still()
102
103
  throw error
103
104
  } finally {
104
105
  await browser.close()
@@ -5,7 +5,7 @@ import {
5
5
  type KeyInput,
6
6
  type PointerInput,
7
7
  type Stage,
8
- TargetMissing,
8
+ TargetRefused,
9
9
  performSteps,
10
10
  } from '@/take/perform'
11
11
  import type { Beat } from '@/take/plan'
@@ -25,7 +25,10 @@ import { type ShellPage, openShell } from '@/take/shell'
25
25
  * - Frame time continues Chromium's monotonic clock, since a frame stamped on
26
26
  * any other base leaves every CSS animation at its first frame
27
27
  * - No frame is begun before the load event, since one sent mid-navigation can
28
- * go unanswered, and each loading frame is capped so a lost one cannot hang
28
+ * go unanswered
29
+ * - A frame is begun only once the one before it settles, since Chromium
30
+ * refuses a second while one is pending, and a frame left pending past the
31
+ * stall limit falls the beat back rather than hanging it
29
32
  */
30
33
 
31
34
  const FRAME_MS = 1000 / FPS
@@ -41,7 +44,6 @@ export const HOLD_MS = 600
41
44
 
42
45
  const LOAD_CAP_MS = 15_000
43
46
  const SETTLE_MS = 2500
44
- const LOADING_FRAME_CAP_MS = 250
45
47
 
46
48
  /** How long a refusal waits on the frame it shows, since the page may be wedged. */
47
49
  const STILL_CAP_MS = 2000
@@ -81,19 +83,25 @@ export async function filmStepped(
81
83
  let still: (() => Promise<Buffer | undefined>) | undefined
82
84
  try {
83
85
  let ticks = await load(page, request.url)
84
- const beginFrame = (screenshot: boolean) =>
85
- page.send<{ screenshotData?: string }>(
86
- 'HeadlessExperimental.beginFrame',
87
- {
88
- frameTimeTicks: (ticks += FRAME_MS),
89
- interval: FRAME_MS,
90
- ...(screenshot
91
- ? { screenshot: { format: 'jpeg', quality: 95 } }
92
- : {}),
93
- },
86
+ let outstanding: Promise<unknown> = Promise.resolve()
87
+ const beginFrame = (screenshot: boolean) => {
88
+ const reply = outstanding.then(() =>
89
+ page.send<{ screenshotData?: string }>(
90
+ 'HeadlessExperimental.beginFrame',
91
+ {
92
+ frameTimeTicks: (ticks += FRAME_MS),
93
+ interval: FRAME_MS,
94
+ ...(screenshot
95
+ ? { screenshot: { format: 'jpeg', quality: 95 } }
96
+ : {}),
97
+ },
98
+ ),
94
99
  )
100
+ outstanding = reply.catch(() => {})
101
+ return reply
102
+ }
95
103
  still = () => frameStill(beginFrame)
96
- await pump(beginFrame, SETTLE_MS)
104
+ await pump(beginFrame, SETTLE_MS, stallMs)
97
105
 
98
106
  const live = liveStage(page, beginFrame, stallMs)
99
107
  await performSteps(live, request.beat.setup, { pointer: START })
@@ -163,7 +171,7 @@ export async function filmStepped(
163
171
  return { frames, events, still: last }
164
172
  } catch (error) {
165
173
  encoder.abort()
166
- if (error instanceof TargetMissing && still) error.still = await still()
174
+ if (error instanceof TargetRefused && still) error.still = await still()
167
175
  throw error
168
176
  } finally {
169
177
  page.close()
@@ -215,17 +223,22 @@ async function load(page: ShellPage, url: string): Promise<number> {
215
223
  return timestamp
216
224
  }
217
225
 
218
- /** Begins frames in real time so loading, fonts, and layout settle. */
226
+ /**
227
+ * Begins frames in real time so loading, fonts, and layout settle. Each frame
228
+ * is awaited, so a slow page stretches the settle rather than overlapping it.
229
+ */
219
230
  async function pump(
220
231
  beginFrame: (screenshot: boolean) => Promise<unknown>,
221
232
  ms: number,
233
+ stallMs: number,
222
234
  ): Promise<void> {
223
235
  const end = Date.now() + ms
224
236
  while (Date.now() < end) {
225
- await Promise.race([
237
+ await withinStall(
226
238
  beginFrame(false).catch(() => {}),
227
- sleep(LOADING_FRAME_CAP_MS),
228
- ])
239
+ 0,
240
+ stallMs,
241
+ )
229
242
  await sleep(12)
230
243
  }
231
244
  }
@@ -242,7 +255,7 @@ function liveStage(
242
255
  const started = Date.now()
243
256
  return {
244
257
  now: () => Date.now() - started,
245
- hold: (ms) => pump(beginFrame, ms),
258
+ hold: (ms) => pump(beginFrame, ms, stallMs),
246
259
  pointer: (input) => {
247
260
  const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
248
261
  return withinStall(
@@ -94,6 +94,8 @@ interface RawPull {
94
94
  * rather than writing a second collapse beside `rollup`.
95
95
  */
96
96
  export interface RawCheck {
97
+ /** The check-run name, which a ruleset's required context matches. */
98
+ readonly name?: string
97
99
  readonly status?: string
98
100
  readonly conclusion?: string
99
101
  readonly state?: string
@@ -137,6 +139,46 @@ export function rollup(checks: readonly RawCheck[]): ChecksState | null {
137
139
  return running ? 'pending' : 'passing'
138
140
  }
139
141
 
142
+ /** A head's state over the checks the base requires, with failing others named apart. */
143
+ export interface RequiredRollup {
144
+ readonly state: ChecksState | null
145
+ /** Names of checks outside the required set that failed, which never gate. */
146
+ readonly advisory: readonly string[]
147
+ }
148
+
149
+ /**
150
+ * Collapses a head's checks over the set the base branch's rules require.
151
+ *
152
+ * An unread or empty set gates by every check, which is `rollup` itself and
153
+ * the safe default for a repository whose rules list nothing. A required name
154
+ * with no run on the head reads pending rather than passing, since a run not
155
+ * yet created has not cleared anything.
156
+ */
157
+ export function rollupRequired(
158
+ checks: readonly RawCheck[],
159
+ required: ReadonlySet<string> | undefined,
160
+ ): RequiredRollup {
161
+ if (required === undefined || required.size === 0) {
162
+ return { state: rollup(checks), advisory: [] }
163
+ }
164
+
165
+ const isRequired = (check: RawCheck): boolean =>
166
+ check.name !== undefined && required.has(check.name)
167
+ const gating = checks.filter(isRequired)
168
+ const advisory = checks
169
+ .filter((check) => !isRequired(check) && rollup([check]) === 'failing')
170
+ .map((check) => check.name ?? '')
171
+
172
+ const state = rollup(gating)
173
+ const isMissing = [...required].some(
174
+ (name) => !gating.some((check) => check.name === name),
175
+ )
176
+ return {
177
+ state: state !== 'failing' && isMissing ? 'pending' : state,
178
+ advisory,
179
+ }
180
+ }
181
+
140
182
  /**
141
183
  * Reads the heading of the newest pass carrying one, matching on the first line
142
184
  * alone the way `poll.ts` does.