@erclx/canon 5.73.0 → 5.74.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 (54) hide show
  1. package/CHANGELOG.md +23 -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-skill/references/skill-practice.md +5 -0
  8. package/claude/skills/deprecation-migration/references/adopted.md +5 -3
  9. package/claude/skills/design-pick/references/board.md +2 -2
  10. package/claude/skills/plan-feature/SKILL.md +2 -1
  11. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  12. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
  13. package/claude/skills/pr-evidence/SKILL.md +2 -0
  14. package/claude/skills/pr-evidence/references/evidence.md +11 -4
  15. package/claude/skills/{ui-film/references/pr-clip.md → pr-evidence/references/reel.md} +21 -21
  16. package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
  17. package/claude/skills/proposal-critique/SKILL.md +126 -0
  18. package/claude/skills/review-craft/references/adopted.md +4 -4
  19. package/claude/skills/search-craft/references/adopted.md +9 -7
  20. package/claude/skills/systematic-debugging/references/adopted.md +4 -2
  21. package/claude/skills/teach-craft/references/adopted.md +8 -6
  22. package/claude/skills/test-craft/references/adopted.md +14 -12
  23. package/claude/skills/test-first/SKILL.md +1 -1
  24. package/claude/skills/test-first/references/adopted.md +9 -5
  25. package/claude/skills/ui-film/REQUIREMENT.md +4 -5
  26. package/claude/skills/ui-film/SKILL.md +13 -12
  27. package/claude/skills/ui-film/references/compose-craft.md +2 -2
  28. package/claude/skills/ui-film/references/handoff.md +2 -7
  29. package/claude/skills/write-human/references/adopted.md +8 -3
  30. package/docs/target/index.md +1 -0
  31. package/docs/target/outside-tools.md +39 -0
  32. package/docs/workflow/skill-map.md +1 -0
  33. package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
  34. package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
  35. package/governance/ledger/skills/search-craft/SKILL.md +54 -0
  36. package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
  37. package/governance/ledger/skills/test-first/SKILL.md +28 -0
  38. package/governance/ledger/skills/write-human/SKILL.md +62 -0
  39. package/governance/ledger/skills/write-human/density.md +36 -0
  40. package/governance/ledger/skills/write-human/machine-tells.md +30 -0
  41. package/package.json +1 -1
  42. package/scripts/ci/docs-only.sh +24 -0
  43. package/src/claude/cases/misc.ts +14 -0
  44. package/src/claude/skills-audit.ts +89 -8
  45. package/src/commands/pr.ts +110 -13
  46. package/src/gate/measures.ts +1 -1
  47. package/src/gate/sequencer.ts +7 -1
  48. package/src/gate/stages.ts +36 -24
  49. package/src/markdown/scope.ts +2 -0
  50. package/src/pr/checks.ts +75 -3
  51. package/src/pr/evidence.ts +62 -1
  52. package/src/pr/lift.ts +23 -2
  53. package/src/pr/merge.ts +13 -3
  54. package/src/targets/pulls.ts +42 -0
