@ontrails/trails 1.0.0-beta.30 → 1.0.0-beta.39

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.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Consolidated, append-only Regrade transition history. One file per
3
+ * transition at `.trails/regrade/history/<transition>.json`; each apply
4
+ * appends a run entry stamped with the plan content hash and the lock hash
5
+ * observed at that run.
6
+ */
7
+
8
+ import { InternalError, Result, ValidationError } from '@ontrails/core';
9
+ import type { Result as TrailsResult } from '@ontrails/core';
10
+ import { regradeReportOutput } from '@ontrails/regrade';
11
+ import type { RegradeReport } from '@ontrails/regrade';
12
+ import { createHash } from 'node:crypto';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { dirname, isAbsolute, join } from 'node:path';
15
+ import { z } from 'zod';
16
+
17
+ import {
18
+ regradePlanArtifactSchema,
19
+ regradePlanContentHash,
20
+ regradePlanDirectory,
21
+ regradePlanSlugForBody,
22
+ regradeSourceHash,
23
+ rootRelativePath,
24
+ } from './plan-artifact.js';
25
+ import type { RegradePlanArtifact, RegradePlanBody } from './plan-artifact.js';
26
+
27
+ /**
28
+ * Consolidated history schema version. Version 1 was the retired
29
+ * one-file-per-run shape whose filename carried the lock hash.
30
+ */
31
+ export const REGRADE_HISTORY_SCHEMA_VERSION = 2;
32
+
33
+ const regradeHistoryRunSchema = z
34
+ .object({
35
+ lockHashAtRun: z
36
+ .string()
37
+ .min(1)
38
+ .describe('Regrade source hash observed when this run applied'),
39
+ plan: regradePlanArtifactSchema.describe(
40
+ 'Plan artifact consumed by this run'
41
+ ),
42
+ planContentHash: z
43
+ .string()
44
+ .min(1)
45
+ .describe('Canonical content hash of the resolved plan body'),
46
+ report: regradeReportOutput.describe('Applied report recorded by the run'),
47
+ })
48
+ .strict();
49
+
50
+ const regradeHistoryArtifactSchema = z
51
+ .object({
52
+ id: z.string().min(1).describe('Stable transition identity'),
53
+ kind: z.literal('regrade-history'),
54
+ path: z.string().describe('Root-relative consolidated history path'),
55
+ runs: z.array(regradeHistoryRunSchema).min(1),
56
+ schemaVersion: z.literal(REGRADE_HISTORY_SCHEMA_VERSION),
57
+ })
58
+ .strict();
59
+
60
+ interface RegradeHistoryRun {
61
+ readonly lockHashAtRun: string;
62
+ readonly plan: RegradePlanArtifact;
63
+ readonly planContentHash: string;
64
+ readonly report: RegradeReport;
65
+ }
66
+
67
+ export interface RegradeHistoryArtifact {
68
+ readonly id: string;
69
+ readonly kind: 'regrade-history';
70
+ readonly path: string;
71
+ readonly runs: readonly RegradeHistoryRun[];
72
+ readonly schemaVersion: typeof REGRADE_HISTORY_SCHEMA_VERSION;
73
+ }
74
+
75
+ export interface RegradeHistorySummary {
76
+ readonly path: string;
77
+ readonly schemaVersion: number;
78
+ readonly status: 'applied' | 'replay';
79
+ }
80
+
81
+ export const regradeHistoryPathForPlan = (
82
+ rootDir: string,
83
+ plan: RegradePlanBody
84
+ ): string =>
85
+ join(
86
+ regradePlanDirectory(rootDir),
87
+ 'history',
88
+ `${regradePlanSlugForBody(plan)}.json`
89
+ );
90
+
91
+ export const readRegradeHistoryArtifact = (
92
+ path: string
93
+ ): TrailsResult<RegradeHistoryArtifact, InternalError | ValidationError> => {
94
+ if (!existsSync(path)) {
95
+ return Result.err(
96
+ new ValidationError(`Regrade history "${path}" not found.`)
97
+ );
98
+ }
99
+ let parsedJson: unknown;
100
+ try {
101
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
102
+ } catch (error) {
103
+ return Result.err(
104
+ new InternalError('Failed to read Regrade history artifact.', {
105
+ ...(error instanceof Error ? { cause: error } : {}),
106
+ context: { path },
107
+ })
108
+ );
109
+ }
110
+ const parsed = regradeHistoryArtifactSchema.safeParse(parsedJson);
111
+ if (!parsed.success) {
112
+ return Result.err(
113
+ new ValidationError('Invalid Regrade history artifact.', {
114
+ context: { issues: parsed.error.issues, path },
115
+ })
116
+ );
117
+ }
118
+ return Result.ok(parsed.data as unknown as RegradeHistoryArtifact);
119
+ };
120
+
121
+ /**
122
+ * Deterministic transition identity minted at the first recorded run and
123
+ * preserved for the life of the consolidated history file.
124
+ */
125
+ export const mintTransitionId = (
126
+ slug: string,
127
+ planContentHash: string,
128
+ lockHashAtRun: string
129
+ ): string =>
130
+ createHash('sha256')
131
+ .update(`${slug}\n${planContentHash}\n${lockHashAtRun}`)
132
+ .digest('hex')
133
+ .slice(0, 12);
134
+
135
+ /**
136
+ * Append one applied run to the transition's consolidated history file. A
137
+ * run whose plan content hash and lock hash both equal the last recorded
138
+ * run's stamps is a replay: nothing is written and `status: 'replay'` is
139
+ * surfaced instead of a duplicate record.
140
+ */
141
+ export const appendRegradeHistoryRun = (params: {
142
+ readonly artifact: RegradePlanArtifact;
143
+ readonly report: RegradeReport;
144
+ readonly rootDir: string;
145
+ }): TrailsResult<RegradeHistorySummary, Error> => {
146
+ const absolutePath = regradeHistoryPathForPlan(
147
+ params.rootDir,
148
+ params.artifact.plan
149
+ );
150
+ const relativePath = rootRelativePath(params.rootDir, absolutePath);
151
+ const planContentHash = regradePlanContentHash(params.artifact.plan);
152
+ const lockHashAtRun = regradeSourceHash(params.report);
153
+ const entry: RegradeHistoryRun = {
154
+ lockHashAtRun,
155
+ plan: params.artifact,
156
+ planContentHash,
157
+ report: params.report,
158
+ };
159
+
160
+ let prior: RegradeHistoryArtifact | undefined;
161
+ if (existsSync(absolutePath)) {
162
+ const existing = readRegradeHistoryArtifact(absolutePath);
163
+ if (existing.isErr()) {
164
+ return existing;
165
+ }
166
+ prior = existing.value;
167
+ if (
168
+ params.artifact.transitionId !== undefined &&
169
+ params.artifact.transitionId !== prior.id
170
+ ) {
171
+ return Result.err(
172
+ new ValidationError(
173
+ 'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
174
+ {
175
+ context: {
176
+ history: prior.id,
177
+ path: relativePath,
178
+ plan: params.artifact.transitionId,
179
+ },
180
+ }
181
+ )
182
+ );
183
+ }
184
+ const lastRun = prior.runs.at(-1);
185
+ // A plan that carries the transition id (adjust round-trips, plan
186
+ // re-derivation) may evolve the plan identity on the same spine. A plan
187
+ // WITHOUT the id that disagrees with the recorded plan identity is a
188
+ // name collision, not a continuation — refuse instead of mixing runs
189
+ // from unrelated transitions into one history.
190
+ if (
191
+ params.artifact.transitionId === undefined &&
192
+ lastRun !== undefined &&
193
+ lastRun.plan.plan.id !== params.artifact.plan.id
194
+ ) {
195
+ return Result.err(
196
+ new ValidationError(
197
+ 'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
198
+ {
199
+ context: {
200
+ history: lastRun.plan.plan.id,
201
+ path: relativePath,
202
+ plan: params.artifact.plan.id,
203
+ },
204
+ }
205
+ )
206
+ );
207
+ }
208
+ if (
209
+ lastRun !== undefined &&
210
+ lastRun.planContentHash === planContentHash &&
211
+ lastRun.lockHashAtRun === lockHashAtRun
212
+ ) {
213
+ return Result.ok({
214
+ path: relativePath,
215
+ schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
216
+ status: 'replay',
217
+ });
218
+ }
219
+ }
220
+
221
+ const artifact: RegradeHistoryArtifact = {
222
+ id:
223
+ prior === undefined
224
+ ? (params.artifact.transitionId ??
225
+ mintTransitionId(
226
+ regradePlanSlugForBody(params.artifact.plan),
227
+ planContentHash,
228
+ lockHashAtRun
229
+ ))
230
+ : prior.id,
231
+ kind: 'regrade-history',
232
+ path: relativePath,
233
+ runs: prior === undefined ? [entry] : [...prior.runs, entry],
234
+ schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
235
+ };
236
+ const parsed = regradeHistoryArtifactSchema.safeParse(artifact);
237
+ if (!parsed.success) {
238
+ return Result.err(
239
+ new ValidationError('Invalid Regrade history artifact.', {
240
+ context: { issues: parsed.error.issues, path: relativePath },
241
+ })
242
+ );
243
+ }
244
+ try {
245
+ mkdirSync(dirname(absolutePath), { recursive: true });
246
+ writeFileSync(absolutePath, `${JSON.stringify(parsed.data, null, 2)}\n`);
247
+ } catch (error) {
248
+ return Result.err(
249
+ new InternalError('Failed to write Regrade history entry.', {
250
+ ...(error instanceof Error ? { cause: error } : {}),
251
+ context: { path: relativePath },
252
+ })
253
+ );
254
+ }
255
+ return Result.ok({
256
+ path: relativePath,
257
+ schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
258
+ status: 'applied',
259
+ });
260
+ };
261
+
262
+ /**
263
+ * Verify every recorded run at its own stamped lock: recompute the plan
264
+ * content hash and lock hash from the recorded plan and report, then compare
265
+ * with the stamped values.
266
+ */
267
+ export const verifyRegradeHistoryRuns = (
268
+ artifact: RegradeHistoryArtifact
269
+ ): TrailsResult<{ readonly runs: number }, ValidationError> => {
270
+ for (const [index, run] of artifact.runs.entries()) {
271
+ if (regradePlanContentHash(run.plan.plan) !== run.planContentHash) {
272
+ return Result.err(
273
+ new ValidationError('Regrade history run stamp mismatch.', {
274
+ context: {
275
+ field: 'planContentHash',
276
+ path: artifact.path,
277
+ run: index,
278
+ },
279
+ })
280
+ );
281
+ }
282
+ if (regradeSourceHash(run.report) !== run.lockHashAtRun) {
283
+ return Result.err(
284
+ new ValidationError('Regrade history run stamp mismatch.', {
285
+ context: { field: 'lockHashAtRun', path: artifact.path, run: index },
286
+ })
287
+ );
288
+ }
289
+ }
290
+ return Result.ok({ runs: artifact.runs.length });
291
+ };
292
+
293
+ const hasPathSeparator = (value: string): boolean =>
294
+ value.includes('/') || value.includes('\\');
295
+
296
+ /**
297
+ * Resolve a transition name (with or without a `.json` suffix) to its
298
+ * consolidated history file. Path references are rejected — graduated
299
+ * history lookups are by transition name only.
300
+ */
301
+ export const resolveRegradeHistoryPath = (
302
+ rootDir: string,
303
+ ref: string
304
+ ): TrailsResult<string, ValidationError> => {
305
+ if (hasPathSeparator(ref) || isAbsolute(ref)) {
306
+ return Result.err(
307
+ new ValidationError(
308
+ `Regrade history reference "${ref}" must be a transition name.`
309
+ )
310
+ );
311
+ }
312
+ const name = ref.endsWith('.json') ? ref.slice(0, -'.json'.length) : ref;
313
+ const path = join(regradePlanDirectory(rootDir), 'history', `${name}.json`);
314
+ if (!existsSync(path)) {
315
+ return Result.err(
316
+ new ValidationError(`No Regrade history for transition "${ref}" found.`)
317
+ );
318
+ }
319
+ return Result.ok(path);
320
+ };
@@ -0,0 +1,8 @@
1
+ import type {
2
+ VocabularyPreserveInventoryEntry,
3
+ VocabularyRegradePlan,
4
+ } from '@ontrails/regrade';
5
+
6
+ export const deriveLiveApiPreserveInventory = async (
7
+ _plan: VocabularyRegradePlan
8
+ ): Promise<readonly VocabularyPreserveInventoryEntry[]> => [];
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Saved Regrade plan artifact shape plus the slug, path, and hash helpers
3
+ * shared by the `regrade` trails and the consolidated Regrade history module.
4
+ */
5
+
6
+ import { isPlainObject } from '@ontrails/core';
7
+ import { vocabularyRegradePlanSchema } from '@ontrails/regrade';
8
+ import type {
9
+ RegradeReport,
10
+ RegradeReportEntry,
11
+ VocabularyRegradePlan,
12
+ } from '@ontrails/regrade';
13
+ import { createHash } from 'node:crypto';
14
+ import { join, normalize, relative } from 'node:path';
15
+ import { z } from 'zod';
16
+
17
+ export const REGRADE_PLAN_SCHEMA_VERSION = 1;
18
+
19
+ const regradePlanProvenanceValueSchema = z.enum(['authored', 'derived']);
20
+
21
+ const classRegradePlanScopeSchema = z
22
+ .object({
23
+ exclude: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe('Root-relative path globs excluded from the class run'),
27
+ extensions: z
28
+ .array(z.string())
29
+ .optional()
30
+ .describe('Source file extensions scanned by the class run'),
31
+ include: z
32
+ .array(z.string())
33
+ .optional()
34
+ .describe('Root-relative path globs collected during the class run'),
35
+ })
36
+ .strict();
37
+
38
+ /**
39
+ * A saved class-mode Regrade plan: which classes run, over what scope, and
40
+ * why. The parallel payload to {@link vocabularyRegradePlanSchema} — the
41
+ * `kind` discriminant keeps existing vocabulary plan artifacts
42
+ * byte-compatible.
43
+ */
44
+ const classRegradePlanSchema = z.object({
45
+ classIds: z
46
+ .array(z.string().min(1))
47
+ .min(1)
48
+ .describe('Regrade class ids this plan runs'),
49
+ id: z.string().min(1).describe('Stable Regrade plan identifier'),
50
+ intent: z
51
+ .string()
52
+ .optional()
53
+ .describe('Human-authored migration intent for the class run'),
54
+ kind: z.literal('class').describe('Regrade plan kind'),
55
+ name: z
56
+ .string()
57
+ .min(1)
58
+ .optional()
59
+ .describe(
60
+ 'Authored transition name; keys the saved plan and consolidated history filenames'
61
+ ),
62
+ scope: classRegradePlanScopeSchema
63
+ .optional()
64
+ .describe('Collection scope for the class run'),
65
+ });
66
+
67
+ export type ClassRegradePlan = z.output<typeof classRegradePlanSchema>;
68
+
69
+ const regradePlanBodySchema = z.discriminatedUnion('kind', [
70
+ vocabularyRegradePlanSchema,
71
+ classRegradePlanSchema,
72
+ ]);
73
+
74
+ export type RegradePlanBody = VocabularyRegradePlan | ClassRegradePlan;
75
+
76
+ const regradeExpansionCandidateSchema = z.union([
77
+ z.object({
78
+ evidence: z
79
+ .array(
80
+ z.object({
81
+ column: z.number().optional(),
82
+ detail: z.string().optional(),
83
+ line: z.number().optional(),
84
+ path: z.string(),
85
+ })
86
+ )
87
+ .default([]),
88
+ kind: z.enum(['file-rename', 'form', 'namespace', 'preserve']),
89
+ reason: z.string().optional(),
90
+ status: z.enum(['pending', 'rejected']).default('pending'),
91
+ suggestedClassification: z.string(),
92
+ value: z.string(),
93
+ }),
94
+ z
95
+ .object({
96
+ detail: z.string().optional(),
97
+ path: z.string(),
98
+ status: z.enum(['pending', 'rejected']).default('pending'),
99
+ })
100
+ .transform((candidate) => ({
101
+ evidence: [
102
+ {
103
+ ...(candidate.detail === undefined
104
+ ? {}
105
+ : { detail: candidate.detail }),
106
+ path: candidate.path,
107
+ },
108
+ ],
109
+ kind: 'file-rename' as const,
110
+ ...(candidate.detail === undefined ? {} : { reason: candidate.detail }),
111
+ status: candidate.status,
112
+ suggestedClassification: 'legacy-path-candidate',
113
+ value: candidate.path,
114
+ })),
115
+ ]);
116
+
117
+ export const regradePlanArtifactSchema = z
118
+ .object({
119
+ expansion: z
120
+ .object({
121
+ candidates: z.array(regradeExpansionCandidateSchema).default([]),
122
+ })
123
+ .optional(),
124
+ kind: z.literal('regrade-plan'),
125
+ path: z.string(),
126
+ plan: regradePlanBodySchema,
127
+ provenance: z.object({
128
+ fields: z.record(z.string(), regradePlanProvenanceValueSchema),
129
+ }),
130
+ schemaVersion: z.literal(REGRADE_PLAN_SCHEMA_VERSION),
131
+ sourceHash: z.string(),
132
+ transitionId: z
133
+ .string()
134
+ .min(1)
135
+ .optional()
136
+ .describe(
137
+ 'Stable transition identity this plan re-runs; preserves the consolidated history spine'
138
+ ),
139
+ })
140
+ .strict();
141
+
142
+ export interface RegradePlanExpansion {
143
+ readonly candidates: readonly {
144
+ readonly evidence: readonly {
145
+ readonly column?: number | undefined;
146
+ readonly detail?: string | undefined;
147
+ readonly line?: number | undefined;
148
+ readonly path: string;
149
+ }[];
150
+ readonly kind: 'file-rename' | 'form' | 'namespace' | 'preserve';
151
+ readonly reason?: string | undefined;
152
+ readonly status: 'pending' | 'rejected';
153
+ readonly suggestedClassification: string;
154
+ readonly value: string;
155
+ }[];
156
+ }
157
+
158
+ export interface RegradePlanArtifact {
159
+ readonly expansion?: RegradePlanExpansion | undefined;
160
+ readonly kind: 'regrade-plan';
161
+ readonly path: string;
162
+ readonly plan: RegradePlanBody;
163
+ readonly provenance: {
164
+ readonly fields: Readonly<Record<string, 'authored' | 'derived'>>;
165
+ };
166
+ readonly schemaVersion: typeof REGRADE_PLAN_SCHEMA_VERSION;
167
+ readonly sourceHash: string;
168
+ readonly transitionId?: string | undefined;
169
+ }
170
+
171
+ /** A plan artifact narrowed to a vocabulary plan body. */
172
+ export type VocabularyRegradePlanArtifact = RegradePlanArtifact & {
173
+ readonly plan: VocabularyRegradePlan;
174
+ };
175
+
176
+ const regradeSlugText = (text: string): string =>
177
+ text
178
+ .toLowerCase()
179
+ .replaceAll(/[^a-z0-9]+/g, '-')
180
+ .replaceAll(/^-|-$/g, '');
181
+
182
+ const regradePlanSlug = (plan: Pick<VocabularyRegradePlan, 'from' | 'to'>) =>
183
+ regradeSlugText(`${plan.from}-to-${plan.to}`);
184
+
185
+ export const regradePlanSlugForBody = (plan: RegradePlanBody): string =>
186
+ plan.kind === 'class'
187
+ ? regradeSlugText(plan.name ?? plan.classIds.join('-'))
188
+ : regradePlanSlug(plan);
189
+
190
+ const normalizeRelativePath = (path: string): string =>
191
+ normalize(path).replaceAll('\\', '/');
192
+
193
+ export const rootRelativePath = (
194
+ rootDir: string,
195
+ absolutePath: string
196
+ ): string => normalizeRelativePath(relative(rootDir, absolutePath));
197
+
198
+ export const regradePlanDirectory = (rootDir: string): string =>
199
+ join(rootDir, '.trails', 'regrade');
200
+
201
+ export const regradePlanPathForPlan = (
202
+ rootDir: string,
203
+ plan: RegradePlanBody
204
+ ): string =>
205
+ join(regradePlanDirectory(rootDir), `${regradePlanSlugForBody(plan)}.json`);
206
+
207
+ const sourceHashEntryFacts = (
208
+ entries: readonly RegradeReportEntry[]
209
+ ): readonly Pick<
210
+ RegradeReportEntry,
211
+ 'classId' | 'notes' | 'outcome' | 'path' | 'reason' | 'reviewDetails'
212
+ >[] =>
213
+ entries
214
+ .filter(
215
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
216
+ )
217
+ .map(({ classId, notes, outcome, path, reason, reviewDetails }) => ({
218
+ ...(classId === undefined ? {} : { classId }),
219
+ ...(notes === undefined ? {} : { notes }),
220
+ outcome,
221
+ path,
222
+ ...(reason === undefined ? {} : { reason }),
223
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
224
+ }));
225
+
226
+ export const regradeSourceHash = (report: RegradeReport): string =>
227
+ createHash('sha256')
228
+ .update(
229
+ JSON.stringify({
230
+ entries: sourceHashEntryFacts(report.entries),
231
+ ledger: report.run?.ledger,
232
+ selectedClassIds: report.selectedClassIds,
233
+ })
234
+ )
235
+ .digest('hex');
236
+
237
+ const canonicalizeJsonValue = (value: unknown): unknown => {
238
+ if (Array.isArray(value)) {
239
+ return value.map((entry) => canonicalizeJsonValue(entry));
240
+ }
241
+ if (isPlainObject(value)) {
242
+ return Object.fromEntries(
243
+ Object.keys(value)
244
+ .toSorted()
245
+ .map((key) => [key, canonicalizeJsonValue(value[key])])
246
+ );
247
+ }
248
+ return value;
249
+ };
250
+
251
+ /**
252
+ * JSON.stringify with recursively sorted object keys so structurally equal
253
+ * values serialize identically regardless of key insertion order. Arrays keep
254
+ * their authored order.
255
+ */
256
+ export const canonicalJsonStringify = (value: unknown): string =>
257
+ JSON.stringify(canonicalizeJsonValue(value));
258
+
259
+ /**
260
+ * Canonical content hash of a resolved Regrade plan body — the authored
261
+ * migration intent. Stable across key insertion order; changes on any edit to
262
+ * the plan contents.
263
+ */
264
+ export const regradePlanContentHash = (plan: RegradePlanBody): string =>
265
+ createHash('sha256').update(canonicalJsonStringify(plan)).digest('hex');
@@ -89,6 +89,18 @@ export {
89
89
  type RegistryView,
90
90
  type RegistryWorkspace,
91
91
  } from './native-bun-registry.js';
92
+ export {
93
+ collectReleaseNotesInput,
94
+ dedupeReleaseChanges,
95
+ extractChangelogEntry,
96
+ renderReleaseNotes,
97
+ type ReleaseNotesCollectOptions,
98
+ type ReleaseNotesChange,
99
+ type ReleaseNotesInput,
100
+ type ReleaseNotesPackageVersion,
101
+ type ReleaseNotesParsedChange,
102
+ } from './notes.js';
103
+ export { runReleaseNotesCli } from './notes-cli.js';
92
104
  export {
93
105
  channelIntentForDistTag,
94
106
  evaluateReleasePolicy,
@@ -115,6 +127,11 @@ export {
115
127
  type ReleaseSmokeCheckResult,
116
128
  type ReleaseSmokeResult,
117
129
  } from './smoke.js';
130
+ export {
131
+ runLockRoundtripSmoke,
132
+ type LockRoundtripSmokeOptions,
133
+ type LockRoundtripSmokeResult,
134
+ } from './lock-roundtrip-smoke.js';
118
135
  export {
119
136
  runPackedArtifactsSmoke,
120
137
  type PackedArtifactsSmokeResult,