@iodes/releasekit 0.1.7 → 0.2.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/dist/project.js CHANGED
@@ -1,41 +1,124 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { configSchema, historyStartSchema, releaseSchema } from './model.js';
4
- import { exists, identifier, readYaml, within, writeYaml, KIT_DIR } from './files.js';
4
+ import { exists, identifier, readYaml, within, writeYaml, parseYaml, KIT_DIR } from './files.js';
5
5
  import { repoRoot, resolveCommit, resolveRange, checkPrevious } from './git.js';
6
+ import { ref, refKey, parseRef, previousRef, link } from './refs.js';
7
+ export function checkLimit(limit) {
8
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
9
+ throw new Error('History limit must be a positive safe integer.');
10
+ }
11
+ // Newest first. Validate every member, not just the displayed window.
12
+ export function linearHistory(releases) {
13
+ if (!releases.length)
14
+ return [];
15
+ const items = new Map(releases.map(r => [refKey(r), r]));
16
+ if (items.size !== releases.length)
17
+ throw new Error('Duplicate release identity.');
18
+ const successors = new Set();
19
+ for (const release of releases) {
20
+ const previous = previousRef(release);
21
+ if (!previous)
22
+ continue;
23
+ const key = refKey(previous);
24
+ if (!items.has(key))
25
+ throw new Error(`Missing previous release: ${key}`);
26
+ if (successors.has(key))
27
+ throw new Error(`Branch in release history at ${key}`);
28
+ successors.add(key);
29
+ }
30
+ const heads = releases.filter(r => !successors.has(refKey(r)));
31
+ if (heads.length !== 1)
32
+ throw new Error('Release history must have one endpoint; disconnected history or cycle found.');
33
+ const chain = [];
34
+ const visited = new Set();
35
+ let cursor = heads[0];
36
+ while (cursor) {
37
+ const key = refKey(cursor);
38
+ if (visited.has(key))
39
+ throw new Error(`Cycle in release history at ${key}`);
40
+ visited.add(key);
41
+ chain.push(cursor);
42
+ const previous = previousRef(cursor);
43
+ cursor = previous ? items.get(refKey(previous)) : undefined;
44
+ }
45
+ if (chain.length !== releases.length)
46
+ throw new Error('Disconnected release history or cycle found.');
47
+ return chain;
48
+ }
6
49
  export class Project {
7
50
  root;
8
51
  constructor(root) { this.root = path.resolve(root); }
9
52
  static find(cwd) { return new Project(repoRoot(cwd)); }
10
53
  async content(relative) { return within(this.root, `${KIT_DIR}/${relative}`); }
11
54
  async config() {
12
- const config = await readYaml(await this.content('config.yaml'), configSchema);
55
+ const file = await this.content('config.yaml');
56
+ if (!(await exists(file)))
57
+ throw new Error('ReleaseKit is not initialized. Run releasekit init first.');
58
+ const raw = parseYaml(await fs.readFile(file, 'utf8'));
59
+ if (raw && typeof raw === 'object' && 'history' in raw && raw.history && typeof raw.history === 'object' && 'limit' in raw.history) {
60
+ throw new Error('Remove history.limit from config.yaml; use export --limit instead. Omit --limit to export all releases.');
61
+ }
62
+ const config = configSchema.parse(raw);
13
63
  if (!config.locales.includes(config.sourceLocale) || new Set(config.locales).size !== config.locales.length) {
14
64
  throw new Error('Project locales must be unique and include the source locale.');
15
65
  }
16
66
  return config;
17
67
  }
18
- async releaseDir(version) {
19
- return this.content(`releases/${identifier(version)}`);
68
+ async requireChannel(channel) {
69
+ ref({ channel, version: 'check' });
70
+ const config = await this.config();
71
+ if (!config.channels || !Object.hasOwn(config.channels, channel))
72
+ throw new Error(`Unknown channel: ${channel}. Configure it in config.yaml first.`);
20
73
  }
21
- async releaseFile(version, relative) {
22
- return within(await this.releaseDir(version), relative);
74
+ async releaseDir(value) {
75
+ return this.content(`releases/${refKey(value)}`);
23
76
  }
24
- async release(version) {
25
- const release = await readYaml(await this.releaseFile(version, 'release.yaml'), releaseSchema);
26
- if (release.version !== version)
27
- throw new Error(`Release directory and version disagree: ${version}`);
77
+ async releaseFile(value, relative) {
78
+ return within(await this.releaseDir(value), relative);
79
+ }
80
+ async release(value) {
81
+ const release = await readYaml(await this.releaseFile(value, 'release.yaml'), releaseSchema);
82
+ if (refKey(release) !== refKey(value))
83
+ throw new Error(`Release directory and identity disagree: ${refKey(value)}`);
28
84
  return release;
29
85
  }
30
86
  async save(release) {
31
- await writeYaml(await this.releaseFile(release.version, 'release.yaml'), releaseSchema.parse(release));
87
+ await writeYaml(await this.releaseFile(release, 'release.yaml'), releaseSchema.parse(release));
32
88
  }
33
- async versions() {
89
+ async allRefs() {
34
90
  const folder = await this.content('releases');
35
91
  if (!(await exists(folder)))
36
92
  return [];
37
- const entries = await fs.readdir(folder, { withFileTypes: true });
38
- return entries.filter(e => e.isDirectory()).map(e => e.name).sort();
93
+ const result = [];
94
+ for (const entry of await fs.readdir(folder, { withFileTypes: true })) {
95
+ if (!entry.isDirectory())
96
+ continue;
97
+ const directory = await this.content(`releases/${identifier(entry.name)}`);
98
+ if (await exists(path.join(directory, 'release.yaml')))
99
+ result.push(ref(entry.name));
100
+ // An unchanneled version may also happen to be a channel name.
101
+ for (const child of await fs.readdir(directory, { withFileTypes: true })) {
102
+ if (child.isDirectory() && await exists(path.join(directory, child.name, 'release.yaml'))) {
103
+ result.push(ref({ channel: entry.name, version: child.name }));
104
+ }
105
+ }
106
+ }
107
+ const keys = result.map(r => refKey(r).toLowerCase());
108
+ if (new Set(keys).size !== keys.length)
109
+ throw new Error('Release identities collide on a case-insensitive filesystem.');
110
+ return result.sort((a, b) => refKey(a) < refKey(b) ? -1 : 1);
111
+ }
112
+ async versions() {
113
+ return (await this.allRefs()).filter(r => r.channel === undefined).map(r => r.version);
114
+ }
115
+ async channelHistory() {
116
+ const refs = (await this.allRefs()).filter(r => r.channel !== undefined);
117
+ const config = await this.config();
118
+ for (const value of refs)
119
+ if (!config.channels || !Object.hasOwn(config.channels, value.channel))
120
+ throw new Error(`Unknown channel: ${value.channel}`);
121
+ return linearHistory(await Promise.all(refs.map(r => this.release(r))));
39
122
  }
40
123
  async latestVersion() {
41
124
  const versions = await this.versions();
@@ -50,21 +133,27 @@ export class Project {
50
133
  throw new Error(`Multiple latest releases found: ${latest.map(release => release.version).join(', ')}. Specify --current <version>.`);
51
134
  return latest[0].version;
52
135
  }
53
- async history(version, limit) {
54
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
55
- throw new Error('History limit must be an integer from 1 to 100.');
136
+ async history(value, limit) {
137
+ checkLimit(limit);
138
+ if (ref(value).channel !== undefined) {
139
+ const chain = await this.channelHistory();
140
+ const index = chain.findIndex(r => refKey(r) === refKey(value));
141
+ if (index < 0)
142
+ throw new Error(`Missing release: ${refKey(value)}`);
143
+ return chain.slice(index, limit === undefined ? undefined : index + limit);
144
+ }
56
145
  const chain = [];
57
146
  const visited = new Set();
58
- let cursor = version;
59
- // Validate the entire linked lineage, including links beyond the requested display window.
147
+ let cursor = ref(value);
60
148
  while (cursor !== null) {
61
- if (visited.has(cursor))
62
- throw new Error(`Cycle in previous-release links at ${cursor}`);
63
- visited.add(cursor);
149
+ const key = refKey(cursor);
150
+ if (visited.has(key))
151
+ throw new Error(`Cycle in previous-release links at ${key}`);
152
+ visited.add(key);
64
153
  const release = await this.release(cursor);
65
- if (chain.length < limit)
154
+ if (limit === undefined || chain.length < limit)
66
155
  chain.push(release);
67
- cursor = release.previous;
156
+ cursor = previousRef(release);
68
157
  }
69
158
  return chain;
70
159
  }
@@ -75,9 +164,12 @@ export function editable(release) {
75
164
  }
76
165
  export async function startProject(project, options) {
77
166
  const config = await project.config();
78
- if (config.history.start)
167
+ if (options.channel !== undefined)
168
+ await project.requireChannel(options.channel);
169
+ const settings = options.channel === undefined ? (config.history ??= {}) : (config.channels[options.channel].history ??= {});
170
+ if (settings.start)
79
171
  throw new Error('A history start is already configured. Reuse the saved choice; it was not overwritten.');
80
- if ((await project.versions()).length)
172
+ if ((await project.allRefs()).some(r => r.channel === options.channel))
81
173
  throw new Error('History setup requires a project without releases. Continue an existing release line with --previous.');
82
174
  if (options.past === 'skip' && options.version !== undefined)
83
175
  throw new Error('--baseline-version is not used when --past is skip.');
@@ -87,12 +179,14 @@ export async function startProject(project, options) {
87
179
  identifier(options.version);
88
180
  const source = resolveRange(project.root, null, options.at);
89
181
  const start = historyStartSchema.parse({ ref: options.at, sha: source.toSha, past: options.past, version: options.version ?? null });
90
- config.history.start = start;
182
+ settings.start = start;
91
183
  await writeYaml(await project.content('config.yaml'), config);
92
184
  return start;
93
185
  }
94
186
  export async function prepare(project, version, options) {
95
187
  identifier(version);
188
+ if (options.channel !== undefined)
189
+ return prepareChannel(project, version, options);
96
190
  const directory = await project.releaseDir(version);
97
191
  if (await exists(directory))
98
192
  throw new Error(`Release ${version} already exists; edit it in place instead of overwriting it.`);
@@ -102,7 +196,9 @@ export async function prepare(project, version, options) {
102
196
  if (options.firstRelease && options.previous)
103
197
  throw new Error('--first-release cannot be combined with --previous.');
104
198
  const versions = await project.versions();
105
- const start = config.history.start;
199
+ if (versions.some(v => v.toLowerCase() === version.toLowerCase()))
200
+ throw new Error(`Release ${version} already exists on a case-insensitive filesystem.`);
201
+ const start = config.history?.start;
106
202
  let previous = options.previous ? await project.release(options.previous) : undefined;
107
203
  let from = options.fromRoot ? null : options.from ?? previous?.source.toSha;
108
204
  let to = options.to ?? 'HEAD';
@@ -151,7 +247,7 @@ export async function prepare(project, version, options) {
151
247
  await project.history(previous.version, 1);
152
248
  }
153
249
  const release = releaseSchema.parse({
154
- schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString().slice(0, 10),
250
+ schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString(),
155
251
  previous: previous?.version ?? null, status: 'draft', source,
156
252
  ...(initialContent ? { initialContent } : {}),
157
253
  sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
@@ -160,3 +256,58 @@ export async function prepare(project, version, options) {
160
256
  await project.save(release);
161
257
  return release;
162
258
  }
259
+ async function prepareChannel(project, version, options) {
260
+ const channel = options.channel;
261
+ await project.requireChannel(channel);
262
+ const identity = ref({ channel, version });
263
+ if (await exists(await project.releaseDir(identity)))
264
+ throw new Error(`Release ${refKey(identity)} already exists; edit it in place.`);
265
+ const config = await project.config();
266
+ const chain = await project.channelHistory();
267
+ if (chain.some(r => refKey(r).toLowerCase() === refKey(identity).toLowerCase()))
268
+ throw new Error(`Release ${refKey(identity)} already exists on a case-insensitive filesystem.`);
269
+ const head = chain[0];
270
+ const sameChannel = chain.filter(r => r.channel === channel);
271
+ if (options.previous !== undefined && (!head || refKey(parseRef(options.previous)) !== refKey(head)))
272
+ throw new Error('--previous must identify the latest release in the whole channel history.');
273
+ if (options.firstRelease && head)
274
+ throw new Error('Channel history already exists; a second independent history is not allowed.');
275
+ if (options.fromRoot && options.from !== undefined)
276
+ throw new Error('--from-root cannot be combined with --from.');
277
+ const start = config.channels && config.channels[channel].history?.start;
278
+ let from = options.fromRoot ? null : options.from ?? sameChannel[0]?.source.toSha;
279
+ let to = options.to ?? 'HEAD';
280
+ let initialContent;
281
+ let savedStart = false;
282
+ if (start && start.past !== 'skip' && version === start.version) {
283
+ if (sameChannel.length || options.from !== undefined)
284
+ throw new Error('Prepare the configured baseline before other releases in its channel.');
285
+ if (options.to !== undefined && resolveCommit(project.root, options.to) !== start.sha)
286
+ throw new Error('The requested end differs from the pinned history start.');
287
+ from = null;
288
+ to = start.sha;
289
+ initialContent = start.past;
290
+ }
291
+ else if (start && from === undefined) {
292
+ if (start.past !== 'skip' && !sameChannel.length)
293
+ throw new Error(`Prepare the configured baseline ${channel}/${start.version} first.`);
294
+ from = start.sha;
295
+ savedStart = true;
296
+ }
297
+ if (from === undefined)
298
+ throw new Error(`Specify --from or --from-root, or configure a history start for ${channel}.`);
299
+ const source = resolveRange(project.root, from, to);
300
+ if (initialContent && start)
301
+ source.toRef = start.ref;
302
+ if (savedStart && start)
303
+ source.fromRef = start.ref;
304
+ const release = releaseSchema.parse({
305
+ schemaVersion: 1, ...identity, previous: link(head), status: 'draft', source,
306
+ releasedAt: options.date ?? new Date().toISOString(),
307
+ ...(initialContent ? { initialContent } : {}),
308
+ sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
309
+ notes: [], emptyReason: null, contentHash: null,
310
+ });
311
+ await project.save(release);
312
+ return release;
313
+ }
package/dist/refs.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type Release, type ReleaseId, type ReleaseRef } from './model.js';
2
+ export declare function ref(value: ReleaseId): ReleaseRef;
3
+ export declare function refKey(value: ReleaseId): string;
4
+ export declare function parseRef(value: string): ReleaseRef;
5
+ export declare function previousRef(release: Release): ReleaseRef | null;
6
+ export declare function link(value: ReleaseRef | undefined): Release['previous'];
package/dist/refs.js ADDED
@@ -0,0 +1,29 @@
1
+ import { channelRefSchema } from './model.js';
2
+ import { identifier } from './files.js';
3
+ export function ref(value) {
4
+ const result = typeof value === 'string' ? { version: value } : value;
5
+ identifier(result.version);
6
+ if (result.channel !== undefined) {
7
+ channelRefSchema.parse({ channel: result.channel, version: result.version });
8
+ identifier(result.channel);
9
+ }
10
+ return result.channel === undefined ? { version: result.version } : { channel: result.channel, version: result.version };
11
+ }
12
+ export function refKey(value) {
13
+ const item = ref(value);
14
+ return item.channel === undefined ? item.version : `${item.channel}/${item.version}`;
15
+ }
16
+ export function parseRef(value) {
17
+ const parts = value.split('/');
18
+ if (parts.length === 1)
19
+ return ref(value);
20
+ if (parts.length !== 2)
21
+ throw new Error(`Expected channel/version: ${value}`);
22
+ return ref({ channel: parts[0], version: parts[1] });
23
+ }
24
+ export function previousRef(release) {
25
+ return release.previous === null ? null : ref(release.previous);
26
+ }
27
+ export function link(value) {
28
+ return value === undefined ? null : value.channel === undefined ? value.version : ref(value);
29
+ }
@@ -0,0 +1,7 @@
1
+ import type { InitOptions } from './install.js';
2
+ export declare const commaList: (value: string) => string[];
3
+ export declare function parseTools(value: string): ("claude" | "codex" | "cursor")[];
4
+ type Ask = (question: string) => Promise<string>;
5
+ export declare function collectSetup(root: string, options: InitOptions, ask: Ask, report: (message: string) => void): Promise<InitOptions>;
6
+ export declare function interactiveSetup(root: string, options: InitOptions): Promise<InitOptions>;
7
+ export {};
package/dist/setup.js ADDED
@@ -0,0 +1,64 @@
1
+ import path from 'node:path';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin, stdout } from 'node:process';
4
+ import { configSchema, defaultConfig } from './model.js';
5
+ export const commaList = (value) => value.split(',').map(s => s.trim()).filter(Boolean);
6
+ export function parseTools(value) {
7
+ if (value === 'none')
8
+ return [];
9
+ const values = commaList(value);
10
+ if (!values.length)
11
+ throw new Error('Choose codex, claude, cursor, or none.');
12
+ return configSchema.shape.tools.parse(values);
13
+ }
14
+ // Collect and validate everything before initProject writes any files.
15
+ export async function collectSetup(root, options, ask, report) {
16
+ const defaults = defaultConfig(path.basename(root));
17
+ async function field(label, fallback, parse) {
18
+ for (;;) {
19
+ const answer = (await ask(`${label} [${fallback}]: `)).trim() || fallback;
20
+ try {
21
+ return parse(answer);
22
+ }
23
+ catch (error) {
24
+ report(error instanceof Error ? error.message : String(error));
25
+ }
26
+ }
27
+ }
28
+ const product = options.product ?? await field('Product name', defaults.product, value => configSchema.shape.product.parse(value));
29
+ const tools = options.tools ?? await field('Agent tools (codex, claude, cursor; comma-separated, or none)', defaults.tools.join(','), parseTools);
30
+ const sourceLocale = options.sourceLocale ?? await field('Original language (locale code)', defaults.sourceLocale, value => configSchema.shape.sourceLocale.parse(value));
31
+ const locales = options.locales ?? await field('All languages, including the original (comma-separated locale codes)', sourceLocale, value => {
32
+ const values = configSchema.shape.locales.parse(commaList(value));
33
+ if (!values.includes(sourceLocale) || new Set(values).size !== values.length)
34
+ throw new Error(`Languages must be unique and include ${sourceLocale}.`);
35
+ return values;
36
+ });
37
+ const themes = options.themes ?? await field('Image themes (both, dark, light)', defaults.visuals.themes, value => configSchema.shape.visuals.shape.themes.parse(value));
38
+ return { product, tools, sourceLocale, locales, themes };
39
+ }
40
+ export function interactiveSetup(root, options) {
41
+ return withTerminal((ask, report) => collectSetup(root, options, ask, report), 'Setup cancelled. No configuration was written.');
42
+ }
43
+ async function withTerminal(collect, cancelled) {
44
+ const terminal = createInterface({ input: stdin, output: stdout });
45
+ const abort = new AbortController();
46
+ const cancel = () => abort.abort();
47
+ terminal.on('SIGINT', cancel);
48
+ terminal.on('close', cancel);
49
+ try {
50
+ return await collect(question => {
51
+ if (abort.signal.aborted)
52
+ throw new Error(cancelled);
53
+ return terminal.question(question, { signal: abort.signal });
54
+ }, message => stdout.write(`${message}\n`));
55
+ }
56
+ catch (error) {
57
+ if (abort.signal.aborted)
58
+ throw new Error(cancelled);
59
+ throw error;
60
+ }
61
+ finally {
62
+ terminal.close();
63
+ }
64
+ }
@@ -0,0 +1,31 @@
1
+ import { Project } from './project.js';
2
+ import type { ReleaseRef } from './model.js';
3
+ export declare function listReleases(project: Project, channel?: string): Promise<{
4
+ version: string;
5
+ channel?: string;
6
+ status: "draft" | "ready";
7
+ releasedAt: string;
8
+ notes: number;
9
+ locales: string[];
10
+ }[]>;
11
+ export declare function projectStatus(project: Project, selected?: ReleaseRef, channel?: string): Promise<{
12
+ initialized: boolean;
13
+ releases: never[];
14
+ next: string[];
15
+ product?: undefined;
16
+ } | {
17
+ initialized: boolean;
18
+ product: string;
19
+ releases: {
20
+ version: string;
21
+ channel?: string | undefined;
22
+ status: "draft" | "ready";
23
+ notes: number;
24
+ valid: boolean;
25
+ errors: string[];
26
+ warnings: string[];
27
+ next: string;
28
+ }[];
29
+ next: string[];
30
+ }>;
31
+ export declare function formatStatus(result: Awaited<ReturnType<typeof projectStatus>>): string;
package/dist/status.js ADDED
@@ -0,0 +1,52 @@
1
+ import { Project } from './project.js';
2
+ import { exists } from './files.js';
3
+ import { refKey } from './refs.js';
4
+ import { validate } from './validate.js';
5
+ export async function listReleases(project, channel) {
6
+ await project.config();
7
+ if (channel !== undefined)
8
+ await project.requireChannel(channel);
9
+ const refs = (await project.allRefs()).filter(value => channel === undefined || value.channel === channel);
10
+ return Promise.all(refs.map(async (value) => {
11
+ const release = await project.release(value);
12
+ return { ...value, status: release.status, releasedAt: release.releasedAt, notes: release.notes.length, locales: release.locales };
13
+ }));
14
+ }
15
+ export async function projectStatus(project, selected, channel) {
16
+ if (!(await exists(await project.content('config.yaml')))) {
17
+ return { initialized: false, releases: [], next: ['releasekit init'] };
18
+ }
19
+ const config = await project.config();
20
+ const releases = selected ? [await project.release(selected)] : await listReleases(project, channel);
21
+ const results = await Promise.all(releases.map(async (release) => {
22
+ const validation = await validate(project, release);
23
+ const identity = refKey(release);
24
+ const args = `${release.version}${release.channel === undefined ? '' : ` --channel ${release.channel}`}`;
25
+ let next;
26
+ if (!validation.valid)
27
+ next = `Use releasekit-draft or releasekit-image for ${identity} to resolve the reported issues, then run releasekit validate ${args}.`;
28
+ else if (release.status === 'ready')
29
+ next = `releasekit export --current ${args} --out <new-directory>`;
30
+ else {
31
+ const predecessors = (await project.history(release)).slice(1).filter(item => item.status !== 'ready');
32
+ next = release.channel === undefined && predecessors.length
33
+ ? `Finalize earlier releases first: ${predecessors.reverse().map(refKey).join(', ')}.`
34
+ : `releasekit finalize ${args}`;
35
+ }
36
+ return { version: release.version, ...(release.channel === undefined ? {} : { channel: release.channel }), status: release.status,
37
+ notes: typeof release.notes === 'number' ? release.notes : release.notes.length,
38
+ valid: validation.valid, errors: validation.errors, warnings: validation.warnings, next };
39
+ }));
40
+ return { initialized: true, product: config.product, releases: results,
41
+ next: results.length ? [] : ['Use releasekit-draft with a version to select the Git range and write the first draft.'] };
42
+ }
43
+ export function formatStatus(result) {
44
+ const lines = [result.initialized ? `${result.product} — release status` : 'ReleaseKit is not initialized.'];
45
+ for (const release of result.releases) {
46
+ lines.push(`\n${refKey(release)} ${release.status} ${release.notes} notes ${release.valid ? 'validation passed' : 'needs attention'}`);
47
+ lines.push(...release.errors.map(error => ` - ${error}`), ...release.warnings.map(warning => ` Warning: ${warning}`));
48
+ lines.push(` Next: ${release.next}`);
49
+ }
50
+ lines.push(...result.next.map(next => `Next: ${next}`));
51
+ return lines.join('\n');
52
+ }
@@ -1,12 +1,15 @@
1
+ import { type ReleaseId } from './model.js';
1
2
  import { type Release } from './model.js';
2
3
  import { Project } from './project.js';
3
4
  export interface Validation {
4
5
  version: string;
6
+ channel?: string;
5
7
  valid: boolean;
6
8
  errors: string[];
7
9
  warnings: string[];
8
10
  contentHash: string | null;
9
11
  }
10
12
  export declare function contentHash(project: Project, release: Release): Promise<string>;
11
- export declare function validate(project: Project, version: string): Promise<Validation>;
12
- export declare function finalize(project: Project, version: string): Promise<Validation>;
13
+ export declare function validate(project: Project, version: ReleaseId): Promise<Validation>;
14
+ export declare function validateMany(project: Project, versions: ReleaseId[]): Promise<Validation[]>;
15
+ export declare function finalize(project: Project, version: ReleaseId): Promise<Validation>;
package/dist/validate.js CHANGED
@@ -1,27 +1,47 @@
1
+ import {} from './model.js';
2
+ import { ref, refKey } from './refs.js';
1
3
  import { imageSource } from './model.js';
2
4
  import { Project, editable } from './project.js';
3
5
  import { canonical, digest, readNote, noteHash, identifier } from './files.js';
4
6
  import { readVisual, checkReferenceFiles } from './content.js';
5
7
  import { validateImages } from './images.js';
6
- import { checkPrevious, collect, collectSnapshot } from './git.js';
8
+ import { collect, collectSnapshot } from './git.js';
7
9
  export async function contentHash(project, release) {
8
10
  const { status: _status, contentHash: _hash, ...metadata } = release;
9
11
  const parts = [metadata];
10
12
  for (const note of release.notes) {
11
13
  for (const language of release.locales)
12
- parts.push(await readNote(await project.releaseFile(release.version, `notes/${note.id}/${language}.md`)));
14
+ parts.push(await readNote(await project.releaseFile(release, `notes/${note.id}/${language}.md`)));
13
15
  if (note.image)
14
- parts.push(await readVisual(project, release.version, note.id));
16
+ parts.push(await readVisual(project, release, note.id));
15
17
  }
16
18
  return digest(canonical(parts));
17
19
  }
18
20
  export async function validate(project, version) {
21
+ return validateOne(project, version);
22
+ }
23
+ // Reuse structural validation within an export or move, without caching across mutations.
24
+ export async function validateMany(project, versions) {
25
+ const checkedHistory = new Set();
26
+ if (versions.some(v => ref(v).channel !== undefined)) {
27
+ for (const release of await project.channelHistory())
28
+ checkedHistory.add(refKey(release));
29
+ }
30
+ for (const version of versions) {
31
+ if (!checkedHistory.has(refKey(version))) {
32
+ for (const release of await project.history(version))
33
+ checkedHistory.add(refKey(release));
34
+ }
35
+ }
36
+ return Promise.all(versions.map(version => validateOne(project, version, checkedHistory)));
37
+ }
38
+ async function validateOne(project, version, checkedHistory) {
19
39
  const errors = [], warnings = [];
20
40
  let hash = null;
21
41
  try {
22
42
  const release = await project.release(version);
23
- if (release.initialContent && (release.source.fromSha !== null || release.source.fromRef !== null || release.previous !== null)) {
24
- errors.push('Initial content requires a root baseline with no previous release.');
43
+ if (release.initialContent && (release.source.fromSha !== null || release.source.fromRef !== null)) {
44
+ errors.push('Initial content requires a root baseline Git range.');
25
45
  }
26
46
  // Summaries inspect only the baseline snapshot; ready releases use their finalized fingerprint.
27
47
  const summary = release.initialContent === 'summary';
@@ -74,19 +94,13 @@ export async function validate(project, version) {
74
94
  }
75
95
  }
76
96
  try {
77
- await project.history(version, 1);
97
+ if (!checkedHistory?.has(refKey(version)))
98
+ await project.history(version, 1);
78
99
  }
79
100
  catch (error) {
80
101
  errors.push(error instanceof Error ? error.message : String(error));
81
102
  }
82
- if (release.status === 'draft' && release.previous) {
83
- try {
84
- checkPrevious(project.root, await project.release(release.previous), release.source);
85
- }
86
- catch (error) {
87
- errors.push(error instanceof Error ? error.message : String(error));
88
- }
89
- }
103
+ // Git scope was pinned independently; display links can change during moves.
90
104
  if (!errors.length)
91
105
  hash = await contentHash(project, release);
92
106
  if (release.status === 'ready' && release.contentHash !== hash && !errors.length)
@@ -95,7 +109,7 @@ export async function validate(project, version) {
95
109
  catch (error) {
96
110
  errors.push(error instanceof Error ? error.message : String(error));
97
111
  }
98
- return { version, valid: errors.length === 0, errors, warnings, contentHash: hash };
112
+ return { ...ref(version), valid: errors.length === 0, errors, warnings, contentHash: hash };
99
113
  }
100
114
  export async function finalize(project, version) {
101
115
  const release = await project.release(version);
@@ -103,8 +117,8 @@ export async function finalize(project, version) {
103
117
  const result = await validate(project, version);
104
118
  if (!result.valid || !result.contentHash)
105
119
  throw new Error(result.errors.join('\n'));
106
- const chain = await project.history(version, 100);
107
- if (chain.slice(1).some(r => r.status !== 'ready'))
120
+ const chain = await project.history(version);
121
+ if (release.channel === undefined && chain.slice(1).some(r => r.status !== 'ready'))
108
122
  throw new Error('Finalize the previous releases before finalizing this release.');
109
123
  release.status = 'ready';
110
124
  release.contentHash = result.contentHash;
@@ -2,7 +2,9 @@
2
2
 
3
3
  Read this when a product has no ReleaseKit releases and the author is choosing where to start. Resolve the likely baseline through [repository inspection](workflow.md#resolve-release-scope-from-the-repository) before asking; a clear inferred tag interval does not require confirmation. Setup has two separate decisions: the baseline commit, and how to present the product's earlier history. A baseline is the end of the earlier period; regular change notes begin **after** it. Choosing a tag does not mean starting at the commit that created that tag's features.
4
4
 
5
- Read existing releases and `history.start` in the project config first. Reuse a saved selection when continuing setup. Existing releases use their explicit `previous` links. An explicitly limited Git interval or a requested full-history release already establishes the work's scope; do not add retrospective work or repeat that choice. For first-use requests with unresolved scope, gather the missing decisions using the workflow's [native question UI](workflow.md#ask-with-the-native-question-ui). Follow [the answer-waiting procedure](workflow.md#wait-for-the-users-answer) after asking. Independent inspection may continue while answers are pending, but do not save an unconfirmed choice or draft dependent copy.
5
+ For channel-aware first drafts, choose use/non-use and the target channel through [the channel workflow](channels.md#first-draft-and-later-drafts) before applying this guide. Use `--channel` on start and prepare, scope existing-release discovery to that channel, and read its `channels.<name>.history.start`. Its display predecessor can be another channel; Git boundaries remain separate.
6
+
7
+ For an unchanneled history, read existing releases and `history.start` in the project config first. Reuse a saved selection when continuing setup. Existing releases use their explicit `previous` links. An explicitly limited Git interval or a requested full-history release already establishes the work's scope; do not add retrospective work or repeat that choice. For first-use requests with unresolved scope, gather the missing decisions using the workflow's [native question UI](workflow.md#ask-with-the-native-question-ui). Follow [the answer-waiting procedure](workflow.md#wait-for-the-users-answer) after asking. Independent inspection may continue while answers are pending, but do not save an unconfirmed choice or draft dependent copy.
6
8
 
7
9
  ## Choose the baseline
8
10