@ontrails/trails 1.0.0-beta.5 → 1.0.0-beta.50

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 (175) hide show
  1. package/CHANGELOG.md +1213 -4
  2. package/README.md +29 -0
  3. package/package.json +35 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +358 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +92 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/audit.ts +571 -0
  15. package/src/regrade/config.ts +152 -0
  16. package/src/regrade/history.ts +636 -0
  17. package/src/regrade/lifecycle.ts +76 -0
  18. package/src/regrade/live-api-preserve.ts +123 -0
  19. package/src/regrade/plan-artifact.ts +461 -0
  20. package/src/regrade/plan-derivation.ts +301 -0
  21. package/src/regrade/prepared-run.ts +259 -0
  22. package/src/regrade/receipt-history.ts +446 -0
  23. package/src/regrade/source-transaction.ts +185 -0
  24. package/src/release/bindings.ts +58 -0
  25. package/src/release/check.ts +1146 -0
  26. package/src/release/cli-bundle.ts +575 -0
  27. package/src/release/config.ts +73 -0
  28. package/src/release/contract-facts.ts +425 -0
  29. package/src/release/homebrew.ts +219 -0
  30. package/src/release/index.ts +180 -0
  31. package/src/release/lock-roundtrip-smoke.ts +230 -0
  32. package/src/release/native-bun-publish.ts +964 -0
  33. package/src/release/native-bun-registry.ts +830 -0
  34. package/src/release/notes-cli.ts +171 -0
  35. package/src/release/notes.ts +390 -0
  36. package/src/release/pack-coherence.ts +455 -0
  37. package/src/release/package-route-facts.ts +146 -0
  38. package/src/release/packed-artifacts-smoke.ts +236 -0
  39. package/src/release/policy.ts +1700 -0
  40. package/src/release/semver.ts +104 -0
  41. package/src/release/smoke.ts +56 -0
  42. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  43. package/src/retired-topo-command.ts +36 -0
  44. package/src/run-adapter-check.ts +76 -0
  45. package/src/run-collision.ts +126 -0
  46. package/src/run-completions-install.ts +179 -0
  47. package/src/run-example.ts +149 -0
  48. package/src/run-examples.ts +148 -0
  49. package/src/run-quiet.ts +75 -0
  50. package/src/run-regrade-progress.ts +47 -0
  51. package/src/run-release-check.ts +74 -0
  52. package/src/run-schema.ts +74 -0
  53. package/src/run-trace.ts +273 -0
  54. package/src/run-warden.ts +39 -0
  55. package/src/run-watch.ts +432 -0
  56. package/src/run-wayfind-outline.ts +170 -0
  57. package/src/scaffold-version-sync.ts +183 -0
  58. package/src/scaffold-versions.generated.ts +12 -0
  59. package/src/trails/adapter-check.ts +244 -0
  60. package/src/trails/add-surface.ts +99 -45
  61. package/src/trails/add-trail.ts +84 -37
  62. package/src/trails/add-verify.ts +100 -30
  63. package/src/trails/compile.ts +58 -0
  64. package/src/trails/completions-complete.ts +165 -0
  65. package/src/trails/completions.ts +47 -0
  66. package/src/trails/create-adapter.ts +785 -0
  67. package/src/trails/create-scaffold.ts +402 -106
  68. package/src/trails/create-versions.ts +62 -0
  69. package/src/trails/create.ts +186 -72
  70. package/src/trails/deprecate.ts +59 -0
  71. package/src/trails/dev-clean.ts +82 -0
  72. package/src/trails/dev-reset.ts +50 -0
  73. package/src/trails/dev-stats.ts +72 -0
  74. package/src/trails/dev-support.ts +360 -0
  75. package/src/trails/doctor.ts +77 -0
  76. package/src/trails/draft-promote.ts +949 -0
  77. package/src/trails/guide.ts +70 -68
  78. package/src/trails/load-app.ts +1123 -15
  79. package/src/trails/operator-context.ts +66 -0
  80. package/src/trails/project.ts +17 -3
  81. package/src/trails/regrade.ts +4497 -0
  82. package/src/trails/release-check.ts +113 -0
  83. package/src/trails/release-smoke.ts +49 -0
  84. package/src/trails/revise.ts +53 -0
  85. package/src/trails/root-dir.ts +21 -0
  86. package/src/trails/run-example.ts +475 -0
  87. package/src/trails/run-examples.ts +129 -0
  88. package/src/trails/run.ts +434 -0
  89. package/src/trails/scaffold-json.ts +58 -0
  90. package/src/trails/survey.ts +877 -214
  91. package/src/trails/topo-activation.ts +14 -0
  92. package/src/trails/topo-constants.ts +2 -0
  93. package/src/trails/topo-history.ts +47 -0
  94. package/src/trails/topo-output-schemas.ts +259 -0
  95. package/src/trails/topo-pin.ts +38 -0
  96. package/src/trails/topo-read-support.ts +368 -0
  97. package/src/trails/topo-reports.ts +809 -0
  98. package/src/trails/topo-store-support.ts +323 -0
  99. package/src/trails/topo-support.ts +228 -0
  100. package/src/trails/topo-unpin.ts +61 -0
  101. package/src/trails/topo.ts +92 -0
  102. package/src/trails/validate.ts +27 -0
  103. package/src/trails/version-lifecycle-support.ts +936 -0
  104. package/src/trails/warden-guide.ts +134 -0
  105. package/src/trails/warden.ts +198 -58
  106. package/src/trails/wayfind-outline.ts +876 -0
  107. package/src/trails/wayfind.ts +1053 -0
  108. package/src/versions.ts +31 -0
  109. package/.turbo/turbo-build.log +0 -1
  110. package/.turbo/turbo-lint.log +0 -3
  111. package/.turbo/turbo-typecheck.log +0 -1
  112. package/__tests__/examples.test.ts +0 -6
  113. package/dist/bin/trails.d.ts +0 -3
  114. package/dist/bin/trails.d.ts.map +0 -1
  115. package/dist/bin/trails.js +0 -4
  116. package/dist/bin/trails.js.map +0 -1
  117. package/dist/src/app.d.ts +0 -2
  118. package/dist/src/app.d.ts.map +0 -1
  119. package/dist/src/app.js +0 -11
  120. package/dist/src/app.js.map +0 -1
  121. package/dist/src/clack.d.ts +0 -9
  122. package/dist/src/clack.d.ts.map +0 -1
  123. package/dist/src/clack.js +0 -84
  124. package/dist/src/clack.js.map +0 -1
  125. package/dist/src/cli.d.ts +0 -2
  126. package/dist/src/cli.d.ts.map +0 -1
  127. package/dist/src/cli.js +0 -13
  128. package/dist/src/cli.js.map +0 -1
  129. package/dist/src/trails/add-surface.d.ts +0 -13
  130. package/dist/src/trails/add-surface.d.ts.map +0 -1
  131. package/dist/src/trails/add-surface.js +0 -88
  132. package/dist/src/trails/add-surface.js.map +0 -1
  133. package/dist/src/trails/add-trail.d.ts +0 -10
  134. package/dist/src/trails/add-trail.d.ts.map +0 -1
  135. package/dist/src/trails/add-trail.js +0 -77
  136. package/dist/src/trails/add-trail.js.map +0 -1
  137. package/dist/src/trails/add-verify.d.ts +0 -10
  138. package/dist/src/trails/add-verify.d.ts.map +0 -1
  139. package/dist/src/trails/add-verify.js +0 -67
  140. package/dist/src/trails/add-verify.js.map +0 -1
  141. package/dist/src/trails/create-scaffold.d.ts +0 -15
  142. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  143. package/dist/src/trails/create-scaffold.js +0 -288
  144. package/dist/src/trails/create-scaffold.js.map +0 -1
  145. package/dist/src/trails/create.d.ts +0 -22
  146. package/dist/src/trails/create.d.ts.map +0 -1
  147. package/dist/src/trails/create.js +0 -121
  148. package/dist/src/trails/create.js.map +0 -1
  149. package/dist/src/trails/guide.d.ts +0 -11
  150. package/dist/src/trails/guide.d.ts.map +0 -1
  151. package/dist/src/trails/guide.js +0 -80
  152. package/dist/src/trails/guide.js.map +0 -1
  153. package/dist/src/trails/load-app.d.ts +0 -4
  154. package/dist/src/trails/load-app.d.ts.map +0 -1
  155. package/dist/src/trails/load-app.js +0 -24
  156. package/dist/src/trails/load-app.js.map +0 -1
  157. package/dist/src/trails/project.d.ts +0 -8
  158. package/dist/src/trails/project.d.ts.map +0 -1
  159. package/dist/src/trails/project.js +0 -43
  160. package/dist/src/trails/project.js.map +0 -1
  161. package/dist/src/trails/survey.d.ts +0 -31
  162. package/dist/src/trails/survey.d.ts.map +0 -1
  163. package/dist/src/trails/survey.js +0 -221
  164. package/dist/src/trails/survey.js.map +0 -1
  165. package/dist/src/trails/warden.d.ts +0 -19
  166. package/dist/src/trails/warden.d.ts.map +0 -1
  167. package/dist/src/trails/warden.js +0 -88
  168. package/dist/src/trails/warden.js.map +0 -1
  169. package/dist/tsconfig.tsbuildinfo +0 -1
  170. package/src/__tests__/create.test.ts +0 -349
  171. package/src/__tests__/guide.test.ts +0 -91
  172. package/src/__tests__/load-app.test.ts +0 -15
  173. package/src/__tests__/survey.test.ts +0 -159
  174. package/src/__tests__/warden.test.ts +0 -74
  175. package/tsconfig.json +0 -9
