@erclx/canon 4.70.0 → 4.72.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +4 -0
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +37 -0
- package/claude/skills/deploy-cloudflare/SKILL.md +61 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +3 -1
- package/claude/skills/draft-and-pick/SKILL.md +10 -6
- package/claude/skills/{identity → draft-identity}/REQUIREMENT.md +2 -2
- package/claude/skills/{identity → draft-identity}/SKILL.md +2 -2
- package/claude/skills/git-ship/SKILL.md +17 -6
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -0
- package/claude/skills/role-planner/SKILL.md +5 -0
- package/claude/skills/role-worker/SKILL.md +6 -0
- package/claude/skills/session-relay/REQUIREMENT.md +41 -0
- package/claude/skills/session-relay/SKILL.md +36 -0
- package/claude/skills/ux-audit/SKILL.md +3 -0
- package/docs/agents/key-changes.md +5 -1
- package/docs/agents/tasks.md +33 -0
- package/docs/workflow/ai-workflow.md +2 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/ui/440-surface-capture.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/commands/pr.ts +128 -6
- package/src/commands/tasks.ts +154 -0
- package/src/commands/teach.ts +2 -0
- package/src/gate/measures.ts +66 -0
- package/src/gate/stages.ts +11 -0
- package/src/git-files.ts +69 -0
- package/src/migrate/skill-names.ts +10 -0
- package/src/pr/bijection.ts +59 -1
- package/src/tasks/reach.ts +383 -0
- package/src/teach/workspace.ts +60 -23
- package/standards/plan.md +2 -0
- package/standards/tasks.md +2 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +103 -0
- package/tooling/cloudflare/manifest.toml +5 -0
- package/tooling/cloudflare/reference.md +24 -0
package/src/commands/pr.ts
CHANGED
|
@@ -6,7 +6,11 @@ import { execa } from 'execa'
|
|
|
6
6
|
import { gitEnv } from '@/git-env'
|
|
7
7
|
import {
|
|
8
8
|
listChangedFiles,
|
|
9
|
+
listIgnoreAdditions,
|
|
10
|
+
listRenames,
|
|
9
11
|
listRepositoryFiles,
|
|
12
|
+
parseIgnoreAdditions,
|
|
13
|
+
type RenamePair,
|
|
10
14
|
resolveBaseRef,
|
|
11
15
|
} from '@/git-files'
|
|
12
16
|
import {
|
|
@@ -292,6 +296,9 @@ interface PullRequestRead {
|
|
|
292
296
|
readonly changed: readonly string[]
|
|
293
297
|
readonly head: string | undefined
|
|
294
298
|
readonly number: number | undefined
|
|
299
|
+
readonly renames: readonly RenamePair[]
|
|
300
|
+
readonly ignoreAdditions: readonly string[]
|
|
301
|
+
readonly evidenceUnread: boolean
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
type SourceRead =
|
|
@@ -349,13 +356,26 @@ async function readFromApi(
|
|
|
349
356
|
return { kind: 'refused', reason: 'gh-truncated' }
|
|
350
357
|
}
|
|
351
358
|
|
|
359
|
+
const body = row.body ?? ''
|
|
360
|
+
const sorted = [...changed].sort()
|
|
361
|
+
const evidence = await resolveApiEvidence(
|
|
362
|
+
cwd,
|
|
363
|
+
body,
|
|
364
|
+
sorted,
|
|
365
|
+
row.headRefOid,
|
|
366
|
+
row.number,
|
|
367
|
+
)
|
|
368
|
+
|
|
352
369
|
return {
|
|
353
370
|
kind: 'read',
|
|
354
371
|
source: {
|
|
355
|
-
body
|
|
356
|
-
changed:
|
|
372
|
+
body,
|
|
373
|
+
changed: sorted,
|
|
357
374
|
head: row.headRefOid,
|
|
358
375
|
number: row.number,
|
|
376
|
+
renames: evidence.renames,
|
|
377
|
+
ignoreAdditions: evidence.ignoreAdditions,
|
|
378
|
+
evidenceUnread: evidence.unread,
|
|
359
379
|
},
|
|
360
380
|
}
|
|
361
381
|
} catch {
|
|
@@ -363,6 +383,95 @@ async function readFromApi(
|
|
|
363
383
|
}
|
|
364
384
|
}
|
|
365
385
|
|
|
386
|
+
/**
|
|
387
|
+
* A rename's source path and a `.gitignore` addition, fetched only when a
|
|
388
|
+
* first pass with neither already reports an unmet claim.
|
|
389
|
+
*
|
|
390
|
+
* The probe pays for a repository listing this read takes again a moment
|
|
391
|
+
* later in `runKeyChanges`, and the `gh api …/files` call besides it only on
|
|
392
|
+
* the pass that already has something to double-check, the same trade the
|
|
393
|
+
* `gh-truncated` pagination fallback makes.
|
|
394
|
+
*
|
|
395
|
+
* `unread` separates a read that failed from one that succeeded and found
|
|
396
|
+
* neither, which an empty `renames`/`ignoreAdditions` cannot do on its own.
|
|
397
|
+
* `listFilesByPage` above refuses the whole comparison on exactly this
|
|
398
|
+
* ground: a set known to be short would let a correct bullet accuse a file
|
|
399
|
+
* nobody changed. This carries the same refusal as a flag rather than a
|
|
400
|
+
* refused `Bijection`, since the base comparison can still run and most
|
|
401
|
+
* claims never touch a rename or a `.gitignore` line at all.
|
|
402
|
+
*/
|
|
403
|
+
async function resolveApiEvidence(
|
|
404
|
+
cwd: string,
|
|
405
|
+
body: string,
|
|
406
|
+
changed: readonly string[],
|
|
407
|
+
head: string | undefined,
|
|
408
|
+
number: number | undefined,
|
|
409
|
+
): Promise<{
|
|
410
|
+
readonly renames: readonly RenamePair[]
|
|
411
|
+
readonly ignoreAdditions: readonly string[]
|
|
412
|
+
readonly unread: boolean
|
|
413
|
+
}> {
|
|
414
|
+
const clean = { renames: [], ignoreAdditions: [], unread: false }
|
|
415
|
+
const unread = { renames: [], ignoreAdditions: [], unread: true }
|
|
416
|
+
|
|
417
|
+
const tracked = await listRepositoryFiles(cwd)
|
|
418
|
+
if (tracked === undefined) return unread
|
|
419
|
+
|
|
420
|
+
const probe = compareKeyChanges({
|
|
421
|
+
body,
|
|
422
|
+
changed,
|
|
423
|
+
roots: treeRoots(tracked, changed),
|
|
424
|
+
...(head !== undefined && { head }),
|
|
425
|
+
})
|
|
426
|
+
// A refusal here means there is no claim to credit at all, and a clean
|
|
427
|
+
// pass means every claim already resolved without the extra evidence, so
|
|
428
|
+
// neither case leaves anything for unread evidence to have mattered to.
|
|
429
|
+
if (probe.kind !== 'measured' || probe.unmet.length === 0) return clean
|
|
430
|
+
|
|
431
|
+
// Only past this point does a missing pull request number become a real
|
|
432
|
+
// gap: there is a claim the probe could not credit, and no number to fetch
|
|
433
|
+
// the evidence that might explain it.
|
|
434
|
+
if (number === undefined) return unread
|
|
435
|
+
|
|
436
|
+
// No `--jq` filter here, unlike `listFilesByPage` above. Shaping each row to
|
|
437
|
+
// {filename, previous_filename, status, patch} would make `--paginate`
|
|
438
|
+
// concatenate one filtered value per page rather than one combined array,
|
|
439
|
+
// and gh's own pretty-printing of an object result (unlike the scalar
|
|
440
|
+
// strings `--jq '.[].filename'` yields) is not guaranteed to stay
|
|
441
|
+
// line-parseable. Parsing the raw paginated array is safe under both.
|
|
442
|
+
const stdout = await gh(cwd, [
|
|
443
|
+
'api',
|
|
444
|
+
'--paginate',
|
|
445
|
+
`repos/{owner}/{repo}/pulls/${number}/files`,
|
|
446
|
+
])
|
|
447
|
+
if (stdout === null) return unread
|
|
448
|
+
|
|
449
|
+
let rows: readonly {
|
|
450
|
+
readonly filename: string
|
|
451
|
+
readonly previous_filename?: string
|
|
452
|
+
readonly status: string
|
|
453
|
+
readonly patch?: string
|
|
454
|
+
}[]
|
|
455
|
+
try {
|
|
456
|
+
rows = JSON.parse(stdout)
|
|
457
|
+
} catch {
|
|
458
|
+
return unread
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const renames = rows
|
|
462
|
+
.filter(
|
|
463
|
+
(row): row is typeof row & { previous_filename: string } =>
|
|
464
|
+
row.status === 'renamed' && row.previous_filename !== undefined,
|
|
465
|
+
)
|
|
466
|
+
.map((row) => ({ from: row.previous_filename, to: row.filename }))
|
|
467
|
+
|
|
468
|
+
const ignoreRow = rows.find((row) => row.filename === '.gitignore')
|
|
469
|
+
const ignoreAdditions =
|
|
470
|
+
ignoreRow?.patch !== undefined ? parseIgnoreAdditions(ignoreRow.patch) : []
|
|
471
|
+
|
|
472
|
+
return { renames, ignoreAdditions, unread: false }
|
|
473
|
+
}
|
|
474
|
+
|
|
366
475
|
/**
|
|
367
476
|
* Every file a pull request changed, read through the paginated endpoint.
|
|
368
477
|
*
|
|
@@ -430,10 +539,11 @@ async function readFromFile(
|
|
|
430
539
|
return { kind: 'refused', reason: 'unreadable-changes' }
|
|
431
540
|
}
|
|
432
541
|
|
|
433
|
-
const head = await
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
542
|
+
const [head, renames, ignoreAdditions] = await Promise.all([
|
|
543
|
+
$`git -C ${root} rev-parse HEAD`.env(gitEnv()).quiet().nothrow(),
|
|
544
|
+
listRenames(root, resolved),
|
|
545
|
+
listIgnoreAdditions(root, resolved),
|
|
546
|
+
])
|
|
437
547
|
|
|
438
548
|
return {
|
|
439
549
|
kind: 'read',
|
|
@@ -442,6 +552,9 @@ async function readFromFile(
|
|
|
442
552
|
changed,
|
|
443
553
|
head: head.exitCode === 0 ? head.text().trim() : undefined,
|
|
444
554
|
number: undefined,
|
|
555
|
+
renames: renames ?? [],
|
|
556
|
+
ignoreAdditions: ignoreAdditions ?? [],
|
|
557
|
+
evidenceUnread: renames === undefined || ignoreAdditions === undefined,
|
|
445
558
|
},
|
|
446
559
|
}
|
|
447
560
|
}
|
|
@@ -469,6 +582,9 @@ async function runKeyChanges(
|
|
|
469
582
|
body: source.source.body,
|
|
470
583
|
changed: source.source.changed,
|
|
471
584
|
roots: treeRoots(tracked, source.source.changed),
|
|
585
|
+
renames: source.source.renames,
|
|
586
|
+
ignoreAdditions: source.source.ignoreAdditions,
|
|
587
|
+
evidenceUnread: source.source.evidenceUnread,
|
|
472
588
|
...(source.source.head !== undefined && { head: source.source.head }),
|
|
473
589
|
})
|
|
474
590
|
|
|
@@ -480,6 +596,11 @@ async function runKeyChanges(
|
|
|
480
596
|
report.head === undefined ? '' : ` at ${report.head.slice(0, 8)}`
|
|
481
597
|
}`,
|
|
482
598
|
)
|
|
599
|
+
if (report.evidenceUnread) {
|
|
600
|
+
logWarn(
|
|
601
|
+
'Rename or .gitignore-addition evidence could not be read, so a claim it might have credited or accused landed in unresolved rather than unmet.',
|
|
602
|
+
)
|
|
603
|
+
}
|
|
483
604
|
|
|
484
605
|
logStep(report.unmet.length === 0 ? 'Claimed' : 'Unmet')
|
|
485
606
|
if (report.unmet.length === 0) {
|
|
@@ -545,6 +666,7 @@ async function runKeyChanges(
|
|
|
545
666
|
unnamed: report.unnamed,
|
|
546
667
|
incidental: report.incidental,
|
|
547
668
|
unresolved: report.unresolved,
|
|
669
|
+
evidenceUnread: report.evidenceUnread,
|
|
548
670
|
})}\n`,
|
|
549
671
|
)
|
|
550
672
|
}
|
package/src/commands/tasks.ts
CHANGED
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
planCitations,
|
|
15
15
|
} from '@/tasks/archive'
|
|
16
16
|
import { type LabelOutcome, nextLabel } from '@/tasks/label'
|
|
17
|
+
import {
|
|
18
|
+
type Claim,
|
|
19
|
+
type Holder,
|
|
20
|
+
planReach,
|
|
21
|
+
type ReachOutcome,
|
|
22
|
+
} from '@/tasks/reach'
|
|
17
23
|
import {
|
|
18
24
|
type CloseOutcome,
|
|
19
25
|
closeOutcomes,
|
|
@@ -84,6 +90,12 @@ interface BranchCommandOptions {
|
|
|
84
90
|
readonly root?: string
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
interface ReachCommandOptions {
|
|
94
|
+
readonly base?: string
|
|
95
|
+
readonly json?: boolean
|
|
96
|
+
readonly root?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
interface PullRequestCommandOptions {
|
|
88
100
|
readonly json?: boolean
|
|
89
101
|
readonly plan?: string
|
|
@@ -326,6 +338,45 @@ export function register(program: Command): void {
|
|
|
326
338
|
process.exitCode = await runBranch(plan, opts)
|
|
327
339
|
})
|
|
328
340
|
|
|
341
|
+
tasks
|
|
342
|
+
.command('plan-reach')
|
|
343
|
+
.description('Read a branch back against what was declared about it')
|
|
344
|
+
.argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
|
|
345
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
346
|
+
.option('--base <ref>', 'Far side of the range, defaulting to the trunk')
|
|
347
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
348
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
349
|
+
.addHelpText(
|
|
350
|
+
'after',
|
|
351
|
+
[
|
|
352
|
+
'',
|
|
353
|
+
'Exit codes:',
|
|
354
|
+
' 0 the reach was read and no other track claims a changed path',
|
|
355
|
+
' 1 refused as no-plan, archived, bad-input, no-base, or no-diff',
|
|
356
|
+
' 2 read, and another live plan or Run now row claims a path',
|
|
357
|
+
'',
|
|
358
|
+
'It reports claimed first and undeclared second. A claimed path is one',
|
|
359
|
+
'another track holds and is the half worth acting on; an undeclared one',
|
|
360
|
+
'is a path this plan never named, which the ship chain writes on nearly',
|
|
361
|
+
'every branch. It reports and never gates, so branch on the record',
|
|
362
|
+
'rather than on the exit code, which a shell function wrapping canon',
|
|
363
|
+
'can flatten to zero.',
|
|
364
|
+
'',
|
|
365
|
+
'The range is read at the current directory and the plans and board at',
|
|
366
|
+
'the board root, so a linked worktree reads its own branch against the',
|
|
367
|
+
'shared records. It reads only what is written down, so the plan and',
|
|
368
|
+
'row counts say how much was there to compare against.',
|
|
369
|
+
'',
|
|
370
|
+
'Examples:',
|
|
371
|
+
' canon tasks plan-reach dispatch-answer-gate',
|
|
372
|
+
' canon tasks plan-reach dispatch-answer-gate --base origin/main --json',
|
|
373
|
+
'',
|
|
374
|
+
].join('\n'),
|
|
375
|
+
)
|
|
376
|
+
.action(async (plan: string, opts: ReachCommandOptions) => {
|
|
377
|
+
process.exitCode = await runReach(plan, opts)
|
|
378
|
+
})
|
|
379
|
+
|
|
329
380
|
tasks
|
|
330
381
|
.command('pull-request')
|
|
331
382
|
.description('Record a pull request number on the task a branch closes')
|
|
@@ -961,6 +1012,109 @@ function reportBranch(
|
|
|
961
1012
|
return EXIT_FINDINGS
|
|
962
1013
|
}
|
|
963
1014
|
|
|
1015
|
+
async function runReach(
|
|
1016
|
+
plan: string,
|
|
1017
|
+
opts: ReachCommandOptions,
|
|
1018
|
+
): Promise<number> {
|
|
1019
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1020
|
+
// The range belongs to the checkout the caller stands in, which is the linked
|
|
1021
|
+
// worktree holding the branch on every dispatched run. The board root is the
|
|
1022
|
+
// main worktree, where reading a range measures the trunk against itself.
|
|
1023
|
+
const outcome = await planReach(root, plan, {
|
|
1024
|
+
repo: process.cwd(),
|
|
1025
|
+
ref: opts.base,
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
return reportReach(outcome, opts.json ?? false, root)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Names a holder, the declaration it matched on when that is wider than the
|
|
1033
|
+
* path, and whether a holding plan carries a dispatch row. A plan with no row
|
|
1034
|
+
* is the shape a plan nobody archived takes, which is what sends a reader to
|
|
1035
|
+
* check the holder rather than treating the claim as a live collision.
|
|
1036
|
+
*/
|
|
1037
|
+
function describeHolder(claim: Claim, holder: Holder): string {
|
|
1038
|
+
const under =
|
|
1039
|
+
holder.declaration === claim.path ? '' : `, under ${holder.declaration}`
|
|
1040
|
+
const rowed = holder.source === 'plan' && !holder.rowed ? ', no row' : ''
|
|
1041
|
+
|
|
1042
|
+
return `${holder.name} (${holder.source}${under}${rowed})`
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function describeReachClaim(claim: Claim): string {
|
|
1046
|
+
const holders = claim.holders
|
|
1047
|
+
.map((holder) => describeHolder(claim, holder))
|
|
1048
|
+
.join(' and ')
|
|
1049
|
+
|
|
1050
|
+
return `${claim.path} is held by ${holders}`
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Leads with the claimed list because it is the short one. Over the wave this
|
|
1055
|
+
* verb was filed against, undeclared paths ran 22 of 26 on one branch and 18 of
|
|
1056
|
+
* 25 on another, so a report opening on that list is noise a reader skips past
|
|
1057
|
+
* and the crossing underneath it goes with them.
|
|
1058
|
+
*/
|
|
1059
|
+
function reportReach(
|
|
1060
|
+
outcome: ReachOutcome,
|
|
1061
|
+
emitJson: boolean,
|
|
1062
|
+
root: string,
|
|
1063
|
+
): number {
|
|
1064
|
+
if (!outcome.ok) {
|
|
1065
|
+
if (emitJson) {
|
|
1066
|
+
process.stdout.write(
|
|
1067
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
1068
|
+
)
|
|
1069
|
+
return 1
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
intro('canon tasks plan-reach')
|
|
1073
|
+
logStep('Refused')
|
|
1074
|
+
logError(outcome.message)
|
|
1075
|
+
outro()
|
|
1076
|
+
return 1
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (emitJson) {
|
|
1080
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
|
|
1081
|
+
return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
intro('canon tasks plan-reach')
|
|
1085
|
+
|
|
1086
|
+
logStep(outcome.claimed.length === 0 ? 'Uncontested' : 'Claimed')
|
|
1087
|
+
if (outcome.claimed.length === 0) {
|
|
1088
|
+
logInfo('no other live plan or Run now row holds a path this branch wrote')
|
|
1089
|
+
} else {
|
|
1090
|
+
for (const claim of outcome.claimed) logWarn(describeReachClaim(claim))
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
logStep('Undeclared')
|
|
1094
|
+
if (outcome.undeclared.length === 0) {
|
|
1095
|
+
logInfo(
|
|
1096
|
+
`${outcome.plan} declared every one of the ${outcome.changed} paths`,
|
|
1097
|
+
)
|
|
1098
|
+
} else {
|
|
1099
|
+
for (const path of outcome.undeclared) logInfo(path)
|
|
1100
|
+
logInfo(
|
|
1101
|
+
`${outcome.undeclared.length} of ${outcome.changed} path(s) this plan did not name, which the ship chain's own steps account for on most branches`,
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// The counts are the report's own bound. A verb reading what is written down
|
|
1106
|
+
// sees no hand-launched track and no track without a plan, so a clear reading
|
|
1107
|
+
// over nothing compared against would otherwise read as a proof.
|
|
1108
|
+
logStep('Compared against')
|
|
1109
|
+
logInfo(
|
|
1110
|
+
`${outcome.plans} other live plan(s) and ${outcome.rows} Run now row(s)${outcome.board ? '' : ', with no board on disk to read'}`,
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
outro()
|
|
1114
|
+
|
|
1115
|
+
return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
|
|
1116
|
+
}
|
|
1117
|
+
|
|
964
1118
|
function reportValidation(
|
|
965
1119
|
outcome: ValidateOutcome,
|
|
966
1120
|
emitJson: boolean,
|
package/src/commands/teach.ts
CHANGED
|
@@ -384,6 +384,7 @@ async function runStylesheet(
|
|
|
384
384
|
root,
|
|
385
385
|
slug: outcome.slug,
|
|
386
386
|
path: outcome.path,
|
|
387
|
+
basePath: outcome.basePath,
|
|
387
388
|
written: outcome.written,
|
|
388
389
|
})}\n`,
|
|
389
390
|
)
|
|
@@ -393,6 +394,7 @@ async function runStylesheet(
|
|
|
393
394
|
intro('canon teach stylesheet')
|
|
394
395
|
logStep(outcome.written ? 'Written' : 'Already present, left alone')
|
|
395
396
|
logInfo(outcome.path)
|
|
397
|
+
logInfo(outcome.basePath)
|
|
396
398
|
outro()
|
|
397
399
|
return 0
|
|
398
400
|
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -708,6 +708,72 @@ export const readmeCitations: Measure = async (ctx) => {
|
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
+
/**
|
|
712
|
+
* `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
|
|
713
|
+
* copy of the eight path globs a landing-page change should trigger on, per
|
|
714
|
+
* Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
|
|
715
|
+
* anchor cannot cross files here, so the two copies are read live and compared
|
|
716
|
+
* rather than one asserting a literal the other could drift behind unnoticed,
|
|
717
|
+
* mirroring how `web/e2e/home.spec.ts`'s rule-group test already reads live.
|
|
718
|
+
*/
|
|
719
|
+
export const visualPathGlobs: Measure = async (ctx) => {
|
|
720
|
+
const deployPath = '.github/workflows/deploy-site.yml'
|
|
721
|
+
const visualPath = '.github/workflows/pr-visual-checks.yml'
|
|
722
|
+
const deployFile = join(ctx.root, deployPath)
|
|
723
|
+
const visualFile = join(ctx.root, visualPath)
|
|
724
|
+
|
|
725
|
+
if (!existsSync(deployFile) || !existsSync(visualFile)) {
|
|
726
|
+
return {
|
|
727
|
+
emissions: [],
|
|
728
|
+
unmeasured: `${deployPath} or ${visualPath} is absent, so no path list was compared.`,
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const deploy = Bun.YAML.parse(readFileSync(deployFile, 'utf8')) as {
|
|
733
|
+
readonly on?: { readonly push?: { readonly paths?: readonly string[] } }
|
|
734
|
+
}
|
|
735
|
+
const visual = Bun.YAML.parse(readFileSync(visualFile, 'utf8')) as {
|
|
736
|
+
readonly on?: {
|
|
737
|
+
readonly pull_request?: { readonly paths?: readonly string[] }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const deployPaths = deploy.on?.push?.paths ?? []
|
|
742
|
+
const visualPaths = visual.on?.pull_request?.paths ?? []
|
|
743
|
+
|
|
744
|
+
if (deployPaths.length === 0 || visualPaths.length === 0) {
|
|
745
|
+
return {
|
|
746
|
+
emissions: [],
|
|
747
|
+
unmeasured: `${deployPath} or ${visualPath} carries no path list to compare.`,
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const onlyDeploy = deployPaths.filter((path) => !visualPaths.includes(path))
|
|
752
|
+
const onlyVisual = visualPaths.filter((path) => !deployPaths.includes(path))
|
|
753
|
+
|
|
754
|
+
if (onlyDeploy.length === 0 && onlyVisual.length === 0) {
|
|
755
|
+
return {
|
|
756
|
+
emissions: [
|
|
757
|
+
info(
|
|
758
|
+
`${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}`,
|
|
759
|
+
),
|
|
760
|
+
],
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
emissions: [
|
|
766
|
+
...onlyDeploy.map((path) =>
|
|
767
|
+
warn(`${deployPath} carries ${path}, absent from ${visualPath}`),
|
|
768
|
+
),
|
|
769
|
+
...onlyVisual.map((path) =>
|
|
770
|
+
warn(`${visualPath} carries ${path}, absent from ${deployPath}`),
|
|
771
|
+
),
|
|
772
|
+
],
|
|
773
|
+
failure: `${deployPath} and ${visualPath} carry different path globs. Bring the two lists back into agreement.`,
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
711
777
|
/**
|
|
712
778
|
* `canon sandbox coverage` moves only when a person runs it, so a scenario added
|
|
713
779
|
* with no expectation ships unnoticed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
shippedReferences,
|
|
13
13
|
standardCriteria,
|
|
14
14
|
unreferencedRules,
|
|
15
|
+
visualPathGlobs,
|
|
15
16
|
} from '@/gate/measures'
|
|
16
17
|
import { SHIPPED_CORPORA } from '@/shipped/references'
|
|
17
18
|
|
|
@@ -424,6 +425,16 @@ export const STAGES: readonly Stage[] = [
|
|
|
424
425
|
skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
|
|
425
426
|
checks: [{ kind: 'measure', measure: readmeCitations }],
|
|
426
427
|
},
|
|
428
|
+
{
|
|
429
|
+
// Scoped to the two files carrying the literal path-glob copy, so an edit
|
|
430
|
+
// to either one runs the check.
|
|
431
|
+
id: 'visual-path-globs',
|
|
432
|
+
label: 'Visual check path globs',
|
|
433
|
+
scope: /^\.github\/workflows\/(deploy-site|pr-visual-checks)\.yml$/,
|
|
434
|
+
skipped:
|
|
435
|
+
'Neither deploy-site.yml nor pr-visual-checks.yml changed, so no path list was compared',
|
|
436
|
+
checks: [{ kind: 'measure', measure: visualPathGlobs }],
|
|
437
|
+
},
|
|
427
438
|
{
|
|
428
439
|
id: 'seed-standards',
|
|
429
440
|
label: 'Seed standards',
|
package/src/git-files.ts
CHANGED
|
@@ -68,6 +68,75 @@ export async function listChangedFiles(
|
|
|
68
68
|
return [...new Set(paths.filter(Boolean))].sort()
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** A file's old and new path across a git-detected rename. */
|
|
72
|
+
export interface RenamePair {
|
|
73
|
+
readonly from: string
|
|
74
|
+
readonly to: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every rename `base..working tree` carries, read through the same similarity
|
|
79
|
+
* detection `git diff -M` applies by default (50%).
|
|
80
|
+
*
|
|
81
|
+
* A move heavy enough on rewritten content to fall under that floor reports
|
|
82
|
+
* as a plain delete and add rather than a rename, which is not a defect this
|
|
83
|
+
* reads around: the diff genuinely does not carry the file a claim citing the
|
|
84
|
+
* old path describes.
|
|
85
|
+
*
|
|
86
|
+
* Returns undefined when git cannot answer, matching `listChangedFiles`.
|
|
87
|
+
*/
|
|
88
|
+
export async function listRenames(
|
|
89
|
+
root: string,
|
|
90
|
+
base: string,
|
|
91
|
+
): Promise<RenamePair[] | undefined> {
|
|
92
|
+
const output = await git(root, ['diff', '--name-status', '-M', base])
|
|
93
|
+
if (output === undefined) return undefined
|
|
94
|
+
|
|
95
|
+
const renames: RenamePair[] = []
|
|
96
|
+
for (const line of output.split('\n')) {
|
|
97
|
+
if (line === '') continue
|
|
98
|
+
const [status, from, to] = line.split('\t')
|
|
99
|
+
if (status === undefined || !status.startsWith('R')) continue
|
|
100
|
+
if (from === undefined || to === undefined) continue
|
|
101
|
+
renames.push({ from, to })
|
|
102
|
+
}
|
|
103
|
+
return renames
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Added, non-comment lines from a unified diff hunk touching `.gitignore`.
|
|
108
|
+
*
|
|
109
|
+
* Shared between a local `git diff` and a patch string GitHub returns inline
|
|
110
|
+
* for the same file, so the two evidence sources read one pattern the same
|
|
111
|
+
* way.
|
|
112
|
+
*/
|
|
113
|
+
export function parseIgnoreAdditions(diffText: string): string[] {
|
|
114
|
+
const patterns: string[] = []
|
|
115
|
+
for (const line of diffText.split('\n')) {
|
|
116
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue
|
|
117
|
+
const pattern = line.slice(1).trim()
|
|
118
|
+
if (pattern === '' || pattern.startsWith('#')) continue
|
|
119
|
+
patterns.push(pattern)
|
|
120
|
+
}
|
|
121
|
+
return patterns
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every pattern a branch added to `.gitignore` since `base`.
|
|
126
|
+
*
|
|
127
|
+
* `-U0` keeps the hunk to changed lines alone, which is all a claim needs.
|
|
128
|
+
*
|
|
129
|
+
* Returns undefined when git cannot answer, matching `listChangedFiles`.
|
|
130
|
+
*/
|
|
131
|
+
export async function listIgnoreAdditions(
|
|
132
|
+
root: string,
|
|
133
|
+
base: string,
|
|
134
|
+
): Promise<string[] | undefined> {
|
|
135
|
+
const output = await git(root, ['diff', '-U0', base, '--', '.gitignore'])
|
|
136
|
+
if (output === undefined) return undefined
|
|
137
|
+
return parseIgnoreAdditions(output)
|
|
138
|
+
}
|
|
139
|
+
|
|
71
140
|
/**
|
|
72
141
|
* Lists the files under `root`: tracked, plus untracked files git does not
|
|
73
142
|
* ignore. The untracked half is what keeps a file added on this branch in scope
|
|
@@ -24,6 +24,16 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
|
|
|
24
24
|
* Every name takes two words. Ten of these would have landed as a bare single
|
|
25
25
|
* word under a plain strip, and a bare word such as `review` or `docs` is a
|
|
26
26
|
* substring of ordinary prose with no token left for a later sweep to find.
|
|
27
|
+
*
|
|
28
|
+
* No bare single-word name may ever join this map, whatever prefix it would
|
|
29
|
+
* otherwise take. `wholeToken: true` below matches a standalone word rather
|
|
30
|
+
* than a namespaced compound, so a one-word key rewrites every unrelated use
|
|
31
|
+
* of that word too. `identity` proved it: a dry run against a fifth row
|
|
32
|
+
* reading `'identity': 'draft-identity'` reported 112 occurrences across 43
|
|
33
|
+
* files, most of them a variable, field, or type name spelling the bare word
|
|
34
|
+
* rather than the skill, including `readonly identity: SelfIdentity` at
|
|
35
|
+
* `src/sessions/resolve.ts:255`, which the rewrite would have turned into
|
|
36
|
+
* invalid TypeScript. The row was never added.
|
|
27
37
|
*/
|
|
28
38
|
export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
29
39
|
'claude-address-review': 'review-address',
|
package/src/pr/bijection.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RenamePair } from '@/git-files'
|
|
1
2
|
import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -53,6 +54,14 @@ export interface BijectionReport {
|
|
|
53
54
|
* branch author.
|
|
54
55
|
*/
|
|
55
56
|
readonly incidental: readonly string[]
|
|
57
|
+
/**
|
|
58
|
+
* True when the rename or `.gitignore`-addition evidence could not be read.
|
|
59
|
+
*
|
|
60
|
+
* A claim `unmet` would otherwise carry is downgraded to `unresolved`
|
|
61
|
+
* instead, since the unread evidence might have credited it and this
|
|
62
|
+
* comparison has no way to tell a stale claim from one it could not check.
|
|
63
|
+
*/
|
|
64
|
+
readonly evidenceUnread: boolean
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export type Bijection =
|
|
@@ -66,6 +75,20 @@ export interface BijectionInput {
|
|
|
66
75
|
readonly roots: ReadonlySet<string>
|
|
67
76
|
readonly head?: string
|
|
68
77
|
readonly title?: string
|
|
78
|
+
/** A rename's source path credits a claim naming it, without ever counting as a changed file. */
|
|
79
|
+
readonly renames?: readonly RenamePair[]
|
|
80
|
+
/** A pattern newly added to `.gitignore`, crediting a claim naming it as the `.gitignore` change it is. */
|
|
81
|
+
readonly ignoreAdditions?: readonly string[]
|
|
82
|
+
/**
|
|
83
|
+
* True when the caller could not read the rename or ignore-addition
|
|
84
|
+
* evidence, rather than reading it and finding neither.
|
|
85
|
+
*
|
|
86
|
+
* The two states produce the same empty `renames`/`ignoreAdditions`, so
|
|
87
|
+
* this is the only way `compareKeyChanges` can tell a claim that is
|
|
88
|
+
* genuinely stale from one the evidence might have credited had the read
|
|
89
|
+
* succeeded.
|
|
90
|
+
*/
|
|
91
|
+
readonly evidenceUnread?: boolean
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
/**
|
|
@@ -94,6 +117,28 @@ function owesNoBullet(path: string): boolean {
|
|
|
94
117
|
return INCIDENTAL.some((pattern) => pattern.test(path))
|
|
95
118
|
}
|
|
96
119
|
|
|
120
|
+
/** Drops a directory claim's trailing slash so it compares against a raw `.gitignore` pattern. */
|
|
121
|
+
function withoutTrailingSlash(path: string): string {
|
|
122
|
+
return path.endsWith('/') ? path.slice(0, -1) : path
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a claim names a pattern newly added to `.gitignore`.
|
|
127
|
+
*
|
|
128
|
+
* Compared with the trailing slash both sides may or may not carry stripped,
|
|
129
|
+
* since a bullet spells a folder pattern the way `.gitignore` itself does
|
|
130
|
+
* (`web/screenshots/`) and the diff line carries the identical text.
|
|
131
|
+
*/
|
|
132
|
+
function coversIgnoreAddition(
|
|
133
|
+
claim: PathClaim,
|
|
134
|
+
ignoreAdditions: readonly string[],
|
|
135
|
+
): boolean {
|
|
136
|
+
const named = withoutTrailingSlash(claim.path)
|
|
137
|
+
return ignoreAdditions.some(
|
|
138
|
+
(pattern) => withoutTrailingSlash(pattern) === named,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
97
142
|
/**
|
|
98
143
|
* Whether one changed path is the file, or a file under the folder, a claim
|
|
99
144
|
* names.
|
|
@@ -147,6 +192,10 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
147
192
|
return { kind: 'refused', reason: 'no-section' }
|
|
148
193
|
if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
|
|
149
194
|
|
|
195
|
+
const renames = input.renames ?? []
|
|
196
|
+
const ignoreAdditions = input.ignoreAdditions ?? []
|
|
197
|
+
const evidenceUnread = input.evidenceUnread ?? false
|
|
198
|
+
|
|
150
199
|
const unmet: PathClaim[] = []
|
|
151
200
|
const unresolved: PathClaim[] = []
|
|
152
201
|
const named = new Set<string>()
|
|
@@ -155,7 +204,15 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
155
204
|
const hits = input.changed.filter((path) => covers(claim, path))
|
|
156
205
|
for (const path of hits) named.add(path)
|
|
157
206
|
if (hits.length > 0) continue
|
|
158
|
-
|
|
207
|
+
|
|
208
|
+
if (renames.some((rename) => covers(claim, rename.from))) continue
|
|
209
|
+
|
|
210
|
+
if (coversIgnoreAddition(claim, ignoreAdditions)) {
|
|
211
|
+
named.add('.gitignore')
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (claim.anchored && claim.leading && !evidenceUnread) unmet.push(claim)
|
|
159
216
|
else unresolved.push(claim)
|
|
160
217
|
}
|
|
161
218
|
|
|
@@ -170,6 +227,7 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
170
227
|
unresolved,
|
|
171
228
|
unnamed: reached.filter((path) => !owesNoBullet(path)),
|
|
172
229
|
incidental: reached.filter(owesNoBullet),
|
|
230
|
+
evidenceUnread,
|
|
173
231
|
}
|
|
174
232
|
}
|
|
175
233
|
|