@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22

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 (150) hide show
  1. package/CHANGELOG.md +647 -0
  2. package/README.md +26 -0
  3. package/package.json +28 -7
  4. package/src/app.ts +86 -2
  5. package/src/clack.ts +22 -0
  6. package/src/cli.ts +330 -11
  7. package/src/completions.ts +240 -0
  8. package/src/lifecycle-source-io.ts +33 -0
  9. package/src/load-app-mirror.ts +202 -0
  10. package/src/local-state-io.ts +153 -0
  11. package/src/mcp-app.ts +30 -0
  12. package/src/mcp-options.ts +77 -0
  13. package/src/mcp.ts +8 -0
  14. package/src/project-writes.ts +377 -0
  15. package/src/release/bindings.ts +39 -0
  16. package/src/release/check.ts +818 -0
  17. package/src/release/config.ts +63 -0
  18. package/src/release/contract-facts.ts +425 -0
  19. package/src/release/index.ts +85 -0
  20. package/src/release/native-bun-publish.ts +651 -0
  21. package/src/release/native-bun-registry.ts +350 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/smoke.ts +46 -0
  24. package/src/release/wayfinder-dogfood-smoke.ts +226 -0
  25. package/src/retired-topo-command.ts +36 -0
  26. package/src/run-adapter-check.ts +76 -0
  27. package/src/run-collision.ts +126 -0
  28. package/src/run-completions-install.ts +179 -0
  29. package/src/run-example.ts +149 -0
  30. package/src/run-examples.ts +148 -0
  31. package/src/run-quiet.ts +75 -0
  32. package/src/run-release-check.ts +74 -0
  33. package/src/run-trace.ts +273 -0
  34. package/src/run-warden.ts +39 -0
  35. package/src/run-watch.ts +432 -0
  36. package/src/scaffold-version-sync.ts +183 -0
  37. package/src/scaffold-versions.generated.ts +12 -0
  38. package/src/trails/adapter-check.ts +244 -0
  39. package/src/trails/add-surface.ts +94 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +95 -25
  42. package/src/trails/compile.ts +67 -0
  43. package/src/trails/completions-complete.ts +165 -0
  44. package/src/trails/completions.ts +47 -0
  45. package/src/trails/create-adapter.ts +1084 -0
  46. package/src/trails/create-scaffold.ts +399 -104
  47. package/src/trails/create-versions.ts +62 -0
  48. package/src/trails/create.ts +185 -71
  49. package/src/trails/deprecate.ts +59 -0
  50. package/src/trails/dev-clean.ts +82 -0
  51. package/src/trails/dev-reset.ts +50 -0
  52. package/src/trails/dev-stats.ts +72 -0
  53. package/src/trails/dev-support.ts +340 -0
  54. package/src/trails/doctor.ts +56 -0
  55. package/src/trails/draft-promote.ts +949 -0
  56. package/src/trails/guide.ts +74 -68
  57. package/src/trails/load-app.ts +1143 -15
  58. package/src/trails/project.ts +17 -3
  59. package/src/trails/release-check.ts +104 -0
  60. package/src/trails/release-smoke.ts +48 -0
  61. package/src/trails/revise.ts +53 -0
  62. package/src/trails/root-dir.ts +21 -0
  63. package/src/trails/run-example.ts +491 -0
  64. package/src/trails/run-examples.ts +145 -0
  65. package/src/trails/run.ts +410 -0
  66. package/src/trails/scaffold-json.ts +58 -0
  67. package/src/trails/survey.ts +881 -226
  68. package/src/trails/topo-activation.ts +385 -0
  69. package/src/trails/topo-constants.ts +2 -0
  70. package/src/trails/topo-history.ts +47 -0
  71. package/src/trails/topo-output-schemas.ts +248 -0
  72. package/src/trails/topo-pin.ts +52 -0
  73. package/src/trails/topo-read-support.ts +313 -0
  74. package/src/trails/topo-reports.ts +807 -0
  75. package/src/trails/topo-store-support.ts +174 -0
  76. package/src/trails/topo-support.ts +220 -0
  77. package/src/trails/topo-unpin.ts +61 -0
  78. package/src/trails/topo.ts +106 -0
  79. package/src/trails/validate.ts +38 -0
  80. package/src/trails/version-lifecycle-support.ts +945 -0
  81. package/src/trails/warden-guide.ts +129 -0
  82. package/src/trails/warden.ts +165 -58
  83. package/src/versions.ts +31 -0
  84. package/.turbo/turbo-build.log +0 -1
  85. package/.turbo/turbo-lint.log +0 -3
  86. package/.turbo/turbo-typecheck.log +0 -1
  87. package/__tests__/examples.test.ts +0 -6
  88. package/dist/bin/trails.d.ts +0 -3
  89. package/dist/bin/trails.d.ts.map +0 -1
  90. package/dist/bin/trails.js +0 -4
  91. package/dist/bin/trails.js.map +0 -1
  92. package/dist/src/app.d.ts +0 -2
  93. package/dist/src/app.d.ts.map +0 -1
  94. package/dist/src/app.js +0 -11
  95. package/dist/src/app.js.map +0 -1
  96. package/dist/src/clack.d.ts +0 -9
  97. package/dist/src/clack.d.ts.map +0 -1
  98. package/dist/src/clack.js +0 -62
  99. package/dist/src/clack.js.map +0 -1
  100. package/dist/src/cli.d.ts +0 -2
  101. package/dist/src/cli.d.ts.map +0 -1
  102. package/dist/src/cli.js +0 -13
  103. package/dist/src/cli.js.map +0 -1
  104. package/dist/src/trails/add-surface.d.ts +0 -13
  105. package/dist/src/trails/add-surface.d.ts.map +0 -1
  106. package/dist/src/trails/add-surface.js +0 -88
  107. package/dist/src/trails/add-surface.js.map +0 -1
  108. package/dist/src/trails/add-trail.d.ts +0 -11
  109. package/dist/src/trails/add-trail.d.ts.map +0 -1
  110. package/dist/src/trails/add-trail.js +0 -85
  111. package/dist/src/trails/add-trail.js.map +0 -1
  112. package/dist/src/trails/add-verify.d.ts +0 -10
  113. package/dist/src/trails/add-verify.d.ts.map +0 -1
  114. package/dist/src/trails/add-verify.js +0 -67
  115. package/dist/src/trails/add-verify.js.map +0 -1
  116. package/dist/src/trails/create-scaffold.d.ts +0 -15
  117. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  118. package/dist/src/trails/create-scaffold.js +0 -288
  119. package/dist/src/trails/create-scaffold.js.map +0 -1
  120. package/dist/src/trails/create.d.ts +0 -22
  121. package/dist/src/trails/create.d.ts.map +0 -1
  122. package/dist/src/trails/create.js +0 -121
  123. package/dist/src/trails/create.js.map +0 -1
  124. package/dist/src/trails/guide.d.ts +0 -11
  125. package/dist/src/trails/guide.d.ts.map +0 -1
  126. package/dist/src/trails/guide.js +0 -80
  127. package/dist/src/trails/guide.js.map +0 -1
  128. package/dist/src/trails/load-app.d.ts +0 -4
  129. package/dist/src/trails/load-app.d.ts.map +0 -1
  130. package/dist/src/trails/load-app.js +0 -24
  131. package/dist/src/trails/load-app.js.map +0 -1
  132. package/dist/src/trails/project.d.ts +0 -8
  133. package/dist/src/trails/project.d.ts.map +0 -1
  134. package/dist/src/trails/project.js +0 -43
  135. package/dist/src/trails/project.js.map +0 -1
  136. package/dist/src/trails/survey.d.ts +0 -33
  137. package/dist/src/trails/survey.d.ts.map +0 -1
  138. package/dist/src/trails/survey.js +0 -225
  139. package/dist/src/trails/survey.js.map +0 -1
  140. package/dist/src/trails/warden.d.ts +0 -19
  141. package/dist/src/trails/warden.d.ts.map +0 -1
  142. package/dist/src/trails/warden.js +0 -88
  143. package/dist/src/trails/warden.js.map +0 -1
  144. package/dist/tsconfig.tsbuildinfo +0 -1
  145. package/src/__tests__/create.test.ts +0 -349
  146. package/src/__tests__/guide.test.ts +0 -91
  147. package/src/__tests__/load-app.test.ts +0 -15
  148. package/src/__tests__/survey.test.ts +0 -161
  149. package/src/__tests__/warden.test.ts +0 -74
  150. package/tsconfig.json +0 -9