@@ -0,0 +1,1146 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, relative, resolve } from 'node:path';
4
+
5
+ import { loadTrailsConfigValue } from '@ontrails/config';
6
+
7
+ import { defaultReleaseConfig, releaseConfigSchema } from './config.js';
8
+ import type { ReleaseConfigInput, ReleaseFactType } from './config.js';
9
+ import { findPublicTrailContractChangeFacts } from './contract-facts.js';
10
+ import type { ContractReleaseFact } from './contract-facts.js';
11
+ import { findPackageRouteReleaseFacts } from './package-route-facts.js';
12
+ import type { PackageRouteReleaseFact } from './package-route-facts.js';
13
+
14
+ interface PackageJson {
15
+ readonly name?: string;
16
+ readonly private?: boolean;
17
+ readonly workspaces?: readonly string[];
18
+ }
19
+
20
+ export interface WorkspaceInfo {
21
+ readonly isPrivate: boolean;
22
+ readonly name: string;
23
+ readonly relativePath: string;
24
+ }
25
+
26
+ export interface ReleaseCheckInput {
27
+ readonly baseRef?: string;
28
+ readonly baseWorkspaceError?: string;
29
+ readonly baseWorkspaces?: readonly WorkspaceInfo[];
30
+ readonly changedFiles: readonly string[];
31
+ readonly contractFacts?: readonly ContractReleaseFact[];
32
+ readonly noReleaseOverride?: boolean;
33
+ readonly releaseConfig?: ReleaseConfigInput;
34
+ /** Compatibility alias for the existing GitHub label and package script. */
35
+ readonly releaseNone?: boolean;
36
+ readonly repoRoot: string;
37
+ readonly workspaces: readonly WorkspaceInfo[];
38
+ }
39
+
40
+ export interface ReleaseCheckResult {
41
+ readonly affectedPackages: readonly string[];
42
+ readonly activePackageChangesetsWithoutReleaseFacts: readonly string[];
43
+ readonly changedChangesets: readonly string[];
44
+ readonly contractFacts: readonly ContractReleaseFact[];
45
+ readonly coveredPackages: readonly string[];
46
+ readonly errors: readonly string[];
47
+ readonly matchedRuleIds: readonly string[];
48
+ readonly noReleaseOverride: boolean;
49
+ readonly packageRouteFacts: readonly PackageRouteReleaseFact[];
50
+ readonly passed: boolean;
51
+ /** Compatibility alias for the existing GitHub label and package script. */
52
+ readonly releaseNone: boolean;
53
+ readonly uncoveredContractFacts: readonly ContractReleaseFact[];
54
+ readonly versionRelease: boolean;
55
+ }
56
+
57
+ interface CliOptions {
58
+ readonly baseRef?: string;
59
+ readonly changedFilesPath?: string;
60
+ readonly configPath?: string;
61
+ readonly releaseNone: boolean;
62
+ readonly repoRoot: string;
63
+ }
64
+
65
+ export interface ReleaseConfigLoadResult {
66
+ readonly config?: ReleaseConfigInput | undefined;
67
+ readonly configPath?: string | undefined;
68
+ }
69
+
70
+ export interface RunReleaseCheckOptions {
71
+ readonly baseRef?: string;
72
+ readonly changedFilesPath?: string;
73
+ readonly configPath?: string;
74
+ readonly env?: Record<string, string | undefined>;
75
+ readonly releaseNone?: boolean;
76
+ readonly repoRoot: string;
77
+ }
78
+
79
+ export interface ReleaseCheckReport extends ReleaseCheckResult {
80
+ readonly configPath?: string;
81
+ readonly formatted: string;
82
+ }
83
+
84
+ const NON_SHIPPING_PACKAGE_PATTERNS = [
85
+ /(?:^|\/)__tests__(?:\/|$)/u,
86
+ /(?:^|\/)__snapshots__(?:\/|$)/u,
87
+ /(?:^|\/)dist(?:\/|$)/u,
88
+ /(?:^|\/)\.turbo(?:\/|$)/u,
89
+ /(?:^|\/)node_modules(?:\/|$)/u,
90
+ /\.(?:test|spec|snap)\.[cm]?[jt]sx?$/u,
91
+ /\.test-d\.ts$/u,
92
+ /\.tsbuildinfo$/u,
93
+ ] as const;
94
+
95
+ const CHANGESET_PATH_PATTERN = /^\.changeset\/[^/]+\.md$/u;
96
+ const CHANGESET_PACKAGE_PATTERN =
97
+ /^['"]?(@ontrails\/[^'":]+)['"]?\s*:\s*(?:major|minor|patch)$/u;
98
+ const CHANGESET_PRERELEASE_STATE_PATH = '.changeset/pre.json';
99
+ const WORKSPACE_GLOB_SYNTAX_PATTERN = /[*?[\]{}]/u;
100
+ const VERSION_RELEASE_WORKSPACE_FILES = new Set([
101
+ 'CHANGELOG.md',
102
+ 'package.json',
103
+ ]);
104
+ const normalizePath = (path: string): string =>
105
+ path.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+$/u, '');
106
+
107
+ const normalizeWorkspacePattern = (pattern: string): string =>
108
+ normalizePath(pattern) || '.';
109
+
110
+ const hasWorkspaceGlobSyntax = (pattern: string): boolean =>
111
+ WORKSPACE_GLOB_SYNTAX_PATTERN.test(pattern);
112
+
113
+ const isSupportedWorkspacePattern = (pattern: string): boolean =>
114
+ !hasWorkspaceGlobSyntax(pattern) ||
115
+ (pattern.endsWith('/*') && !hasWorkspaceGlobSyntax(pattern.slice(0, -2)));
116
+
117
+ const unsupportedWorkspacePatternError = (pattern: string): string =>
118
+ `Unsupported workspace pattern '${pattern}'. The release check supports exact workspace paths and one-level '/*' globs.`;
119
+
120
+ const readJson = async <T>(path: string): Promise<T> =>
121
+ (await Bun.file(path).json()) as T;
122
+
123
+ const discoverWorkspaceDirs = async (
124
+ repoRoot: string,
125
+ patterns: readonly string[]
126
+ ): Promise<string[]> => {
127
+ const dirs: string[] = [];
128
+
129
+ for (const pattern of patterns) {
130
+ if (!isSupportedWorkspacePattern(pattern)) {
131
+ throw new Error(unsupportedWorkspacePatternError(pattern));
132
+ }
133
+
134
+ if (pattern.endsWith('/*')) {
135
+ const parent = join(repoRoot, pattern.slice(0, -2));
136
+ let names: string[] = [];
137
+
138
+ try {
139
+ const entries = await readdir(parent, { withFileTypes: true });
140
+ names = entries
141
+ .filter((entry) => entry.isDirectory())
142
+ .map((entry) =>
143
+ typeof entry.name === 'string' ? entry.name : String(entry.name)
144
+ );
145
+ } catch {
146
+ continue;
147
+ }
148
+
149
+ for (const name of names) {
150
+ const dir = join(parent, name);
151
+
152
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
153
+ dirs.push(dir);
154
+ }
155
+ }
156
+
157
+ continue;
158
+ }
159
+
160
+ const dir = join(repoRoot, pattern);
161
+
162
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
163
+ dirs.push(dir);
164
+ continue;
165
+ }
166
+
167
+ throw new Error(
168
+ `Workspace pattern '${pattern}' did not resolve to a package.json`
169
+ );
170
+ }
171
+
172
+ return dirs;
173
+ };
174
+
175
+ export const discoverWorkspaces = async (
176
+ repoRoot: string
177
+ ): Promise<readonly WorkspaceInfo[]> => {
178
+ const root = await readJson<PackageJson>(join(repoRoot, 'package.json'));
179
+
180
+ if (!root.workspaces || root.workspaces.length === 0) {
181
+ return [];
182
+ }
183
+
184
+ const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces);
185
+ const workspaces: WorkspaceInfo[] = [];
186
+
187
+ for (const dir of dirs) {
188
+ const pkg = await readJson<PackageJson>(join(dir, 'package.json'));
189
+
190
+ if (!pkg.name) {
191
+ continue;
192
+ }
193
+
194
+ workspaces.push({
195
+ isPrivate: pkg.private === true,
196
+ name: pkg.name,
197
+ relativePath: normalizePath(relative(repoRoot, dir)),
198
+ });
199
+ }
200
+
201
+ return workspaces.toSorted((left, right) =>
202
+ left.relativePath.localeCompare(right.relativePath)
203
+ );
204
+ };
205
+
206
+ interface BaseWorkspaceDiscovery {
207
+ readonly error?: string;
208
+ readonly workspaces: readonly WorkspaceInfo[];
209
+ }
210
+
211
+ const baseWorkspaceReadError = (baseRef: string): string =>
212
+ `Release check could not read the base workspace inventory from '${baseRef}'. Fetch or provide a valid --base-ref before checking package routes.`;
213
+
214
+ const discoverBaseWorkspaces = (
215
+ repoRoot: string,
216
+ baseRef: string | undefined
217
+ ): BaseWorkspaceDiscovery => {
218
+ if (baseRef === undefined) {
219
+ return { workspaces: [] };
220
+ }
221
+
222
+ const root = Bun.spawnSync({
223
+ cmd: ['git', 'show', `${baseRef}:package.json`],
224
+ cwd: repoRoot,
225
+ stderr: 'pipe',
226
+ stdout: 'pipe',
227
+ });
228
+ if (root.exitCode !== 0) {
229
+ return { error: baseWorkspaceReadError(baseRef), workspaces: [] };
230
+ }
231
+ const workspacePatterns = (
232
+ JSON.parse(root.stdout.toString()) as PackageJson
233
+ ).workspaces?.map(normalizeWorkspacePattern);
234
+ if (!workspacePatterns || workspacePatterns.length === 0) {
235
+ return { workspaces: [] };
236
+ }
237
+ const unsupportedPattern = workspacePatterns.find(
238
+ (pattern) => !isSupportedWorkspacePattern(pattern)
239
+ );
240
+ if (unsupportedPattern) {
241
+ return {
242
+ error: unsupportedWorkspacePatternError(unsupportedPattern),
243
+ workspaces: [],
244
+ };
245
+ }
246
+
247
+ const listed = Bun.spawnSync({
248
+ cmd: ['git', 'ls-tree', '-r', '--name-only', baseRef],
249
+ cwd: repoRoot,
250
+ stderr: 'pipe',
251
+ stdout: 'pipe',
252
+ });
253
+ if (listed.exitCode !== 0) {
254
+ return { error: baseWorkspaceReadError(baseRef), workspaces: [] };
255
+ }
256
+
257
+ const workspaces: WorkspaceInfo[] = [];
258
+ for (const relativePath of listed.stdout.toString().split(/\r?\n/u)) {
259
+ if (
260
+ !relativePath.endsWith('/package.json') &&
261
+ relativePath !== 'package.json'
262
+ ) {
263
+ continue;
264
+ }
265
+ const workspacePath =
266
+ relativePath === 'package.json'
267
+ ? '.'
268
+ : normalizePath(relativePath.replace(/\/package\.json$/u, ''));
269
+ const isWorkspace = workspacePatterns.some(
270
+ (pattern) =>
271
+ pattern === workspacePath ||
272
+ (pattern.endsWith('/*') &&
273
+ workspacePath.startsWith(pattern.slice(0, -1)) &&
274
+ !workspacePath.slice(pattern.length - 1).includes('/'))
275
+ );
276
+ if (!isWorkspace) {
277
+ continue;
278
+ }
279
+ const shown = Bun.spawnSync({
280
+ cmd: ['git', 'show', `${baseRef}:${relativePath}`],
281
+ cwd: repoRoot,
282
+ stderr: 'pipe',
283
+ stdout: 'pipe',
284
+ });
285
+ if (shown.exitCode !== 0) {
286
+ return { error: baseWorkspaceReadError(baseRef), workspaces: [] };
287
+ }
288
+ const pkg = JSON.parse(shown.stdout.toString()) as PackageJson;
289
+ if (!pkg.name) {
290
+ continue;
291
+ }
292
+ workspaces.push({
293
+ isPrivate: pkg.private === true,
294
+ name: pkg.name,
295
+ relativePath: workspacePath,
296
+ });
297
+ }
298
+ return {
299
+ workspaces: workspaces.toSorted((left, right) =>
300
+ left.name.localeCompare(right.name)
301
+ ),
302
+ };
303
+ };
304
+
305
+ const resolveBaseWorkspaceInput = (
306
+ input: ReleaseCheckInput
307
+ ): BaseWorkspaceDiscovery => {
308
+ if (input.baseWorkspaces !== undefined) {
309
+ return {
310
+ ...(input.baseWorkspaceError === undefined
311
+ ? {}
312
+ : { error: input.baseWorkspaceError }),
313
+ workspaces: input.baseWorkspaces,
314
+ };
315
+ }
316
+
317
+ const discovered = discoverBaseWorkspaces(input.repoRoot, input.baseRef);
318
+ const error = input.baseWorkspaceError ?? discovered.error;
319
+
320
+ return {
321
+ ...(error === undefined ? {} : { error }),
322
+ workspaces: discovered.workspaces,
323
+ };
324
+ };
325
+
326
+ const isPublishableOnTrailsWorkspace = (workspace: WorkspaceInfo): boolean =>
327
+ !workspace.isPrivate && workspace.name.startsWith('@ontrails/');
328
+
329
+ const isUnderWorkspace = (filePath: string, workspacePath: string): boolean =>
330
+ filePath === workspacePath || filePath.startsWith(`${workspacePath}/`);
331
+
332
+ const getWorkspaceRelativePath = (
333
+ filePath: string,
334
+ workspacePath: string
335
+ ): string => filePath.slice(workspacePath.length + 1);
336
+
337
+ const isNonShippingPackagePath = (workspaceRelativePath: string): boolean =>
338
+ NON_SHIPPING_PACKAGE_PATTERNS.some((pattern) =>
339
+ pattern.test(workspaceRelativePath)
340
+ );
341
+
342
+ const findAffectedPackages = (
343
+ changedFiles: readonly string[],
344
+ workspaces: readonly WorkspaceInfo[]
345
+ ): readonly string[] => {
346
+ const affected = new Set<string>();
347
+ const publishableWorkspaces = workspaces.filter(
348
+ isPublishableOnTrailsWorkspace
349
+ );
350
+
351
+ for (const file of changedFiles.map(normalizePath)) {
352
+ for (const workspace of publishableWorkspaces) {
353
+ if (!isUnderWorkspace(file, workspace.relativePath)) {
354
+ continue;
355
+ }
356
+
357
+ const workspaceRelativePath = getWorkspaceRelativePath(
358
+ file,
359
+ workspace.relativePath
360
+ );
361
+
362
+ if (isNonShippingPackagePath(workspaceRelativePath)) {
363
+ continue;
364
+ }
365
+
366
+ affected.add(workspace.name);
367
+ }
368
+ }
369
+
370
+ return [...affected].toSorted();
371
+ };
372
+
373
+ const isVersionReleaseChangeSet = (
374
+ changedFiles: readonly string[],
375
+ workspaces: readonly WorkspaceInfo[],
376
+ coveredPackages: readonly string[]
377
+ ): boolean => {
378
+ const normalizedFiles = changedFiles.map(normalizePath);
379
+ const coveredPackageSet = new Set(coveredPackages);
380
+
381
+ if (!normalizedFiles.includes(CHANGESET_PRERELEASE_STATE_PATH)) {
382
+ return false;
383
+ }
384
+
385
+ const publishableWorkspaces = workspaces.filter(
386
+ isPublishableOnTrailsWorkspace
387
+ );
388
+ let hasWorkspaceVersionFile = false;
389
+
390
+ for (const file of normalizedFiles) {
391
+ for (const workspace of publishableWorkspaces) {
392
+ if (!isUnderWorkspace(file, workspace.relativePath)) {
393
+ continue;
394
+ }
395
+
396
+ const workspaceRelativePath = getWorkspaceRelativePath(
397
+ file,
398
+ workspace.relativePath
399
+ );
400
+
401
+ if (isNonShippingPackagePath(workspaceRelativePath)) {
402
+ continue;
403
+ }
404
+
405
+ if (!VERSION_RELEASE_WORKSPACE_FILES.has(workspaceRelativePath)) {
406
+ if (!coveredPackageSet.has(workspace.name)) {
407
+ return false;
408
+ }
409
+
410
+ continue;
411
+ }
412
+
413
+ hasWorkspaceVersionFile = true;
414
+ }
415
+ }
416
+
417
+ return hasWorkspaceVersionFile;
418
+ };
419
+
420
+ const parseChangesetPackages = (content: string): readonly string[] => {
421
+ const lines = content.split(/\r?\n/u);
422
+
423
+ if (lines[0] !== '---') {
424
+ return [];
425
+ }
426
+
427
+ const closingIndex = lines.slice(1).indexOf('---');
428
+
429
+ if (closingIndex === -1) {
430
+ return [];
431
+ }
432
+
433
+ return lines.slice(1, closingIndex + 1).flatMap((line): string[] => {
434
+ const match = line.match(CHANGESET_PACKAGE_PATTERN);
435
+ return match?.[1] ? [match[1]] : [];
436
+ });
437
+ };
438
+
439
+ const readBaseChangeset = (
440
+ repoRoot: string,
441
+ baseRef: string | undefined,
442
+ path: string
443
+ ): string | null => {
444
+ if (baseRef === undefined) {
445
+ return null;
446
+ }
447
+
448
+ const result = Bun.spawnSync({
449
+ cmd: ['git', 'show', `${baseRef}:${path}`],
450
+ cwd: repoRoot,
451
+ stderr: 'pipe',
452
+ stdout: 'pipe',
453
+ });
454
+
455
+ return result.exitCode === 0 ? result.stdout.toString() : null;
456
+ };
457
+
458
+ const removeChangesetPackageRows = (
459
+ content: string,
460
+ packages: ReadonlySet<string>
461
+ ): string => {
462
+ let insideFrontmatter = false;
463
+
464
+ return content
465
+ .split('\n')
466
+ .filter((line, index) => {
467
+ const normalizedLine = line.replace(/\r$/u, '');
468
+
469
+ if (normalizedLine === '---') {
470
+ insideFrontmatter = index === 0;
471
+ return true;
472
+ }
473
+
474
+ if (!insideFrontmatter) {
475
+ return true;
476
+ }
477
+
478
+ const packageName = normalizedLine.match(CHANGESET_PACKAGE_PATTERN)?.[1];
479
+ return packageName === undefined || !packages.has(packageName);
480
+ })
481
+ .join('\n');
482
+ };
483
+
484
+ const findRetiredPackageChangesetCleanups = (
485
+ changedChangesetPaths: readonly string[],
486
+ input: ReleaseCheckInput
487
+ ): readonly string[] => {
488
+ const workspaceNames = new Set(
489
+ input.workspaces.map((workspace) => workspace.name)
490
+ );
491
+
492
+ return changedChangesetPaths.filter((path) => {
493
+ const absolutePath = join(input.repoRoot, path);
494
+ if (!existsSync(absolutePath)) {
495
+ return false;
496
+ }
497
+
498
+ const baseContent = readBaseChangeset(input.repoRoot, input.baseRef, path);
499
+ if (baseContent === null) {
500
+ return false;
501
+ }
502
+
503
+ const retiredPackages = new Set(
504
+ parseChangesetPackages(baseContent).filter(
505
+ (packageName) => !workspaceNames.has(packageName)
506
+ )
507
+ );
508
+
509
+ return (
510
+ retiredPackages.size > 0 &&
511
+ removeChangesetPackageRows(baseContent, retiredPackages) ===
512
+ readFileSync(absolutePath, 'utf8')
513
+ );
514
+ });
515
+ };
516
+
517
+ const findChangedChangesetPaths = (
518
+ changedFiles: readonly string[]
519
+ ): readonly string[] =>
520
+ changedFiles
521
+ .map(normalizePath)
522
+ .filter((path) => CHANGESET_PATH_PATTERN.test(path));
523
+
524
+ const findActiveChangedChangesetPaths = (
525
+ changedFiles: readonly string[],
526
+ repoRoot: string
527
+ ): readonly string[] =>
528
+ findChangedChangesetPaths(changedFiles).filter((path) =>
529
+ existsSync(join(repoRoot, path))
530
+ );
531
+
532
+ const findChangedChangesets = (
533
+ changedChangesetPaths: readonly string[],
534
+ repoRoot: string
535
+ ): readonly {
536
+ readonly packages: readonly string[];
537
+ readonly path: string;
538
+ }[] =>
539
+ changedChangesetPaths.flatMap((path) => {
540
+ const absolutePath = join(repoRoot, path);
541
+
542
+ if (!existsSync(absolutePath)) {
543
+ return [];
544
+ }
545
+
546
+ const packages = parseChangesetPackages(readFileSync(absolutePath, 'utf8'));
547
+
548
+ return packages.length === 0 ? [] : [{ packages, path }];
549
+ });
550
+
551
+ const findGateContractFacts = (
552
+ input: ReleaseCheckInput
553
+ ): readonly ContractReleaseFact[] =>
554
+ input.contractFacts ??
555
+ findPublicTrailContractChangeFacts({
556
+ ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef }),
557
+ changedFiles: input.changedFiles,
558
+ repoRoot: input.repoRoot,
559
+ workspaces: input.workspaces,
560
+ });
561
+
562
+ const isContractFactCovered = (
563
+ fact: ContractReleaseFact,
564
+ coveredPackages: readonly string[]
565
+ ): boolean =>
566
+ fact.packageName !== undefined && coveredPackages.includes(fact.packageName);
567
+
568
+ const formatContractFact = (fact: ContractReleaseFact): string =>
569
+ `${fact.trailId} ${fact.aspect} (${fact.packageName ?? fact.path})`;
570
+
571
+ const ruleMatchesFactType = (
572
+ input: ReleaseCheckInput,
573
+ factType: ReleaseFactType
574
+ ): boolean => {
575
+ const releaseConfig = input.releaseConfig
576
+ ? releaseConfigSchema.parse(input.releaseConfig)
577
+ : defaultReleaseConfig;
578
+ return releaseConfig.rules.some(
579
+ (rule) =>
580
+ rule.enabled && rule.severity === 'error' && rule.facts.includes(factType)
581
+ );
582
+ };
583
+
584
+ const findMatchedRuleIds = (input: ReleaseCheckInput): readonly string[] => {
585
+ const releaseConfig = input.releaseConfig
586
+ ? releaseConfigSchema.parse(input.releaseConfig)
587
+ : defaultReleaseConfig;
588
+ return releaseConfig.rules
589
+ .filter((rule) => rule.enabled && rule.severity === 'error')
590
+ .map((rule) => rule.id)
591
+ .toSorted();
592
+ };
593
+
594
+ const findPackageRouteRuleErrors = ({
595
+ coveredPackages,
596
+ packageRoute,
597
+ requiresPackageRoute,
598
+ }: {
599
+ readonly coveredPackages: readonly string[];
600
+ readonly packageRoute: ReturnType<typeof findPackageRouteReleaseFacts>;
601
+ readonly requiresPackageRoute: boolean;
602
+ }): readonly string[] => {
603
+ if (!requiresPackageRoute) {
604
+ return [];
605
+ }
606
+
607
+ const errors = packageRoute.diagnostics.map(
608
+ (diagnostic) => diagnostic.message
609
+ );
610
+
611
+ const uncoveredIntents = packageRoute.intents.filter(
612
+ (intent) =>
613
+ !intent.eligiblePackages.some((packageName) =>
614
+ coveredPackages.includes(packageName)
615
+ )
616
+ );
617
+
618
+ if (uncoveredIntents.length > 0) {
619
+ errors.push(
620
+ `Public package route changesets must cover a surviving owner: ${uncoveredIntents
621
+ .map((intent) => intent.sourcePackage)
622
+ .join(', ')}`
623
+ );
624
+ }
625
+
626
+ return errors;
627
+ };
628
+
629
+ export const checkReleaseRules = (
630
+ input: ReleaseCheckInput
631
+ ): ReleaseCheckResult => {
632
+ const baseWorkspaceInput = resolveBaseWorkspaceInput(input);
633
+ const noReleaseOverride =
634
+ input.noReleaseOverride === true || input.releaseNone === true;
635
+ const affectedPackages = findAffectedPackages(
636
+ input.changedFiles,
637
+ input.workspaces
638
+ );
639
+ const changedChangesets = findChangedChangesetPaths(input.changedFiles);
640
+ const activeChangedChangesets = findActiveChangedChangesetPaths(
641
+ input.changedFiles,
642
+ input.repoRoot
643
+ );
644
+ const retiredPackageChangesetCleanups = new Set(
645
+ findRetiredPackageChangesetCleanups(changedChangesets, input)
646
+ );
647
+ const changesets = findChangedChangesets(changedChangesets, input.repoRoot);
648
+ const coveredPackages = [
649
+ ...new Set(changesets.flatMap((changeset) => changeset.packages)),
650
+ ].toSorted();
651
+ const contractFacts = findGateContractFacts(input);
652
+ const packageRoute = findPackageRouteReleaseFacts({
653
+ baseWorkspaces: baseWorkspaceInput.workspaces,
654
+ workspaces: input.workspaces,
655
+ });
656
+ const versionRelease = isVersionReleaseChangeSet(
657
+ input.changedFiles,
658
+ input.workspaces,
659
+ coveredPackages
660
+ );
661
+ const uncoveredPackages = affectedPackages.filter(
662
+ (packageName) => !coveredPackages.includes(packageName)
663
+ );
664
+ const uncoveredContractFacts = contractFacts.filter(
665
+ (fact) => !isContractFactCovered(fact, coveredPackages)
666
+ );
667
+ const hasReleaseFacts =
668
+ affectedPackages.length > 0 ||
669
+ contractFacts.length > 0 ||
670
+ packageRoute.facts.length > 0 ||
671
+ versionRelease;
672
+ const activePackageChangesetsWithoutReleaseFacts =
673
+ activeChangedChangesets.length > 0 && !hasReleaseFacts
674
+ ? activeChangedChangesets.filter(
675
+ (path) => !retiredPackageChangesetCleanups.has(path)
676
+ )
677
+ : [];
678
+ const matchedRuleIds = findMatchedRuleIds(input);
679
+ const requiresPackageIntent = ruleMatchesFactType(input, 'package-content');
680
+ const requiresPublicContractIntent = ruleMatchesFactType(
681
+ input,
682
+ 'public-trail-contract'
683
+ );
684
+ const requiresPackageRoute = ruleMatchesFactType(
685
+ input,
686
+ 'public-package-route'
687
+ );
688
+ const errors: string[] = [];
689
+
690
+ if (baseWorkspaceInput.error) {
691
+ errors.push(baseWorkspaceInput.error);
692
+ }
693
+
694
+ if (noReleaseOverride && changedChangesets.length > 0) {
695
+ errors.push(
696
+ '`release:none` conflicts with changed changeset files. Remove the label or the changeset.'
697
+ );
698
+ }
699
+
700
+ if (activePackageChangesetsWithoutReleaseFacts.length > 0) {
701
+ errors.push(
702
+ `Active changesets require a matching package or release fact on this branch. Remove ${activePackageChangesetsWithoutReleaseFacts.join(', ')} or include the package-facing change here.`
703
+ );
704
+ }
705
+
706
+ if (
707
+ !noReleaseOverride &&
708
+ !versionRelease &&
709
+ requiresPackageIntent &&
710
+ uncoveredPackages.length > 0
711
+ ) {
712
+ errors.push(
713
+ `Release rules require intent for package content changes: ${uncoveredPackages.join(', ')}`
714
+ );
715
+ }
716
+
717
+ errors.push(
718
+ ...findPackageRouteRuleErrors({
719
+ coveredPackages,
720
+ packageRoute,
721
+ requiresPackageRoute,
722
+ })
723
+ );
724
+
725
+ if (
726
+ !noReleaseOverride &&
727
+ !versionRelease &&
728
+ requiresPublicContractIntent &&
729
+ uncoveredContractFacts.length > 0
730
+ ) {
731
+ errors.push(
732
+ `Release rules require intent for public trail contract changes: ${uncoveredContractFacts
733
+ .map(formatContractFact)
734
+ .join(', ')}`
735
+ );
736
+ }
737
+
738
+ return {
739
+ activePackageChangesetsWithoutReleaseFacts,
740
+ affectedPackages,
741
+ changedChangesets,
742
+ contractFacts,
743
+ coveredPackages,
744
+ errors,
745
+ matchedRuleIds,
746
+ noReleaseOverride,
747
+ packageRouteFacts: packageRoute.facts,
748
+ passed: errors.length === 0,
749
+ releaseNone: noReleaseOverride,
750
+ uncoveredContractFacts,
751
+ versionRelease,
752
+ };
753
+ };
754
+
755
+ const readChangedFiles = (path: string): readonly string[] =>
756
+ readFileSync(path, 'utf8')
757
+ .split(/\r?\n/u)
758
+ .map((line) => line.trim())
759
+ .filter((line) => line.length > 0);
760
+
761
+ const parseChangedFilesOutput = (output: string): readonly string[] =>
762
+ output
763
+ .split(/\r?\n/u)
764
+ .map((line) => line.trim())
765
+ .filter((line) => line.length > 0);
766
+
767
+ const readLocalChangedFiles = (
768
+ repoRoot: string,
769
+ baseRef: string
770
+ ): readonly string[] => {
771
+ const result = Bun.spawnSync({
772
+ cmd: [
773
+ 'git',
774
+ 'diff',
775
+ '--name-only',
776
+ '--diff-filter=ACMRTUXB',
777
+ `${baseRef}...HEAD`,
778
+ '--',
779
+ '.',
780
+ ],
781
+ cwd: repoRoot,
782
+ stderr: 'pipe',
783
+ stdout: 'pipe',
784
+ });
785
+
786
+ if (result.exitCode !== 0) {
787
+ throw new Error(
788
+ `Failed to derive local changed files: ${result.stderr.toString()}`
789
+ );
790
+ }
791
+
792
+ return parseChangedFilesOutput(result.stdout.toString());
793
+ };
794
+
795
+ const hasGitRef = (repoRoot: string, ref: string): boolean => {
796
+ const result = Bun.spawnSync({
797
+ cmd: ['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
798
+ cwd: repoRoot,
799
+ stderr: 'pipe',
800
+ stdout: 'pipe',
801
+ });
802
+
803
+ return result.exitCode === 0;
804
+ };
805
+
806
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
807
+ typeof value === 'object' && value !== null;
808
+
809
+ interface ResultLike {
810
+ readonly error?: unknown;
811
+ readonly value?: unknown;
812
+ isErr(): boolean;
813
+ isOk(): boolean;
814
+ }
815
+
816
+ const isResultLike = (value: unknown): value is ResultLike =>
817
+ isRecord(value) &&
818
+ typeof value['isOk'] === 'function' &&
819
+ typeof value['isErr'] === 'function';
820
+
821
+ interface ResolvableConfig {
822
+ resolve(options: {
823
+ readonly cwd: string;
824
+ readonly env: Record<string, string | undefined>;
825
+ }): Promise<unknown>;
826
+ }
827
+
828
+ const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
829
+ isRecord(value) && typeof value['resolve'] === 'function';
830
+
831
+ const errorMessage = (error: unknown): string =>
832
+ error instanceof Error ? error.message : String(error);
833
+
834
+ const findConfigPath = (
835
+ repoRoot: string,
836
+ configPath: string | undefined
837
+ ): string | undefined => {
838
+ if (configPath !== undefined) {
839
+ const resolvedPath = resolve(repoRoot, configPath);
840
+ if (!existsSync(resolvedPath)) {
841
+ throw new Error(`Release config file not found: ${resolvedPath}`);
842
+ }
843
+ return resolvedPath;
844
+ }
845
+
846
+ return undefined;
847
+ };
848
+
849
+ const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
850
+ isRecord(value) && 'release' in value
851
+ ? (value['release'] as ReleaseConfigInput)
852
+ : undefined;
853
+
854
+ export const loadReleaseConfig = async ({
855
+ configPath,
856
+ env = {},
857
+ repoRoot,
858
+ }: {
859
+ readonly configPath?: string | undefined;
860
+ readonly env?: Record<string, string | undefined> | undefined;
861
+ readonly repoRoot: string;
862
+ }): Promise<ReleaseConfigLoadResult> => {
863
+ const locatedConfigPath = findConfigPath(repoRoot, configPath);
864
+ if (configPath !== undefined && locatedConfigPath === undefined) {
865
+ return {};
866
+ }
867
+
868
+ try {
869
+ const loaded = await loadTrailsConfigValue({
870
+ configPath,
871
+ rootDir: repoRoot,
872
+ });
873
+ const exported = loaded.value;
874
+ if (exported === undefined) {
875
+ return {};
876
+ }
877
+
878
+ if (isResolvableConfig(exported)) {
879
+ const resolved = await exported.resolve({ cwd: repoRoot, env });
880
+ if (isResultLike(resolved)) {
881
+ if (resolved.isOk()) {
882
+ return {
883
+ config: extractReleaseConfig(resolved.value),
884
+ configPath: loaded.configPath,
885
+ };
886
+ }
887
+ throw new Error(
888
+ `Failed to resolve release config: ${errorMessage(resolved.error)}`
889
+ );
890
+ }
891
+
892
+ return {
893
+ config: extractReleaseConfig(resolved),
894
+ configPath: loaded.configPath,
895
+ };
896
+ }
897
+
898
+ return {
899
+ config: extractReleaseConfig(exported),
900
+ configPath: loaded.configPath,
901
+ };
902
+ } catch (error) {
903
+ throw new Error(`Failed to load release config: ${errorMessage(error)}`, {
904
+ cause: error,
905
+ });
906
+ }
907
+ };
908
+
909
+ const parseArgs = (args: readonly string[]): CliOptions => {
910
+ let baseRef: string | undefined;
911
+ let changedFilesPath: string | undefined;
912
+ let configPath: string | undefined;
913
+ let releaseNone = false;
914
+ let repoRoot = process.cwd();
915
+
916
+ for (let index = 0; index < args.length; index += 1) {
917
+ const arg = args[index];
918
+
919
+ if (arg === '--release-none') {
920
+ releaseNone = true;
921
+ continue;
922
+ }
923
+
924
+ if (arg === '--base-ref') {
925
+ const value = args[index + 1];
926
+
927
+ if (!value) {
928
+ throw new Error('--base-ref requires a git ref or commit');
929
+ }
930
+
931
+ baseRef = value;
932
+ index += 1;
933
+ continue;
934
+ }
935
+
936
+ if (arg === '--changed-files') {
937
+ const value = args[index + 1];
938
+
939
+ if (!value) {
940
+ throw new Error('--changed-files requires a file path');
941
+ }
942
+
943
+ changedFilesPath = value;
944
+ index += 1;
945
+ continue;
946
+ }
947
+
948
+ if (arg === '--config-path') {
949
+ const value = args[index + 1];
950
+
951
+ if (!value) {
952
+ throw new Error('--config-path requires a config file path');
953
+ }
954
+
955
+ configPath = value;
956
+ index += 1;
957
+ continue;
958
+ }
959
+
960
+ if (arg === '--repo-root') {
961
+ const value = args[index + 1];
962
+
963
+ if (!value) {
964
+ throw new Error('--repo-root requires a directory path');
965
+ }
966
+
967
+ repoRoot = resolve(value);
968
+ index += 1;
969
+ continue;
970
+ }
971
+
972
+ throw new Error(`Unknown argument: ${arg ?? ''}`);
973
+ }
974
+
975
+ return {
976
+ ...(baseRef === undefined ? {} : { baseRef }),
977
+ ...(changedFilesPath === undefined ? {} : { changedFilesPath }),
978
+ ...(configPath === undefined ? {} : { configPath }),
979
+ releaseNone,
980
+ repoRoot,
981
+ };
982
+ };
983
+
984
+ export const formatReleaseCheckReport = (
985
+ result: ReleaseCheckResult
986
+ ): string => {
987
+ const lines: string[] = [];
988
+
989
+ if (result.passed) {
990
+ if (result.affectedPackages.length === 0) {
991
+ lines.push(
992
+ 'Release check passed: no publishable package content files changed.'
993
+ );
994
+ return lines.join('\n');
995
+ }
996
+
997
+ if (result.noReleaseOverride) {
998
+ lines.push(
999
+ `Release check passed via release:none override for: ${result.affectedPackages.join(', ')}`
1000
+ );
1001
+ return lines.join('\n');
1002
+ }
1003
+
1004
+ if (result.versionRelease) {
1005
+ lines.push(
1006
+ `Release check passed for generated version release: ${result.affectedPackages.join(', ')}`
1007
+ );
1008
+ return lines.join('\n');
1009
+ }
1010
+
1011
+ lines.push(
1012
+ `Release check passed for: ${result.affectedPackages.join(', ')}`
1013
+ );
1014
+ lines.push(`Changed changesets: ${result.changedChangesets.join(', ')}`);
1015
+ return lines.join('\n');
1016
+ }
1017
+
1018
+ for (const error of result.errors) {
1019
+ lines.push(error);
1020
+ }
1021
+
1022
+ if (result.affectedPackages.length > 0) {
1023
+ lines.push(`Affected packages: ${result.affectedPackages.join(', ')}`);
1024
+ }
1025
+
1026
+ if (result.contractFacts.length > 0) {
1027
+ lines.push(
1028
+ `Public trail contract facts: ${result.contractFacts
1029
+ .map(formatContractFact)
1030
+ .join(', ')}`
1031
+ );
1032
+ }
1033
+
1034
+ if (result.changedChangesets.length > 0) {
1035
+ lines.push(`Changed changesets: ${result.changedChangesets.join(', ')}`);
1036
+ }
1037
+
1038
+ return lines.join('\n');
1039
+ };
1040
+
1041
+ const renderResult = (result: ReleaseCheckResult): void => {
1042
+ const formatted = formatReleaseCheckReport(result);
1043
+ if (formatted.length === 0) {
1044
+ return;
1045
+ }
1046
+
1047
+ if (result.passed) {
1048
+ console.log(formatted);
1049
+ return;
1050
+ }
1051
+
1052
+ console.error(formatted);
1053
+ };
1054
+
1055
+ export const runReleaseCheck = async (
1056
+ options: RunReleaseCheckOptions
1057
+ ): Promise<ReleaseCheckReport> => {
1058
+ const workspaces = await discoverWorkspaces(options.repoRoot);
1059
+ const changedFilesBaseError =
1060
+ options.changedFilesPath !== undefined && options.baseRef === undefined
1061
+ ? 'Release check requires --base-ref when --changed-files is used.'
1062
+ : undefined;
1063
+ const baseRef =
1064
+ options.baseRef ??
1065
+ (options.changedFilesPath === undefined &&
1066
+ (workspaces.length > 0 || hasGitRef(options.repoRoot, 'origin/main'))
1067
+ ? 'origin/main'
1068
+ : undefined);
1069
+ let changedFiles: readonly string[];
1070
+
1071
+ if (options.changedFilesPath !== undefined) {
1072
+ changedFiles = readChangedFiles(options.changedFilesPath);
1073
+ } else if (workspaces.length > 0) {
1074
+ changedFiles = readLocalChangedFiles(
1075
+ options.repoRoot,
1076
+ baseRef ?? 'origin/main'
1077
+ );
1078
+ } else {
1079
+ changedFiles = [];
1080
+ }
1081
+
1082
+ const loadedConfig = await loadReleaseConfig({
1083
+ ...(options.configPath === undefined
1084
+ ? {}
1085
+ : { configPath: options.configPath }),
1086
+ env: options.env,
1087
+ repoRoot: options.repoRoot,
1088
+ });
1089
+ const baseWorkspaceDiscovery = discoverBaseWorkspaces(
1090
+ options.repoRoot,
1091
+ baseRef
1092
+ );
1093
+ const baseWorkspaceError =
1094
+ changedFilesBaseError ?? baseWorkspaceDiscovery.error;
1095
+ const result = checkReleaseRules({
1096
+ ...(baseRef === undefined ? {} : { baseRef }),
1097
+ ...(baseWorkspaceError === undefined ? {} : { baseWorkspaceError }),
1098
+ baseWorkspaces: baseWorkspaceDiscovery.workspaces,
1099
+ changedFiles,
1100
+ ...(loadedConfig.config === undefined
1101
+ ? {}
1102
+ : { releaseConfig: loadedConfig.config }),
1103
+ releaseNone: options.releaseNone === true,
1104
+ repoRoot: options.repoRoot,
1105
+ workspaces,
1106
+ });
1107
+
1108
+ return {
1109
+ ...result,
1110
+ ...(loadedConfig.configPath === undefined
1111
+ ? {}
1112
+ : { configPath: loadedConfig.configPath }),
1113
+ formatted: formatReleaseCheckReport(result),
1114
+ };
1115
+ };
1116
+
1117
+ export const runReleaseCheckCli = async (
1118
+ args: readonly string[]
1119
+ ): Promise<number> => {
1120
+ const options = parseArgs(args);
1121
+ const result = await runReleaseCheck({
1122
+ ...(options.baseRef === undefined ? {} : { baseRef: options.baseRef }),
1123
+ ...(options.changedFilesPath === undefined
1124
+ ? {}
1125
+ : { changedFilesPath: options.changedFilesPath }),
1126
+ ...(options.configPath === undefined
1127
+ ? {}
1128
+ : { configPath: options.configPath }),
1129
+ env: process.env as Record<string, string | undefined>,
1130
+ releaseNone: options.releaseNone,
1131
+ repoRoot: options.repoRoot,
1132
+ });
1133
+
1134
+ renderResult(result);
1135
+
1136
+ return result.passed ? 0 : 1;
1137
+ };
1138
+
1139
+ if (import.meta.main) {
1140
+ try {
1141
+ process.exit(await runReleaseCheckCli(process.argv.slice(2)));
1142
+ } catch (error) {
1143
+ console.error(error instanceof Error ? error.message : String(error));
1144
+ process.exit(1);
1145
+ }
1146
+ }