@ontrails/trails 1.0.0-beta.32 → 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.
- package/CHANGELOG.md +64 -0
- package/README.md +2 -2
- package/package.json +17 -16
- package/src/app.ts +27 -6
- package/src/cli.ts +2 -2
- package/src/mcp-app.ts +2 -0
- package/src/mcp-options.ts +20 -6
- package/src/regrade/history.ts +320 -0
- package/src/regrade/live-api-preserve.ts +8 -0
- package/src/regrade/plan-artifact.ts +265 -0
- package/src/release/index.ts +17 -0
- package/src/release/lock-roundtrip-smoke.ts +230 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/smoke.ts +11 -1
- package/src/release/wayfinder-dogfood-smoke.ts +1 -1
- package/src/run-schema.ts +34 -1
- package/src/trails/compile.ts +4 -5
- package/src/trails/guide.ts +1 -1
- package/src/trails/load-app.ts +30 -48
- package/src/trails/regrade.ts +2877 -107
- package/src/trails/release-smoke.ts +2 -1
- package/src/trails/survey.ts +25 -38
- package/src/trails/topo-read-support.ts +15 -11
- package/src/trails/topo-store-support.ts +21 -12
- package/src/trails/topo-support.ts +1 -0
- package/src/trails/validate.ts +1 -1
- package/src/trails/wayfind.ts +64 -12
|
@@ -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');
|
package/src/release/index.ts
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock round-trip invariant (TRL-1200).
|
|
3
|
+
*
|
|
4
|
+
* For every committed `trails.lock` in the repo, a fresh `trails compile`
|
|
5
|
+
* against a cold per-user store followed by `trails validate` must be
|
|
6
|
+
* green, and the recompiled lock must be byte-identical to the committed
|
|
7
|
+
* one. Evidence the toolchain cannot reproduce does not merge.
|
|
8
|
+
*
|
|
9
|
+
* Failures name the divergence and the command that fixes it. Hand-editing
|
|
10
|
+
* a lock is never the remediation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const trailsBinFor = (repoRoot: string): string =>
|
|
19
|
+
join(repoRoot, 'apps/trails/bin/trails.ts');
|
|
20
|
+
|
|
21
|
+
export interface LockRoundtripSmokeResult {
|
|
22
|
+
readonly check: 'lock-roundtrip';
|
|
23
|
+
readonly lockCount: number;
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly passed: true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LockRoundtripSmokeOptions {
|
|
29
|
+
/** Override lock discovery with explicit repo-relative lock paths. */
|
|
30
|
+
readonly lockPaths?: readonly string[];
|
|
31
|
+
readonly repoRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const discoverCommittedLocks = (repoRoot: string): readonly string[] => {
|
|
35
|
+
const result = Bun.spawnSync({
|
|
36
|
+
cmd: ['git', 'ls-files', '--', 'trails.lock', '*/trails.lock'],
|
|
37
|
+
cwd: repoRoot,
|
|
38
|
+
stderr: 'pipe',
|
|
39
|
+
stdout: 'pipe',
|
|
40
|
+
});
|
|
41
|
+
if (result.exitCode !== 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`lock-roundtrip: git ls-files failed: ${result.stderr.toString()}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return result.stdout
|
|
47
|
+
.toString()
|
|
48
|
+
.split('\n')
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter((line) => line.length > 0);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const resolveAppModule = (appDir: string): string => {
|
|
54
|
+
if (existsSync(join(appDir, 'src', 'app.ts'))) {
|
|
55
|
+
return './src/app.ts';
|
|
56
|
+
}
|
|
57
|
+
const packageJsonPath = join(appDir, 'package.json');
|
|
58
|
+
if (existsSync(packageJsonPath)) {
|
|
59
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
|
60
|
+
readonly main?: unknown;
|
|
61
|
+
};
|
|
62
|
+
if (typeof parsed.main === 'string') {
|
|
63
|
+
return parsed.main;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error(
|
|
67
|
+
`lock-roundtrip: unable to resolve the app module for "${appDir}" — expected src/app.ts or a package.json "main" entry.`
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const runTrailsCommand = (
|
|
72
|
+
repoRoot: string,
|
|
73
|
+
stateHome: string,
|
|
74
|
+
args: readonly string[]
|
|
75
|
+
): { readonly exitCode: number; readonly output: string } => {
|
|
76
|
+
const result = Bun.spawnSync({
|
|
77
|
+
cmd: [process.execPath, trailsBinFor(repoRoot), ...args],
|
|
78
|
+
cwd: repoRoot,
|
|
79
|
+
env: {
|
|
80
|
+
...process.env,
|
|
81
|
+
NO_COLOR: '1',
|
|
82
|
+
TRAILS_STATE_HOME: stateHome,
|
|
83
|
+
XDG_STATE_HOME: stateHome,
|
|
84
|
+
} as Record<string, string | undefined>,
|
|
85
|
+
stderr: 'pipe',
|
|
86
|
+
stdout: 'pipe',
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
exitCode: result.exitCode,
|
|
90
|
+
output: `${result.stdout.toString()}${result.stderr.toString()}`,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const summarizeEntryIds = (value: unknown): readonly string[] => {
|
|
95
|
+
if (typeof value !== 'object' || value === null) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const { topoGraph } = value as {
|
|
99
|
+
readonly topoGraph?: { readonly entries?: readonly { id?: string }[] };
|
|
100
|
+
};
|
|
101
|
+
return (topoGraph?.entries ?? [])
|
|
102
|
+
.map((entry) => entry.id)
|
|
103
|
+
.filter((id): id is string => typeof id === 'string');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Name what diverged between the committed and recompiled lock bytes. */
|
|
107
|
+
const describeLockDivergence = (
|
|
108
|
+
committed: string,
|
|
109
|
+
recompiled: string
|
|
110
|
+
): readonly string[] => {
|
|
111
|
+
const details: string[] = [];
|
|
112
|
+
let committedParsed: unknown;
|
|
113
|
+
let recompiledParsed: unknown;
|
|
114
|
+
try {
|
|
115
|
+
committedParsed = JSON.parse(committed);
|
|
116
|
+
recompiledParsed = JSON.parse(recompiled);
|
|
117
|
+
} catch {
|
|
118
|
+
return ['committed lock is not valid JSON'];
|
|
119
|
+
}
|
|
120
|
+
const committedLock = committedParsed as Record<string, unknown>;
|
|
121
|
+
const recompiledLock = recompiledParsed as Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
for (const section of ['scope', 'summary', 'topoGraphHash', 'version']) {
|
|
124
|
+
const left = JSON.stringify(committedLock[section]);
|
|
125
|
+
const right = JSON.stringify(recompiledLock[section]);
|
|
126
|
+
if (left !== right) {
|
|
127
|
+
details.push(`${section}: committed ${left} vs recompiled ${right}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const committedIds = new Set(summarizeEntryIds(committedParsed));
|
|
132
|
+
const recompiledIds = new Set(summarizeEntryIds(recompiledParsed));
|
|
133
|
+
const missing = [...committedIds].filter((id) => !recompiledIds.has(id));
|
|
134
|
+
const added = [...recompiledIds].filter((id) => !committedIds.has(id));
|
|
135
|
+
if (missing.length > 0) {
|
|
136
|
+
details.push(`entries only in committed lock: ${missing.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
if (added.length > 0) {
|
|
139
|
+
details.push(`entries only in recompiled lock: ${added.join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
if (details.length === 0) {
|
|
142
|
+
details.push('topoGraph entry content differs (same ids, different facts)');
|
|
143
|
+
}
|
|
144
|
+
return details;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const checkSingleLock = async (
|
|
148
|
+
repoRoot: string,
|
|
149
|
+
lockPath: string
|
|
150
|
+
): Promise<void> => {
|
|
151
|
+
const absoluteLockPath = resolve(repoRoot, lockPath);
|
|
152
|
+
const appDir = dirname(absoluteLockPath);
|
|
153
|
+
const relativeAppDir = relative(repoRoot, appDir) || '.';
|
|
154
|
+
const appModule = resolveAppModule(appDir);
|
|
155
|
+
const committedBytes = readFileSync(absoluteLockPath, 'utf8');
|
|
156
|
+
const stateHome = await mkdtemp(join(tmpdir(), 'lock-roundtrip-'));
|
|
157
|
+
const fixCommand = `bun apps/trails/bin/trails.ts compile --module ${appModule} --root-dir ${relativeAppDir} --permit '{"id":"lock-refresh","scopes":["topo:write"]}'`;
|
|
158
|
+
const remediation = `Fix: run \`${fixCommand}\` and commit the refreshed trails.lock. Never hand-edit the lock.`;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const compileResult = runTrailsCommand(repoRoot, stateHome, [
|
|
162
|
+
'compile',
|
|
163
|
+
'--module',
|
|
164
|
+
appModule,
|
|
165
|
+
'--root-dir',
|
|
166
|
+
relativeAppDir,
|
|
167
|
+
'--permit',
|
|
168
|
+
'{"id":"lock-roundtrip-gate","scopes":["topo:write"]}',
|
|
169
|
+
'--json',
|
|
170
|
+
]);
|
|
171
|
+
if (compileResult.exitCode !== 0) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`lock-roundtrip: cold compile failed for ${lockPath}.\n${compileResult.output}\n${remediation}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const validateResult = runTrailsCommand(repoRoot, stateHome, [
|
|
178
|
+
'validate',
|
|
179
|
+
'--module',
|
|
180
|
+
appModule,
|
|
181
|
+
'--root-dir',
|
|
182
|
+
relativeAppDir,
|
|
183
|
+
'--json',
|
|
184
|
+
]);
|
|
185
|
+
if (validateResult.exitCode !== 0) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`lock-roundtrip: validate failed for ${lockPath} after a cold recompile.\n${validateResult.output}\n${remediation}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const recompiledBytes = readFileSync(absoluteLockPath, 'utf8');
|
|
192
|
+
if (recompiledBytes !== committedBytes) {
|
|
193
|
+
const details = describeLockDivergence(committedBytes, recompiledBytes);
|
|
194
|
+
throw new Error(
|
|
195
|
+
[
|
|
196
|
+
`lock-roundtrip: ${lockPath} is not byte-identical after a cold recompile.`,
|
|
197
|
+
...details.map((detail) => ` - ${detail}`),
|
|
198
|
+
remediation,
|
|
199
|
+
].join('\n')
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
// The gate is read-only: always restore the committed bytes.
|
|
204
|
+
writeFileSync(absoluteLockPath, committedBytes);
|
|
205
|
+
await rm(stateHome, { force: true, recursive: true });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const runLockRoundtripSmoke = async (
|
|
210
|
+
options?: LockRoundtripSmokeOptions
|
|
211
|
+
): Promise<LockRoundtripSmokeResult> => {
|
|
212
|
+
const repoRoot = resolve(options?.repoRoot ?? process.cwd());
|
|
213
|
+
const lockPaths = options?.lockPaths ?? discoverCommittedLocks(repoRoot);
|
|
214
|
+
|
|
215
|
+
for (const lockPath of lockPaths) {
|
|
216
|
+
await checkSingleLock(repoRoot, lockPath);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const message =
|
|
220
|
+
lockPaths.length === 0
|
|
221
|
+
? 'lock-roundtrip: no committed trails.lock files found — nothing to verify'
|
|
222
|
+
: `lock-roundtrip: ${lockPaths.length} committed trails.lock file(s) recompiled cold — validate green, byte-identical (${lockPaths.join(', ')})`;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
check: 'lock-roundtrip',
|
|
226
|
+
lockCount: lockPaths.length,
|
|
227
|
+
message,
|
|
228
|
+
passed: true,
|
|
229
|
+
};
|
|
230
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { collectReleaseNotesInput, renderReleaseNotes } from './notes.js';
|
|
6
|
+
import type { ReleaseNotesCollectOptions } from './notes.js';
|
|
7
|
+
|
|
8
|
+
const RELEASE_BRANCH = 'changeset-release/main';
|
|
9
|
+
|
|
10
|
+
type CliOptions = Omit<ReleaseNotesCollectOptions, 'mode'>;
|
|
11
|
+
|
|
12
|
+
const runText = async (cmd: readonly string[]): Promise<string> => {
|
|
13
|
+
const proc = Bun.spawn([...cmd], { stderr: 'pipe', stdout: 'pipe' });
|
|
14
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
15
|
+
new Response(proc.stdout).text(),
|
|
16
|
+
new Response(proc.stderr).text(),
|
|
17
|
+
proc.exited,
|
|
18
|
+
]);
|
|
19
|
+
if (exitCode !== 0) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Command failed (${exitCode}): ${cmd.join(' ')}\n${stderr.trim()}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return stdout.trim();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const readRepository = async (): Promise<string> =>
|
|
28
|
+
process.env['GITHUB_REPOSITORY'] ??
|
|
29
|
+
(await runText([
|
|
30
|
+
'gh',
|
|
31
|
+
'repo',
|
|
32
|
+
'view',
|
|
33
|
+
'--json',
|
|
34
|
+
'nameWithOwner',
|
|
35
|
+
'--jq',
|
|
36
|
+
'.nameWithOwner',
|
|
37
|
+
]));
|
|
38
|
+
|
|
39
|
+
const findReleasePullRequestNumber = async (
|
|
40
|
+
repo: string
|
|
41
|
+
): Promise<string | undefined> => {
|
|
42
|
+
const output = await runText([
|
|
43
|
+
'gh',
|
|
44
|
+
'pr',
|
|
45
|
+
'list',
|
|
46
|
+
'--repo',
|
|
47
|
+
repo,
|
|
48
|
+
'--head',
|
|
49
|
+
RELEASE_BRANCH,
|
|
50
|
+
'--base',
|
|
51
|
+
'main',
|
|
52
|
+
'--state',
|
|
53
|
+
'open',
|
|
54
|
+
'--json',
|
|
55
|
+
'number',
|
|
56
|
+
'--jq',
|
|
57
|
+
'.[0].number // empty',
|
|
58
|
+
]);
|
|
59
|
+
return output || undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const parseArgs = (args: readonly string[]): CliOptions => {
|
|
63
|
+
let distTag: string | undefined;
|
|
64
|
+
let repo: string | undefined;
|
|
65
|
+
let repoRoot = resolve(process.cwd());
|
|
66
|
+
let version: string | undefined;
|
|
67
|
+
|
|
68
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
69
|
+
const arg = args[index];
|
|
70
|
+
if (arg === '--dist-tag') {
|
|
71
|
+
index += 1;
|
|
72
|
+
distTag = args[index];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg === '--repo') {
|
|
76
|
+
index += 1;
|
|
77
|
+
repo = args[index];
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === '--repo-root') {
|
|
81
|
+
index += 1;
|
|
82
|
+
repoRoot = resolve(args[index] ?? repoRoot);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg === '--version') {
|
|
86
|
+
index += 1;
|
|
87
|
+
version = args[index];
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Unknown release-notes option: ${arg}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...(distTag === undefined ? {} : { distTag }),
|
|
95
|
+
...(repo === undefined ? {} : { repo }),
|
|
96
|
+
repoRoot,
|
|
97
|
+
...(version === undefined ? {} : { version }),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const commandGithubRelease = async (args: readonly string[]): Promise<void> => {
|
|
102
|
+
const input = await collectReleaseNotesInput({
|
|
103
|
+
...parseArgs(args),
|
|
104
|
+
mode: 'github-release',
|
|
105
|
+
});
|
|
106
|
+
process.stdout.write(renderReleaseNotes(input));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const commandReleasePr = async (args: readonly string[]): Promise<void> => {
|
|
110
|
+
const input = await collectReleaseNotesInput({
|
|
111
|
+
...parseArgs(args),
|
|
112
|
+
mode: 'release-pr',
|
|
113
|
+
});
|
|
114
|
+
process.stdout.write(renderReleaseNotes(input));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const commandUpdateReleasePr = async (
|
|
118
|
+
args: readonly string[]
|
|
119
|
+
): Promise<void> => {
|
|
120
|
+
const options = parseArgs(args);
|
|
121
|
+
const repo = options.repo ?? (await readRepository());
|
|
122
|
+
const number = await findReleasePullRequestNumber(repo);
|
|
123
|
+
if (!number) {
|
|
124
|
+
throw new Error(`No open ${RELEASE_BRANCH} pull request found`);
|
|
125
|
+
}
|
|
126
|
+
const input = await collectReleaseNotesInput({
|
|
127
|
+
...options,
|
|
128
|
+
mode: 'release-pr',
|
|
129
|
+
repo,
|
|
130
|
+
});
|
|
131
|
+
const dir = await mkdtemp(join(tmpdir(), 'trails-release-notes-'));
|
|
132
|
+
const bodyPath = join(dir, 'body.md');
|
|
133
|
+
await writeFile(bodyPath, renderReleaseNotes(input));
|
|
134
|
+
await runText([
|
|
135
|
+
'gh',
|
|
136
|
+
'pr',
|
|
137
|
+
'edit',
|
|
138
|
+
number,
|
|
139
|
+
'--repo',
|
|
140
|
+
repo,
|
|
141
|
+
'--body-file',
|
|
142
|
+
bodyPath,
|
|
143
|
+
]);
|
|
144
|
+
console.error(`trails: updated release PR #${number} body`);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const runReleaseNotesCli = async (
|
|
148
|
+
args: readonly string[] = process.argv.slice(2)
|
|
149
|
+
): Promise<number> => {
|
|
150
|
+
try {
|
|
151
|
+
const [command, ...rest] = args;
|
|
152
|
+
if (command === 'github-release') {
|
|
153
|
+
await commandGithubRelease(rest);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (command === 'release-pr') {
|
|
157
|
+
await commandReleasePr(rest);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (command === 'update-release-pr') {
|
|
161
|
+
await commandUpdateReleasePr(rest);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
throw new Error(
|
|
165
|
+
'Usage: bun scripts/release-notes.ts <github-release|release-pr|update-release-pr> [--version <version>] [--dist-tag <tag>] [--repo <owner/name>] [--repo-root <path>]'
|
|
166
|
+
);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
};
|