@erclx/canon 5.50.0 → 5.52.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 (61) hide show
  1. package/CHANGELOG.md +4037 -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 +2 -2
  12. package/claude/skills/plan-feature/references/batches.md +1 -1
  13. package/claude/skills/review-address/SKILL.md +1 -1
  14. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +2 -2
  16. package/claude/skills/target-check/SKILL.md +2 -0
  17. package/claude/skills/target-check/references/domains.md +8 -6
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/docs/agents/index.md +1 -1
  22. package/docs/agents/migrate.md +3 -0
  23. package/docs/agents/targets.md +8 -1
  24. package/docs/target/migrations.md +3 -1
  25. package/docs/target/projects.md +1 -1
  26. package/docs/target/sync.md +1 -1
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map.md +1 -0
  29. package/governance/detect.toml +1 -1
  30. package/governance/ledger/tooling/ci-workflow.md +6 -0
  31. package/governance/ledger/ui/design-reference.md +8 -13
  32. package/governance/rules/tooling/ci-workflow.md +4 -0
  33. package/governance/rules/ui/design-reference.md +3 -6
  34. package/package.json +2 -1
  35. package/scripts/ci/check-browsers.sh +13 -0
  36. package/scripts/ci/delete-previews.sh +34 -0
  37. package/scripts/ci/publish-check.sh +29 -0
  38. package/scripts/ci/refresh-frames-pr.sh +63 -0
  39. package/scripts/ci/report-baseline.sh +23 -0
  40. package/scripts/ci/stack-issues.sh +24 -0
  41. package/scripts/ci/verify-npm-token.sh +14 -0
  42. package/src/claude/cases/setup.ts +7 -0
  43. package/src/cli/help.ts +1 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/changelog.ts +146 -0
  46. package/src/commands/hooks.ts +7 -5
  47. package/src/commands/labels.ts +9 -7
  48. package/src/commands/migrate.ts +77 -0
  49. package/src/commands/worktrees.ts +273 -0
  50. package/src/gov/detect.ts +28 -10
  51. package/src/hooks/post-merge.ts +45 -6
  52. package/src/migrate/status.ts +132 -0
  53. package/src/version/changelog.ts +156 -0
  54. package/src/worktrees/members.ts +540 -0
  55. package/tooling/base/configs/.husky/post-merge +7 -6
  56. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  57. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  58. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  59. package/tooling/cloudflare/reference.md +7 -4
  60. package/tooling/web/reference.md +5 -0
  61. package/tooling/web/seeds/canon/design/index.md +23 -0
@@ -0,0 +1,132 @@
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
+ // The flags ride in the verb, since a caller appending --write to a bare
72
+ // name would apply a different plan than the one this dry run read.
73
+ const verb = ['canon', 'migrate', migration.name, ...migration.flags].join(
74
+ ' ',
75
+ )
76
+ const result = await run([
77
+ 'migrate',
78
+ migration.name,
79
+ ...migration.flags,
80
+ '--json',
81
+ ])
82
+
83
+ if (result.exitCode === 0) {
84
+ return { name: migration.name, verb, state: 'clean' }
85
+ }
86
+
87
+ if (result.exitCode === 2) {
88
+ return {
89
+ name: migration.name,
90
+ verb,
91
+ state: 'pending',
92
+ record: parseRecord(result.stdout),
93
+ }
94
+ }
95
+
96
+ const reason =
97
+ result.spawnError ??
98
+ (refusalReason(result.stderr) ||
99
+ `exited ${result.exitCode} with no message`)
100
+ return {
101
+ name: migration.name,
102
+ verb,
103
+ state: 'refused',
104
+ record: parseRecord(result.stdout),
105
+ reason,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Runs each migration's own dry run and sorts it by the exit contract every
111
+ * subcommand shares: 0 with nothing to move, 2 with a plan and no `--write`,
112
+ * anything else a refusal. It never passes `--write`, and it reads no count,
113
+ * since the records share no field to count from.
114
+ */
115
+ export async function readMigrationStatus(
116
+ run: RunCommand,
117
+ ): Promise<StatusReport> {
118
+ const migrations = await Promise.all(
119
+ MIGRATIONS.map((migration) => readOne(run, migration)),
120
+ )
121
+ return {
122
+ migrations,
123
+ pending: migrations.filter((m) => m.state === 'pending').length,
124
+ refused: migrations.filter((m) => m.state === 'refused').length,
125
+ }
126
+ }
127
+
128
+ /** A refusal outranks a pending plan, since a refused read measured nothing. */
129
+ export function statusExitCode(report: StatusReport): 0 | 1 | 2 {
130
+ if (report.refused > 0) return 1
131
+ return report.pending > 0 ? 2 : 0
132
+ }
@@ -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
+ }