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