@@ -23,6 +23,8 @@ import {
23
23
  import {
24
24
  type CheckRunListing,
25
25
  collapseChecks,
26
+ type RawStatus,
27
+ requiredChecks,
26
28
  withMergeState,
27
29
  } from '@/pr/checks'
28
30
  import {
@@ -33,6 +35,7 @@ import {
33
35
  findEvidenceLocal,
34
36
  findEvidencePreview,
35
37
  findEvidenceReel,
38
+ findEvidenceSkips,
36
39
  type EvidenceItem,
37
40
  type EvidenceReading,
38
41
  groupEvidence,
@@ -171,6 +174,8 @@ interface EvidenceOptions extends ReadOptions {
171
174
  readonly checklist?: string
172
175
  readonly local?: string
173
176
  readonly reel?: string
177
+ readonly localSkip?: string
178
+ readonly reelSkip?: string
174
179
  readonly check?: boolean
175
180
  }
176
181
 
@@ -433,7 +438,7 @@ type EvidenceRefusal =
433
438
 
434
439
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
435
440
  'check-writes':
436
- '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, or --reel. Run the check and the render as two calls.',
441
+ '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
437
442
  'gh-failed':
438
443
  'gh could not answer for this repository. Name the pull request number.',
439
444
  'unreadable-checklist':
@@ -775,6 +780,14 @@ export function register(program: Command): void {
775
780
  '--reel <link>',
776
781
  'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
777
782
  )
783
+ .option(
784
+ '--local-skip <reason>',
785
+ 'Name why no local address is posted, from the reason pr-evidence holds for canon pr local',
786
+ )
787
+ .option(
788
+ '--reel-skip <reason>',
789
+ 'Name why no reel is embedded, from the reason pr-evidence holds for canon pr frames --reel',
790
+ )
778
791
  .option(
779
792
  '--check',
780
793
  'Report which post-pull-request steps the thread is still owed, rendering nothing',
@@ -854,6 +867,17 @@ export function register(program: Command): void {
854
867
  'current, and a passed one replaces it. Like --local it rides on a',
855
868
  'checklist or a comparison and never opens a comment alone.',
856
869
  '',
870
+ '--local-skip and --reel-skip each put one line under the addresses,',
871
+ '**No local preview:** or **No reel:** followed by the text passed, a',
872
+ 'reason code and any one-line message joined to it, folded onto one',
873
+ 'line. Without the flag, a skip line the marked comment already carries',
874
+ 'is carried forward. A skip line is dropped whenever its address or',
875
+ 'reel renders, passed or carried, so a later run that posts the link or',
876
+ 'films the reel replaces it. A passed --local-skip drops a carried',
877
+ 'local address, since that line names no head and would read as live,',
878
+ 'while a carried reel names its head and stays beside --reel-skip. Like',
879
+ '--local, neither opens a comment alone.',
880
+ '',
857
881
  "A render at a new head also resets the pull request body's UI pass",
858
882
  'line, the one `canon pr tick` writes under ## Testing, to unchecked',
859
883
  'naming that head. It touches no other body text, leaves a line already',
@@ -893,7 +917,8 @@ export function register(program: Command): void {
893
917
  'request changed, and the marked comment carries no **Preview:**',
894
918
  'address. A failed deploy reads the same as a skipped one.',
895
919
  'The record carries `owed` and the same marked fields. --check refuses',
896
- 'with --preview, --local, --checklist, or --reel as check-writes.',
920
+ 'with --preview, --local, --checklist, --reel, --local-skip, or',
921
+ '--reel-skip as check-writes.',
897
922
  '',
898
923
  'Refusals, read off `reason`:',
899
924
  ' unreadable-checklist --checklist named a path that could not be read,',
@@ -917,6 +942,7 @@ export function register(program: Command): void {
917
942
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
918
943
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
919
944
  ' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
945
+ " canon pr evidence 1341 --reel-skip 'no-server: <message>' --json",
920
946
  ' canon pr evidence 1341 --check --json',
921
947
  '',
922
948
  ].join('\n'),
@@ -960,7 +986,12 @@ export function register(program: Command): void {
960
986
  ' or needing eyes refuses `ui-undriven`, carried or not',
961
987
  ' evidence the evidence check reads settled',
962
988
  ' standing the head is fresh against the base, not stale or conflicted',
963
- ' ci every check run on the tip passed, waited on up to --wait',
989
+ ' ci every required check run on the tip passed, waited on up to --wait',
990
+ '',
991
+ "The required checks are the ones the base branch's rules list. A",
992
+ 'check outside them is waited on by nobody, and one that failed is',
993
+ 'named in the record as `advisory` without blocking. Rules that do not',
994
+ 'read, or that list no check, gate by every check run on the tip.',
964
995
  '',
965
996
  'Review state, UI state, evidence, and standing are read before the CI',
966
997
  'wait, so a reviewer whose partner is still driving refuses at once. The',
@@ -1039,6 +1070,10 @@ export function register(program: Command): void {
1039
1070
  'request already passed `canon pr lift`. It does not wait on checks or',
1040
1071
  'delete the branch.',
1041
1072
  '',
1073
+ "The checks gate by the ones the base branch's rules require, the way",
1074
+ '`canon pr lift` reads them. A failing check outside that set is named',
1075
+ 'in the record as `advisory` and does not refuse the merge.',
1076
+ '',
1042
1077
  'Reasons:',
1043
1078
  ' merged, dry-run (--dry-run)',
1044
1079
  ' pull-closed, pull-draft, missing-summary, missing-key-changes,',
@@ -2658,7 +2693,9 @@ async function runEvidence(
2658
2693
  (opts.preview !== undefined ||
2659
2694
  opts.local !== undefined ||
2660
2695
  opts.checklist !== undefined ||
2661
- opts.reel !== undefined)
2696
+ opts.reel !== undefined ||
2697
+ opts.localSkip !== undefined ||
2698
+ opts.reelSkip !== undefined)
2662
2699
  ) {
2663
2700
  return refuseWith(
2664
2701
  'check-writes',
@@ -2868,8 +2905,17 @@ async function runEvidence(
2868
2905
  }
2869
2906
 
2870
2907
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
2871
- const local = opts.local ?? carriedLocal
2908
+ // A carried local line names no head and reads as live, so a passed skip
2909
+ // drops it the way a passed address replaces it. A carried reel names the
2910
+ // head it was filmed at, so it stays beside a passed skip.
2911
+ const local =
2912
+ opts.local ?? (opts.localSkip === undefined ? carriedLocal : undefined)
2872
2913
  const reel = nextEvidenceReel(opts.reel, identity.head, comments)
2914
+ const carriedSkips = findEvidenceSkips(comments)
2915
+ const skips = {
2916
+ local: opts.localSkip ?? carriedSkips.local,
2917
+ reel: opts.reelSkip ?? carriedSkips.reel,
2918
+ }
2873
2919
  const unsettled = suppliedChecklist ?? carriedChecklist
2874
2920
  const checklist =
2875
2921
  unsettled === undefined
@@ -2886,6 +2932,7 @@ async function runEvidence(
2886
2932
  checklist,
2887
2933
  local,
2888
2934
  reel,
2935
+ skips,
2889
2936
  )
2890
2937
 
2891
2938
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -4209,24 +4256,64 @@ async function readChecksGate(
4209
4256
  number: string,
4210
4257
  tip: string,
4211
4258
  ): Promise<ChecksGate | undefined> {
4212
- const [listed, pulled] = await Promise.all([
4259
+ // A status read that fails leaves a required status context unseen, which
4260
+ // reads pending rather than passing, so it needs no refusal of its own.
4261
+ const [listed, pulled, combined] = await Promise.all([
4213
4262
  gh(root, [
4214
4263
  'api',
4215
4264
  `repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
4216
4265
  ]),
4217
4266
  gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
4267
+ gh(root, [
4268
+ 'api',
4269
+ `repos/{owner}/{repo}/commits/${tip}/status?per_page=100`,
4270
+ ]),
4218
4271
  ])
4219
4272
  if (listed === null) return undefined
4220
4273
  try {
4221
- const mergeState =
4222
- pulled === null
4223
- ? undefined
4224
- : mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
4274
+ const row = pulled === null ? undefined : (JSON.parse(pulled) as RestPull)
4275
+ const required = await readRequiredChecks(root, row?.base?.ref)
4276
+ const statuses =
4277
+ combined === null
4278
+ ? []
4279
+ : ((JSON.parse(combined) as { statuses?: readonly RawStatus[] })
4280
+ .statuses ?? [])
4225
4281
  const reading = withMergeState(
4226
- mergeState,
4227
- collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
4282
+ mergeStateOf(row?.mergeable_state),
4283
+ collapseChecks(
4284
+ tip,
4285
+ JSON.parse(listed) as CheckRunListing,
4286
+ required,
4287
+ statuses,
4288
+ ),
4228
4289
  )
4229
- return { state: reading.state, isConflicted: reading.conflicted }
4290
+ return {
4291
+ state: reading.state,
4292
+ isConflicted: reading.conflicted,
4293
+ ...(reading.advisory !== undefined && { advisory: reading.advisory }),
4294
+ }
4295
+ } catch {
4296
+ return undefined
4297
+ }
4298
+ }
4299
+
4300
+ /**
4301
+ * The checks the base branch's rules require, so a check they leave out runs
4302
+ * without holding the gate. Undefined on any read that fails, which gates by
4303
+ * every check as the verbs did before the rules were read.
4304
+ */
4305
+ async function readRequiredChecks(
4306
+ root: string,
4307
+ base: string | undefined,
4308
+ ): Promise<ReadonlySet<string> | undefined> {
4309
+ if (base === undefined || base === '') return undefined
4310
+ const rules = await gh(root, [
4311
+ 'api',
4312
+ `repos/{owner}/{repo}/rules/branches/${base}`,
4313
+ ])
4314
+ if (rules === null) return undefined
4315
+ try {
4316
+ return requiredChecks(JSON.parse(rules))
4230
4317
  } catch {
4231
4318
  return undefined
4232
4319
  }
@@ -4360,6 +4447,7 @@ async function runLift(
4360
4447
  for (const gate of record.gates) {
4361
4448
  if (!gate.isOk) logWarn(`${gate.gate}: ${gate.reason ?? 'not clear'}`)
4362
4449
  }
4450
+ warnAdvisory(record.advisory)
4363
4451
  outro()
4364
4452
 
4365
4453
  if (emitJson) {
@@ -4371,6 +4459,14 @@ async function runLift(
4371
4459
  return record.isOk ? 0 : 1
4372
4460
  }
4373
4461
 
4462
+ function warnAdvisory(advisory: readonly string[] | undefined): void {
4463
+ for (const name of advisory ?? []) {
4464
+ logWarn(
4465
+ `advisory: ${name} failed, which the base's rules do not require, so it did not block`,
4466
+ )
4467
+ }
4468
+ }
4469
+
4374
4470
  const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
4375
4471
  'gh-missing': PULL_REFUSALS['gh-missing'],
4376
4472
  'pull-closed': 'The pull request is closed, so there is nothing to merge.',
@@ -4516,6 +4612,7 @@ async function runMerge(
4516
4612
  ? `#${record.number} would merge with the message below`
4517
4613
  : MERGE_REFUSALS[record.reason],
4518
4614
  )
4615
+ warnAdvisory(record.advisory)
4519
4616
  outro()
4520
4617
 
4521
4618
  if (emitJson) {
@@ -1111,7 +1111,7 @@ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
1111
1111
 
1112
1112
  /**
1113
1113
  * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
1114
- * copy of the eight path globs a landing-page change should trigger on, plus
1114
+ * copy of the five path globs a landing-page change should trigger on, plus
1115
1115
  * the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
1116
1116
  * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
1117
1117
  * anchor cannot cross files here, so the two copies are read live and compared
@@ -386,7 +386,13 @@ async function runCheck(
386
386
  }
387
387
 
388
388
  const run = check.kind === 'cli' ? ctx.cli : ctx.run
389
- const result = await run(check.argv, env)
389
+ const argv =
390
+ check.kind === 'command' &&
391
+ check.changedArgv !== undefined &&
392
+ ctx.changed !== undefined
393
+ ? check.changedArgv(ctx.changed)
394
+ : check.argv
395
+ const result = await run(argv, env)
390
396
  return {
391
397
  emissions: [{ kind: 'output', text: result.all }],
392
398
  failure: result.exitCode === 0 ? undefined : check.failure,
@@ -45,6 +45,12 @@ export type Check =
45
45
  | {
46
46
  readonly kind: 'command'
47
47
  readonly argv: readonly string[]
48
+ /**
49
+ * Builds the argv from the changed set when scoping is on. `argv` runs
50
+ * where it is off, so `--all` and every widening fallback keep the full
51
+ * command.
52
+ */
53
+ readonly changedArgv?: (changed: readonly string[]) => readonly string[]
48
54
  readonly failure: string
49
55
  }
50
56
  /** This checkout's own CLI, so a worktree gate never reads the main tree. */
@@ -92,33 +98,36 @@ export interface Stage {
92
98
  }
93
99
 
94
100
  /**
95
- * Corpora a `src/` test asserts over from outside `src/`. Nothing compares
96
- * this list to the tests reading those paths, so a test that starts reading a
97
- * new corpus joins it here in the same change, or a branch touching only that
98
- * corpus skips the Tests stage. The first four are directory prefixes because
99
- * their tests walk the tree whole, which is what reaches a rule or a skill a
100
- * branch adds rather than edits.
101
+ * Files a scoped run hands to `vitest related`, which follows imports from
102
+ * them to the tests that reach them. A corpus a test reads from disk is not
103
+ * among them, so a Markdown-only push runs no tests locally and CI's full run
104
+ * is what catches a broken corpus reader.
101
105
  */
102
- export const TEST_CORPORA_PATTERNS = [
103
- '^claude/skills/',
104
- '^governance/rules/',
105
- '^\\.claude/hooks/',
106
- '^tooling/claude/seeds/\\.claude/hooks/',
107
- '^tooling/base/reference\\.md$',
108
- '^tooling/web/configs/scripts/worktree-port\\.sh$',
109
- ]
106
+ const RELATED_SOURCES = /\.tsx?$|^commitlint\.config\.js$/
107
+
108
+ /** Files every test depends on without importing, so no related run sees them. */
109
+ const FULL_SUITE_TRIGGERS =
110
+ /^vitest\.(config|setup|global-setup)\.ts$|^tsconfig\.json$|^package\.json$/
110
111
 
111
112
  const TESTS_SCOPE = new RegExp(
112
- [
113
- '^src/',
114
- '^vitest\\.config\\.ts$',
115
- '^tsconfig\\.json$',
116
- '^package\\.json$',
117
- '^commitlint\\.config\\.(js|test\\.ts)$',
118
- ...TEST_CORPORA_PATTERNS,
119
- ].join('|'),
113
+ `${RELATED_SOURCES.source}|${FULL_SUITE_TRIGGERS.source}`,
120
114
  )
121
115
 
116
+ const FULL_SUITE = ['bun', 'run', 'test']
117
+
118
+ function relatedTests(changed: readonly string[]): readonly string[] {
119
+ if (changed.some((file) => FULL_SUITE_TRIGGERS.test(file))) return FULL_SUITE
120
+ return [
121
+ 'bun',
122
+ '--bun',
123
+ 'vitest',
124
+ 'related',
125
+ '--run',
126
+ '--passWithNoTests',
127
+ ...changed.filter((file) => RELATED_SOURCES.test(file)),
128
+ ]
129
+ }
130
+
122
131
  /**
123
132
  * Every stage the gate runs, in the order it runs them.
124
133
  *
@@ -590,14 +599,17 @@ export const STAGES: readonly Stage[] = [
590
599
  id: 'tests',
591
600
  label: 'Tests',
592
601
  scope: TESTS_SCOPE,
593
- skipped: 'Skipped, no TypeScript or asserted-corpus changes',
602
+ skipped: 'Skipped, no TypeScript or test config changes',
594
603
  // Concurrent suites from a wave of worktrees starve each other's spawn
595
604
  // and browser tests past their timeouts, so the box runs one at a time.
605
+ // A related run can still reach a spawn or browser test, so it holds the
606
+ // lock too.
596
607
  lock: 'tests',
597
608
  checks: [
598
609
  {
599
610
  kind: 'command',
600
- argv: ['bun', 'run', 'test'],
611
+ argv: FULL_SUITE,
612
+ changedArgv: relatedTests,
601
613
  failure: 'Tests failed',
602
614
  },
603
615
  ],
@@ -280,6 +280,8 @@ export const GATED_PATHS: readonly string[] = [
280
280
  'claude/skills/pr-evidence/REQUIREMENT.md',
281
281
  'claude/skills/pr-evidence/SKILL.md',
282
282
  'claude/skills/pr-evidence/references/evidence.md',
283
+ 'claude/skills/proposal-critique/REQUIREMENT.md',
284
+ 'claude/skills/proposal-critique/SKILL.md',
283
285
  'claude/skills/review-address/REQUIREMENT.md',
284
286
  'claude/skills/review-address/SKILL.md',
285
287
  'claude/skills/review-address/references/post-review-findings.md',
package/src/pr/checks.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { type ChecksState, type RawCheck, rollup } from '@/targets/pulls'
1
+ import {
2
+ type ChecksState,
3
+ type RawCheck,
4
+ rollupRequired,
5
+ } from '@/targets/pulls'
2
6
 
3
7
  /** One row of `repos/{owner}/{repo}/commits/<sha>/check-runs`. */
4
8
  export interface RawCheckRun {
@@ -9,6 +13,12 @@ export interface RawCheckRun {
9
13
  readonly id?: number
10
14
  }
11
15
 
16
+ /** One row of `repos/{owner}/{repo}/commits/<sha>/status`, newest per context already. */
17
+ export interface RawStatus {
18
+ readonly context?: string
19
+ readonly state?: string
20
+ }
21
+
12
22
  /** What that endpoint returns, count and rows apart. */
13
23
  export interface CheckRunListing {
14
24
  readonly total_count?: number
@@ -27,6 +37,41 @@ export interface ChecksReading {
27
37
  readonly reported: number
28
38
  /** Runs `matched` carried for a check name this reading already kept a newer run for. */
29
39
  readonly collapsed: number
40
+ /**
41
+ * Failing checks outside the required set, present only when a required set
42
+ * was read, so a reading over every check keeps its old shape.
43
+ */
44
+ readonly advisory?: readonly string[]
45
+ }
46
+
47
+ /**
48
+ * Reads the check names a base branch's rules require, from
49
+ * `repos/{owner}/{repo}/rules/branches/<base>`.
50
+ *
51
+ * Undefined when the answer is not a rule list, which a repository without
52
+ * rulesets or a refused read returns, so the caller falls back to every check.
53
+ * Classic branch protection is not read, and falls back the same way.
54
+ */
55
+ export function requiredChecks(
56
+ rules: unknown,
57
+ ): ReadonlySet<string> | undefined {
58
+ if (!Array.isArray(rules)) return undefined
59
+
60
+ const names = new Set<string>()
61
+ for (const rule of rules as readonly RequiredRule[]) {
62
+ if (rule?.type !== 'required_status_checks') continue
63
+ for (const check of rule.parameters?.required_status_checks ?? []) {
64
+ if (typeof check.context === 'string') names.add(check.context)
65
+ }
66
+ }
67
+ return names
68
+ }
69
+
70
+ interface RequiredRule {
71
+ readonly type?: string
72
+ readonly parameters?: {
73
+ readonly required_status_checks?: readonly { readonly context?: unknown }[]
74
+ }
30
75
  }
31
76
 
32
77
  export interface ConflictedReading extends ChecksReading {
@@ -68,6 +113,7 @@ export function withMergeState(
68
113
  */
69
114
  function adapt(run: RawCheckRun): RawCheck {
70
115
  return {
116
+ ...(run.name !== undefined && { name: run.name }),
71
117
  ...(run.status !== undefined && { status: run.status.toUpperCase() }),
72
118
  ...(run.conclusion !== undefined &&
73
119
  run.conclusion !== null && {
@@ -125,10 +171,22 @@ function newestPerCheck(runs: readonly RawCheckRun[]): {
125
171
  * rolls up from `newestPerCheck`'s kept rows rather than from `matched`
126
172
  * directly, so a stale failing run left behind by a rerun no longer outvotes
127
173
  * the passing run beside it.
174
+ *
175
+ * A required set narrows only that last rollup. The foreign and partial
176
+ * listing guards still read every run, since either says the set in hand is
177
+ * incomplete whichever checks gate.
178
+ *
179
+ * A ruleset can require a commit status, which the check-run listing never
180
+ * carries, so the statuses join the rollup by `context` once a non-empty
181
+ * required set was read. An unread or empty set keeps them out, since the
182
+ * every-check fallback is the reading these verbs gave before rules were read
183
+ * and it saw no status.
128
184
  */
129
185
  export function collapseChecks(
130
186
  tip: string,
131
187
  listing: CheckRunListing,
188
+ required?: ReadonlySet<string>,
189
+ statuses: readonly RawStatus[] = [],
132
190
  ): ChecksReading {
133
191
  const runs = listing.check_runs ?? []
134
192
  const matched = runs.filter((run) => run.head_sha === tip)
@@ -139,6 +197,7 @@ export function collapseChecks(
139
197
  foreign: runs.length - matched.length,
140
198
  reported: listing.total_count ?? runs.length,
141
199
  collapsed,
200
+ ...(required !== undefined && { advisory: [] }),
142
201
  }
143
202
 
144
203
  if (reading.foreign > 0) return { ...reading, state: 'pending' }
@@ -149,7 +208,20 @@ export function collapseChecks(
149
208
  // sitting outside of, which is the false green keyed on a sha alone.
150
209
  if (reading.reported > runs.length) return { ...reading, state: 'pending' }
151
210
 
152
- if (matched.length === 0) return { ...reading, state: 'pending' }
211
+ const statusRows: RawCheck[] =
212
+ required === undefined || required.size === 0
213
+ ? []
214
+ : statuses.map((status) => ({
215
+ ...(status.context !== undefined && { name: status.context }),
216
+ state: (status.state ?? '').toUpperCase(),
217
+ }))
218
+ const rows = [...kept.map(adapt), ...statusRows]
219
+ if (rows.length === 0) return { ...reading, state: 'pending' }
153
220
 
154
- return { ...reading, state: rollup(kept.map(adapt)) ?? 'pending' }
221
+ const { state, advisory } = rollupRequired(rows, required)
222
+ return {
223
+ ...reading,
224
+ state: state ?? 'pending',
225
+ ...(required !== undefined && { advisory }),
226
+ }
155
227
  }
@@ -25,6 +25,12 @@ const REEL_PREFIX = '**Reel** filmed at'
25
25
  const REEL_LINE =
26
26
  /^\*\*Reel\*\* filmed at `([0-9a-f]{7,40})`:<br><img src="([^"]+)"/
27
27
 
28
+ /** How the line naming why no local address was posted opens. */
29
+ const LOCAL_SKIP_PREFIX = '**No local preview:**'
30
+
31
+ /** How the line naming why no reel was filmed opens. */
32
+ const REEL_SKIP_PREFIX = '**No reel:**'
33
+
28
34
  /** The width a reel is filmed at, which it asks for in the comment. */
29
35
  const REEL_WIDTH = 960
30
36
 
@@ -198,6 +204,15 @@ function escapeCell(text: string): string {
198
204
  .replaceAll('|', '&#124;')
199
205
  }
200
206
 
207
+ function unescapeCell(text: string): string {
208
+ return text
209
+ .replaceAll('&#124;', '|')
210
+ .replaceAll('&quot;', '"')
211
+ .replaceAll('&gt;', '>')
212
+ .replaceAll('&lt;', '<')
213
+ .replaceAll('&amp;', '&')
214
+ }
215
+
201
216
  function evidenceImage(url: string, alt: string, width?: number): string {
202
217
  const shown = Math.min(width ?? IMAGE_WIDTH, IMAGE_WIDTH)
203
218
  return `<img src="${url}" width="${shown}" alt="${alt}">`
@@ -449,6 +464,18 @@ function reelLine(reel: EvidenceReel): string {
449
464
  return `${REEL_PREFIX} \`${head}\`:<br><img src="${escapeCell(reel.link)}" width="${REEL_WIDTH}" alt="reel filmed at ${head}">`
450
465
  }
451
466
 
467
+ /** Why a render carries no local address or no reel, each a reason code and any one-line message the caller joined to it. */
468
+ export interface EvidenceSkips {
469
+ readonly local?: string
470
+ readonly reel?: string
471
+ }
472
+
473
+ /** A skip line, or nothing for an empty reason. A reason is folded onto one line, since a line break would end the address region early. */
474
+ function skipLines(prefix: string, reason: string | undefined): string[] {
475
+ const text = reason?.replace(/\s+/g, ' ').trim() ?? ''
476
+ return text === '' ? [] : [`${prefix} ${escapeCell(text)}`, '']
477
+ }
478
+
452
479
  export function evidenceMarker(head: string): string {
453
480
  return `${MARKER_PREFIX} head=${head} -->`
454
481
  }
@@ -480,6 +507,10 @@ export function evidenceMarker(head: string): string {
480
507
  *
481
508
  * A reel sits on its own line under the addresses, labeled with the head it
482
509
  * was filmed at, so a reel carried past a later push never reads as current.
510
+ *
511
+ * A skip names why the local address or the reel is missing, on its own line
512
+ * where that address or reel would sit. Each is dropped whenever its address
513
+ * or reel renders, carried or passed, so a skip never outlives its cause.
483
514
  */
484
515
  export function renderEvidenceBody(
485
516
  states: readonly EvidenceState[],
@@ -490,6 +521,7 @@ export function renderEvidenceBody(
490
521
  checklist?: string,
491
522
  local?: string,
492
523
  reel?: EvidenceReel,
524
+ skips: EvidenceSkips = {},
493
525
  ): string {
494
526
  const segments = [
495
527
  ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
@@ -499,7 +531,10 @@ export function renderEvidenceBody(
499
531
  EVIDENCE_HEADING,
500
532
  '',
501
533
  ...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
502
- ...(reel === undefined ? [] : [reelLine(reel), '']),
534
+ ...(local === undefined ? skipLines(LOCAL_SKIP_PREFIX, skips.local) : []),
535
+ ...(reel === undefined
536
+ ? skipLines(REEL_SKIP_PREFIX, skips.reel)
537
+ : [reelLine(reel), '']),
503
538
  ]
504
539
  const closing =
505
540
  checklist === undefined
@@ -756,6 +791,32 @@ export function findEvidenceReel(
756
791
  return undefined
757
792
  }
758
793
 
794
+ /**
795
+ * The skips the marked comment already carries, read from the address region
796
+ * only, so a line quoted in a checklist is never taken for one. A re-render
797
+ * passing no skip carries these forward the way it carries an address.
798
+ */
799
+ export function findEvidenceSkips(
800
+ comments: readonly EvidenceComment[],
801
+ ): EvidenceSkips {
802
+ const marked = findMarked(comments)
803
+ if (marked === undefined) return {}
804
+ let local: string | undefined
805
+ let reel: string | undefined
806
+ for (const raw of marked.body.split('\n')) {
807
+ const line = raw.trim()
808
+ if (REGION_ENDS.some((end) => line.startsWith(end))) break
809
+ const ownSkip = readSegment(line, LOCAL_SKIP_PREFIX)
810
+ const reelSkip = readSegment(line, REEL_SKIP_PREFIX)
811
+ if (ownSkip !== undefined) local ??= unescapeCell(ownSkip)
812
+ if (reelSkip !== undefined) reel ??= unescapeCell(reelSkip)
813
+ }
814
+ return {
815
+ ...(local !== undefined && { local }),
816
+ ...(reel !== undefined && { reel }),
817
+ }
818
+ }
819
+
759
820
  /**
760
821
  * The reel a render embeds: a passed link, filmed at this head, replaces
761
822
  * whatever the comment carries, and with none passed the posted reel carries
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