@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.
- 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/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 +1 -1
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/target-check/references/domains.md +8 -6
- package/docs/agents/index.md +1 -1
- package/docs/agents/migrate.md +2 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- 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/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/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 +128 -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/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/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
|
|
package/src/hooks/post-merge.ts
CHANGED
|
@@ -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
|
|
275
|
-
* upgrade, plugin update. 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`
|
|
313
|
-
// directory to refuse the worktree it stands in, so a
|
|
314
|
-
// running worktree into an ordinary candidate and let 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
|
+
}
|