@@ -0,0 +1,818 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import { defaultReleaseConfig, releaseConfigSchema } from './config.js';
7
+ import type { ReleaseConfigInput, ReleaseFactType } from './config.js';
8
+ import { findPublicTrailContractChangeFacts } from './contract-facts.js';
9
+ import type { ContractReleaseFact } from './contract-facts.js';
10
+
11
+ interface PackageJson {
12
+ readonly name?: string;
13
+ readonly private?: boolean;
14
+ readonly workspaces?: readonly string[];
15
+ }
16
+
17
+ export interface WorkspaceInfo {
18
+ readonly isPrivate: boolean;
19
+ readonly name: string;
20
+ readonly relativePath: string;
21
+ }
22
+
23
+ export interface ReleaseCheckInput {
24
+ readonly baseRef?: string;
25
+ readonly changedFiles: readonly string[];
26
+ readonly contractFacts?: readonly ContractReleaseFact[];
27
+ readonly noReleaseOverride?: boolean;
28
+ readonly releaseConfig?: ReleaseConfigInput;
29
+ /** Compatibility alias for the existing GitHub label and package script. */
30
+ readonly releaseNone?: boolean;
31
+ readonly repoRoot: string;
32
+ readonly workspaces: readonly WorkspaceInfo[];
33
+ }
34
+
35
+ export interface ReleaseCheckResult {
36
+ readonly affectedPackages: readonly string[];
37
+ readonly changedChangesets: readonly string[];
38
+ readonly contractFacts: readonly ContractReleaseFact[];
39
+ readonly coveredPackages: readonly string[];
40
+ readonly errors: readonly string[];
41
+ readonly matchedRuleIds: readonly string[];
42
+ readonly noReleaseOverride: boolean;
43
+ readonly passed: boolean;
44
+ /** Compatibility alias for the existing GitHub label and package script. */
45
+ readonly releaseNone: boolean;
46
+ readonly uncoveredContractFacts: readonly ContractReleaseFact[];
47
+ readonly versionRelease: boolean;
48
+ }
49
+
50
+ interface CliOptions {
51
+ readonly baseRef?: string;
52
+ readonly changedFilesPath?: string;
53
+ readonly configPath?: string;
54
+ readonly releaseNone: boolean;
55
+ readonly repoRoot: string;
56
+ }
57
+
58
+ export interface ReleaseConfigLoadResult {
59
+ readonly config?: ReleaseConfigInput | undefined;
60
+ readonly configPath?: string | undefined;
61
+ }
62
+
63
+ export interface RunReleaseCheckOptions {
64
+ readonly baseRef?: string;
65
+ readonly changedFilesPath?: string;
66
+ readonly configPath?: string;
67
+ readonly env?: Record<string, string | undefined>;
68
+ readonly releaseNone?: boolean;
69
+ readonly repoRoot: string;
70
+ }
71
+
72
+ export interface ReleaseCheckReport extends ReleaseCheckResult {
73
+ readonly configPath?: string;
74
+ readonly formatted: string;
75
+ }
76
+
77
+ const NON_SHIPPING_PACKAGE_PATTERNS = [
78
+ /(?:^|\/)__tests__(?:\/|$)/u,
79
+ /(?:^|\/)__snapshots__(?:\/|$)/u,
80
+ /(?:^|\/)dist(?:\/|$)/u,
81
+ /(?:^|\/)\.turbo(?:\/|$)/u,
82
+ /(?:^|\/)node_modules(?:\/|$)/u,
83
+ /\.(?:test|spec|snap)\.[cm]?[jt]sx?$/u,
84
+ /\.test-d\.ts$/u,
85
+ /\.tsbuildinfo$/u,
86
+ ] as const;
87
+
88
+ const CHANGESET_PATH_PATTERN = /^\.changeset\/[^/]+\.md$/u;
89
+ const CHANGESET_PACKAGE_PATTERN =
90
+ /^['"]?(@ontrails\/[^'":]+)['"]?\s*:\s*(?:major|minor|patch)$/u;
91
+ const CHANGESET_PRERELEASE_STATE_PATH = '.changeset/pre.json';
92
+ const WORKSPACE_GLOB_SYNTAX_PATTERN = /[*?[\]{}]/u;
93
+ const VERSION_RELEASE_WORKSPACE_FILES = new Set([
94
+ 'CHANGELOG.md',
95
+ 'package.json',
96
+ ]);
97
+ const CONFIG_CANDIDATES = [
98
+ 'trails.config.ts',
99
+ 'trails.config.mts',
100
+ 'trails.config.js',
101
+ 'trails.config.mjs',
102
+ ] as const;
103
+
104
+ const normalizePath = (path: string): string =>
105
+ path.replaceAll('\\', '/').replace(/^\.\//u, '');
106
+
107
+ const hasWorkspaceGlobSyntax = (pattern: string): boolean =>
108
+ WORKSPACE_GLOB_SYNTAX_PATTERN.test(pattern);
109
+
110
+ const readJson = async <T>(path: string): Promise<T> =>
111
+ (await Bun.file(path).json()) as T;
112
+
113
+ const discoverWorkspaceDirs = async (
114
+ repoRoot: string,
115
+ patterns: readonly string[]
116
+ ): Promise<string[]> => {
117
+ const dirs: string[] = [];
118
+
119
+ for (const pattern of patterns) {
120
+ if (pattern.endsWith('/*')) {
121
+ const parent = join(repoRoot, pattern.slice(0, -2));
122
+ let names: string[] = [];
123
+
124
+ try {
125
+ const entries = await readdir(parent, { withFileTypes: true });
126
+ names = entries
127
+ .filter((entry) => entry.isDirectory())
128
+ .map((entry) =>
129
+ typeof entry.name === 'string' ? entry.name : String(entry.name)
130
+ );
131
+ } catch {
132
+ continue;
133
+ }
134
+
135
+ for (const name of names) {
136
+ const dir = join(parent, name);
137
+
138
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
139
+ dirs.push(dir);
140
+ }
141
+ }
142
+
143
+ continue;
144
+ }
145
+
146
+ const dir = join(repoRoot, pattern);
147
+
148
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
149
+ dirs.push(dir);
150
+ continue;
151
+ }
152
+
153
+ if (hasWorkspaceGlobSyntax(pattern)) {
154
+ throw new Error(
155
+ `Unsupported workspace pattern '${pattern}'. The release check supports exact workspace paths and one-level '/*' globs.`
156
+ );
157
+ }
158
+
159
+ throw new Error(
160
+ `Workspace pattern '${pattern}' did not resolve to a package.json`
161
+ );
162
+ }
163
+
164
+ return dirs;
165
+ };
166
+
167
+ export const discoverWorkspaces = async (
168
+ repoRoot: string
169
+ ): Promise<readonly WorkspaceInfo[]> => {
170
+ const root = await readJson<PackageJson>(join(repoRoot, 'package.json'));
171
+
172
+ if (!root.workspaces || root.workspaces.length === 0) {
173
+ throw new Error('Root package.json has no workspaces field');
174
+ }
175
+
176
+ const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces);
177
+ const workspaces: WorkspaceInfo[] = [];
178
+
179
+ for (const dir of dirs) {
180
+ const pkg = await readJson<PackageJson>(join(dir, 'package.json'));
181
+
182
+ if (!pkg.name) {
183
+ continue;
184
+ }
185
+
186
+ workspaces.push({
187
+ isPrivate: pkg.private === true,
188
+ name: pkg.name,
189
+ relativePath: normalizePath(relative(repoRoot, dir)),
190
+ });
191
+ }
192
+
193
+ return workspaces.toSorted((left, right) =>
194
+ left.relativePath.localeCompare(right.relativePath)
195
+ );
196
+ };
197
+
198
+ const isPublishableOnTrailsWorkspace = (workspace: WorkspaceInfo): boolean =>
199
+ !workspace.isPrivate && workspace.name.startsWith('@ontrails/');
200
+
201
+ const isUnderWorkspace = (filePath: string, workspacePath: string): boolean =>
202
+ filePath === workspacePath || filePath.startsWith(`${workspacePath}/`);
203
+
204
+ const getWorkspaceRelativePath = (
205
+ filePath: string,
206
+ workspacePath: string
207
+ ): string => filePath.slice(workspacePath.length + 1);
208
+
209
+ const isNonShippingPackagePath = (workspaceRelativePath: string): boolean =>
210
+ NON_SHIPPING_PACKAGE_PATTERNS.some((pattern) =>
211
+ pattern.test(workspaceRelativePath)
212
+ );
213
+
214
+ const findAffectedPackages = (
215
+ changedFiles: readonly string[],
216
+ workspaces: readonly WorkspaceInfo[]
217
+ ): readonly string[] => {
218
+ const affected = new Set<string>();
219
+ const publishableWorkspaces = workspaces.filter(
220
+ isPublishableOnTrailsWorkspace
221
+ );
222
+
223
+ for (const file of changedFiles.map(normalizePath)) {
224
+ for (const workspace of publishableWorkspaces) {
225
+ if (!isUnderWorkspace(file, workspace.relativePath)) {
226
+ continue;
227
+ }
228
+
229
+ const workspaceRelativePath = getWorkspaceRelativePath(
230
+ file,
231
+ workspace.relativePath
232
+ );
233
+
234
+ if (isNonShippingPackagePath(workspaceRelativePath)) {
235
+ continue;
236
+ }
237
+
238
+ affected.add(workspace.name);
239
+ }
240
+ }
241
+
242
+ return [...affected].toSorted();
243
+ };
244
+
245
+ const isVersionReleaseChangeSet = (
246
+ changedFiles: readonly string[],
247
+ workspaces: readonly WorkspaceInfo[],
248
+ coveredPackages: readonly string[]
249
+ ): boolean => {
250
+ const normalizedFiles = changedFiles.map(normalizePath);
251
+ const coveredPackageSet = new Set(coveredPackages);
252
+
253
+ if (!normalizedFiles.includes(CHANGESET_PRERELEASE_STATE_PATH)) {
254
+ return false;
255
+ }
256
+
257
+ const publishableWorkspaces = workspaces.filter(
258
+ isPublishableOnTrailsWorkspace
259
+ );
260
+ let hasWorkspaceVersionFile = false;
261
+
262
+ for (const file of normalizedFiles) {
263
+ for (const workspace of publishableWorkspaces) {
264
+ if (!isUnderWorkspace(file, workspace.relativePath)) {
265
+ continue;
266
+ }
267
+
268
+ const workspaceRelativePath = getWorkspaceRelativePath(
269
+ file,
270
+ workspace.relativePath
271
+ );
272
+
273
+ if (isNonShippingPackagePath(workspaceRelativePath)) {
274
+ continue;
275
+ }
276
+
277
+ if (!VERSION_RELEASE_WORKSPACE_FILES.has(workspaceRelativePath)) {
278
+ if (!coveredPackageSet.has(workspace.name)) {
279
+ return false;
280
+ }
281
+
282
+ continue;
283
+ }
284
+
285
+ hasWorkspaceVersionFile = true;
286
+ }
287
+ }
288
+
289
+ return hasWorkspaceVersionFile;
290
+ };
291
+
292
+ const parseChangesetPackages = (content: string): readonly string[] => {
293
+ const lines = content.split(/\r?\n/u);
294
+
295
+ if (lines[0] !== '---') {
296
+ return [];
297
+ }
298
+
299
+ const closingIndex = lines.slice(1).indexOf('---');
300
+
301
+ if (closingIndex === -1) {
302
+ return [];
303
+ }
304
+
305
+ return lines.slice(1, closingIndex + 1).flatMap((line): string[] => {
306
+ const match = line.match(CHANGESET_PACKAGE_PATTERN);
307
+ return match?.[1] ? [match[1]] : [];
308
+ });
309
+ };
310
+
311
+ const findChangedChangesetPaths = (
312
+ changedFiles: readonly string[]
313
+ ): readonly string[] =>
314
+ changedFiles
315
+ .map(normalizePath)
316
+ .filter((path) => CHANGESET_PATH_PATTERN.test(path));
317
+
318
+ const findChangedChangesets = (
319
+ changedChangesetPaths: readonly string[],
320
+ repoRoot: string
321
+ ): readonly {
322
+ readonly packages: readonly string[];
323
+ readonly path: string;
324
+ }[] =>
325
+ changedChangesetPaths.flatMap((path) => {
326
+ const absolutePath = join(repoRoot, path);
327
+
328
+ if (!existsSync(absolutePath)) {
329
+ return [];
330
+ }
331
+
332
+ const packages = parseChangesetPackages(readFileSync(absolutePath, 'utf8'));
333
+
334
+ return packages.length === 0 ? [] : [{ packages, path }];
335
+ });
336
+
337
+ const findGateContractFacts = (
338
+ input: ReleaseCheckInput
339
+ ): readonly ContractReleaseFact[] =>
340
+ input.contractFacts ??
341
+ findPublicTrailContractChangeFacts({
342
+ ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef }),
343
+ changedFiles: input.changedFiles,
344
+ repoRoot: input.repoRoot,
345
+ workspaces: input.workspaces,
346
+ });
347
+
348
+ const isContractFactCovered = (
349
+ fact: ContractReleaseFact,
350
+ coveredPackages: readonly string[]
351
+ ): boolean =>
352
+ fact.packageName !== undefined && coveredPackages.includes(fact.packageName);
353
+
354
+ const formatContractFact = (fact: ContractReleaseFact): string =>
355
+ `${fact.trailId} ${fact.aspect} (${fact.packageName ?? fact.path})`;
356
+
357
+ const ruleMatchesFactType = (
358
+ input: ReleaseCheckInput,
359
+ factType: ReleaseFactType
360
+ ): boolean => {
361
+ const releaseConfig = input.releaseConfig
362
+ ? releaseConfigSchema.parse(input.releaseConfig)
363
+ : defaultReleaseConfig;
364
+ return releaseConfig.rules.some(
365
+ (rule) =>
366
+ rule.enabled && rule.severity === 'error' && rule.facts.includes(factType)
367
+ );
368
+ };
369
+
370
+ const findMatchedRuleIds = (input: ReleaseCheckInput): readonly string[] => {
371
+ const releaseConfig = input.releaseConfig
372
+ ? releaseConfigSchema.parse(input.releaseConfig)
373
+ : defaultReleaseConfig;
374
+ return releaseConfig.rules
375
+ .filter((rule) => rule.enabled && rule.severity === 'error')
376
+ .map((rule) => rule.id)
377
+ .toSorted();
378
+ };
379
+
380
+ export const checkReleaseRules = (
381
+ input: ReleaseCheckInput
382
+ ): ReleaseCheckResult => {
383
+ const noReleaseOverride =
384
+ input.noReleaseOverride === true || input.releaseNone === true;
385
+ const affectedPackages = findAffectedPackages(
386
+ input.changedFiles,
387
+ input.workspaces
388
+ );
389
+ const changedChangesets = findChangedChangesetPaths(input.changedFiles);
390
+ const changesets = findChangedChangesets(changedChangesets, input.repoRoot);
391
+ const coveredPackages = [
392
+ ...new Set(changesets.flatMap((changeset) => changeset.packages)),
393
+ ].toSorted();
394
+ const contractFacts = findGateContractFacts(input);
395
+ const versionRelease = isVersionReleaseChangeSet(
396
+ input.changedFiles,
397
+ input.workspaces,
398
+ coveredPackages
399
+ );
400
+ const uncoveredPackages = affectedPackages.filter(
401
+ (packageName) => !coveredPackages.includes(packageName)
402
+ );
403
+ const uncoveredContractFacts = contractFacts.filter(
404
+ (fact) => !isContractFactCovered(fact, coveredPackages)
405
+ );
406
+ const matchedRuleIds = findMatchedRuleIds(input);
407
+ const requiresPackageIntent = ruleMatchesFactType(input, 'package-content');
408
+ const requiresPublicContractIntent = ruleMatchesFactType(
409
+ input,
410
+ 'public-trail-contract'
411
+ );
412
+ const errors: string[] = [];
413
+
414
+ if (noReleaseOverride && changedChangesets.length > 0) {
415
+ errors.push(
416
+ '`release:none` conflicts with changed changeset files. Remove the label or the changeset.'
417
+ );
418
+ }
419
+
420
+ if (
421
+ !noReleaseOverride &&
422
+ !versionRelease &&
423
+ requiresPackageIntent &&
424
+ uncoveredPackages.length > 0
425
+ ) {
426
+ errors.push(
427
+ `Release rules require intent for package content changes: ${uncoveredPackages.join(', ')}`
428
+ );
429
+ }
430
+
431
+ if (
432
+ !noReleaseOverride &&
433
+ !versionRelease &&
434
+ requiresPublicContractIntent &&
435
+ uncoveredContractFacts.length > 0
436
+ ) {
437
+ errors.push(
438
+ `Release rules require intent for public trail contract changes: ${uncoveredContractFacts
439
+ .map(formatContractFact)
440
+ .join(', ')}`
441
+ );
442
+ }
443
+
444
+ return {
445
+ affectedPackages,
446
+ changedChangesets,
447
+ contractFacts,
448
+ coveredPackages,
449
+ errors,
450
+ matchedRuleIds,
451
+ noReleaseOverride,
452
+ passed: errors.length === 0,
453
+ releaseNone: noReleaseOverride,
454
+ uncoveredContractFacts,
455
+ versionRelease,
456
+ };
457
+ };
458
+
459
+ const readChangedFiles = (path: string): readonly string[] =>
460
+ readFileSync(path, 'utf8')
461
+ .split(/\r?\n/u)
462
+ .map((line) => line.trim())
463
+ .filter((line) => line.length > 0);
464
+
465
+ const parseChangedFilesOutput = (output: string): readonly string[] =>
466
+ output
467
+ .split(/\r?\n/u)
468
+ .map((line) => line.trim())
469
+ .filter((line) => line.length > 0);
470
+
471
+ const readLocalChangedFiles = (
472
+ repoRoot: string,
473
+ baseRef: string
474
+ ): readonly string[] => {
475
+ const result = Bun.spawnSync({
476
+ cmd: [
477
+ 'git',
478
+ 'diff',
479
+ '--name-only',
480
+ '--diff-filter=ACMRTUXB',
481
+ `${baseRef}...HEAD`,
482
+ '--',
483
+ '.',
484
+ ],
485
+ cwd: repoRoot,
486
+ stderr: 'pipe',
487
+ stdout: 'pipe',
488
+ });
489
+
490
+ if (result.exitCode !== 0) {
491
+ throw new Error(
492
+ `Failed to derive local changed files: ${result.stderr.toString()}`
493
+ );
494
+ }
495
+
496
+ return parseChangedFilesOutput(result.stdout.toString());
497
+ };
498
+
499
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
500
+ typeof value === 'object' && value !== null;
501
+
502
+ interface ResultLike {
503
+ readonly error?: unknown;
504
+ readonly value?: unknown;
505
+ isErr(): boolean;
506
+ isOk(): boolean;
507
+ }
508
+
509
+ const isResultLike = (value: unknown): value is ResultLike =>
510
+ isRecord(value) &&
511
+ typeof value['isOk'] === 'function' &&
512
+ typeof value['isErr'] === 'function';
513
+
514
+ interface ResolvableConfig {
515
+ resolve(options: {
516
+ readonly cwd: string;
517
+ readonly env: Record<string, string | undefined>;
518
+ }): Promise<unknown>;
519
+ }
520
+
521
+ const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
522
+ isRecord(value) && typeof value['resolve'] === 'function';
523
+
524
+ const errorMessage = (error: unknown): string =>
525
+ error instanceof Error ? error.message : String(error);
526
+
527
+ const findConfigPath = (
528
+ repoRoot: string,
529
+ configPath: string | undefined
530
+ ): string | undefined => {
531
+ if (configPath !== undefined) {
532
+ const resolvedPath = resolve(repoRoot, configPath);
533
+ if (!existsSync(resolvedPath)) {
534
+ throw new Error(`Release config file not found: ${resolvedPath}`);
535
+ }
536
+ return resolvedPath;
537
+ }
538
+
539
+ return CONFIG_CANDIDATES.map((entry) => resolve(repoRoot, entry)).find(
540
+ (entry) => existsSync(entry)
541
+ );
542
+ };
543
+
544
+ const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
545
+ isRecord(value) && 'release' in value
546
+ ? (value['release'] as ReleaseConfigInput)
547
+ : undefined;
548
+
549
+ const importConfigModule = async (
550
+ configPath: string
551
+ ): Promise<Record<string, unknown>> => {
552
+ const url = pathToFileURL(configPath);
553
+ url.searchParams.set('t', Date.now().toString());
554
+ return (await import(url.href)) as Record<string, unknown>;
555
+ };
556
+
557
+ export const loadReleaseConfig = async ({
558
+ configPath,
559
+ env = {},
560
+ repoRoot,
561
+ }: {
562
+ readonly configPath?: string | undefined;
563
+ readonly env?: Record<string, string | undefined> | undefined;
564
+ readonly repoRoot: string;
565
+ }): Promise<ReleaseConfigLoadResult> => {
566
+ const locatedConfigPath = findConfigPath(repoRoot, configPath);
567
+ if (locatedConfigPath === undefined) {
568
+ return {};
569
+ }
570
+
571
+ try {
572
+ const mod = await importConfigModule(locatedConfigPath);
573
+ const exported = mod['default'] ?? mod;
574
+
575
+ if (isResolvableConfig(exported)) {
576
+ const resolved = await exported.resolve({ cwd: repoRoot, env });
577
+ if (isResultLike(resolved)) {
578
+ if (resolved.isOk()) {
579
+ return {
580
+ config: extractReleaseConfig(resolved.value),
581
+ configPath: locatedConfigPath,
582
+ };
583
+ }
584
+ throw new Error(
585
+ `Failed to resolve release config: ${errorMessage(resolved.error)}`
586
+ );
587
+ }
588
+
589
+ return {
590
+ config: extractReleaseConfig(resolved),
591
+ configPath: locatedConfigPath,
592
+ };
593
+ }
594
+
595
+ return {
596
+ config: extractReleaseConfig(exported),
597
+ configPath: locatedConfigPath,
598
+ };
599
+ } catch (error) {
600
+ throw new Error(`Failed to load release config: ${errorMessage(error)}`, {
601
+ cause: error,
602
+ });
603
+ }
604
+ };
605
+
606
+ const parseArgs = (args: readonly string[]): CliOptions => {
607
+ let baseRef: string | undefined;
608
+ let changedFilesPath: string | undefined;
609
+ let configPath: string | undefined;
610
+ let releaseNone = false;
611
+ let repoRoot = process.cwd();
612
+
613
+ for (let index = 0; index < args.length; index += 1) {
614
+ const arg = args[index];
615
+
616
+ if (arg === '--release-none') {
617
+ releaseNone = true;
618
+ continue;
619
+ }
620
+
621
+ if (arg === '--base-ref') {
622
+ const value = args[index + 1];
623
+
624
+ if (!value) {
625
+ throw new Error('--base-ref requires a git ref or commit');
626
+ }
627
+
628
+ baseRef = value;
629
+ index += 1;
630
+ continue;
631
+ }
632
+
633
+ if (arg === '--changed-files') {
634
+ const value = args[index + 1];
635
+
636
+ if (!value) {
637
+ throw new Error('--changed-files requires a file path');
638
+ }
639
+
640
+ changedFilesPath = value;
641
+ index += 1;
642
+ continue;
643
+ }
644
+
645
+ if (arg === '--config-path') {
646
+ const value = args[index + 1];
647
+
648
+ if (!value) {
649
+ throw new Error('--config-path requires a config file path');
650
+ }
651
+
652
+ configPath = value;
653
+ index += 1;
654
+ continue;
655
+ }
656
+
657
+ if (arg === '--repo-root') {
658
+ const value = args[index + 1];
659
+
660
+ if (!value) {
661
+ throw new Error('--repo-root requires a directory path');
662
+ }
663
+
664
+ repoRoot = resolve(value);
665
+ index += 1;
666
+ continue;
667
+ }
668
+
669
+ throw new Error(`Unknown argument: ${arg ?? ''}`);
670
+ }
671
+
672
+ return {
673
+ ...(baseRef === undefined ? {} : { baseRef }),
674
+ ...(changedFilesPath === undefined ? {} : { changedFilesPath }),
675
+ ...(configPath === undefined ? {} : { configPath }),
676
+ releaseNone,
677
+ repoRoot,
678
+ };
679
+ };
680
+
681
+ export const formatReleaseCheckReport = (
682
+ result: ReleaseCheckResult
683
+ ): string => {
684
+ const lines: string[] = [];
685
+
686
+ if (result.passed) {
687
+ if (result.affectedPackages.length === 0) {
688
+ lines.push(
689
+ 'Release check passed: no publishable package content files changed.'
690
+ );
691
+ return lines.join('\n');
692
+ }
693
+
694
+ if (result.noReleaseOverride) {
695
+ lines.push(
696
+ `Release check passed via release:none override for: ${result.affectedPackages.join(', ')}`
697
+ );
698
+ return lines.join('\n');
699
+ }
700
+
701
+ if (result.versionRelease) {
702
+ lines.push(
703
+ `Release check passed for generated version release: ${result.affectedPackages.join(', ')}`
704
+ );
705
+ return lines.join('\n');
706
+ }
707
+
708
+ lines.push(
709
+ `Release check passed for: ${result.affectedPackages.join(', ')}`
710
+ );
711
+ lines.push(`Changed changesets: ${result.changedChangesets.join(', ')}`);
712
+ return lines.join('\n');
713
+ }
714
+
715
+ for (const error of result.errors) {
716
+ lines.push(error);
717
+ }
718
+
719
+ if (result.affectedPackages.length > 0) {
720
+ lines.push(`Affected packages: ${result.affectedPackages.join(', ')}`);
721
+ }
722
+
723
+ if (result.contractFacts.length > 0) {
724
+ lines.push(
725
+ `Public trail contract facts: ${result.contractFacts
726
+ .map(formatContractFact)
727
+ .join(', ')}`
728
+ );
729
+ }
730
+
731
+ if (result.changedChangesets.length > 0) {
732
+ lines.push(`Changed changesets: ${result.changedChangesets.join(', ')}`);
733
+ }
734
+
735
+ return lines.join('\n');
736
+ };
737
+
738
+ const renderResult = (result: ReleaseCheckResult): void => {
739
+ const formatted = formatReleaseCheckReport(result);
740
+ if (formatted.length === 0) {
741
+ return;
742
+ }
743
+
744
+ if (result.passed) {
745
+ console.log(formatted);
746
+ return;
747
+ }
748
+
749
+ console.error(formatted);
750
+ };
751
+
752
+ export const runReleaseCheck = async (
753
+ options: RunReleaseCheckOptions
754
+ ): Promise<ReleaseCheckReport> => {
755
+ const workspaces = await discoverWorkspaces(options.repoRoot);
756
+ const baseRef =
757
+ options.baseRef ??
758
+ (options.changedFilesPath === undefined ? 'origin/main' : undefined);
759
+ const changedFiles = options.changedFilesPath
760
+ ? readChangedFiles(options.changedFilesPath)
761
+ : readLocalChangedFiles(options.repoRoot, baseRef ?? 'origin/main');
762
+ const loadedConfig = await loadReleaseConfig({
763
+ ...(options.configPath === undefined
764
+ ? {}
765
+ : { configPath: options.configPath }),
766
+ env: options.env,
767
+ repoRoot: options.repoRoot,
768
+ });
769
+ const result = checkReleaseRules({
770
+ ...(baseRef === undefined ? {} : { baseRef }),
771
+ changedFiles,
772
+ ...(loadedConfig.config === undefined
773
+ ? {}
774
+ : { releaseConfig: loadedConfig.config }),
775
+ releaseNone: options.releaseNone === true,
776
+ repoRoot: options.repoRoot,
777
+ workspaces,
778
+ });
779
+
780
+ return {
781
+ ...result,
782
+ ...(loadedConfig.configPath === undefined
783
+ ? {}
784
+ : { configPath: loadedConfig.configPath }),
785
+ formatted: formatReleaseCheckReport(result),
786
+ };
787
+ };
788
+
789
+ export const runReleaseCheckCli = async (
790
+ args: readonly string[]
791
+ ): Promise<number> => {
792
+ const options = parseArgs(args);
793
+ const result = await runReleaseCheck({
794
+ ...(options.baseRef === undefined ? {} : { baseRef: options.baseRef }),
795
+ ...(options.changedFilesPath === undefined
796
+ ? {}
797
+ : { changedFilesPath: options.changedFilesPath }),
798
+ ...(options.configPath === undefined
799
+ ? {}
800
+ : { configPath: options.configPath }),
801
+ env: process.env as Record<string, string | undefined>,
802
+ releaseNone: options.releaseNone,
803
+ repoRoot: options.repoRoot,
804
+ });
805
+
806
+ renderResult(result);
807
+
808
+ return result.passed ? 0 : 1;
809
+ };
810
+
811
+ if (import.meta.main) {
812
+ try {
813
+ process.exit(await runReleaseCheckCli(process.argv.slice(2)));
814
+ } catch (error) {
815
+ console.error(error instanceof Error ? error.message : String(error));
816
+ process.exit(1);
817
+ }
818
+ }