@erclx/canon 5.49.0 → 5.51.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 +4024 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +3 -2
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +13 -1
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/target-check/references/domains.md +8 -6
- package/docs/agents/index.md +2 -2
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/migrate.md +2 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +8 -1
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -12
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +4 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/hooks.ts +7 -5
- package/src/commands/markdown.ts +98 -19
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -0
- package/src/migrate/status.ts +128 -0
- package/src/tasks/validate.ts +15 -12
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- package/tooling/web/seeds/canon/design/index.md +23 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
|
@@ -17,6 +17,14 @@ import {
|
|
|
17
17
|
type WorktreeVerdict,
|
|
18
18
|
} from '@/worktrees/reclaim'
|
|
19
19
|
import { mainWorktreeRoot } from '@/git/worktree'
|
|
20
|
+
import {
|
|
21
|
+
type Half,
|
|
22
|
+
type MemberOutcome,
|
|
23
|
+
type MemberRefusal,
|
|
24
|
+
type MembersRefusal,
|
|
25
|
+
type MembersReport,
|
|
26
|
+
retireMembers,
|
|
27
|
+
} from '@/worktrees/members'
|
|
20
28
|
import { machineListenerReader } from '@/listeners/machine'
|
|
21
29
|
import { repairBareFlag } from '@/worktrees/bare-flag'
|
|
22
30
|
import { type StopReport, stopListeners } from '@/worktrees/stop'
|
|
@@ -40,6 +48,31 @@ interface ReclaimCommandOptions {
|
|
|
40
48
|
readonly json?: boolean
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
interface RetireMembersCommandOptions {
|
|
52
|
+
readonly pullRequest: string
|
|
53
|
+
readonly dryRun?: boolean
|
|
54
|
+
readonly json?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the post-merge hook reads after an integrator merges. Every field it
|
|
59
|
+
* branches on is a number or a fixed word, and `members` sits last, for the
|
|
60
|
+
* reason `ReclaimRecord` states.
|
|
61
|
+
*/
|
|
62
|
+
export interface RetireMembersRecord {
|
|
63
|
+
readonly pullRequest: number
|
|
64
|
+
readonly reason: MembersRefusal | null
|
|
65
|
+
readonly detail: string | null
|
|
66
|
+
readonly dryRun: boolean
|
|
67
|
+
/** Members with a half deleted, or that a dry run would delete, and none kept or failed. */
|
|
68
|
+
readonly retired: number
|
|
69
|
+
/** Members with a half kept and none failed. */
|
|
70
|
+
readonly kept: number
|
|
71
|
+
/** Members with a half whose deletion failed. */
|
|
72
|
+
readonly failed: number
|
|
73
|
+
readonly members: readonly MemberOutcome[]
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
/**
|
|
44
77
|
* What a caller reads instead of the frame.
|
|
45
78
|
*
|
|
@@ -145,6 +178,59 @@ const CONDITIONS = [
|
|
|
145
178
|
'keeps a directory rather than removing one.',
|
|
146
179
|
]
|
|
147
180
|
|
|
181
|
+
const MEMBERS_REFUSALS: Record<MembersRefusal, string> = {
|
|
182
|
+
'gh-missing': 'gh is not on the path, so the pull request could not be read',
|
|
183
|
+
'gh-failed': 'the pull request read failed',
|
|
184
|
+
'sessions-unreadable': 'the session roster could not be read',
|
|
185
|
+
'sessions-unchecked':
|
|
186
|
+
'some session records come from another pid namespace and no socket answers for them',
|
|
187
|
+
'not-merged': 'the pull request has not merged',
|
|
188
|
+
'no-members': 'the body carries no "Fan-in members:" line',
|
|
189
|
+
'trunk-named': 'the line names the branch the pull request merged into',
|
|
190
|
+
'head-named': "the line names the integrator's own branch",
|
|
191
|
+
'open-pull-request': 'a named branch has an open pull request of its own',
|
|
192
|
+
'head-unreadable':
|
|
193
|
+
'refs/pull/<n>/head could not be fetched, or names another commit than the merged head',
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const MEMBER_REFUSALS: Record<MemberRefusal, string> = {
|
|
197
|
+
'not-in-merged-head':
|
|
198
|
+
"its tip is not an ancestor of the integrator's merged head, so it holds work the merge did not carry",
|
|
199
|
+
'remote-unreadable': 'origin could not be read for this branch',
|
|
200
|
+
'worktree-kept':
|
|
201
|
+
'its worktree was kept, and git refuses to delete a branch a worktree holds',
|
|
202
|
+
'main-worktree': 'it is checked out in the main worktree',
|
|
203
|
+
'current-worktree': 'its worktree is the one this command runs in',
|
|
204
|
+
'detached-head': 'its worktree has a detached HEAD',
|
|
205
|
+
'uncommitted-changes': 'its worktree holds uncommitted work',
|
|
206
|
+
'unreadable-worktree': 'its worktree could not be read',
|
|
207
|
+
'held-by-session': 'a live session holds its worktree',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const RETIRE_CONDITIONS = [
|
|
211
|
+
'Reads the "Fan-in members:" line of a merged integrator pull request,',
|
|
212
|
+
"whose backticked branches are the members, and deletes each one's",
|
|
213
|
+
'origin branch, worktree, and local branch. Nothing outside that line',
|
|
214
|
+
'is read.',
|
|
215
|
+
'',
|
|
216
|
+
"A member half is merged when its tip is an ancestor of the integrator's",
|
|
217
|
+
'merged head, fetched from refs/pull/<n>/head, rather than of the trunk,',
|
|
218
|
+
'which a squash merge never makes it. Origin and local are judged apart,',
|
|
219
|
+
'so an unpushed commit keeps the local branch and a commit pushed after',
|
|
220
|
+
'the merge keeps the origin branch. The worktree takes the same reading',
|
|
221
|
+
'`canon worktrees list` gives, with the members counted as merged.',
|
|
222
|
+
'',
|
|
223
|
+
'These refuse the whole run, and nothing is deleted:',
|
|
224
|
+
...Object.entries(MEMBERS_REFUSALS).map(
|
|
225
|
+
([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
|
|
226
|
+
),
|
|
227
|
+
'',
|
|
228
|
+
'These keep one half of one member, named in its "reasons":',
|
|
229
|
+
...Object.entries(MEMBER_REFUSALS).map(
|
|
230
|
+
([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
|
|
231
|
+
),
|
|
232
|
+
]
|
|
233
|
+
|
|
148
234
|
export function register(program: Command): void {
|
|
149
235
|
const worktrees = program
|
|
150
236
|
.command('worktrees')
|
|
@@ -240,6 +326,42 @@ export function register(program: Command): void {
|
|
|
240
326
|
process.exitCode = await runReclaim(opts)
|
|
241
327
|
})
|
|
242
328
|
|
|
329
|
+
worktrees
|
|
330
|
+
.command('retire-members')
|
|
331
|
+
.description(
|
|
332
|
+
'Delete the fan-in members a merged integrator pull request names, unless --dry-run',
|
|
333
|
+
)
|
|
334
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
335
|
+
.requiredOption(
|
|
336
|
+
'--pull-request <number>',
|
|
337
|
+
'The merged integrator pull request',
|
|
338
|
+
)
|
|
339
|
+
.option('--dry-run', 'Report what would be deleted without deleting it')
|
|
340
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
341
|
+
.addHelpText(
|
|
342
|
+
'after',
|
|
343
|
+
[
|
|
344
|
+
'',
|
|
345
|
+
'Exit codes:',
|
|
346
|
+
' 0 every member was retired or kept, or the body names none',
|
|
347
|
+
' 1 refused, or a deletion failed, with the reason on stderr',
|
|
348
|
+
'',
|
|
349
|
+
...RETIRE_CONDITIONS,
|
|
350
|
+
'',
|
|
351
|
+
'The post-merge hook runs this once per merged pull request number,',
|
|
352
|
+
'and CANON_SKIP_RECLAIM turns it off there. Run it by hand for a',
|
|
353
|
+
'merge the hook missed.',
|
|
354
|
+
'',
|
|
355
|
+
'Examples:',
|
|
356
|
+
' canon worktrees retire-members --pull-request 2570 --dry-run',
|
|
357
|
+
' canon worktrees retire-members --pull-request 2570 --json',
|
|
358
|
+
'',
|
|
359
|
+
].join('\n'),
|
|
360
|
+
)
|
|
361
|
+
.action(async (opts: RetireMembersCommandOptions) => {
|
|
362
|
+
process.exitCode = await runRetireMembers(opts)
|
|
363
|
+
})
|
|
364
|
+
|
|
243
365
|
worktrees
|
|
244
366
|
.command('repair-bare-flag', { hidden: true })
|
|
245
367
|
.description('Clear a core.bare flag that worktree entry left set')
|
|
@@ -563,3 +685,154 @@ export function describe(verdict: WorktreeVerdict): string {
|
|
|
563
685
|
` Reclaimable. Pull request #${verdict.pullRequest} merged it, ${tree}, and no session holds it.`,
|
|
564
686
|
].join('\n')
|
|
565
687
|
}
|
|
688
|
+
|
|
689
|
+
function halves(member: MemberOutcome): readonly Half[] {
|
|
690
|
+
return member.worktree === null
|
|
691
|
+
? [member.origin, member.local]
|
|
692
|
+
: [member.origin, member.worktree, member.local]
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Builds the record from the report, separate from the run so the counts are
|
|
697
|
+
* testable without a repository. A member already gone everywhere counts
|
|
698
|
+
* nowhere, so a run repeated by hand does not report a retirement it did not
|
|
699
|
+
* make.
|
|
700
|
+
*/
|
|
701
|
+
export function retireMembersRecord(
|
|
702
|
+
pullRequest: number,
|
|
703
|
+
dryRun: boolean,
|
|
704
|
+
report: MembersReport,
|
|
705
|
+
): RetireMembersRecord {
|
|
706
|
+
if (report.kind === 'refused') {
|
|
707
|
+
return {
|
|
708
|
+
pullRequest,
|
|
709
|
+
reason: report.reason,
|
|
710
|
+
detail: report.detail,
|
|
711
|
+
dryRun,
|
|
712
|
+
retired: 0,
|
|
713
|
+
kept: 0,
|
|
714
|
+
failed: 0,
|
|
715
|
+
members: [],
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const states = report.members.map((member) =>
|
|
720
|
+
halves(member).map((half) => half.state),
|
|
721
|
+
)
|
|
722
|
+
const failed = states.filter((each) => each.includes('failed')).length
|
|
723
|
+
const kept = states.filter(
|
|
724
|
+
(each) => !each.includes('failed') && each.includes('kept'),
|
|
725
|
+
).length
|
|
726
|
+
const retired = states.filter(
|
|
727
|
+
(each) =>
|
|
728
|
+
!each.includes('failed') &&
|
|
729
|
+
!each.includes('kept') &&
|
|
730
|
+
each.some((state) => state === 'deleted' || state === 'would-delete'),
|
|
731
|
+
).length
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
pullRequest,
|
|
735
|
+
reason: null,
|
|
736
|
+
detail: null,
|
|
737
|
+
dryRun,
|
|
738
|
+
retired,
|
|
739
|
+
kept,
|
|
740
|
+
failed,
|
|
741
|
+
members: report.members,
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* A kept half is a refusal doing its job and a body naming no members is the
|
|
747
|
+
* ordinary merge, so neither fails the run.
|
|
748
|
+
*/
|
|
749
|
+
export function retireMembersExit(record: RetireMembersRecord): number {
|
|
750
|
+
if (record.reason !== null && record.reason !== 'no-members') return 1
|
|
751
|
+
return record.failed > 0 ? 1 : 0
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function describeHalf(name: string, half: Half): string {
|
|
755
|
+
if (half.state === 'kept') {
|
|
756
|
+
const reasons = half.reasons.map((reason) => MEMBER_REFUSALS[reason])
|
|
757
|
+
return ` ${name}: kept, since ${reasons.join('; ')}.`
|
|
758
|
+
}
|
|
759
|
+
const state = half.state.replace('-', ' ')
|
|
760
|
+
return half.detail === null
|
|
761
|
+
? ` ${name}: ${state}.`
|
|
762
|
+
: ` ${name}: ${state}. ${half.detail}`
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function runRetireMembers(
|
|
766
|
+
opts: RetireMembersCommandOptions,
|
|
767
|
+
): Promise<number> {
|
|
768
|
+
const dryRun = opts.dryRun ?? false
|
|
769
|
+
const pullRequest = Number(opts.pullRequest)
|
|
770
|
+
|
|
771
|
+
intro(
|
|
772
|
+
dryRun
|
|
773
|
+
? 'canon worktrees retire-members (dry-run)'
|
|
774
|
+
: 'canon worktrees retire-members',
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
if (!Number.isInteger(pullRequest) || pullRequest <= 0) {
|
|
778
|
+
logStep('Refused')
|
|
779
|
+
logWarn(
|
|
780
|
+
`--pull-request takes a pull request number, and "${opts.pullRequest}" is not one.`,
|
|
781
|
+
)
|
|
782
|
+
outro()
|
|
783
|
+
return 1
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// The reading stays on the caller's directory, which is what refuses the
|
|
787
|
+
// worktree it stands in, and every write runs from the main root.
|
|
788
|
+
const report = await retireMembers(pullRequest, {
|
|
789
|
+
cwd: process.cwd(),
|
|
790
|
+
root: await mainWorktreeRoot(),
|
|
791
|
+
dryRun,
|
|
792
|
+
})
|
|
793
|
+
const record = retireMembersRecord(pullRequest, dryRun, report)
|
|
794
|
+
|
|
795
|
+
if (report.kind === 'refused') {
|
|
796
|
+
logStep(report.reason === 'no-members' ? 'Members' : 'Refused')
|
|
797
|
+
if (report.reason === 'no-members') logInfo(report.detail)
|
|
798
|
+
else {
|
|
799
|
+
logWarn(`Nothing was deleted: ${MEMBERS_REFUSALS[report.reason]}.`)
|
|
800
|
+
logInfo(report.detail)
|
|
801
|
+
}
|
|
802
|
+
} else {
|
|
803
|
+
logStep('Members')
|
|
804
|
+
pipeOutput(
|
|
805
|
+
report.members
|
|
806
|
+
.map((member) =>
|
|
807
|
+
[
|
|
808
|
+
member.branch,
|
|
809
|
+
describeHalf('origin', member.origin),
|
|
810
|
+
...(member.worktree === null
|
|
811
|
+
? []
|
|
812
|
+
: [
|
|
813
|
+
describeHalf(
|
|
814
|
+
`worktree ${member.worktree.path}`,
|
|
815
|
+
member.worktree,
|
|
816
|
+
),
|
|
817
|
+
]),
|
|
818
|
+
describeHalf('local', member.local),
|
|
819
|
+
].join('\n'),
|
|
820
|
+
)
|
|
821
|
+
.join('\n'),
|
|
822
|
+
)
|
|
823
|
+
if (record.failed > 0) {
|
|
824
|
+
logWarn(
|
|
825
|
+
`${plural(record.failed, 'member')} could not be fully deleted. Read the reason above and clear it by hand.`,
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
if (dryRun) {
|
|
829
|
+
logWarn(
|
|
830
|
+
'Nothing was deleted. Run this again without --dry-run to delete.',
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
outro()
|
|
836
|
+
if (opts.json === true) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
837
|
+
return retireMembersExit(record)
|
|
838
|
+
}
|
package/src/context/audit.ts
CHANGED
|
@@ -108,13 +108,14 @@ export const OTHER_FOLDERS = 'Other folders'
|
|
|
108
108
|
export const SCRIPTS_HEADING = 'Scripts'
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* Whether an entry path is the development entry, being the flat entry
|
|
112
|
-
*
|
|
113
|
-
* `local-loop`
|
|
114
|
-
* `Scripts` H3 for that entry alone, so the path is what
|
|
111
|
+
* Whether an entry path is the development entry, being the flat entry or any
|
|
112
|
+
* entry directly under a `development/` folder, since a split folder names its
|
|
113
|
+
* scripts entry by project, as `local-loop` here and `local` in another. The
|
|
114
|
+
* standard exempts the `Scripts` H3 for that entry alone, so the path is what
|
|
115
|
+
* tells.
|
|
115
116
|
*/
|
|
116
117
|
function isDevelopmentEntry(rel: string): boolean {
|
|
117
|
-
return /(?:^|\/)development(?:\/
|
|
118
|
+
return /(?:^|\/)development(?:\/[^/]+)?\.md$/.test(rel)
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
package/src/design/budget.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { canonicalHeading } from '@/design/parse'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The prose budget `claude/skills/
|
|
4
|
+
* The prose budget `claude/skills/design-record/references/design.md` sets for a design record, and the
|
|
5
5
|
* counter the `design` gate stage reads it through.
|
|
6
6
|
*
|
|
7
7
|
* The limits live here and in the standard, never in the stage, so the stage
|
package/src/design/components.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `buildDesignCss`'s `components` option, which is what widening that option
|
|
16
16
|
* from a boolean to `boolean | readonly Component[]` bought.
|
|
17
17
|
*
|
|
18
|
-
* It does not live in `canon/DESIGN.md`. `claude/skills/
|
|
18
|
+
* It does not live in `canon/DESIGN.md`. `claude/skills/design-record/references/design.md` keeps CSS class
|
|
19
19
|
* names out of that record and says they live in code, which is here.
|
|
20
20
|
*/
|
|
21
21
|
|
package/src/design/document.ts
CHANGED
|
@@ -94,7 +94,7 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
|
|
|
94
94
|
* and every reason behind them lives in the entry.
|
|
95
95
|
*/
|
|
96
96
|
const LEAD = [
|
|
97
|
-
'Authoring guidance: `claude/skills/
|
|
97
|
+
'Authoring guidance: `claude/skills/design-record/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
|
|
98
98
|
'Why each value was chosen lives in `canon/context/features/design.md`.',
|
|
99
99
|
].join(' ')
|
|
100
100
|
|
package/src/design/regen.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { NEUTRAL_TOKENS } from '@/design/neutral'
|
|
|
16
16
|
* gate stage is what keeps silently out of it.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
/** The rendered record, at the path `claude/skills/
|
|
19
|
+
/** The rendered record, at the path `claude/skills/design-record/references/design.md` fixes for it. */
|
|
20
20
|
export const DESIGN_DOCUMENT = join('canon', 'DESIGN.md')
|
|
21
21
|
|
|
22
22
|
/**
|
package/src/design/render.ts
CHANGED
|
@@ -105,7 +105,7 @@ interface Confidence {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* The columns the confidence ratio reads, fixed by `claude/skills/
|
|
108
|
+
* The columns the confidence ratio reads, fixed by `claude/skills/design-record/references/design.md`. The
|
|
109
109
|
* first column of each table names its row, and `Multiplier` and `When used`
|
|
110
110
|
* restate what the row already carries, so none of them is something a source
|
|
111
111
|
* could anchor and none belongs in the denominator.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names the machine lock a gate holds while a stage's commands run, so the
|
|
3
|
+
* suite it spawns knows not to wait on its own parent.
|
|
4
|
+
*/
|
|
5
|
+
export const HELD_LOCK_VARIABLE = 'CANON_HELD_LOCK'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The vitest invocation a hand-run lock decision reads.
|
|
9
|
+
*/
|
|
10
|
+
export interface VitestRun {
|
|
11
|
+
/** The CLI arguments after the vitest entry, as `process.argv.slice(2)`. */
|
|
12
|
+
readonly args: readonly string[]
|
|
13
|
+
readonly ci: boolean
|
|
14
|
+
readonly watch: boolean
|
|
15
|
+
/**
|
|
16
|
+
* The lock a parent gate already holds for this run, from `HELD_LOCK_VARIABLE`.
|
|
17
|
+
* A gate's own suite waiting on that lock would wait on its parent forever.
|
|
18
|
+
*/
|
|
19
|
+
readonly heldLock: string | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SUBCOMMANDS = new Set(['run'])
|
|
23
|
+
|
|
24
|
+
/** `-t` and its long forms filter by test name, which scopes the run. */
|
|
25
|
+
function isNameFilter(arg: string): boolean {
|
|
26
|
+
return (
|
|
27
|
+
arg === '-t' ||
|
|
28
|
+
arg.startsWith('-t=') ||
|
|
29
|
+
arg === '--testNamePattern' ||
|
|
30
|
+
arg.startsWith('--testNamePattern=') ||
|
|
31
|
+
arg === '--test-name-pattern' ||
|
|
32
|
+
arg.startsWith('--test-name-pattern=')
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a hand-run vitest invocation runs the whole suite and so queues on
|
|
38
|
+
* the machine lock the gate's Tests stage holds.
|
|
39
|
+
*
|
|
40
|
+
* A scoped run is seconds of work, and queueing it behind a full suite costs
|
|
41
|
+
* more than any collision it causes. Vitest exposes no setup-time API naming
|
|
42
|
+
* the CLI filters, so any bare argument other than the subcommand reads as a
|
|
43
|
+
* file filter. That includes the value of a flag spelled `--flag value`, which
|
|
44
|
+
* misreads such a run as scoped. The misread skips a lock rather than queueing
|
|
45
|
+
* a scoped run, which is the side to err on.
|
|
46
|
+
*/
|
|
47
|
+
export function locksHandSuite(run: VitestRun): boolean {
|
|
48
|
+
if (run.ci || run.watch || run.heldLock !== undefined) return false
|
|
49
|
+
|
|
50
|
+
const args = SUBCOMMANDS.has(run.args[0] ?? '') ? run.args.slice(1) : run.args
|
|
51
|
+
return !args.some((arg) => isNameFilter(arg) || !arg.startsWith('-'))
|
|
52
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -56,7 +56,14 @@ export interface CommandResult {
|
|
|
56
56
|
readonly spawnError?: string
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Spawns one command. `env` adds variables to the child's environment, which a
|
|
61
|
+
* locked stage uses to tell its child which lock the gate already holds.
|
|
62
|
+
*/
|
|
63
|
+
export type RunCommand = (
|
|
64
|
+
argv: readonly string[],
|
|
65
|
+
env?: Readonly<Record<string, string>>,
|
|
66
|
+
) => Promise<CommandResult>
|
|
60
67
|
|
|
61
68
|
export interface MeasureContext {
|
|
62
69
|
readonly root: string
|
|
@@ -331,7 +338,7 @@ export const designProse: Measure = async (ctx) => {
|
|
|
331
338
|
if (failures.length > 0) {
|
|
332
339
|
return {
|
|
333
340
|
emissions: [],
|
|
334
|
-
failure: `${failures.join(' ')} Cut to the budget in claude/skills/
|
|
341
|
+
failure: `${failures.join(' ')} Cut to the budget in claude/skills/design-record/references/design.md, moving the why to the context entry.`,
|
|
335
342
|
}
|
|
336
343
|
}
|
|
337
344
|
if (emissions.length === 0)
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
MeasureContext,
|
|
9
9
|
RunCommand,
|
|
10
10
|
} from '@/gate/measures'
|
|
11
|
+
import { HELD_LOCK_VARIABLE } from '@/gate/hand-suite'
|
|
11
12
|
import type { Check, Stage } from '@/gate/stages'
|
|
12
13
|
import {
|
|
13
14
|
acquireSuiteLock,
|
|
@@ -248,7 +249,7 @@ async function executeStage(
|
|
|
248
249
|
}
|
|
249
250
|
|
|
250
251
|
// CI runs one suite per runner, so a lock there queues behind nothing.
|
|
251
|
-
if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [])
|
|
252
|
+
if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [], {})
|
|
252
253
|
|
|
253
254
|
const held = await acquireSuiteLock({
|
|
254
255
|
path: machineLockPath(stage.lock),
|
|
@@ -266,8 +267,10 @@ async function executeStage(
|
|
|
266
267
|
]
|
|
267
268
|
|
|
268
269
|
try {
|
|
270
|
+
// The suite's own global setup takes this lock for a hand run, so the
|
|
271
|
+
// child is told its parent holds it rather than waiting on the parent.
|
|
269
272
|
const outcome = withLoadLine(
|
|
270
|
-
await runChecks(stage, ctx, queued),
|
|
273
|
+
await runChecks(stage, ctx, queued, { [HELD_LOCK_VARIABLE]: stage.lock }),
|
|
271
274
|
stage,
|
|
272
275
|
ctx,
|
|
273
276
|
)
|
|
@@ -310,11 +313,12 @@ async function runChecks(
|
|
|
310
313
|
stage: Stage,
|
|
311
314
|
ctx: GateContext,
|
|
312
315
|
leading: readonly Emission[],
|
|
316
|
+
env: Readonly<Record<string, string>>,
|
|
313
317
|
): Promise<StageOutcome> {
|
|
314
318
|
const emissions: Emission[] = [...leading]
|
|
315
319
|
|
|
316
320
|
for (const check of stage.checks) {
|
|
317
|
-
const outcome = await runCheck(check, ctx)
|
|
321
|
+
const outcome = await runCheck(check, ctx, env)
|
|
318
322
|
emissions.push(...outcome.emissions)
|
|
319
323
|
|
|
320
324
|
if (outcome.failure !== undefined) {
|
|
@@ -363,7 +367,11 @@ interface CheckOutcome {
|
|
|
363
367
|
readonly unmeasured?: string
|
|
364
368
|
}
|
|
365
369
|
|
|
366
|
-
async function runCheck(
|
|
370
|
+
async function runCheck(
|
|
371
|
+
check: Check,
|
|
372
|
+
ctx: GateContext,
|
|
373
|
+
env: Readonly<Record<string, string>>,
|
|
374
|
+
): Promise<CheckOutcome> {
|
|
367
375
|
if (check.kind === 'measure') {
|
|
368
376
|
const report = await check.measure(ctx)
|
|
369
377
|
return {
|
|
@@ -378,7 +386,7 @@ async function runCheck(check: Check, ctx: GateContext): Promise<CheckOutcome> {
|
|
|
378
386
|
}
|
|
379
387
|
|
|
380
388
|
const run = check.kind === 'cli' ? ctx.cli : ctx.run
|
|
381
|
-
const result = await run(check.argv)
|
|
389
|
+
const result = await run(check.argv, env)
|
|
382
390
|
return {
|
|
383
391
|
emissions: [{ kind: 'output', text: result.all }],
|
|
384
392
|
failure: result.exitCode === 0 ? undefined : check.failure,
|
|
@@ -492,7 +500,7 @@ export function exitCodeFor(results: readonly StageResult[]): number {
|
|
|
492
500
|
* repository the hook points at rather than the tree being verified.
|
|
493
501
|
*/
|
|
494
502
|
export function commandRunner(root: string): RunCommand {
|
|
495
|
-
return async (argv) => spawn(argv[0], argv.slice(1), root)
|
|
503
|
+
return async (argv, env) => spawn(argv[0], argv.slice(1), root, env)
|
|
496
504
|
}
|
|
497
505
|
|
|
498
506
|
/**
|
|
@@ -504,19 +512,20 @@ export function commandRunner(root: string): RunCommand {
|
|
|
504
512
|
*/
|
|
505
513
|
export function cliRunner(root: string): RunCommand {
|
|
506
514
|
const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
|
|
507
|
-
return async (argv) => spawn(process.execPath, [cli, ...argv], root)
|
|
515
|
+
return async (argv, env) => spawn(process.execPath, [cli, ...argv], root, env)
|
|
508
516
|
}
|
|
509
517
|
|
|
510
518
|
async function spawn(
|
|
511
519
|
file: string,
|
|
512
520
|
args: readonly string[],
|
|
513
521
|
cwd: string,
|
|
522
|
+
env: Readonly<Record<string, string>> = {},
|
|
514
523
|
): Promise<CommandResult> {
|
|
515
524
|
const result = await execa(file, [...args], {
|
|
516
525
|
cwd,
|
|
517
526
|
reject: false,
|
|
518
527
|
all: true,
|
|
519
|
-
env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1' },
|
|
528
|
+
env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1', ...env },
|
|
520
529
|
extendEnv: false,
|
|
521
530
|
})
|
|
522
531
|
|
package/src/gov/detect.ts
CHANGED
|
@@ -99,25 +99,35 @@ function walkFiles(target: string): string[] {
|
|
|
99
99
|
return files.sort()
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
interface Candidates {
|
|
103
|
+
readonly paths: readonly string[]
|
|
104
|
+
readonly folders: readonly string[]
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
/**
|
|
103
108
|
* The paths a file glob is matched against: every file to depth two, plus
|
|
104
109
|
* every folder on the way to any listed file, so an evidence folder such as
|
|
105
|
-
* `.github/workflows` matches without naming the file inside it.
|
|
110
|
+
* `.github/workflows` matches without naming the file inside it. `folders`
|
|
111
|
+
* holds the folders alone, which a pattern ending in `/` is matched against.
|
|
106
112
|
*/
|
|
107
|
-
async function candidatePaths(target: string): Promise<
|
|
113
|
+
async function candidatePaths(target: string): Promise<Candidates> {
|
|
108
114
|
const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
|
|
109
115
|
const paths = new Set<string>()
|
|
116
|
+
const folders = new Set<string>()
|
|
110
117
|
|
|
111
118
|
for (const file of listed) {
|
|
112
119
|
let dir = dirname(file)
|
|
113
120
|
while (dir !== '.') {
|
|
114
|
-
if (depth(dir) <= MAX_DIRS)
|
|
121
|
+
if (depth(dir) <= MAX_DIRS) {
|
|
122
|
+
paths.add(dir)
|
|
123
|
+
folders.add(dir)
|
|
124
|
+
}
|
|
115
125
|
dir = dirname(dir)
|
|
116
126
|
}
|
|
117
127
|
if (depth(file) <= MAX_DIRS) paths.add(file)
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
return [...paths].sort()
|
|
130
|
+
return { paths: [...paths].sort(), folders: [...folders].sort() }
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
/** A PEP 508 requirement's distribution name, normalized per PEP 503. */
|
|
@@ -231,6 +241,11 @@ function readDependencies(
|
|
|
231
241
|
return deps
|
|
232
242
|
}
|
|
233
243
|
|
|
244
|
+
/** A pattern ending in `/` matches folders only, so a file at that depth misses. */
|
|
245
|
+
function isFolderPattern(pattern: string): boolean {
|
|
246
|
+
return pattern.endsWith('/')
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
/** A glob carrying no wildcard names one path rather than a pattern. */
|
|
235
250
|
function isLiteral(pattern: string): boolean {
|
|
236
251
|
return !/[*?[\]{}]/.test(pattern)
|
|
@@ -245,7 +260,7 @@ function matchEntry(
|
|
|
245
260
|
entry: DetectEntry,
|
|
246
261
|
target: string,
|
|
247
262
|
deps: ReadonlyMap<string, readonly string[]>,
|
|
248
|
-
|
|
263
|
+
candidates: Candidates,
|
|
249
264
|
): Evidence[] {
|
|
250
265
|
const evidence: Evidence[] = []
|
|
251
266
|
|
|
@@ -255,9 +270,12 @@ function matchEntry(
|
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
for (const pattern of entry.files) {
|
|
258
|
-
const
|
|
273
|
+
const folderOnly = isFolderPattern(pattern)
|
|
274
|
+
const glob = new Bun.Glob(folderOnly ? pattern.slice(0, -1) : pattern)
|
|
259
275
|
const hit =
|
|
260
|
-
paths.find((path) =>
|
|
276
|
+
(folderOnly ? candidates.folders : candidates.paths).find((path) =>
|
|
277
|
+
glob.match(path),
|
|
278
|
+
) ??
|
|
261
279
|
(isLiteral(pattern) && existsSync(join(target, pattern))
|
|
262
280
|
? pattern
|
|
263
281
|
: undefined)
|
|
@@ -287,13 +305,13 @@ export async function detectRules(
|
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
const live = indexRuleSources(root)
|
|
290
|
-
const
|
|
291
|
-
const deps = readDependencies(target, paths)
|
|
308
|
+
const candidates = await candidatePaths(target)
|
|
309
|
+
const deps = readDependencies(target, candidates.paths)
|
|
292
310
|
|
|
293
311
|
const proposals = loadDetectEntries(root)
|
|
294
312
|
.filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
|
|
295
313
|
.flatMap((entry) => {
|
|
296
|
-
const evidence = matchEntry(entry, target, deps,
|
|
314
|
+
const evidence = matchEntry(entry, target, deps, candidates)
|
|
297
315
|
return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
|
|
298
316
|
})
|
|
299
317
|
|