@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.
- package/CHANGELOG.md +25 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/references/labels.md +6 -2
- package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
- package/claude/skills/git-pr-close/SKILL.md +55 -0
- package/claude/skills/git-pr-close/references/close.md +54 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -2
- package/claude/skills/pr-evidence/references/evidence.md +3 -1
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/test-craft/references/e2e.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
- package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
- package/governance/ledger/skills/codebase-layout/tests.md +26 -0
- package/governance/ledger/skills/review-craft/SKILL.md +61 -0
- package/governance/ledger/skills/review-craft/security.md +34 -0
- package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
- package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
- package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
- package/governance/ledger/skills/test-craft/SKILL.md +67 -0
- package/governance/ledger/skills/test-craft/component.md +49 -0
- package/governance/ledger/skills/test-craft/e2e.md +55 -0
- package/governance/ledger/skills/test-craft/unit.md +54 -0
- package/package.json +3 -3
- package/src/claude/cases/git.ts +4 -0
- package/src/claude/skills-audit.ts +7 -0
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +263 -2
- package/src/commands/sandbox.ts +247 -2
- package/src/gate/measures.ts +3 -0
- package/src/gate/stages.ts +25 -0
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/migrate/records.ts +6 -0
- package/src/pr/close.ts +152 -0
- package/src/pr/health.ts +56 -0
- package/src/pr/local.ts +49 -8
- package/src/pr/rest.ts +10 -2
- package/src/sandbox/replay/expected.ts +137 -0
- package/src/sandbox/replay/run.ts +348 -0
- package/src/sandbox/replay/verdict.ts +102 -0
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/astro/configs/playwright.config.ts +2 -2
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
- package/tooling/nextjs/configs/playwright.config.ts +2 -2
- package/tooling/vite-react/configs/playwright.config.ts +2 -2
package/src/commands/sandbox.ts
CHANGED
|
@@ -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
|
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -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] ?? ''
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
package/src/labels/apply.ts
CHANGED
|
@@ -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
|
|
30
|
-
|
|
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
|
+
}
|
package/src/migrate/records.ts
CHANGED
|
@@ -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[] = [
|
package/src/pr/close.ts
ADDED
|
@@ -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
|
+
}
|
package/src/pr/health.ts
ADDED
|
@@ -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
|
|
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
|
-
| {
|
|
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
|
|
20
|
-
* than a process is the unit, since one
|
|
21
|
-
* socket for its reload channel, and the
|
|
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
|
|
84
|
+
return unhealthy === undefined
|
|
85
|
+
? { kind: 'refused', reason: 'no-server' }
|
|
86
|
+
: { kind: 'refused', reason: 'unhealthy', ...unhealthy }
|
|
46
87
|
}
|
|
47
88
|
|
|
48
89
|
/**
|