@erclx/canon 5.50.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +4024 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  6. package/claude/skills/ci-workflow/SKILL.md +26 -1
  7. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  8. package/claude/skills/design-record/references/folder.md +1 -1
  9. package/claude/skills/design-taste/SKILL.md +2 -1
  10. package/claude/skills/git-followup/SKILL.md +1 -0
  11. package/claude/skills/git-pr/references/pr.md +1 -1
  12. package/claude/skills/plan-feature/references/batches.md +1 -1
  13. package/claude/skills/target-check/references/domains.md +8 -6
  14. package/docs/agents/index.md +1 -1
  15. package/docs/agents/migrate.md +2 -0
  16. package/docs/agents/targets.md +8 -1
  17. package/docs/target/migrations.md +1 -1
  18. package/docs/workflow/parallel-features.md +1 -1
  19. package/governance/detect.toml +1 -1
  20. package/governance/ledger/tooling/ci-workflow.md +6 -0
  21. package/governance/ledger/ui/design-reference.md +8 -13
  22. package/governance/rules/tooling/ci-workflow.md +4 -0
  23. package/governance/rules/ui/design-reference.md +3 -6
  24. package/package.json +2 -1
  25. package/scripts/ci/check-browsers.sh +13 -0
  26. package/scripts/ci/delete-previews.sh +34 -0
  27. package/scripts/ci/publish-check.sh +29 -0
  28. package/scripts/ci/refresh-frames-pr.sh +63 -0
  29. package/scripts/ci/report-baseline.sh +23 -0
  30. package/scripts/ci/stack-issues.sh +24 -0
  31. package/scripts/ci/verify-npm-token.sh +14 -0
  32. package/src/cli/help.ts +1 -0
  33. package/src/cli.ts +2 -0
  34. package/src/commands/changelog.ts +146 -0
  35. package/src/commands/hooks.ts +7 -5
  36. package/src/commands/migrate.ts +77 -0
  37. package/src/commands/worktrees.ts +273 -0
  38. package/src/gov/detect.ts +28 -10
  39. package/src/hooks/post-merge.ts +45 -6
  40. package/src/migrate/status.ts +128 -0
  41. package/src/version/changelog.ts +156 -0
  42. package/src/worktrees/members.ts +540 -0
  43. package/tooling/base/configs/.husky/post-merge +7 -6
  44. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  45. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  46. package/tooling/cloudflare/reference.md +7 -4
  47. package/tooling/web/reference.md +5 -0
  48. package/tooling/web/seeds/canon/design/index.md +23 -0
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<string[]> {
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) paths.add(dir)
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
- paths: readonly string[],
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 glob = new Bun.Glob(pattern)
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) => glob.match(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 paths = await candidatePaths(target)
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, paths)
314
+ const evidence = matchEntry(entry, target, deps, candidates)
297
315
  return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
298
316
  })
299
317
 
