@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.
- package/CHANGELOG.md +4037 -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/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/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/SKILL.md +2 -1
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +2 -2
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-check/references/domains.md +8 -6
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +33 -0
- package/claude/skills/target-update/SKILL.md +136 -0
- package/docs/agents/index.md +1 -1
- package/docs/agents/migrate.md +3 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +3 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -13
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +3 -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/setup.ts +7 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/hooks.ts +7 -5
- package/src/commands/labels.ts +9 -7
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/migrate/status.ts +132 -0
- 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/seeds/.claude/hooks/review-post-scan.sh +1 -1
- 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
|
@@ -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
|
+
}
|