@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,571 @@
1
+ import {
2
+ InternalError,
3
+ isPlainObject,
4
+ matchesAnyPathGlob,
5
+ NotFoundError,
6
+ Result,
7
+ ValidationError,
8
+ } from '@ontrails/core';
9
+ import type { Result as TrailsResult } from '@ontrails/core';
10
+ import {
11
+ listVocabularyRegradeAuditPlansFromRegistry,
12
+ regradeReportOutput,
13
+ runVocabularyRegrade,
14
+ vocabularyDispositionValues,
15
+ vocabularyRegradePlanForInput,
16
+ vocabularyRegradeTransitionForInput,
17
+ } from '@ontrails/regrade';
18
+ import type { VocabularyRegradePlan } from '@ontrails/regrade';
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { basename, extname, join } from 'node:path';
21
+ import { z } from 'zod';
22
+
23
+ import { deriveLiveApiPreserveInventory } from './live-api-preserve.js';
24
+ import {
25
+ readRegradeHistoryArtifact,
26
+ regradeHistoryPathForPlan,
27
+ } from './history.js';
28
+ import { rootRelativePath } from './plan-artifact.js';
29
+
30
+ const sourceCommentExtensions = [
31
+ '.cjs',
32
+ '.cts',
33
+ '.js',
34
+ '.jsx',
35
+ '.mjs',
36
+ '.mts',
37
+ '.ts',
38
+ '.tsx',
39
+ ] as const;
40
+
41
+ export const regradeAuditInputSchema = z.object({
42
+ failOnOpen: z
43
+ .boolean()
44
+ .default(false)
45
+ .describe('Fail when a completed transition has current-tree residue'),
46
+ includeEntries: z
47
+ .enum(['actionable', 'all'])
48
+ .default('actionable')
49
+ .describe('Report entry detail to include for each audited transition'),
50
+ rootDir: z.string().optional().describe('Workspace root directory'),
51
+ transitionIds: z
52
+ .array(z.string().min(1))
53
+ .optional()
54
+ .describe('Governed transition ids to audit; defaults to applied history'),
55
+ });
56
+
57
+ const regradeAuditTransitionSchema = z.object({
58
+ report: z
59
+ .object({
60
+ dispositions: z
61
+ .partialRecord(
62
+ z.enum(vocabularyDispositionValues),
63
+ z.number().int().nonnegative()
64
+ )
65
+ .describe('Current source occurrence counts by classification'),
66
+ entries: regradeReportOutput.shape.entries
67
+ .readonly()
68
+ .describe('Actionable file-level residue details'),
69
+ occurrences: z
70
+ .number()
71
+ .int()
72
+ .nonnegative()
73
+ .describe('Total classified current-source occurrences'),
74
+ open: z
75
+ .number()
76
+ .int()
77
+ .nonnegative()
78
+ .describe('Unresolved current-source occurrences'),
79
+ scanned: z.number().int().nonnegative().describe('Files scanned'),
80
+ skippedByReason: z
81
+ .record(z.string(), z.number().int().nonnegative())
82
+ .describe('Collection skips grouped by machine-readable reason'),
83
+ status: z.enum(['green', 'open']).describe('Transition audit status'),
84
+ })
85
+ .describe('Concise current-tree audit of the latest applied plan'),
86
+ source: z.string().describe('Committed Regrade history artifact'),
87
+ transitionId: z.string().describe('Stable Regrade transition identity'),
88
+ });
89
+
90
+ export const regradeAuditOutputSchema = z.object({
91
+ gate: z.object({
92
+ open: z
93
+ .number()
94
+ .int()
95
+ .nonnegative()
96
+ .describe('Current-tree unresolved occurrence count'),
97
+ status: z
98
+ .enum(['green', 'open'])
99
+ .describe('Whether every audited transition remains complete'),
100
+ }),
101
+ transitions: z
102
+ .array(regradeAuditTransitionSchema)
103
+ .describe('Applied vocabulary transitions audited against current source'),
104
+ });
105
+
106
+ export type RegradeAuditInput = z.output<typeof regradeAuditInputSchema>;
107
+ export type RegradeAuditOutput = z.output<typeof regradeAuditOutputSchema>;
108
+
109
+ interface RegradeAuditCandidate {
110
+ readonly plan: VocabularyRegradePlan;
111
+ readonly source: string;
112
+ readonly transitionId: string;
113
+ }
114
+
115
+ /**
116
+ * Read the governed transition id from legacy history or v3 embedded intent.
117
+ *
118
+ * @internal
119
+ */
120
+ export const historyTransitionId = (
121
+ rawHistory: unknown
122
+ ): string | undefined => {
123
+ if (!isPlainObject(rawHistory)) {
124
+ return undefined;
125
+ }
126
+ const { id, runs } = rawHistory;
127
+ const historyRuns = Array.isArray(runs) ? runs : [];
128
+ const latestRun = historyRuns.at(-1);
129
+ const planArtifact = isPlainObject(latestRun) ? latestRun['plan'] : undefined;
130
+ const plan = isPlainObject(planArtifact) ? planArtifact['plan'] : undefined;
131
+ const planId = isPlainObject(plan) ? plan['id'] : undefined;
132
+ if (typeof planId === 'string') {
133
+ return planId;
134
+ }
135
+ for (const run of historyRuns.toReversed()) {
136
+ const intent = isPlainObject(run) ? run['intent'] : undefined;
137
+ const intentPlan = isPlainObject(intent) ? intent['plan'] : undefined;
138
+ const intentPlanId = isPlainObject(intentPlan)
139
+ ? intentPlan['id']
140
+ : undefined;
141
+ if (typeof intentPlanId === 'string') {
142
+ return intentPlanId;
143
+ }
144
+ }
145
+ return typeof id === 'string' ? id : undefined;
146
+ };
147
+
148
+ const selectedHistoryFileNamesFor = (
149
+ selectedTransitionIds: ReadonlySet<string> | null,
150
+ registryPlansById: ReadonlyMap<string, VocabularyRegradePlan>
151
+ ): ReadonlySet<string> =>
152
+ new Set(
153
+ selectedTransitionIds === null
154
+ ? []
155
+ : [...selectedTransitionIds].flatMap((transitionId) => {
156
+ const plan = registryPlansById.get(transitionId);
157
+ return plan === undefined
158
+ ? []
159
+ : [basename(regradeHistoryPathForPlan('.', plan))];
160
+ })
161
+ );
162
+
163
+ const historyIsSelected = (
164
+ rawHistory: unknown,
165
+ historyFile: string,
166
+ selectedTransitionIds: ReadonlySet<string> | null,
167
+ selectedHistoryFileNames: ReadonlySet<string>
168
+ ): boolean =>
169
+ selectedTransitionIds === null ||
170
+ selectedTransitionIds.has(historyTransitionId(rawHistory) ?? '') ||
171
+ selectedHistoryFileNames.has(basename(historyFile));
172
+
173
+ export const mergeAuditPlan = (
174
+ latestPlan: VocabularyRegradePlan,
175
+ registryPlan: VocabularyRegradePlan | undefined
176
+ ): VocabularyRegradePlan => {
177
+ if (registryPlan === undefined) {
178
+ return latestPlan;
179
+ }
180
+ const policyClassified = [
181
+ ...(latestPlan.scope?.policyClassified ?? []),
182
+ ...(registryPlan.scope?.policyClassified ?? []),
183
+ ];
184
+ const teachingSurfaces = [
185
+ ...new Set([
186
+ ...(latestPlan.scope?.teachingSurfaces ?? []),
187
+ ...(registryPlan.scope?.teachingSurfaces ?? []),
188
+ ]),
189
+ ].toSorted();
190
+ const scope =
191
+ latestPlan.scope === undefined && registryPlan.scope === undefined
192
+ ? undefined
193
+ : {
194
+ ...registryPlan.scope,
195
+ ...latestPlan.scope,
196
+ ...(policyClassified.length === 0 ? {} : { policyClassified }),
197
+ ...(teachingSurfaces.length === 0 ? {} : { teachingSurfaces }),
198
+ };
199
+ return {
200
+ ...registryPlan,
201
+ ...latestPlan,
202
+ preserve: [
203
+ ...(latestPlan.preserve ?? []),
204
+ ...(registryPlan.preserve ?? []),
205
+ ],
206
+ ...(scope === undefined ? {} : { scope }),
207
+ };
208
+ };
209
+
210
+ const readRegradeAuditCandidates = (
211
+ rootDir: string,
212
+ selectedTransitionIds: ReadonlySet<string> | null
213
+ ): TrailsResult<readonly RegradeAuditCandidate[], Error> => {
214
+ const historyDir = join(rootDir, '.trails', 'regrade', 'history');
215
+ if (!existsSync(historyDir)) {
216
+ return Result.ok([]);
217
+ }
218
+ let historyFiles: readonly string[];
219
+ try {
220
+ historyFiles = readdirSync(historyDir, { withFileTypes: true })
221
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
222
+ .map((entry) => join(historyDir, entry.name))
223
+ .toSorted();
224
+ } catch (error) {
225
+ return Result.err(
226
+ new InternalError('Failed to list Regrade history artifacts.', {
227
+ ...(error instanceof Error ? { cause: error } : {}),
228
+ context: { historyDir },
229
+ })
230
+ );
231
+ }
232
+ const registryPlansById = new Map(
233
+ listVocabularyRegradeAuditPlansFromRegistry().flatMap((plan) =>
234
+ plan.id === undefined ? [] : [[plan.id, plan] as const]
235
+ )
236
+ );
237
+ const selectedHistoryFileNames = selectedHistoryFileNamesFor(
238
+ selectedTransitionIds,
239
+ registryPlansById
240
+ );
241
+ const selectedHistoriesHaveCanonicalFileNames =
242
+ selectedTransitionIds !== null &&
243
+ selectedHistoryFileNames.size === selectedTransitionIds.size;
244
+ const candidates: RegradeAuditCandidate[] = [];
245
+ for (const historyFile of historyFiles) {
246
+ if (
247
+ selectedHistoriesHaveCanonicalFileNames &&
248
+ !selectedHistoryFileNames.has(basename(historyFile))
249
+ ) {
250
+ continue;
251
+ }
252
+ let rawHistory: unknown;
253
+ try {
254
+ rawHistory = JSON.parse(readFileSync(historyFile, 'utf8'));
255
+ } catch (error) {
256
+ return Result.err(
257
+ new InternalError('Failed to read Regrade history audit plan.', {
258
+ ...(error instanceof Error ? { cause: error } : {}),
259
+ context: { historyFile },
260
+ })
261
+ );
262
+ }
263
+ if (
264
+ !historyIsSelected(
265
+ rawHistory,
266
+ historyFile,
267
+ selectedTransitionIds,
268
+ selectedHistoryFileNames
269
+ )
270
+ ) {
271
+ continue;
272
+ }
273
+ const history = readRegradeHistoryArtifact(historyFile);
274
+ if (history.isErr()) {
275
+ return history;
276
+ }
277
+ const latestPlan = history.value.runs.at(-1)?.plan.plan as
278
+ | VocabularyRegradePlan
279
+ | undefined;
280
+ if (latestPlan?.kind !== 'vocabulary') {
281
+ continue;
282
+ }
283
+ const transitionId = latestPlan.id ?? history.value.id;
284
+ if (
285
+ selectedTransitionIds !== null &&
286
+ !selectedTransitionIds.has(transitionId)
287
+ ) {
288
+ continue;
289
+ }
290
+ const registryPlan =
291
+ registryPlansById.get(transitionId) ??
292
+ vocabularyRegradePlanForInput(latestPlan.from, latestPlan.to) ??
293
+ undefined;
294
+ candidates.push({
295
+ plan: mergeAuditPlan(latestPlan, registryPlan),
296
+ source: rootRelativePath(rootDir, historyFile),
297
+ transitionId,
298
+ });
299
+ }
300
+ return Result.ok(
301
+ candidates.toSorted((left, right) =>
302
+ left.transitionId.localeCompare(right.transitionId)
303
+ )
304
+ );
305
+ };
306
+
307
+ type VocabularyPolicyClassified = NonNullable<
308
+ NonNullable<VocabularyRegradePlan['scope']>['policyClassified']
309
+ >;
310
+
311
+ /**
312
+ * Select full evidence for classified paths and comments elsewhere.
313
+ *
314
+ * @internal
315
+ */
316
+ export const sourceKindForRegradeAuditPath = (
317
+ path: string,
318
+ policyClassified: VocabularyPolicyClassified | undefined
319
+ ): 'all' | 'comments' => {
320
+ const isPolicyClassified = policyClassified?.some((policy) =>
321
+ matchesAnyPathGlob(path, policy.paths)
322
+ );
323
+ return sourceCommentExtensions.includes(
324
+ extname(path) as (typeof sourceCommentExtensions)[number]
325
+ ) && !isPolicyClassified
326
+ ? 'comments'
327
+ : 'all';
328
+ };
329
+
330
+ export const projectPolicyClassifiedForMarkdownAudit = (
331
+ plan: VocabularyRegradePlan,
332
+ includeCodeComments = false
333
+ ): VocabularyPolicyClassified | undefined => {
334
+ const auditedExtensions = new Set<string>([
335
+ '.md',
336
+ '.mdx',
337
+ ...(includeCodeComments ? sourceCommentExtensions : []),
338
+ ]);
339
+ return plan.scope?.policyClassified
340
+ ?.map((policy) => ({
341
+ ...policy,
342
+ paths: policy.paths.filter(
343
+ (path) =>
344
+ !plan.scope?.exclude?.some(
345
+ (excludedPath) =>
346
+ excludedPath === path || matchesAnyPathGlob(path, [excludedPath])
347
+ )
348
+ ),
349
+ }))
350
+ .filter((policy) => policy.paths.length > 0)
351
+ .map((policy) => {
352
+ const hasAuditablePath = policy.paths.some((path) => {
353
+ const extension = extname(path);
354
+ if (auditedExtensions.has(extension)) {
355
+ return true;
356
+ }
357
+ const terminalPattern = basename(path);
358
+ const trailingWildcards = terminalPattern.match(/[*?]+$/)?.[0];
359
+ const canSelectAuditedExtension =
360
+ extension === '' &&
361
+ trailingWildcards !== undefined &&
362
+ [...auditedExtensions].some(
363
+ (auditedExtension) =>
364
+ trailingWildcards.includes('*') ||
365
+ (trailingWildcards.length >= auditedExtension.length &&
366
+ (terminalPattern.length > trailingWildcards.length ||
367
+ trailingWildcards.length > auditedExtension.length))
368
+ );
369
+ return (
370
+ includeCodeComments &&
371
+ (canSelectAuditedExtension ||
372
+ [...auditedExtensions].some((auditedExtension) =>
373
+ matchesAnyPathGlob(auditedExtension, [extension])
374
+ ))
375
+ );
376
+ });
377
+ if (policy.expectMatches !== true || hasAuditablePath) {
378
+ return policy;
379
+ }
380
+ // An extension-filtered audit cannot prove evidence for a path whose
381
+ // authored pattern does not identify one of the audited file families.
382
+ const { expectMatches: _, ...markdownPolicy } = policy;
383
+ return markdownPolicy;
384
+ });
385
+ };
386
+
387
+ export const projectExcludesForMarkdownAudit = (
388
+ plan: VocabularyRegradePlan,
389
+ policyClassified: VocabularyPolicyClassified | undefined
390
+ ): readonly string[] | undefined =>
391
+ plan.scope?.exclude?.filter(
392
+ (excludedPath) =>
393
+ !policyClassified?.some((policy) =>
394
+ policy.paths.some((path) => matchesAnyPathGlob(excludedPath, [path]))
395
+ )
396
+ );
397
+
398
+ export const projectIncludesForMarkdownAudit = (
399
+ plan: VocabularyRegradePlan,
400
+ policyClassified: VocabularyPolicyClassified | undefined
401
+ ): readonly string[] | undefined => {
402
+ if (plan.scope?.include === undefined) {
403
+ return undefined;
404
+ }
405
+ return [
406
+ ...new Set([
407
+ ...plan.scope.include,
408
+ ...(policyClassified?.flatMap((policy) => policy.paths) ?? []),
409
+ ...(plan.scope.teachingSurfaces ?? []),
410
+ ]),
411
+ ].toSorted();
412
+ };
413
+
414
+ const runRegradeAuditCandidate = async (
415
+ candidate: RegradeAuditCandidate,
416
+ input: RegradeAuditInput,
417
+ rootDir: string
418
+ ): Promise<TrailsResult<RegradeAuditOutput['transitions'][number], Error>> => {
419
+ const includeCodeComments =
420
+ vocabularyRegradeTransitionForInput(candidate.plan.from, candidate.plan.to)
421
+ ?.target.kind === 'classified';
422
+ const policyClassified = projectPolicyClassifiedForMarkdownAudit(
423
+ candidate.plan,
424
+ includeCodeComments
425
+ );
426
+ const exclude = projectExcludesForMarkdownAudit(
427
+ candidate.plan,
428
+ policyClassified
429
+ );
430
+ const include = projectIncludesForMarkdownAudit(
431
+ candidate.plan,
432
+ policyClassified
433
+ );
434
+ const auditPlan: VocabularyRegradePlan = {
435
+ ...candidate.plan,
436
+ scope: {
437
+ ...candidate.plan.scope,
438
+ extensions: [
439
+ '.md',
440
+ '.mdx',
441
+ ...(includeCodeComments ? sourceCommentExtensions : []),
442
+ ],
443
+ ...(exclude === undefined ? {} : { exclude }),
444
+ ...(include === undefined ? {} : { include }),
445
+ ...(policyClassified === undefined ? {} : { policyClassified }),
446
+ },
447
+ };
448
+ const preserveResult = await deriveLiveApiPreserveInventory(
449
+ auditPlan,
450
+ rootDir
451
+ );
452
+ if (preserveResult.isErr()) {
453
+ return preserveResult;
454
+ }
455
+ const reportResult = runVocabularyRegrade({
456
+ apply: false,
457
+ includeEntries: input.includeEntries,
458
+ plan: auditPlan,
459
+ ...(preserveResult.value.length === 0
460
+ ? {}
461
+ : { preserveInventory: preserveResult.value }),
462
+ root: rootDir,
463
+ ...(includeCodeComments
464
+ ? {
465
+ sourceKindForPath: (path: string) =>
466
+ sourceKindForRegradeAuditPath(path, policyClassified),
467
+ }
468
+ : {}),
469
+ });
470
+ if (reportResult.isErr()) {
471
+ return reportResult;
472
+ }
473
+ if (reportResult.value === null) {
474
+ return Result.err(
475
+ new NotFoundError('Regrade audit root could not be collected.', {
476
+ context: { rootDir },
477
+ })
478
+ );
479
+ }
480
+ const { run } = reportResult.value;
481
+ if (run === undefined) {
482
+ return Result.err(
483
+ new InternalError('Regrade audit did not produce a vocabulary run.', {
484
+ context: { transitionId: candidate.transitionId },
485
+ })
486
+ );
487
+ }
488
+ const output = regradeAuditTransitionSchema.safeParse({
489
+ report: {
490
+ dispositions: run.report.dispositions,
491
+ entries: reportResult.value.entries,
492
+ occurrences: run.ledger.occurrences.length,
493
+ open: run.report.open,
494
+ scanned: reportResult.value.scanned,
495
+ skippedByReason: reportResult.value.skipsByReason,
496
+ status: run.report.gate.status,
497
+ },
498
+ source: candidate.source,
499
+ transitionId: candidate.transitionId,
500
+ });
501
+ if (!output.success) {
502
+ return Result.err(
503
+ new InternalError('Regrade audit produced an invalid transition.', {
504
+ context: {
505
+ issues: output.error.issues,
506
+ transitionId: candidate.transitionId,
507
+ },
508
+ })
509
+ );
510
+ }
511
+ return Result.ok(output.data);
512
+ };
513
+
514
+ export const auditRegradeHistory = async (
515
+ input: RegradeAuditInput,
516
+ rootDir: string
517
+ ): Promise<TrailsResult<RegradeAuditOutput, Error>> => {
518
+ const selectedTransitionIds =
519
+ input.transitionIds === undefined ? null : new Set(input.transitionIds);
520
+ const candidatesResult = readRegradeAuditCandidates(
521
+ rootDir,
522
+ selectedTransitionIds
523
+ );
524
+ if (candidatesResult.isErr()) {
525
+ return candidatesResult;
526
+ }
527
+ const candidates = candidatesResult.value;
528
+ if (selectedTransitionIds !== null) {
529
+ const auditedTransitionIds = new Set(
530
+ candidates.map((candidate) => candidate.transitionId)
531
+ );
532
+ const missingTransitionIds = [...selectedTransitionIds].filter(
533
+ (transitionId) => !auditedTransitionIds.has(transitionId)
534
+ );
535
+ if (missingTransitionIds.length > 0) {
536
+ return Result.err(
537
+ new ValidationError(
538
+ 'Selected Regrade audit transitions do not have committed history.',
539
+ { context: { transitionIds: missingTransitionIds.toSorted() } }
540
+ )
541
+ );
542
+ }
543
+ }
544
+ const transitions: RegradeAuditOutput['transitions'][number][] = [];
545
+ for (const candidate of candidates) {
546
+ const transitionResult = await runRegradeAuditCandidate(
547
+ candidate,
548
+ input,
549
+ rootDir
550
+ );
551
+ if (transitionResult.isErr()) {
552
+ return transitionResult;
553
+ }
554
+ transitions.push(transitionResult.value);
555
+ }
556
+ const open = transitions.reduce(
557
+ (total, transition) => total + transition.report.open,
558
+ 0
559
+ );
560
+ return Result.ok({
561
+ gate: {
562
+ open,
563
+ status: transitions.some(
564
+ (transition) => transition.report.status === 'open'
565
+ )
566
+ ? 'open'
567
+ : 'green',
568
+ },
569
+ transitions,
570
+ });
571
+ };
@@ -0,0 +1,152 @@
1
+ import { loadTrailsConfigValue } from '@ontrails/config';
2
+ import {
3
+ InternalError,
4
+ Result,
5
+ ValidationError,
6
+ pathScopeSchema,
7
+ } from '@ontrails/core';
8
+ import type { Result as TrailsResult } from '@ontrails/core';
9
+ import { z } from 'zod';
10
+
11
+ export const regradeConfigSchema = z
12
+ .object({
13
+ scope: pathScopeSchema.optional(),
14
+ })
15
+ .default({});
16
+
17
+ export type RegradeConfig = z.output<typeof regradeConfigSchema>;
18
+
19
+ interface RegradeConfigLoadResult {
20
+ readonly config?: RegradeConfig;
21
+ readonly configPath?: string;
22
+ }
23
+
24
+ interface ResolvableConfig {
25
+ readonly resolve: (options?: {
26
+ readonly cwd?: string;
27
+ readonly env?: Record<string, string | undefined>;
28
+ }) => unknown;
29
+ }
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
32
+ typeof value === 'object' && value !== null && !Array.isArray(value);
33
+
34
+ const isResultLike = (
35
+ value: unknown
36
+ ): value is {
37
+ readonly error?: unknown;
38
+ readonly isErr: () => boolean;
39
+ readonly isOk: () => boolean;
40
+ readonly value?: unknown;
41
+ } =>
42
+ isRecord(value) &&
43
+ typeof value['isOk'] === 'function' &&
44
+ typeof value['isErr'] === 'function';
45
+
46
+ const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
47
+ isRecord(value) && typeof value['resolve'] === 'function';
48
+
49
+ const errorMessage = (error: unknown): string =>
50
+ error instanceof Error ? error.message : String(error);
51
+
52
+ const extractRegradeConfig = (
53
+ value: unknown
54
+ ): TrailsResult<RegradeConfig | undefined, ValidationError> => {
55
+ if (!(isRecord(value) && 'regrade' in value)) {
56
+ return Result.ok();
57
+ }
58
+
59
+ const parsed = regradeConfigSchema.safeParse(value['regrade']);
60
+ if (parsed.success) {
61
+ return Result.ok(parsed.data);
62
+ }
63
+
64
+ return Result.err(
65
+ new ValidationError('Invalid regrade config in Trails config file.', {
66
+ context: { issues: parsed.error.issues },
67
+ })
68
+ );
69
+ };
70
+
71
+ export const loadRegradeConfig = async ({
72
+ configPath,
73
+ env = {},
74
+ rootDir,
75
+ }: {
76
+ readonly configPath?: string | undefined;
77
+ readonly env?: Record<string, string | undefined> | undefined;
78
+ readonly rootDir: string;
79
+ }): Promise<
80
+ TrailsResult<RegradeConfigLoadResult, InternalError | ValidationError>
81
+ > => {
82
+ try {
83
+ const loaded = await loadTrailsConfigValue({
84
+ configPath,
85
+ rootDir,
86
+ });
87
+ const exported = loaded.value;
88
+ if (exported === undefined) {
89
+ return Result.ok({});
90
+ }
91
+
92
+ if (isResolvableConfig(exported)) {
93
+ const resolved = await exported.resolve({ cwd: rootDir, env });
94
+ if (isResultLike(resolved)) {
95
+ if (resolved.isErr()) {
96
+ return Result.err(
97
+ new InternalError(
98
+ `Failed to resolve regrade config: ${errorMessage(resolved.error)}`
99
+ )
100
+ );
101
+ }
102
+ const configResult = extractRegradeConfig(resolved.value);
103
+ if (configResult.isErr()) {
104
+ return configResult;
105
+ }
106
+ return Result.ok({
107
+ ...(configResult.value === undefined
108
+ ? {}
109
+ : { config: configResult.value }),
110
+ ...(loaded.configPath === undefined
111
+ ? {}
112
+ : { configPath: loaded.configPath }),
113
+ });
114
+ }
115
+
116
+ const configResult = extractRegradeConfig(resolved);
117
+ if (configResult.isErr()) {
118
+ return configResult;
119
+ }
120
+ return Result.ok({
121
+ ...(configResult.value === undefined
122
+ ? {}
123
+ : { config: configResult.value }),
124
+ ...(loaded.configPath === undefined
125
+ ? {}
126
+ : { configPath: loaded.configPath }),
127
+ });
128
+ }
129
+
130
+ const configResult = extractRegradeConfig(exported);
131
+ if (configResult.isErr()) {
132
+ return configResult;
133
+ }
134
+ return Result.ok({
135
+ ...(configResult.value === undefined
136
+ ? {}
137
+ : { config: configResult.value }),
138
+ ...(loaded.configPath === undefined
139
+ ? {}
140
+ : { configPath: loaded.configPath }),
141
+ });
142
+ } catch (error) {
143
+ return Result.err(
144
+ new InternalError(
145
+ `Failed to load regrade config: ${errorMessage(error)}`,
146
+ {
147
+ cause: error instanceof Error ? error : new Error(String(error)),
148
+ }
149
+ )
150
+ );
151
+ }
152
+ };