@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,301 @@
1
+ import {
2
+ createAstIdentifierRenameClass,
3
+ deriveFileRenameCandidates,
4
+ deriveVocabularyFormProposals,
5
+ runFileRenameRegrade,
6
+ runRegrade,
7
+ } from '@ontrails/regrade';
8
+ import type {
9
+ RegradeReport,
10
+ VocabularyPreserveInventoryEntry,
11
+ VocabularyRegradePlan,
12
+ } from '@ontrails/regrade';
13
+
14
+ import { isGeneratedRegradeArtifactPath } from './plan-artifact.js';
15
+ import type {
16
+ RegradePlanArtifact,
17
+ RegradePlanDerivation,
18
+ } from './plan-artifact.js';
19
+
20
+ const provenanceForForm = (
21
+ source: RegradePlanDerivation['forms'][number]['source'],
22
+ fields: RegradePlanArtifact['provenance']['fields']
23
+ ): 'authored' | 'derived' => {
24
+ if (source === 'seed') {
25
+ return 'authored';
26
+ }
27
+ if (source === 'plan-override') {
28
+ return fields['overrides'] ?? 'derived';
29
+ }
30
+ if (source === 'plan-defer') {
31
+ return fields['deferForms'] ?? 'derived';
32
+ }
33
+ return 'derived';
34
+ };
35
+
36
+ const namespaceCensus = (
37
+ report: RegradeReport
38
+ ): RegradePlanDerivation['namespaces'] => {
39
+ const namespaces = new Map<
40
+ string,
41
+ { inScope: number; policyClassified: number }
42
+ >();
43
+ for (const occurrence of report.run?.ledger.occurrences ?? []) {
44
+ if (isGeneratedRegradeArtifactPath(occurrence.path)) {
45
+ continue;
46
+ }
47
+ const namespace = occurrence.path.split('/')[0] ?? occurrence.path;
48
+ const counts = namespaces.get(namespace) ?? {
49
+ inScope: 0,
50
+ policyClassified: 0,
51
+ };
52
+ if (occurrence.scopeTier === 'policy-classified') {
53
+ counts.policyClassified += 1;
54
+ } else {
55
+ counts.inScope += 1;
56
+ }
57
+ namespaces.set(namespace, counts);
58
+ }
59
+ return [...namespaces.entries()]
60
+ .map(([namespace, counts]) => ({
61
+ ...counts,
62
+ namespace,
63
+ provenance: 'derived' as const,
64
+ }))
65
+ .toSorted((left, right) => left.namespace.localeCompare(right.namespace));
66
+ };
67
+
68
+ const observedReviews = (
69
+ report: RegradeReport
70
+ ): RegradePlanDerivation['reviews'] => {
71
+ const reviews = new Map<string, RegradePlanDerivation['reviews'][number]>();
72
+ for (const occurrence of report.run?.ledger.occurrences ?? []) {
73
+ if (
74
+ isGeneratedRegradeArtifactPath(occurrence.path) ||
75
+ (occurrence.verdict !== 'deferred' &&
76
+ occurrence.disposition !== 'code-context-out-of-engine' &&
77
+ occurrence.disposition !== 'in-family-unresolved')
78
+ ) {
79
+ continue;
80
+ }
81
+ const key = `${occurrence.form}\0${occurrence.reason}`;
82
+ const current = reviews.get(key);
83
+ const evidence = {
84
+ column: occurrence.column,
85
+ line: occurrence.line,
86
+ path: occurrence.path,
87
+ };
88
+ reviews.set(key, {
89
+ evidence:
90
+ current === undefined ? [evidence] : [...current.evidence, evidence],
91
+ provenance: 'derived',
92
+ reason: occurrence.reason,
93
+ status: 'pending',
94
+ value: occurrence.form,
95
+ });
96
+ }
97
+ return [...reviews.values()]
98
+ .map((review) => ({
99
+ ...review,
100
+ evidence: review.evidence.toSorted((left, right) =>
101
+ left.path === right.path
102
+ ? (left.line ?? 0) - (right.line ?? 0)
103
+ : left.path.localeCompare(right.path)
104
+ ),
105
+ }))
106
+ .toSorted((left, right) => left.value.localeCompare(right.value));
107
+ };
108
+
109
+ const identifierReviews = (params: {
110
+ readonly plan: VocabularyRegradePlan;
111
+ readonly rootDir: string;
112
+ }): RegradePlanDerivation['reviews'] => {
113
+ const { scope } = params.plan;
114
+ const sourceForms = [
115
+ ...new Map(
116
+ deriveVocabularyFormProposals(params.plan).map((form) => [
117
+ form.from,
118
+ form,
119
+ ])
120
+ ).values(),
121
+ ];
122
+ const collection = {
123
+ ...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
124
+ extensions: scope?.extensions ?? ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
125
+ ...(scope?.include === undefined ? {} : { include: scope.include }),
126
+ };
127
+ const entries: RegradeReport['entries'][number][] = [];
128
+ for (const form of sourceForms) {
129
+ const report = runRegrade({
130
+ classes: [
131
+ createAstIdentifierRenameClass({
132
+ from: form.from,
133
+ id: `derived-plan-identifiers:${form.from}->${form.to ?? params.plan.to}`,
134
+ match: 'identifier-segment',
135
+ reviewAllMatches: true,
136
+ to: form.to ?? params.plan.to,
137
+ }),
138
+ ],
139
+ collection,
140
+ root: params.rootDir,
141
+ });
142
+ if (report.isErr()) {
143
+ return [
144
+ {
145
+ evidence: [],
146
+ provenance: 'derived',
147
+ reason: `identifier-review-scan-failed: ${report.error.message}`,
148
+ status: 'pending',
149
+ value: form.from,
150
+ },
151
+ ];
152
+ }
153
+ entries.push(...(report.value?.entries ?? []));
154
+ }
155
+ return entries
156
+ .flatMap((entry) =>
157
+ (entry.reviewDetails ?? []).flatMap((detail) =>
158
+ detail.symbol === undefined
159
+ ? []
160
+ : [
161
+ {
162
+ evidence: [
163
+ {
164
+ ...(detail.span?.column === undefined
165
+ ? {}
166
+ : { column: detail.span.column }),
167
+ ...(detail.span?.line === undefined
168
+ ? {}
169
+ : { line: detail.span.line }),
170
+ path: entry.path,
171
+ },
172
+ ],
173
+ provenance: 'derived' as const,
174
+ reason: detail.reason,
175
+ status: 'pending' as const,
176
+ value: detail.symbol,
177
+ },
178
+ ]
179
+ )
180
+ )
181
+ .toSorted((left, right) => left.value.localeCompare(right.value));
182
+ };
183
+
184
+ const mergeReviews = (
185
+ ...groups: readonly RegradePlanDerivation['reviews'][]
186
+ ): RegradePlanDerivation['reviews'] => {
187
+ const merged = new Map<string, RegradePlanDerivation['reviews'][number]>();
188
+ for (const review of groups.flat()) {
189
+ const key = `${review.value}\0${review.reason}`;
190
+ const current = merged.get(key);
191
+ merged.set(key, {
192
+ ...review,
193
+ evidence: [...(current?.evidence ?? []), ...review.evidence]
194
+ .filter(
195
+ (entry, index, evidence) =>
196
+ evidence.findIndex(
197
+ (candidate) =>
198
+ candidate.path === entry.path &&
199
+ candidate.line === entry.line &&
200
+ candidate.column === entry.column
201
+ ) === index
202
+ )
203
+ .toSorted((left, right) => left.path.localeCompare(right.path)),
204
+ });
205
+ }
206
+ return [...merged.values()].toSorted((left, right) =>
207
+ left.value === right.value
208
+ ? left.reason.localeCompare(right.reason)
209
+ : left.value.localeCompare(right.value)
210
+ );
211
+ };
212
+
213
+ const referenceClosure = (params: {
214
+ readonly candidates: ReturnType<typeof deriveFileRenameCandidates>;
215
+ readonly plan: VocabularyRegradePlan;
216
+ readonly rootDir: string;
217
+ }): RegradePlanDerivation['referenceClosure'] => {
218
+ if (params.candidates.length === 0) {
219
+ return { entries: [], moves: [] };
220
+ }
221
+ const result = runFileRenameRegrade({
222
+ excludeGeneratedArtifacts: true,
223
+ renames: params.candidates,
224
+ root: params.rootDir,
225
+ ...(params.plan.scope === undefined ? {} : { scope: params.plan.scope }),
226
+ });
227
+ if (result.isErr()) {
228
+ return { entries: [], issue: result.error.message, moves: [] };
229
+ }
230
+ return {
231
+ entries: result.value.report.entries
232
+ .filter(
233
+ (
234
+ entry
235
+ ): entry is typeof entry & {
236
+ readonly outcome: 'needs-review' | 'rewrite';
237
+ } => entry.outcome === 'needs-review' || entry.outcome === 'rewrite'
238
+ )
239
+ .map((entry) => ({
240
+ outcome: entry.outcome,
241
+ path: entry.path,
242
+ provenance: 'derived' as const,
243
+ ...(entry.reason === undefined ? {} : { reason: entry.reason }),
244
+ }))
245
+ .toSorted((left, right) => left.path.localeCompare(right.path)),
246
+ moves: result.value.evidence.map(
247
+ ({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
248
+ deferred,
249
+ from,
250
+ historical,
251
+ preserved,
252
+ provenance: 'derived' as const,
253
+ rewritten,
254
+ skipped,
255
+ to,
256
+ })
257
+ ),
258
+ };
259
+ };
260
+
261
+ export const deriveRegradePlanDerivation = (params: {
262
+ readonly plan: VocabularyRegradePlan;
263
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
264
+ readonly provenance: RegradePlanArtifact['provenance'];
265
+ readonly report: RegradeReport;
266
+ readonly rootDir: string;
267
+ }): RegradePlanDerivation => {
268
+ const candidates = deriveFileRenameCandidates({
269
+ plan: params.plan,
270
+ root: params.rootDir,
271
+ });
272
+ return {
273
+ fileRenames: candidates.map((candidate) => ({
274
+ ...candidate,
275
+ evidence: [...candidate.evidence],
276
+ provenance: 'derived' as const,
277
+ status: 'pending' as const,
278
+ })),
279
+ forms: deriveVocabularyFormProposals(params.plan).map((form) => ({
280
+ ...form,
281
+ provenance: provenanceForForm(form.source, params.provenance.fields),
282
+ })),
283
+ namespaces: namespaceCensus(params.report),
284
+ preserves: params.preserveInventory.map((entry) => ({
285
+ disposition: 'preserve-current-live-api' as const,
286
+ evidence: [...entry.evidence],
287
+ pattern: entry.pattern,
288
+ provenance: 'derived' as const,
289
+ ...(entry.reason === undefined ? {} : { reason: entry.reason }),
290
+ })),
291
+ referenceClosure: referenceClosure({
292
+ candidates,
293
+ plan: params.plan,
294
+ rootDir: params.rootDir,
295
+ }),
296
+ reviews: mergeReviews(
297
+ observedReviews(params.report),
298
+ identifierReviews({ plan: params.plan, rootDir: params.rootDir })
299
+ ),
300
+ };
301
+ };
@@ -0,0 +1,259 @@
1
+ import { InternalError, Result, ValidationError } from '@ontrails/core';
2
+ import type { Result as TrailsResult } from '@ontrails/core';
3
+ import {
4
+ regradeReceiptContentHash,
5
+ regradeReceiptPlanContentHash,
6
+ regradeReceiptPlanSchema,
7
+ } from '@ontrails/regrade';
8
+ import type {
9
+ PreparedRegradeRunIdentity,
10
+ RegradeClass,
11
+ } from '@ontrails/regrade';
12
+ import { listGovernedVocabularyTransitions } from '@ontrails/warden';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
15
+ import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
16
+
17
+ import { trailsPackageVersion } from '../versions.js';
18
+ import { canonicalJsonStringify } from './plan-artifact.js';
19
+ import type { RegradePlanArtifact } from './plan-artifact.js';
20
+
21
+ const ignoredLockDirectories = new Set([
22
+ '.agents',
23
+ '.git',
24
+ '.turbo',
25
+ 'dist',
26
+ 'node_modules',
27
+ ]);
28
+
29
+ const normalizePath = (path: string): string => path.split(sep).join('/');
30
+
31
+ const compareCodeUnits = (left: string, right: string): number => {
32
+ if (left < right) {
33
+ return -1;
34
+ }
35
+ if (left > right) {
36
+ return 1;
37
+ }
38
+ return 0;
39
+ };
40
+
41
+ const loaderForModuleExtension = (
42
+ extension: string
43
+ ): 'js' | 'jsx' | 'ts' | 'tsx' => {
44
+ if (extension === '.tsx') {
45
+ return 'tsx';
46
+ }
47
+ if (extension === '.jsx') {
48
+ return 'jsx';
49
+ }
50
+ return ['.cts', '.mts', '.ts'].includes(extension) ? 'ts' : 'js';
51
+ };
52
+
53
+ const collectLockPaths = (rootDir: string): readonly string[] => {
54
+ const paths: string[] = [];
55
+ const visit = (directory: string): void => {
56
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
57
+ if (entry.isDirectory()) {
58
+ if (!ignoredLockDirectories.has(entry.name)) {
59
+ visit(join(directory, entry.name));
60
+ }
61
+ continue;
62
+ }
63
+ if (entry.isFile() && entry.name === 'trails.lock') {
64
+ paths.push(join(directory, entry.name));
65
+ }
66
+ }
67
+ };
68
+ visit(rootDir);
69
+ return paths.toSorted(compareCodeUnits);
70
+ };
71
+
72
+ const lockStateHash = (
73
+ rootDir: string
74
+ ): TrailsResult<string, InternalError> => {
75
+ try {
76
+ const locks = collectLockPaths(rootDir).map((absolutePath) => ({
77
+ contentHash: createHash('sha256')
78
+ .update(readFileSync(absolutePath))
79
+ .digest('hex'),
80
+ path: normalizePath(relative(rootDir, absolutePath)),
81
+ }));
82
+ return Result.ok(regradeReceiptContentHash(locks));
83
+ } catch (error) {
84
+ return Result.err(
85
+ new InternalError('Failed to derive Regrade lock identity.', {
86
+ ...(error instanceof Error ? { cause: error } : {}),
87
+ context: { rootDir },
88
+ })
89
+ );
90
+ }
91
+ };
92
+
93
+ const projectRuleStateHash = (
94
+ rootDir: string
95
+ ): TrailsResult<string, InternalError> => {
96
+ try {
97
+ const root = realpathSync(rootDir);
98
+ const entryPaths: string[] = [];
99
+ const rulesFile = join(root, '.trails/rules.ts');
100
+ const rulesDirectory = join(root, '.trails/rules');
101
+ if (existsSync(rulesFile)) {
102
+ entryPaths.push(rulesFile);
103
+ }
104
+ if (existsSync(rulesDirectory)) {
105
+ for (const entry of readdirSync(rulesDirectory, {
106
+ withFileTypes: true,
107
+ })) {
108
+ if (
109
+ entry.isFile() &&
110
+ entry.name.endsWith('.ts') &&
111
+ !entry.name.endsWith('.test.ts') &&
112
+ !entry.name.startsWith('_')
113
+ ) {
114
+ entryPaths.push(join(rulesDirectory, entry.name));
115
+ }
116
+ }
117
+ }
118
+
119
+ const paths = new Set<string>();
120
+ const visitModule = (absolutePath: string): void => {
121
+ if (paths.has(absolutePath)) {
122
+ return;
123
+ }
124
+ paths.add(absolutePath);
125
+ const extension = extname(absolutePath);
126
+ if (
127
+ ![
128
+ '.cjs',
129
+ '.cts',
130
+ '.js',
131
+ '.jsx',
132
+ '.mjs',
133
+ '.mts',
134
+ '.ts',
135
+ '.tsx',
136
+ ].includes(extension)
137
+ ) {
138
+ return;
139
+ }
140
+ const imports = new Bun.Transpiler({
141
+ loader: loaderForModuleExtension(extension),
142
+ }).scanImports(readFileSync(absolutePath, 'utf8'));
143
+ for (const imported of imports) {
144
+ const resolved = Bun.resolveSync(imported.path, dirname(absolutePath));
145
+ const relativePath = relative(root, resolved);
146
+ if (
147
+ isAbsolute(resolved) &&
148
+ relativePath !== '..' &&
149
+ !relativePath.startsWith(`..${sep}`) &&
150
+ !relativePath.split(sep).includes('node_modules')
151
+ ) {
152
+ visitModule(resolved);
153
+ }
154
+ }
155
+ };
156
+ for (const entryPath of entryPaths) {
157
+ visitModule(entryPath);
158
+ }
159
+ return Result.ok(
160
+ regradeReceiptContentHash(
161
+ [...paths].toSorted(compareCodeUnits).map((absolutePath) => ({
162
+ contentHash: createHash('sha256')
163
+ .update(readFileSync(absolutePath))
164
+ .digest('hex'),
165
+ path: normalizePath(relative(rootDir, absolutePath)),
166
+ }))
167
+ )
168
+ );
169
+ } catch (error) {
170
+ return Result.err(
171
+ new InternalError('Failed to derive Regrade policy identity.', {
172
+ ...(error instanceof Error ? { cause: error } : {}),
173
+ context: { rootDir },
174
+ })
175
+ );
176
+ }
177
+ };
178
+
179
+ /**
180
+ * Build the command-local reuse identity from receipt-aligned facts.
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * const identity = preparedRegradeRunIdentity({ artifact, rootDir: '.' });
185
+ * if (identity.isOk()) console.log(identity.value.planContentHash);
186
+ * ```
187
+ */
188
+ export const preparedRegradeRunIdentity = (params: {
189
+ readonly artifact: RegradePlanArtifact;
190
+ readonly classIds?: readonly string[] | undefined;
191
+ readonly classes?: readonly RegradeClass[] | undefined;
192
+ readonly includeEntries: 'actionable' | 'all';
193
+ readonly rootDir: string;
194
+ }): TrailsResult<PreparedRegradeRunIdentity, Error> => {
195
+ const parsedPlan = regradeReceiptPlanSchema.safeParse(params.artifact.plan);
196
+ if (!parsedPlan.success) {
197
+ return Result.err(
198
+ new InternalError('Failed to derive Regrade plan identity.', {
199
+ context: { issues: parsedPlan.error.issues },
200
+ })
201
+ );
202
+ }
203
+ const lockHash = lockStateHash(params.rootDir);
204
+ if (lockHash.isErr()) {
205
+ return lockHash;
206
+ }
207
+ const usesProjectRules = parsedPlan.data.kind === 'class';
208
+ const policyStateHash = usesProjectRules
209
+ ? projectRuleStateHash(params.rootDir)
210
+ : Result.ok(null);
211
+ if (policyStateHash.isErr()) {
212
+ return policyStateHash;
213
+ }
214
+ return Result.ok({
215
+ lockStateHash: lockHash.value,
216
+ planContentHash: regradeReceiptPlanContentHash({
217
+ plan: parsedPlan.data,
218
+ provenance: params.artifact.provenance,
219
+ }),
220
+ policyHash: regradeReceiptContentHash({
221
+ classIds: [...(params.classIds ?? [])].toSorted(),
222
+ classes: (params.classes ?? []).map((regradeClass) => ({
223
+ describe: regradeClass.describe,
224
+ id: regradeClass.id,
225
+ scanTargets: regradeClass.scanTargets ?? null,
226
+ })),
227
+ ...(policyStateHash.value === null
228
+ ? {}
229
+ : { projectRulesHash: policyStateHash.value }),
230
+ transitions: listGovernedVocabularyTransitions(),
231
+ }),
232
+ scopeHash: regradeReceiptContentHash({
233
+ includeEntries: params.includeEntries,
234
+ scope: params.artifact.plan.scope ?? null,
235
+ }),
236
+ toolVersion: trailsPackageVersion,
237
+ });
238
+ };
239
+
240
+ /** Reject an active plan that changed after its prepared evaluation. */
241
+ export const validatePreparedRegradePlanArtifact = (params: {
242
+ readonly current: RegradePlanArtifact;
243
+ readonly currentPath: string;
244
+ readonly expected: RegradePlanArtifact;
245
+ readonly expectedPath: string;
246
+ }): TrailsResult<void, ValidationError> => {
247
+ if (
248
+ params.currentPath !== params.expectedPath ||
249
+ canonicalJsonStringify(params.current) !==
250
+ canonicalJsonStringify(params.expected)
251
+ ) {
252
+ return Result.err(
253
+ new ValidationError('Regrade plan changed during apply preflight.', {
254
+ context: { plan: params.expected.path },
255
+ })
256
+ );
257
+ }
258
+ return Result.ok();
259
+ };