@@ -39,6 +39,7 @@ export interface PostMergeInput {
39
39
  export const STEP_VERBS = {
40
40
  archive: ['tasks', 'archive'],
41
41
  push: ['records', 'push'],
42
+ members: ['worktrees', 'retire-members'],
42
43
  reclaim: ['worktrees', 'reclaim'],
43
44
  upgrade: ['upgrade'],
44
45
  pluginUpdate: ['claude', 'plugin-update'],
@@ -225,6 +226,33 @@ function reclaimLines(fields: Fields): string | undefined {
225
226
  return lines.length > 0 ? lines : undefined
226
227
  }
227
228
 
229
+ /**
230
+ * Keyed on the fields, like the reclaim, since a run that retires one member
231
+ * and fails on another exits 1 with a branch already deleted. A body naming no
232
+ * members is every merge but an integrator's, so it stays quiet.
233
+ */
234
+ function memberLines(number: number, fields: Fields): string | undefined {
235
+ let lines = ''
236
+ const retired = count(fields, 'retired')
237
+ if (retired > 0) {
238
+ lines += `\n🌿 Retired ${retired} fan-in member(s) of #${number}.\n\n`
239
+ }
240
+
241
+ const hint = `Run canon worktrees retire-members --pull-request ${number} --dry-run to read why.\n\n`
242
+ const reason = text(fields, 'reason')
243
+ const unfinished = count(fields, 'kept') + count(fields, 'failed')
244
+ if (
245
+ reason !== undefined &&
246
+ reason !== 'no-members' &&
247
+ reason !== 'gh-missing'
248
+ ) {
249
+ lines += `\n🌿 Fan-in members of #${number} not retired: ${reason}\n${hint}`
250
+ } else if (unfinished > 0) {
251
+ lines += `\n🌿 ${unfinished} fan-in member(s) of #${number} kept or not fully deleted.\n${hint}`
252
+ }
253
+ return lines.length > 0 ? lines : undefined
254
+ }
255
+
228
256
  /**
229
257
  * Shared by the binary reinstall and the plugin update. `current` is the
230
258
  * ordinary outcome on most merges and stays quiet, and `quiet` names the
@@ -271,8 +299,10 @@ async function step(
271
299
  }
272
300
 
273
301
  /**
274
- * Runs the five post-merge steps in order: archive, records push, reclaim,
275
- * upgrade, plugin update. The reclaim sits before the upgrade, since the
302
+ * Runs the six post-merge steps in order: archive, records push, member
303
+ * retirement, reclaim, upgrade, plugin update. The member retirement sits
304
+ * before the reclaim, so the reclaim reads a retired member's worktree as
305
+ * gone rather than racing it. Both sit before the upgrade, since the
276
306
  * upgrade reinstalls the binary every later child runs under, and the two
277
307
  * network-bound moves sit last so a slow registry delays nothing else.
278
308
  *
@@ -309,11 +339,20 @@ export async function runPostMerge(input: PostMergeInput): Promise<void> {
309
339
  pushLines,
310
340
  )
311
341
 
312
- // No `--root` here, and that is load-bearing. The verb reads its own working
313
- // directory to refuse the worktree it stands in, so a root would turn the
314
- // running worktree into an ordinary candidate and let a pull inside a
315
- // linked worktree delete the ground under itself.
342
+ // No `--root` on either worktree step, and that is load-bearing. Each verb
343
+ // reads its own working directory to refuse the worktree it stands in, so a
344
+ // root would turn the running worktree into an ordinary candidate and let a
345
+ // pull inside a linked worktree delete the ground under itself. One switch
346
+ // covers both, since both remove worktrees.
316
347
  if (!isSkipped('CANON_SKIP_RECLAIM')) {
348
+ for (const number of pullRequestNumbers(input.subjects)) {
349
+ await step(
350
+ input,
351
+ at([...STEP_VERBS.members, '--pull-request', String(number), '--json']),
352
+ (_, fields) => memberLines(number, fields),
353
+ )
354
+ }
355
+
317
356
  await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
318
357
  reclaimLines(fields),
319
358
  )
@@ -0,0 +1,128 @@
1
+ import type { RunCommand } from '@/gate/measures'
2
+
3
+ interface Migration {
4
+ readonly name: string
5
+ readonly flags: readonly string[]
6
+ }
7
+
8
+ /**
9
+ * Every `canon migrate` subcommand in the order a project runs them, records
10
+ * first. This is the source for that order, and `docs/target/migrations.md`
11
+ * cites the verb rather than keeping a second list. A new migration joins by
12
+ * one line here.
13
+ */
14
+ export const MIGRATIONS: readonly Migration[] = [
15
+ { name: 'records', flags: [] },
16
+ { name: 'record-tree', flags: [] },
17
+ { name: 'scratch-evidence', flags: [] },
18
+ { name: 'record-layout', flags: [] },
19
+ { name: 'rule-layout', flags: [] },
20
+ { name: 'rename', flags: ['--scope', 'target'] },
21
+ { name: 'skill-names', flags: [] },
22
+ { name: 'surface-roots', flags: [] },
23
+ { name: 'session-defaults', flags: [] },
24
+ ]
25
+
26
+ export type MigrationState = 'pending' | 'clean' | 'refused'
27
+
28
+ export interface MigrationStatus {
29
+ readonly name: string
30
+ readonly verb: string
31
+ readonly state: MigrationState
32
+ /** The subcommand's own `--json` record, carried through unread. */
33
+ readonly record?: unknown
34
+ readonly reason?: string
35
+ }
36
+
37
+ export interface StatusReport {
38
+ readonly migrations: readonly MigrationStatus[]
39
+ readonly pending: number
40
+ readonly refused: number
41
+ }
42
+
43
+ function parseRecord(stdout: string): unknown {
44
+ try {
45
+ return JSON.parse(stdout)
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ const FRAME = /^[\s│├┌└]+/
52
+
53
+ /**
54
+ * A refusing subcommand writes its stderr inside the CLI's frame, so the raw
55
+ * text would carry the glyphs into a record a skill reads as data. The error
56
+ * lines are the reason, and a stderr without a marker keeps every line.
57
+ */
58
+ function refusalReason(stderr: string): string {
59
+ const lines = stderr.split('\n').map((line) => line.replace(FRAME, '').trim())
60
+ const errors = lines
61
+ .filter((line) => line.startsWith('✗'))
62
+ .map((line) => line.replace(/^✗\s*/, ''))
63
+ const kept = errors.length > 0 ? errors : lines.filter(Boolean)
64
+ return kept.map((line) => line.replace(/\.+$/, '')).join('. ')
65
+ }
66
+
67
+ async function readOne(
68
+ run: RunCommand,
69
+ migration: Migration,
70
+ ): Promise<MigrationStatus> {
71
+ const verb = `canon migrate ${migration.name}`
72
+ const result = await run([
73
+ 'migrate',
74
+ migration.name,
75
+ ...migration.flags,
76
+ '--json',
77
+ ])
78
+
79
+ if (result.exitCode === 0) {
80
+ return { name: migration.name, verb, state: 'clean' }
81
+ }
82
+
83
+ if (result.exitCode === 2) {
84
+ return {
85
+ name: migration.name,
86
+ verb,
87
+ state: 'pending',
88
+ record: parseRecord(result.stdout),
89
+ }
90
+ }
91
+
92
+ const reason =
93
+ result.spawnError ??
94
+ (refusalReason(result.stderr) ||
95
+ `exited ${result.exitCode} with no message`)
96
+ return {
97
+ name: migration.name,
98
+ verb,
99
+ state: 'refused',
100
+ record: parseRecord(result.stdout),
101
+ reason,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Runs each migration's own dry run and sorts it by the exit contract every
107
+ * subcommand shares: 0 with nothing to move, 2 with a plan and no `--write`,
108
+ * anything else a refusal. It never passes `--write`, and it reads no count,
109
+ * since the records share no field to count from.
110
+ */
111
+ export async function readMigrationStatus(
112
+ run: RunCommand,
113
+ ): Promise<StatusReport> {
114
+ const migrations = await Promise.all(
115
+ MIGRATIONS.map((migration) => readOne(run, migration)),
116
+ )
117
+ return {
118
+ migrations,
119
+ pending: migrations.filter((m) => m.state === 'pending').length,
120
+ refused: migrations.filter((m) => m.state === 'refused').length,
121
+ }
122
+ }
123
+
124
+ /** A refusal outranks a pending plan, since a refused read measured nothing. */
125
+ export function statusExitCode(report: StatusReport): 0 | 1 | 2 {
126
+ if (report.refused > 0) return 1
127
+ return report.pending > 0 ? 2 : 0
128
+ }
@@ -0,0 +1,156 @@
1
+ import { compareVersions, parseVersion } from '@/version/compare'
2
+
3
+ export interface ChangelogEntry {
4
+ readonly version: string
5
+ /** The entry's heading line through its last line, trimmed. */
6
+ readonly markdown: string
7
+ }
8
+
9
+ export type ChangelogRange =
10
+ | { readonly kind: 'range'; readonly entries: readonly ChangelogEntry[] }
11
+ | { readonly kind: 'unknown-version'; readonly version: string }
12
+
13
+ /**
14
+ * `release-please` writes each release as `## [x.y.z](compare-url) (date)`, so
15
+ * this is the one form the split reads. A heading in any other form is body
16
+ * text of the entry above it, and a change to that form in the release tool
17
+ * leaves the file reading as no entries at all.
18
+ */
19
+ const HEADING = /^## \[([^\]]+)\]/
20
+
21
+ /** Entries in file order, which `release-please` keeps newest first. */
22
+ export function parseChangelog(text: string): ChangelogEntry[] {
23
+ const entries: ChangelogEntry[] = []
24
+ let current: { version: string; lines: string[] } | undefined
25
+
26
+ const close = () => {
27
+ if (current === undefined) return
28
+ entries.push({
29
+ version: current.version,
30
+ markdown: current.lines.join('\n').trim(),
31
+ })
32
+ }
33
+
34
+ for (const line of text.split('\n')) {
35
+ const match = HEADING.exec(line)
36
+ if (match !== null) {
37
+ close()
38
+ current = { version: match[1] ?? '', lines: [line] }
39
+ } else {
40
+ current?.lines.push(line)
41
+ }
42
+ }
43
+ close()
44
+
45
+ return entries
46
+ }
47
+
48
+ /**
49
+ * The releases after `since` up to and including `running`, oldest first so a
50
+ * reader meets them in the order they shipped.
51
+ *
52
+ * A version the file never listed, or one that does not parse, or a `since`
53
+ * newer than `running`, names no range. Each reads as `unknown-version` rather
54
+ * than an empty success, since an empty range reads as a target with nothing
55
+ * to catch up on. `since` equal to `running` is the one empty range, because
56
+ * that target is current.
57
+ */
58
+ export function entriesSince(
59
+ entries: readonly ChangelogEntry[],
60
+ since: string,
61
+ running: string,
62
+ ): ChangelogRange {
63
+ const sinceParsed = parseVersion(since)
64
+ const runningParsed = parseVersion(running)
65
+ const listed = new Set(entries.map((entry) => entry.version))
66
+
67
+ if (sinceParsed === undefined || !listed.has(since)) {
68
+ return { kind: 'unknown-version', version: since }
69
+ }
70
+ if (runningParsed === undefined || !listed.has(running)) {
71
+ return { kind: 'unknown-version', version: running }
72
+ }
73
+ if (compareVersions(sinceParsed, runningParsed) > 0) {
74
+ return { kind: 'unknown-version', version: since }
75
+ }
76
+
77
+ const inRange = entries.filter((entry) => {
78
+ const parsed = parseVersion(entry.version)
79
+ return (
80
+ parsed !== undefined &&
81
+ compareVersions(parsed, sinceParsed) > 0 &&
82
+ compareVersions(parsed, runningParsed) <= 0
83
+ )
84
+ })
85
+
86
+ return { kind: 'range', entries: inRange.toReversed() }
87
+ }
88
+
89
+ export interface ChangelogInput {
90
+ /** The file's text, or undefined when it could not be read. */
91
+ readonly text: string | undefined
92
+ readonly running: string
93
+ /** An explicit start, which wins over the stamp. */
94
+ readonly since?: string
95
+ /** The version each stamped domain recorded. */
96
+ readonly stamped: readonly string[]
97
+ }
98
+
99
+ export type ChangelogReport =
100
+ | {
101
+ readonly kind: 'range'
102
+ readonly since: string
103
+ readonly running: string
104
+ readonly entries: readonly ChangelogEntry[]
105
+ }
106
+ | {
107
+ readonly kind: 'refused'
108
+ readonly reason: 'no-stamp' | 'no-changelog' | 'unknown-version'
109
+ readonly version?: string
110
+ }
111
+
112
+ /**
113
+ * Every input arrives as a value, so the refusals are decided here rather than
114
+ * where the file and the stamp are read. A file with no release heading refuses
115
+ * as `no-changelog`, since the entries it would range over are not there.
116
+ */
117
+ export function resolveChangelog(input: ChangelogInput): ChangelogReport {
118
+ const since = input.since ?? oldestVersion(input.stamped)
119
+ if (since === undefined) return { kind: 'refused', reason: 'no-stamp' }
120
+
121
+ const entries = input.text === undefined ? [] : parseChangelog(input.text)
122
+ if (entries.length === 0) return { kind: 'refused', reason: 'no-changelog' }
123
+
124
+ const range = entriesSince(entries, since, input.running)
125
+ if (range.kind === 'unknown-version') {
126
+ return {
127
+ kind: 'refused',
128
+ reason: 'unknown-version',
129
+ version: range.version,
130
+ }
131
+ }
132
+
133
+ return {
134
+ kind: 'range',
135
+ since,
136
+ running: input.running,
137
+ entries: range.entries,
138
+ }
139
+ }
140
+
141
+ /** The oldest version that parses, or undefined when none does. */
142
+ export function oldestVersion(versions: readonly string[]): string | undefined {
143
+ let oldest:
144
+ | { raw: string; parsed: NonNullable<ReturnType<typeof parseVersion>> }
145
+ | undefined
146
+
147
+ for (const raw of versions) {
148
+ const parsed = parseVersion(raw)
149
+ if (parsed === undefined) continue
150
+ if (oldest === undefined || compareVersions(parsed, oldest.parsed) < 0) {
151
+ oldest = { raw, parsed }
152
+ }
153
+ }
154
+
155
+ return oldest?.raw
156
+ }