@ontrails/trails 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.
Files changed (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,455 @@
1
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { escapeRegExp } from '@ontrails/core';
4
+
5
+ export interface ReleasePackCoherenceInput {
6
+ readonly branchName?: string | undefined;
7
+ readonly changedFiles: readonly string[];
8
+ }
9
+
10
+ export interface ReleasePackCoherenceWorkspace {
11
+ readonly name: string;
12
+ readonly path: string;
13
+ readonly version?: string | undefined;
14
+ }
15
+
16
+ export interface ReleasePackCoherenceLockfileWorkspace {
17
+ readonly name?: string | undefined;
18
+ readonly version?: string | undefined;
19
+ }
20
+
21
+ export interface ReleasePackCoherenceLockfileInput {
22
+ readonly lockfileWorkspaces: Readonly<
23
+ Record<string, ReleasePackCoherenceLockfileWorkspace | undefined>
24
+ >;
25
+ readonly sourceWorkspaces: readonly ReleasePackCoherenceWorkspace[];
26
+ }
27
+
28
+ export interface ReleasePackCoherenceLockfileSyncResult {
29
+ readonly text: string;
30
+ readonly updates: readonly string[];
31
+ }
32
+
33
+ interface RootPackageJson {
34
+ readonly workspaces?: readonly string[] | undefined;
35
+ }
36
+
37
+ interface WorkspacePackageJson {
38
+ readonly name?: string | undefined;
39
+ readonly version?: string | undefined;
40
+ }
41
+
42
+ interface BunLockfile {
43
+ readonly workspaces?:
44
+ | Readonly<
45
+ Record<string, ReleasePackCoherenceLockfileWorkspace | undefined>
46
+ >
47
+ | undefined;
48
+ }
49
+
50
+ const RELEASE_BRANCH_NAME = 'changeset-release/main';
51
+ const REPO_ROOT = process.cwd();
52
+
53
+ export const isReleasePackCoherenceFile = (path: string): boolean =>
54
+ path === 'bun.lock' ||
55
+ path === '.changeset/pre.json' ||
56
+ path === 'package.json' ||
57
+ path.endsWith('/package.json') ||
58
+ path.endsWith('/CHANGELOG.md');
59
+
60
+ export const shouldRunReleasePackCoherenceCheck = ({
61
+ branchName,
62
+ changedFiles,
63
+ }: ReleasePackCoherenceInput): boolean =>
64
+ branchName === RELEASE_BRANCH_NAME ||
65
+ changedFiles.some(isReleasePackCoherenceFile);
66
+
67
+ const commandText = (cmd: readonly string[]): string => cmd.join(' ');
68
+
69
+ const spawnCapture = async (cmd: readonly string[]): Promise<string> => {
70
+ const proc = Bun.spawn(cmd as string[], {
71
+ stderr: 'pipe',
72
+ stdin: 'ignore',
73
+ stdout: 'pipe',
74
+ });
75
+ const [stdout, stderr, exitCode] = await Promise.all([
76
+ new Response(proc.stdout).text(),
77
+ new Response(proc.stderr).text(),
78
+ proc.exited,
79
+ ]);
80
+ if (exitCode !== 0) {
81
+ throw new Error(
82
+ [
83
+ `Command failed: ${commandText(cmd)}`,
84
+ `exit: ${exitCode}`,
85
+ stdout.trim() ? `stdout:\n${stdout}` : undefined,
86
+ stderr.trim() ? `stderr:\n${stderr}` : undefined,
87
+ ]
88
+ .filter((line): line is string => typeof line === 'string')
89
+ .join('\n')
90
+ );
91
+ }
92
+ return stdout;
93
+ };
94
+
95
+ const spawnInherit = async (cmd: readonly string[]): Promise<number> =>
96
+ await Bun.spawn(cmd as string[], {
97
+ env: { ...process.env, GIT_PAGER: 'cat' },
98
+ stderr: 'inherit',
99
+ stdin: 'inherit',
100
+ stdout: 'inherit',
101
+ }).exited;
102
+
103
+ const currentBranchName = async (): Promise<string | undefined> => {
104
+ const output = await spawnCapture(['git', 'branch', '--show-current']);
105
+ const branch = output.trim();
106
+ return branch.length > 0 ? branch : undefined;
107
+ };
108
+
109
+ const changedFilesFromGit = async (): Promise<readonly string[]> => {
110
+ const mergeBaseOutput = await spawnCapture([
111
+ 'git',
112
+ 'merge-base',
113
+ 'origin/main',
114
+ 'HEAD',
115
+ ]);
116
+ const mergeBase = mergeBaseOutput.trim();
117
+ const output = await spawnCapture([
118
+ 'git',
119
+ 'diff',
120
+ '--name-only',
121
+ '--diff-filter=ACMRT',
122
+ `${mergeBase}...HEAD`,
123
+ ]);
124
+ return output
125
+ .split(/\r?\n/u)
126
+ .map((line) => line.trim())
127
+ .filter(Boolean);
128
+ };
129
+
130
+ const changedFilesFromPath = async (
131
+ path: string
132
+ ): Promise<readonly string[]> => {
133
+ const text = await readFile(path, 'utf8');
134
+ return text
135
+ .split(/\r?\n/u)
136
+ .map((line) => line.trim())
137
+ .filter(Boolean);
138
+ };
139
+
140
+ const removeJsonTrailingCommas = (text: string): string =>
141
+ text.replaceAll(/,(\s*[}\]])/gu, '$1');
142
+
143
+ const readJson = async <T>(path: string): Promise<T> =>
144
+ JSON.parse(await readFile(path, 'utf8')) as T;
145
+
146
+ const readJsonc = async <T>(path: string): Promise<T> =>
147
+ JSON.parse(removeJsonTrailingCommas(await readFile(path, 'utf8'))) as T;
148
+
149
+ const discoverWorkspaceDirs = async (
150
+ patterns: readonly string[]
151
+ ): Promise<string[]> => {
152
+ const dirs: string[] = [];
153
+ for (const pattern of patterns) {
154
+ if (pattern.endsWith('/*')) {
155
+ const parent = join(REPO_ROOT, pattern.slice(0, -2));
156
+ let names: string[] = [];
157
+ try {
158
+ const entries = await readdir(parent, { withFileTypes: true });
159
+ names = entries
160
+ .filter((entry) => entry.isDirectory())
161
+ .map((entry) => entry.name);
162
+ } catch {
163
+ continue;
164
+ }
165
+ for (const name of names) {
166
+ const dir = join(parent, name);
167
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
168
+ dirs.push(dir);
169
+ }
170
+ }
171
+ continue;
172
+ }
173
+
174
+ const dir = join(REPO_ROOT, pattern);
175
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
176
+ dirs.push(dir);
177
+ }
178
+ }
179
+ return dirs;
180
+ };
181
+
182
+ const discoverSourceWorkspaces = async (): Promise<
183
+ ReleasePackCoherenceWorkspace[]
184
+ > => {
185
+ const root = await readJson<RootPackageJson>(join(REPO_ROOT, 'package.json'));
186
+ if (!root.workspaces || root.workspaces.length === 0) {
187
+ throw new Error('Root package.json has no "workspaces" field');
188
+ }
189
+
190
+ const dirs = await discoverWorkspaceDirs(root.workspaces);
191
+ const workspaces: ReleasePackCoherenceWorkspace[] = [];
192
+ for (const dir of dirs) {
193
+ const pkg = await readJson<WorkspacePackageJson>(join(dir, 'package.json'));
194
+ if (!pkg.name) {
195
+ continue;
196
+ }
197
+ workspaces.push({
198
+ name: pkg.name,
199
+ path: relative(REPO_ROOT, dir),
200
+ version: pkg.version,
201
+ });
202
+ }
203
+ return workspaces;
204
+ };
205
+
206
+ export const findLockfileWorkspaceMetadataMismatches = ({
207
+ lockfileWorkspaces,
208
+ sourceWorkspaces,
209
+ }: ReleasePackCoherenceLockfileInput): string[] => {
210
+ const mismatches: string[] = [];
211
+ for (const workspace of sourceWorkspaces) {
212
+ const lockWorkspace = lockfileWorkspaces[workspace.path];
213
+ if (!lockWorkspace) {
214
+ mismatches.push(
215
+ `${workspace.path}/package.json is missing from bun.lock workspaces`
216
+ );
217
+ continue;
218
+ }
219
+
220
+ if (lockWorkspace.name !== workspace.name) {
221
+ mismatches.push(
222
+ `${workspace.path}/package.json has name ${workspace.name}, but bun.lock records ${lockWorkspace.name ?? '(missing)'}`
223
+ );
224
+ }
225
+
226
+ if (
227
+ typeof workspace.version === 'string' &&
228
+ lockWorkspace.version !== workspace.version
229
+ ) {
230
+ mismatches.push(
231
+ `${workspace.path}/package.json has version ${workspace.version}, but bun.lock records ${lockWorkspace.version ?? '(missing)'}`
232
+ );
233
+ }
234
+ }
235
+ return mismatches;
236
+ };
237
+
238
+ const findWorkspaceBlockRange = (
239
+ text: string,
240
+ workspacePath: string
241
+ ): { readonly start: number; readonly end: number } | undefined => {
242
+ const keyMatch = new RegExp(
243
+ `^ "${escapeRegExp(workspacePath)}": \\{`,
244
+ 'mu'
245
+ ).exec(text);
246
+ if (!keyMatch) {
247
+ return undefined;
248
+ }
249
+
250
+ const openBrace = text.indexOf('{', keyMatch.index);
251
+ if (openBrace === -1) {
252
+ return undefined;
253
+ }
254
+
255
+ let depth = 0;
256
+ let inString = false;
257
+ let escaped = false;
258
+ for (let index = openBrace; index < text.length; index += 1) {
259
+ const char = text[index];
260
+ if (escaped) {
261
+ escaped = false;
262
+ continue;
263
+ }
264
+ if (char === '\\') {
265
+ escaped = true;
266
+ continue;
267
+ }
268
+ if (char === '"') {
269
+ inString = !inString;
270
+ continue;
271
+ }
272
+ if (inString) {
273
+ continue;
274
+ }
275
+ if (char === '{') {
276
+ depth += 1;
277
+ continue;
278
+ }
279
+ if (char === '}') {
280
+ depth -= 1;
281
+ if (depth === 0) {
282
+ return { end: index + 1, start: openBrace };
283
+ }
284
+ }
285
+ }
286
+
287
+ return undefined;
288
+ };
289
+
290
+ export const syncLockfileWorkspaceMetadataText = (
291
+ text: string,
292
+ sourceWorkspaces: readonly ReleasePackCoherenceWorkspace[]
293
+ ): ReleasePackCoherenceLockfileSyncResult => {
294
+ let nextText = text;
295
+ const updates: string[] = [];
296
+
297
+ for (const workspace of sourceWorkspaces) {
298
+ if (typeof workspace.version !== 'string') {
299
+ continue;
300
+ }
301
+
302
+ const range = findWorkspaceBlockRange(nextText, workspace.path);
303
+ if (!range) {
304
+ continue;
305
+ }
306
+
307
+ const block = nextText.slice(range.start, range.end);
308
+ const versionMatch = /^(\s*"version":\s*")([^"]*)(",?)$/mu.exec(block);
309
+ if (!versionMatch || versionMatch[2] === workspace.version) {
310
+ continue;
311
+ }
312
+
313
+ const replacement = `${versionMatch[1]}${workspace.version}${versionMatch[3]}`;
314
+ const blockStart = range.start + versionMatch.index;
315
+ const valueEnd = blockStart + versionMatch[0].length;
316
+ nextText =
317
+ nextText.slice(0, blockStart) + replacement + nextText.slice(valueEnd);
318
+ updates.push(
319
+ `${workspace.path}/package.json: ${versionMatch[2]} -> ${workspace.version}`
320
+ );
321
+ }
322
+
323
+ return { text: nextText, updates };
324
+ };
325
+
326
+ const loadBunLockfile = async (): Promise<BunLockfile> =>
327
+ await readJsonc<BunLockfile>(join(REPO_ROOT, 'bun.lock'));
328
+
329
+ const syncLockfileWorkspaceMetadata = async (): Promise<void> => {
330
+ const lockfilePath = join(REPO_ROOT, 'bun.lock');
331
+ const [lockfile, sourceWorkspaces] = await Promise.all([
332
+ readFile(lockfilePath, 'utf8'),
333
+ discoverSourceWorkspaces(),
334
+ ]);
335
+ const { text, updates } = syncLockfileWorkspaceMetadataText(
336
+ lockfile,
337
+ sourceWorkspaces
338
+ );
339
+
340
+ if (updates.length === 0) {
341
+ console.error('release-pack: bun.lock workspace metadata already synced');
342
+ return;
343
+ }
344
+
345
+ await writeFile(lockfilePath, text);
346
+ console.error('release-pack: synced bun.lock workspace metadata');
347
+ for (const update of updates) {
348
+ console.error(` ${update}`);
349
+ }
350
+ };
351
+
352
+ const runLockfileWorkspaceMetadataCheck = async (): Promise<number> => {
353
+ const lockfile = await loadBunLockfile();
354
+ if (!lockfile.workspaces) {
355
+ throw new Error('bun.lock has no "workspaces" object');
356
+ }
357
+
358
+ const mismatches = findLockfileWorkspaceMetadataMismatches({
359
+ lockfileWorkspaces: lockfile.workspaces,
360
+ sourceWorkspaces: await discoverSourceWorkspaces(),
361
+ });
362
+
363
+ if (mismatches.length === 0) {
364
+ console.error('release-pack: bun.lock workspace metadata is coherent');
365
+ return 0;
366
+ }
367
+
368
+ console.error('release-pack: bun.lock workspace metadata is stale');
369
+ for (const mismatch of mismatches) {
370
+ console.error(` ${mismatch}`);
371
+ }
372
+ return 1;
373
+ };
374
+
375
+ export interface ReleasePackCoherenceParsedArgs {
376
+ readonly branchName?: string | undefined;
377
+ readonly changedFilesPath?: string | undefined;
378
+ readonly fixLockfile: boolean;
379
+ readonly lockfileOnly: boolean;
380
+ }
381
+
382
+ export const parseReleasePackCoherenceArgs = (
383
+ args: readonly string[]
384
+ ): ReleasePackCoherenceParsedArgs => {
385
+ let branchName: string | undefined;
386
+ let changedFilesPath: string | undefined;
387
+ let fixLockfile = false;
388
+ let lockfileOnly = false;
389
+ const readValue = (index: number, flag: string): string => {
390
+ const value = args[index + 1];
391
+ if (!value || value.startsWith('--')) {
392
+ throw new Error(
393
+ `Missing value for release pack coherence argument: ${flag}`
394
+ );
395
+ }
396
+ return value;
397
+ };
398
+ for (let index = 0; index < args.length; index += 1) {
399
+ const arg = args[index];
400
+ if (arg === '--branch') {
401
+ branchName = readValue(index, arg);
402
+ index += 1;
403
+ } else if (arg === '--changed-files') {
404
+ changedFilesPath = readValue(index, arg);
405
+ index += 1;
406
+ } else if (arg === '--lockfile-only') {
407
+ lockfileOnly = true;
408
+ } else if (arg === '--fix-lockfile') {
409
+ fixLockfile = true;
410
+ } else {
411
+ throw new Error(`Unknown release pack coherence argument: ${arg}`);
412
+ }
413
+ }
414
+ return { branchName, changedFilesPath, fixLockfile, lockfileOnly };
415
+ };
416
+
417
+ export const runReleasePackCoherenceCli = async (
418
+ args: readonly string[] = process.argv.slice(2)
419
+ ): Promise<number> => {
420
+ try {
421
+ const parsed = parseReleasePackCoherenceArgs(args);
422
+ if (parsed.fixLockfile) {
423
+ await syncLockfileWorkspaceMetadata();
424
+ console.error('release-pack: checking bun.lock workspace metadata');
425
+ return await runLockfileWorkspaceMetadataCheck();
426
+ }
427
+
428
+ const [branchName, changedFiles] = await Promise.all([
429
+ parsed.branchName
430
+ ? Promise.resolve(parsed.branchName)
431
+ : currentBranchName(),
432
+ parsed.changedFilesPath
433
+ ? changedFilesFromPath(parsed.changedFilesPath)
434
+ : changedFilesFromGit(),
435
+ ]);
436
+
437
+ if (!shouldRunReleasePackCoherenceCheck({ branchName, changedFiles })) {
438
+ console.error(
439
+ 'release-pack: skipped; no package release metadata changed'
440
+ );
441
+ return 0;
442
+ }
443
+
444
+ if (parsed.lockfileOnly) {
445
+ console.error('release-pack: checking bun.lock workspace metadata');
446
+ return await runLockfileWorkspaceMetadataCheck();
447
+ }
448
+
449
+ console.error('release-pack: checking packed workspace metadata');
450
+ return await spawnInherit(['bun', 'run', 'publish:check']);
451
+ } catch (error) {
452
+ console.error(error instanceof Error ? error.message : String(error));
453
+ return 1;
454
+ }
455
+ };
@@ -0,0 +1,146 @@
1
+ import { listGovernedVocabularyTransitions } from '@ontrails/warden';
2
+
3
+ import type { WorkspaceInfo } from './check.js';
4
+
5
+ export interface PackageRouteReleaseFact {
6
+ readonly kind: 'classified' | 'single';
7
+ readonly sourcePackage: string;
8
+ readonly targetPackage?: string;
9
+ readonly transitionId: string;
10
+ }
11
+
12
+ export interface PackageRouteReleaseDiagnostic {
13
+ readonly code:
14
+ | 'missing-governed-package-route'
15
+ | 'missing-governed-package-route-target';
16
+ readonly message: string;
17
+ readonly sourcePackage: string;
18
+ }
19
+
20
+ export interface PackageRouteReleaseIntent {
21
+ readonly eligiblePackages: readonly string[];
22
+ readonly sourcePackage: string;
23
+ }
24
+
25
+ const isPublishableTrailsPackage = (workspace: WorkspaceInfo): boolean =>
26
+ !workspace.isPrivate && workspace.name.startsWith('@ontrails/');
27
+
28
+ const findPackageRouteTransition = (
29
+ transitions: ReturnType<typeof listGovernedVocabularyTransitions>,
30
+ sourcePackage: string
31
+ ) => {
32
+ const direct = transitions.find(
33
+ (transition) => transition.from === sourcePackage
34
+ );
35
+
36
+ if (direct) {
37
+ return { target: direct.target, transition: direct };
38
+ }
39
+
40
+ for (const transition of transitions) {
41
+ const moduleSpecifierRoute = transition.stringLiteralRenames.find(
42
+ (route) =>
43
+ route.from === sourcePackage &&
44
+ route.moduleSpecifier?.targetPackage !== undefined
45
+ );
46
+
47
+ if (moduleSpecifierRoute?.moduleSpecifier?.targetPackage) {
48
+ return {
49
+ target: {
50
+ kind: 'single' as const,
51
+ to: moduleSpecifierRoute.moduleSpecifier.targetPackage,
52
+ },
53
+ transition,
54
+ };
55
+ }
56
+ }
57
+
58
+ return null;
59
+ };
60
+
61
+ export const findPackageRouteReleaseFacts = ({
62
+ baseWorkspaces,
63
+ workspaces,
64
+ }: {
65
+ readonly baseWorkspaces: readonly WorkspaceInfo[];
66
+ readonly workspaces: readonly WorkspaceInfo[];
67
+ }): {
68
+ readonly diagnostics: readonly PackageRouteReleaseDiagnostic[];
69
+ readonly facts: readonly PackageRouteReleaseFact[];
70
+ readonly intents: readonly PackageRouteReleaseIntent[];
71
+ } => {
72
+ const currentPackages = new Set(
73
+ workspaces
74
+ .filter(isPublishableTrailsPackage)
75
+ .map((workspace) => workspace.name)
76
+ );
77
+ const removedPackages = baseWorkspaces
78
+ .filter(isPublishableTrailsPackage)
79
+ .map((workspace) => workspace.name)
80
+ .filter((name) => !currentPackages.has(name))
81
+ .toSorted();
82
+ const transitions = listGovernedVocabularyTransitions();
83
+ const diagnostics: PackageRouteReleaseDiagnostic[] = [];
84
+ const facts: PackageRouteReleaseFact[] = [];
85
+ const intents: PackageRouteReleaseIntent[] = [];
86
+
87
+ for (const sourcePackage of removedPackages) {
88
+ const resolvedRoute = findPackageRouteTransition(
89
+ transitions,
90
+ sourcePackage
91
+ );
92
+
93
+ if (!resolvedRoute) {
94
+ diagnostics.push({
95
+ code: 'missing-governed-package-route',
96
+ message: `Public package removal '${sourcePackage}' requires an exact governed Regrade route. Add the route to the governed transition registry, then run trails regrade plan/check.`,
97
+ sourcePackage,
98
+ });
99
+ continue;
100
+ }
101
+
102
+ const { target, transition } = resolvedRoute;
103
+
104
+ if (target.kind === 'classified') {
105
+ const classifiedTarget = target;
106
+ const eligiblePackages = [...currentPackages]
107
+ .filter((packageName) =>
108
+ classifiedTarget.options.some(
109
+ (option) =>
110
+ option.to === packageName ||
111
+ option.to.startsWith(`${packageName}/`)
112
+ )
113
+ )
114
+ .toSorted();
115
+ facts.push({
116
+ kind: 'classified',
117
+ sourcePackage,
118
+ transitionId: transition.id,
119
+ });
120
+ intents.push({ eligiblePackages, sourcePackage });
121
+ continue;
122
+ }
123
+
124
+ if (!currentPackages.has(target.to)) {
125
+ diagnostics.push({
126
+ code: 'missing-governed-package-route-target',
127
+ message: `Governed Regrade route '${transition.id}' maps '${sourcePackage}' to '${target.to}', but that publishable target package is absent. Add the target package before applying the route, or use a classified transition with an explicit non-migratable reason for a multi-owner fold.`,
128
+ sourcePackage,
129
+ });
130
+ continue;
131
+ }
132
+
133
+ facts.push({
134
+ kind: 'single',
135
+ sourcePackage,
136
+ targetPackage: target.to,
137
+ transitionId: transition.id,
138
+ });
139
+ intents.push({
140
+ eligiblePackages: [target.to],
141
+ sourcePackage,
142
+ });
143
+ }
144
+
145
+ return { diagnostics, facts, intents };
146
+ };