@erclx/canon 5.64.0 → 5.66.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 (47) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/git-pr/references/labels.md +6 -2
  4. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  5. package/claude/skills/git-pr-close/SKILL.md +55 -0
  6. package/claude/skills/git-pr-close/references/close.md +54 -0
  7. package/claude/skills/pr-evidence/SKILL.md +2 -2
  8. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  9. package/claude/skills/role-worker/SKILL.md +2 -2
  10. package/claude/skills/test-craft/references/e2e.md +1 -1
  11. package/docs/workflow/skill-map.md +1 -0
  12. package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
  13. package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
  14. package/governance/ledger/skills/codebase-layout/tests.md +26 -0
  15. package/governance/ledger/skills/review-craft/SKILL.md +61 -0
  16. package/governance/ledger/skills/review-craft/security.md +34 -0
  17. package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
  18. package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
  19. package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
  20. package/governance/ledger/skills/test-craft/SKILL.md +67 -0
  21. package/governance/ledger/skills/test-craft/component.md +49 -0
  22. package/governance/ledger/skills/test-craft/e2e.md +55 -0
  23. package/governance/ledger/skills/test-craft/unit.md +54 -0
  24. package/package.json +3 -3
  25. package/src/claude/cases/git.ts +4 -0
  26. package/src/claude/skills-audit.ts +7 -0
  27. package/src/commands/labels.ts +152 -4
  28. package/src/commands/pr.ts +263 -2
  29. package/src/commands/sandbox.ts +247 -2
  30. package/src/gate/measures.ts +3 -0
  31. package/src/gate/stages.ts +25 -0
  32. package/src/labels/apply.ts +10 -5
  33. package/src/labels/sync.ts +17 -0
  34. package/src/migrate/records.ts +6 -0
  35. package/src/pr/close.ts +152 -0
  36. package/src/pr/health.ts +56 -0
  37. package/src/pr/local.ts +49 -8
  38. package/src/pr/rest.ts +10 -2
  39. package/src/sandbox/replay/expected.ts +137 -0
  40. package/src/sandbox/replay/run.ts +348 -0
  41. package/src/sandbox/replay/verdict.ts +102 -0
  42. package/src/tooling/verify-stack.ts +4 -5
  43. package/tooling/astro/configs/playwright.config.ts +2 -2
  44. package/tooling/base/reference.md +1 -0
  45. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  46. package/tooling/nextjs/configs/playwright.config.ts +2 -2
  47. package/tooling/vite-react/configs/playwright.config.ts +2 -2
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { basename, dirname, join, relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
5
5
  import {
@@ -27,8 +27,24 @@ import {
27
27
  } from '@/sandbox/expect'
28
28
  import { runHeadless } from '@/sandbox/headless/run'
29
29
  import { runSandbox } from '@/sandbox/provision'
30
+ import {
31
+ EXPECTED_FILE,
32
+ expectationOf,
33
+ judgeDrift,
34
+ readExpectation,
35
+ writeExpectation,
36
+ type ReplayExpectation,
37
+ } from '@/sandbox/replay/expected'
38
+ import {
39
+ checkoutRefusal,
40
+ hasFormatter,
41
+ listSnapshots,
42
+ replaySource,
43
+ type ReplayResult,
44
+ type ReplaySource,
45
+ } from '@/sandbox/replay/run'
30
46
  import { listCategories, listScenarioFiles } from '@/sandbox/scenario'
31
- import { sandboxTree } from '@/sandbox/tree'
47
+ import { assertSandboxDirSafe, sandboxRoots, sandboxTree } from '@/sandbox/tree'
32
48
  import {
33
49
  frameError,
34
50
  intro,
@@ -434,6 +450,194 @@ async function runEquivalence(
434
450
  process.exitCode = equivalenceExitCode(record)
435
451
  }
436
452
 
453
+ interface ReplayOptions {
454
+ readonly json?: boolean
455
+ readonly record?: boolean
456
+ }
457
+
458
+ const REPLAY_FINDINGS_SHOWN = 5
459
+
460
+ function refuseReplay(reason: string, message: string, json: boolean): void {
461
+ logError(message)
462
+ if (json)
463
+ process.stdout.write(`${JSON.stringify({ ok: false, reason, message })}\n`)
464
+ outro()
465
+ process.exitCode = 1
466
+ }
467
+
468
+ /**
469
+ * Replays each consumer through a sync with this checkout's CLI and judges
470
+ * the reads that follow. Bare, it replays the frozen snapshots, which is what
471
+ * the gate runs. Given paths, it copies each real checkout, the operator's
472
+ * step before a release. Every source is checked before any replay starts, so
473
+ * a refusal never arrives after half the runs. A snapshot is judged against
474
+ * the answer recorded beside it, since a frozen tree answers the same on every
475
+ * push, and `--record` rewrites that answer from a run that broke nothing.
476
+ */
477
+ function runReplay(paths: string[], options: ReplayOptions): void {
478
+ if (reportAbsentScenarioTree()) return
479
+
480
+ intro('canon sandbox replay')
481
+ const json = options.json === true
482
+ const record = options.record === true
483
+
484
+ if (record && paths.length > 0) {
485
+ refuseReplay(
486
+ 'record-checkout',
487
+ 'A named checkout moves between runs, so --record pins snapshots only. Drop the paths.',
488
+ json,
489
+ )
490
+ return
491
+ }
492
+
493
+ if (!hasFormatter(PROJECT_ROOT)) {
494
+ refuseReplay(
495
+ 'no-formatter',
496
+ 'No oxfmt under node_modules, so the format step cannot run. Run bun install in this checkout.',
497
+ json,
498
+ )
499
+ return
500
+ }
501
+
502
+ let sources: ReplaySource[]
503
+ if (paths.length === 0) {
504
+ sources = listSnapshots(join(SANDBOX_DIR, 'fixtures', 'replay'))
505
+ if (sources.length === 0) {
506
+ refuseReplay(
507
+ 'no-snapshots',
508
+ 'No snapshot under sandbox/fixtures/replay/',
509
+ json,
510
+ )
511
+ return
512
+ }
513
+ } else {
514
+ sources = []
515
+ for (const raw of paths) {
516
+ const path = resolve(process.cwd(), raw)
517
+ const refusal = checkoutRefusal(path)
518
+ if (refusal !== undefined) {
519
+ refuseReplay(refusal.reason, refusal.message, json)
520
+ return
521
+ }
522
+ sources.push({ name: basename(path), path, kind: 'checkout' })
523
+ }
524
+ }
525
+
526
+ // Beside the provisioned tree rather than inside it, since `canon sandbox
527
+ // clean` wipes that one and a concurrent replay would lose its tree.
528
+ const tempRoot = join(dirname(sandboxTree()), 'replay')
529
+ const unsafe = assertSandboxDirSafe(
530
+ join(tempRoot, 'tree'),
531
+ sandboxRoots(PROJECT_ROOT),
532
+ )
533
+ if (unsafe !== undefined) {
534
+ refuseReplay('unsafe-root', unsafe, json)
535
+ return
536
+ }
537
+
538
+ const expectations = new Map<string, ReplayExpectation | undefined>()
539
+ if (!record)
540
+ for (const source of sources) {
541
+ if (source.kind !== 'snapshot') continue
542
+ try {
543
+ expectations.set(source.name, readExpectation(source.path))
544
+ } catch (error) {
545
+ refuseReplay('bad-expectation', (error as Error).message, json)
546
+ return
547
+ }
548
+ }
549
+
550
+ const replays: ReplayResult[] = []
551
+ for (const source of sources) {
552
+ const run = replaySource(source, {
553
+ checkoutRoot: PROJECT_ROOT,
554
+ tempRoot,
555
+ })
556
+ const result: ReplayResult =
557
+ source.kind === 'snapshot' && !record
558
+ ? {
559
+ ...run,
560
+ steps: judgeDrift(run.steps, expectations.get(source.name)),
561
+ }
562
+ : run
563
+ replays.push(result)
564
+
565
+ logStep(`${result.name} (${result.kind})`)
566
+ for (const step of result.steps) {
567
+ if (step.verdict === 'ok') logInfo(`${step.step}, exit ${step.exit}`)
568
+ else
569
+ logError(`${result.name}: ${step.step} ${step.verdict}: ${step.detail}`)
570
+ // A sync over an old layout prints a line per rule it moves, so the
571
+ // frame shows the first few and the record carries every one.
572
+ if (json) continue
573
+ for (const finding of step.findings.slice(0, REPLAY_FINDINGS_SHOWN))
574
+ logWarn(`${step.step}: ${finding}`)
575
+ const hidden = step.findings.length - REPLAY_FINDINGS_SHOWN
576
+ if (hidden > 0)
577
+ logWarn(`${step.step}: ${hidden} more, listed under --json`)
578
+ }
579
+ }
580
+
581
+ const failed = replays
582
+ .flatMap((r) => r.steps)
583
+ .filter((s) => s.verdict !== 'ok')
584
+
585
+ if (record) {
586
+ recordReplays(sources, replays, failed.length, json)
587
+ return
588
+ }
589
+
590
+ logStep('Replay')
591
+ const summary = `${replays.length} replayed, ${failed.length} failed steps`
592
+ if (failed.length === 0) logInfo(summary)
593
+ else logWarn(summary)
594
+
595
+ if (json)
596
+ process.stdout.write(
597
+ `${JSON.stringify({ ok: true, failed: failed.length, replays })}\n`,
598
+ )
599
+
600
+ outro()
601
+ process.exitCode = failed.length === 0 ? 0 : 2
602
+ }
603
+
604
+ /**
605
+ * Writes each snapshot's expectation from its run, or refuses and writes none
606
+ * when any step broke, since pinning a crash would make it the expected answer.
607
+ */
608
+ function recordReplays(
609
+ sources: readonly ReplaySource[],
610
+ replays: readonly ReplayResult[],
611
+ broken: number,
612
+ json: boolean,
613
+ ): void {
614
+ if (broken > 0) {
615
+ refuseReplay(
616
+ 'broken-run',
617
+ `${broken} steps broke, so nothing was recorded. Fix the run, then record it.`,
618
+ json,
619
+ )
620
+ return
621
+ }
622
+
623
+ logStep('Record')
624
+ const recorded: string[] = []
625
+ for (const [i, source] of sources.entries()) {
626
+ const replay = replays[i]
627
+ if (replay === undefined) continue
628
+ writeExpectation(source.path, expectationOf(replay.steps))
629
+ const path = relative(PROJECT_ROOT, join(source.path, EXPECTED_FILE))
630
+ recorded.push(path)
631
+ logInfo(path)
632
+ }
633
+
634
+ if (json)
635
+ process.stdout.write(`${JSON.stringify({ ok: true, recorded, replays })}\n`)
636
+
637
+ outro()
638
+ process.exitCode = 0
639
+ }
640
+
437
641
  function runCheck(
438
642
  target: string,
439
643
  arm: string | undefined,
@@ -683,4 +887,45 @@ export function register(program: Command): void {
683
887
  .action((targets: string[], options: EquivalenceOptions) =>
684
888
  runEquivalence(targets, options),
685
889
  )
890
+
891
+ sandbox
892
+ .command('replay')
893
+ .description(
894
+ 'Sync a copy of each consumer with this checkout and judge the reads that follow',
895
+ )
896
+ .argument(
897
+ '[paths...]',
898
+ 'Consumer checkouts to copy, defaulting to every snapshot under sandbox/fixtures/replay/',
899
+ )
900
+ .helpOption('-h, --help', 'Show this help message')
901
+ .option('--json', 'Emit the record as JSON on stdout')
902
+ .option(
903
+ '--record',
904
+ `Write each snapshot's ${EXPECTED_FILE} from this run instead of judging against it`,
905
+ )
906
+ .addHelpText(
907
+ 'after',
908
+ [
909
+ '',
910
+ 'Examples:',
911
+ ' canon sandbox replay',
912
+ ' canon sandbox replay --record',
913
+ ' canon sandbox replay ../frameboard ../jobtriage --json',
914
+ '',
915
+ 'Each replay copies the source to a temp tree outside the repository,',
916
+ 'runs sync, then sync --check, context audit, markdown audit, and',
917
+ 'migrate status, and checks what sync wrote with oxfmt. A finding or a',
918
+ 'refusal from a read is the verb working. Only a crash, stdout a --json',
919
+ 'caller cannot parse, or an unformatted write fails the run.',
920
+ 'A snapshot also fails a step whose exit or finding count drifted from',
921
+ `the ${EXPECTED_FILE} beside it, and fails every step when it has none.`,
922
+ '--record rewrites those files, refusing named paths and a broken run.',
923
+ 'A named checkout with uncommitted changes is refused, never synced over.',
924
+ '',
925
+ 'Exit codes: 0 clean or recorded, 1 refused, 2 when any step failed.',
926
+ ].join('\n'),
927
+ )
928
+ .action((paths: string[], options: ReplayOptions) => {
929
+ runReplay(paths, options)
930
+ })
686
931
  }
@@ -519,9 +519,12 @@ export const documentCeiling: Measure = async (ctx) => {
519
519
  *
520
520
  * `canon/context/context-model.md` is exempt, since "context model" is the
521
521
  * domain's own name and the entry reads as nothing else without the prefix.
522
+ * So is a replay snapshot, which copies a consumer's paths verbatim, such as
523
+ * `canon/config/config.json`, and a renamed copy would sync as another file.
522
524
  */
523
525
  export function isFolderEchoedName(path: string): boolean {
524
526
  if (path === 'canon/context/context-model.md') return false
527
+ if (path.startsWith('sandbox/fixtures/replay/')) return false
525
528
  const segments = path.split('/')
526
529
  if (segments.length < 2) return false
527
530
  const folder = segments[segments.length - 2] ?? ''
@@ -304,6 +304,31 @@ export const STAGES: readonly Stage[] = [
304
304
  label: 'Unreferenced rules',
305
305
  checks: [{ kind: 'measure', measure: unreferencedRules }],
306
306
  },
307
+ {
308
+ // A defect that only shows after a sync, such as a read crashing on a
309
+ // rule layout the sync just renamed, passes every unit test and every
310
+ // stage that reads this repository. Replaying the frozen consumer
311
+ // snapshots through this checkout's sync is what reaches it before a
312
+ // consumer does. Scoped to what a sync ships or reads, since a replay
313
+ // costs seconds a docs-only push should not pay. Governance is whole
314
+ // but for the ledger, since sync resolves stacks and renames as well as
315
+ // rules, and a rename is the class the stage was built against.
316
+ id: 'consumer-replay',
317
+ label: 'Consumer replay',
318
+ scope:
319
+ /^src\/|^governance\/(?!ledger\/)|^tooling\/|^sandbox\/fixtures\/replay\//,
320
+ skipped:
321
+ 'No synced surface or snapshot changed, so no consumer was replayed',
322
+ checks: [
323
+ {
324
+ kind: 'cli',
325
+ argv: ['sandbox', 'replay'],
326
+ failure:
327
+ 'A consumer replay crashed, printed unparsable JSON, left an unformatted write, or drifted from its recorded answer. Run bun src/cli.ts sandbox replay, and when a drift is one the change meant, such as a governance rename moving a sync count, run it with --record and commit the expectations.',
328
+ },
329
+ ],
330
+ success: 'Consumer replays clean',
331
+ },
307
332
  {
308
333
  // The facts the audit can answer from the files gate: a cited path that
309
334
  // does not resolve, a required section an entry does not declare, an H2
@@ -20,20 +20,25 @@ export interface LabelDiff {
20
20
  * A label the remote lacks is separated from the addable set rather than
21
21
  * dropped, because the REST add endpoint creates any label it is handed and
22
22
  * that write to the repository settings is not the map's call.
23
+ *
24
+ * Names match regardless of case, as GitHub holds them, so this agrees with
25
+ * `missingLabels` on which labels the remote carries.
23
26
  */
24
27
  export function diffLabels(
25
28
  earned: readonly string[],
26
29
  present: readonly string[],
27
30
  remote: readonly string[],
28
31
  ): LabelDiff {
29
- const has = new Set(present)
30
- const carried = new Set(remote)
32
+ const fold = (labels: readonly string[]): Set<string> =>
33
+ new Set(labels.map((label) => label.toLowerCase()))
34
+ const has = fold(present)
35
+ const carried = fold(remote)
31
36
 
32
- const owed = earned.filter((label) => !has.has(label))
37
+ const owed = earned.filter((label) => !has.has(label.toLowerCase()))
33
38
 
34
39
  return {
35
40
  owed,
36
- toAdd: owed.filter((label) => carried.has(label)),
37
- missingOnRemote: owed.filter((label) => !carried.has(label)),
41
+ toAdd: owed.filter((label) => carried.has(label.toLowerCase())),
42
+ missingOnRemote: owed.filter((label) => !carried.has(label.toLowerCase())),
38
43
  }
39
44
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The labels a map names that the remote does not carry yet, in map order.
3
+ *
4
+ * Only ever a subset of the map, so a label a person made by hand never
5
+ * appears and nothing here can become an edit or a deletion on the remote.
6
+ *
7
+ * GitHub holds label names unique regardless of case, so a remote `Docs`
8
+ * counts as carrying `docs`. Creating it would be refused, and every later
9
+ * run would report the same failure.
10
+ */
11
+ export function missingLabels(
12
+ mapLabels: readonly string[],
13
+ remote: readonly string[],
14
+ ): string[] {
15
+ const carried = new Set(remote.map((label) => label.toLowerCase()))
16
+ return mapLabels.filter((label) => !carried.has(label.toLowerCase()))
17
+ }
@@ -103,6 +103,11 @@ function escape(value: string): string {
103
103
  * never match. The guard then stops firing in a project the move has not
104
104
  * reached, which is silent: the index goes stale while every save succeeds.
105
105
  *
106
+ * A replay snapshot is a frozen copy of a consumer, its hooks included, and
107
+ * the replay tests the upgrade from exactly that state. Sweeping one rewrites
108
+ * the consumer's own old-root citations, so the snapshot stops matching the
109
+ * release it was cut at.
110
+ *
106
111
  * A file outside this list can still couple to one inside it, naming an
107
112
  * excluded path as plain text while it also carries a live citation of its
108
113
  * own, which `referencesExcluded` reports separately below. Widening this list
@@ -115,6 +120,7 @@ const EXCLUDED_PREFIXES: readonly string[] = [
115
120
  'scripts/eval/result-',
116
121
  '.claude/hooks/',
117
122
  'tooling/claude/seeds/.claude/hooks/',
123
+ 'sandbox/fixtures/replay/',
118
124
  ]
119
125
 
120
126
  const EXCLUDED_PATHS: readonly string[] = [
@@ -0,0 +1,152 @@
1
+ import { scanPhaseLabels } from '@/labels/phase'
2
+ import { BAN_SETS } from '@/markdown/bans'
3
+ import { type DensityFinding, densityFindings } from '@/markdown/density'
4
+ import { bodyLines, scanBans } from '@/markdown/scan'
5
+
6
+ /** Why the verb did not close, in the order the refusals are read. */
7
+ export type CloseRefusal =
8
+ | 'no-reason'
9
+ | 'reason-unpublishable'
10
+ | 'gh-failed'
11
+ | 'pull-closed'
12
+ | 'head-mismatch'
13
+ | 'comment-failed'
14
+ | 'close-failed'
15
+
16
+ export interface ClosePull {
17
+ readonly isOpen: boolean
18
+ /** The head branch name, which the caller's expected head is checked against. */
19
+ readonly branch: string
20
+ /** Whether the head lives in another repository, whose branch this one cannot delete. */
21
+ readonly isFork: boolean
22
+ }
23
+
24
+ /**
25
+ * Everything the verb reads or writes, injected so every refusal runs without
26
+ * a network. A read or write that failed returns undefined or false, never a
27
+ * guess.
28
+ */
29
+ export interface CloseApi {
30
+ readPull(): Promise<ClosePull | undefined>
31
+ /** Posts the reason and answers the comment's URL. */
32
+ postComment(body: string): Promise<string | undefined>
33
+ patchClosed(): Promise<boolean>
34
+ deleteBranch(branch: string): Promise<boolean>
35
+ }
36
+
37
+ export interface CloseOptions {
38
+ readonly number: number
39
+ readonly reason: string
40
+ /** The head branch the caller means, so a mistyped number closes nothing. */
41
+ readonly head: string
42
+ readonly isKeepBranch: boolean
43
+ }
44
+
45
+ /** What the reason carries that a reader on the remote cannot use. */
46
+ export interface ReasonFindings {
47
+ readonly isClean: boolean
48
+ readonly bannedCharacters: readonly string[]
49
+ readonly phaseLabels: readonly string[]
50
+ readonly boardReferences: readonly string[]
51
+ readonly sessionLinks: readonly string[]
52
+ readonly density: readonly DensityFinding[]
53
+ }
54
+
55
+ /** What happened to the head branch once the pull request closed. */
56
+ export type BranchOutcome = 'deleted' | 'kept' | 'fork' | 'delete-failed'
57
+
58
+ export interface CloseRecord {
59
+ readonly reason: 'closed' | CloseRefusal
60
+ readonly isOk: boolean
61
+ readonly number: number
62
+ readonly commentUrl?: string
63
+ readonly branch?: BranchOutcome
64
+ readonly findings?: ReasonFindings
65
+ }
66
+
67
+ /**
68
+ * Runs the checks `canon labels scan` runs on a pull request body, plus
69
+ * density measured on the reason itself.
70
+ *
71
+ * The scan's own post density reads a body only when it opens on a known
72
+ * post heading, and a closing comment carries none, so an overloaded reason
73
+ * would pass it unmeasured.
74
+ */
75
+ export function checkReason(reason: string): ReasonFindings {
76
+ const lines = bodyLines(reason)
77
+ const bannedCharacters = scanBans(lines, BAN_SETS).map(
78
+ (finding) => finding.term,
79
+ )
80
+ const { phaseLabels, boardReferences, sessionLinks } = scanPhaseLabels({
81
+ title: '',
82
+ body: reason,
83
+ headRefName: '',
84
+ })
85
+ const density = densityFindings(lines)
86
+
87
+ return {
88
+ isClean:
89
+ bannedCharacters.length === 0 &&
90
+ phaseLabels.length === 0 &&
91
+ boardReferences.length === 0 &&
92
+ sessionLinks.length === 0 &&
93
+ density.length === 0,
94
+ bannedCharacters,
95
+ phaseLabels,
96
+ boardReferences,
97
+ sessionLinks,
98
+ density,
99
+ }
100
+ }
101
+
102
+ function refused(
103
+ reason: CloseRefusal,
104
+ number: number,
105
+ extra: Partial<CloseRecord> = {},
106
+ ): CloseRecord {
107
+ return { reason, isOk: false, number, ...extra }
108
+ }
109
+
110
+ /**
111
+ * Closes one pull request unmerged with its reason posted first.
112
+ *
113
+ * The comment lands before the close, so a failure between the two leaves a
114
+ * comment on an open pull request rather than a closed one carrying no
115
+ * reason, which is the defect this verb exists to remove. The branch delete
116
+ * runs last and never fails the close, since the pull request is already
117
+ * closed with its reason by then.
118
+ */
119
+ export async function close(
120
+ api: CloseApi,
121
+ opts: CloseOptions,
122
+ ): Promise<CloseRecord> {
123
+ const { number } = opts
124
+ const reason = opts.reason.trim()
125
+
126
+ if (reason === '') return refused('no-reason', number)
127
+ const findings = checkReason(reason)
128
+ if (!findings.isClean) {
129
+ return refused('reason-unpublishable', number, { findings })
130
+ }
131
+
132
+ const pull = await api.readPull()
133
+ if (pull === undefined) return refused('gh-failed', number)
134
+ if (!pull.isOpen) return refused('pull-closed', number)
135
+ if (pull.branch !== opts.head) return refused('head-mismatch', number)
136
+
137
+ const commentUrl = await api.postComment(reason)
138
+ if (commentUrl === undefined) return refused('comment-failed', number)
139
+
140
+ if (!(await api.patchClosed())) {
141
+ return refused('close-failed', number, { commentUrl })
142
+ }
143
+
144
+ let branch: BranchOutcome
145
+ if (opts.isKeepBranch) branch = 'kept'
146
+ else if (pull.isFork) branch = 'fork'
147
+ else {
148
+ branch = (await api.deleteBranch(pull.branch)) ? 'deleted' : 'delete-failed'
149
+ }
150
+
151
+ return { reason: 'closed', isOk: true, number, commentUrl, branch }
152
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** Where a project declares what `canon pr local` checks beyond the page. */
5
+ export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
6
+
7
+ /**
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.
12
+ */
13
+ export type HealthDeclaration =
14
+ | { readonly kind: 'paths'; readonly paths: readonly string[] }
15
+ | { readonly kind: 'none' }
16
+ | { readonly kind: 'invalid'; readonly reason: string }
17
+
18
+ /**
19
+ * A path on the page's own host. A second leading slash would read as a
20
+ * protocol-relative host, so it is refused to keep the probe on localhost.
21
+ */
22
+ function isLocalPath(entry: unknown): entry is string {
23
+ return (
24
+ typeof entry === 'string' &&
25
+ entry.startsWith('/') &&
26
+ !entry.startsWith('//') &&
27
+ !entry.startsWith('/\\')
28
+ )
29
+ }
30
+
31
+ export function readHealthPaths(root: string): HealthDeclaration {
32
+ const path = join(root, LOCAL_CONFIG_REL)
33
+ if (!existsSync(path)) return { kind: 'none' }
34
+
35
+ let parsed: Record<string, unknown>
36
+ try {
37
+ parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
38
+ string,
39
+ unknown
40
+ >
41
+ } catch {
42
+ return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
43
+ }
44
+
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 /`,
50
+ }
51
+ }
52
+
53
+ return declared.length === 0
54
+ ? { kind: 'none' }
55
+ : { kind: 'paths', paths: declared }
56
+ }
package/src/pr/local.ts CHANGED
@@ -2,27 +2,62 @@ import { belongsTo } from '@/listeners/belongs'
2
2
  import type { ListenerReader } from '@/listeners/machine'
3
3
  import { readAddresses } from '@/pr/evidence'
4
4
 
5
- export type LocalRefusal = 'no-server' | 'no-listener-reader'
5
+ export type LocalRefusal = 'no-server' | 'no-listener-reader' | 'unhealthy'
6
6
 
7
- /** The listener reads a detection makes plus an HTML probe, injected so it can be driven without binding a socket. */
7
+ /** The listener reads a detection makes plus two HTTP probes, injected so it can be driven without binding a socket. */
8
8
  export interface LocalRunner extends ListenerReader {
9
9
  /** Whether the port serves an HTML page at its root inside the runner's timeout. */
10
10
  probe(port: number): Promise<boolean>
11
+ /** The final status a path on the port answers after redirects, or null when nothing answers inside the timeout. */
12
+ check(port: number, path: string): Promise<number | null>
11
13
  }
12
14
 
13
15
  export type LocalResult =
14
16
  | { readonly kind: 'found'; readonly port: number; readonly url: string }
15
- | { readonly kind: 'refused'; readonly reason: LocalRefusal }
17
+ | {
18
+ readonly kind: 'refused'
19
+ readonly reason: Exclude<LocalRefusal, 'unhealthy'>
20
+ }
21
+ | {
22
+ readonly kind: 'refused'
23
+ readonly reason: 'unhealthy'
24
+ readonly port: number
25
+ readonly path: string
26
+ readonly status: number | null
27
+ }
28
+
29
+ interface HealthFailure {
30
+ readonly path: string
31
+ readonly status: number | null
32
+ }
33
+
34
+ async function firstFailingPath(
35
+ port: number,
36
+ healthPaths: readonly string[],
37
+ runner: LocalRunner,
38
+ ): Promise<HealthFailure | undefined> {
39
+ for (const path of healthPaths) {
40
+ const status = await runner.check(port, path)
41
+ if (status === null || status < 200 || status >= 300) {
42
+ return { path, status }
43
+ }
44
+ }
45
+ return undefined
46
+ }
16
47
 
17
48
  /**
18
49
  * Finds the server this worktree is running: the lowest port whose listening
19
- * process runs inside the worktree and answers an HTTP probe. A port rather
20
- * than a process is the unit, since one dev server often holds a second
21
- * socket for its reload channel, and the lower one is the page by convention.
50
+ * process runs inside the worktree, answers an HTTP probe, and passes every
51
+ * declared health path. A port rather than a process is the unit, since one
52
+ * dev server often holds a second socket for its reload channel, and the
53
+ * lower one is the page by convention. Health is read on the page's own port,
54
+ * since that is the address a reviewer drives, and a page whose backend fails
55
+ * refuses as `unhealthy` naming the lowest page port's first failing path.
22
56
  */
23
57
  export async function findLocalServer(
24
58
  worktreeRoot: string,
25
59
  runner: LocalRunner,
60
+ healthPaths: readonly string[] = [],
26
61
  ): Promise<LocalResult> {
27
62
  const listeners = await runner.listListeners()
28
63
  if (listeners === undefined) {
@@ -37,12 +72,18 @@ export async function findLocalServer(
37
72
  if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
38
73
  }
39
74
 
75
+ let unhealthy: (HealthFailure & { readonly port: number }) | undefined
40
76
  for (const port of [...ports].sort((a, b) => a - b)) {
41
- if (await runner.probe(port)) {
77
+ if (!(await runner.probe(port))) continue
78
+ const failure = await firstFailingPath(port, healthPaths, runner)
79
+ if (failure === undefined) {
42
80
  return { kind: 'found', port, url: `http://localhost:${port}` }
43
81
  }
82
+ unhealthy ??= { port, ...failure }
44
83
  }
45
- return { kind: 'refused', reason: 'no-server' }
84
+ return unhealthy === undefined
85
+ ? { kind: 'refused', reason: 'no-server' }
86
+ : { kind: 'refused', reason: 'unhealthy', ...unhealthy }
46
87
  }
47
88
 
48
89
  /**