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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +1213 -4
  2. package/README.md +29 -0
  3. package/package.json +35 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +358 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +92 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/audit.ts +571 -0
  15. package/src/regrade/config.ts +152 -0
  16. package/src/regrade/history.ts +636 -0
  17. package/src/regrade/lifecycle.ts +76 -0
  18. package/src/regrade/live-api-preserve.ts +123 -0
  19. package/src/regrade/plan-artifact.ts +461 -0
  20. package/src/regrade/plan-derivation.ts +301 -0
  21. package/src/regrade/prepared-run.ts +259 -0
  22. package/src/regrade/receipt-history.ts +446 -0
  23. package/src/regrade/source-transaction.ts +185 -0
  24. package/src/release/bindings.ts +58 -0
  25. package/src/release/check.ts +1146 -0
  26. package/src/release/cli-bundle.ts +575 -0
  27. package/src/release/config.ts +73 -0
  28. package/src/release/contract-facts.ts +425 -0
  29. package/src/release/homebrew.ts +219 -0
  30. package/src/release/index.ts +180 -0
  31. package/src/release/lock-roundtrip-smoke.ts +230 -0
  32. package/src/release/native-bun-publish.ts +964 -0
  33. package/src/release/native-bun-registry.ts +830 -0
  34. package/src/release/notes-cli.ts +171 -0
  35. package/src/release/notes.ts +390 -0
  36. package/src/release/pack-coherence.ts +455 -0
  37. package/src/release/package-route-facts.ts +146 -0
  38. package/src/release/packed-artifacts-smoke.ts +236 -0
  39. package/src/release/policy.ts +1700 -0
  40. package/src/release/semver.ts +104 -0
  41. package/src/release/smoke.ts +56 -0
  42. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  43. package/src/retired-topo-command.ts +36 -0
  44. package/src/run-adapter-check.ts +76 -0
  45. package/src/run-collision.ts +126 -0
  46. package/src/run-completions-install.ts +179 -0
  47. package/src/run-example.ts +149 -0
  48. package/src/run-examples.ts +148 -0
  49. package/src/run-quiet.ts +75 -0
  50. package/src/run-regrade-progress.ts +47 -0
  51. package/src/run-release-check.ts +74 -0
  52. package/src/run-schema.ts +74 -0
  53. package/src/run-trace.ts +273 -0
  54. package/src/run-warden.ts +39 -0
  55. package/src/run-watch.ts +432 -0
  56. package/src/run-wayfind-outline.ts +170 -0
  57. package/src/scaffold-version-sync.ts +183 -0
  58. package/src/scaffold-versions.generated.ts +12 -0
  59. package/src/trails/adapter-check.ts +244 -0
  60. package/src/trails/add-surface.ts +99 -45
  61. package/src/trails/add-trail.ts +84 -37
  62. package/src/trails/add-verify.ts +100 -30
  63. package/src/trails/compile.ts +58 -0
  64. package/src/trails/completions-complete.ts +165 -0
  65. package/src/trails/completions.ts +47 -0
  66. package/src/trails/create-adapter.ts +785 -0
  67. package/src/trails/create-scaffold.ts +402 -106
  68. package/src/trails/create-versions.ts +62 -0
  69. package/src/trails/create.ts +186 -72
  70. package/src/trails/deprecate.ts +59 -0
  71. package/src/trails/dev-clean.ts +82 -0
  72. package/src/trails/dev-reset.ts +50 -0
  73. package/src/trails/dev-stats.ts +72 -0
  74. package/src/trails/dev-support.ts +360 -0
  75. package/src/trails/doctor.ts +77 -0
  76. package/src/trails/draft-promote.ts +949 -0
  77. package/src/trails/guide.ts +70 -68
  78. package/src/trails/load-app.ts +1123 -15
  79. package/src/trails/operator-context.ts +66 -0
  80. package/src/trails/project.ts +17 -3
  81. package/src/trails/regrade.ts +4497 -0
  82. package/src/trails/release-check.ts +113 -0
  83. package/src/trails/release-smoke.ts +49 -0
  84. package/src/trails/revise.ts +53 -0
  85. package/src/trails/root-dir.ts +21 -0
  86. package/src/trails/run-example.ts +475 -0
  87. package/src/trails/run-examples.ts +129 -0
  88. package/src/trails/run.ts +434 -0
  89. package/src/trails/scaffold-json.ts +58 -0
  90. package/src/trails/survey.ts +877 -214
  91. package/src/trails/topo-activation.ts +14 -0
  92. package/src/trails/topo-constants.ts +2 -0
  93. package/src/trails/topo-history.ts +47 -0
  94. package/src/trails/topo-output-schemas.ts +259 -0
  95. package/src/trails/topo-pin.ts +38 -0
  96. package/src/trails/topo-read-support.ts +368 -0
  97. package/src/trails/topo-reports.ts +809 -0
  98. package/src/trails/topo-store-support.ts +323 -0
  99. package/src/trails/topo-support.ts +228 -0
  100. package/src/trails/topo-unpin.ts +61 -0
  101. package/src/trails/topo.ts +92 -0
  102. package/src/trails/validate.ts +27 -0
  103. package/src/trails/version-lifecycle-support.ts +936 -0
  104. package/src/trails/warden-guide.ts +134 -0
  105. package/src/trails/warden.ts +198 -58
  106. package/src/trails/wayfind-outline.ts +876 -0
  107. package/src/trails/wayfind.ts +1053 -0
  108. package/src/versions.ts +31 -0
  109. package/.turbo/turbo-build.log +0 -1
  110. package/.turbo/turbo-lint.log +0 -3
  111. package/.turbo/turbo-typecheck.log +0 -1
  112. package/__tests__/examples.test.ts +0 -6
  113. package/dist/bin/trails.d.ts +0 -3
  114. package/dist/bin/trails.d.ts.map +0 -1
  115. package/dist/bin/trails.js +0 -4
  116. package/dist/bin/trails.js.map +0 -1
  117. package/dist/src/app.d.ts +0 -2
  118. package/dist/src/app.d.ts.map +0 -1
  119. package/dist/src/app.js +0 -11
  120. package/dist/src/app.js.map +0 -1
  121. package/dist/src/clack.d.ts +0 -9
  122. package/dist/src/clack.d.ts.map +0 -1
  123. package/dist/src/clack.js +0 -84
  124. package/dist/src/clack.js.map +0 -1
  125. package/dist/src/cli.d.ts +0 -2
  126. package/dist/src/cli.d.ts.map +0 -1
  127. package/dist/src/cli.js +0 -13
  128. package/dist/src/cli.js.map +0 -1
  129. package/dist/src/trails/add-surface.d.ts +0 -13
  130. package/dist/src/trails/add-surface.d.ts.map +0 -1
  131. package/dist/src/trails/add-surface.js +0 -88
  132. package/dist/src/trails/add-surface.js.map +0 -1
  133. package/dist/src/trails/add-trail.d.ts +0 -10
  134. package/dist/src/trails/add-trail.d.ts.map +0 -1
  135. package/dist/src/trails/add-trail.js +0 -77
  136. package/dist/src/trails/add-trail.js.map +0 -1
  137. package/dist/src/trails/add-verify.d.ts +0 -10
  138. package/dist/src/trails/add-verify.d.ts.map +0 -1
  139. package/dist/src/trails/add-verify.js +0 -67
  140. package/dist/src/trails/add-verify.js.map +0 -1
  141. package/dist/src/trails/create-scaffold.d.ts +0 -15
  142. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  143. package/dist/src/trails/create-scaffold.js +0 -288
  144. package/dist/src/trails/create-scaffold.js.map +0 -1
  145. package/dist/src/trails/create.d.ts +0 -22
  146. package/dist/src/trails/create.d.ts.map +0 -1
  147. package/dist/src/trails/create.js +0 -121
  148. package/dist/src/trails/create.js.map +0 -1
  149. package/dist/src/trails/guide.d.ts +0 -11
  150. package/dist/src/trails/guide.d.ts.map +0 -1
  151. package/dist/src/trails/guide.js +0 -80
  152. package/dist/src/trails/guide.js.map +0 -1
  153. package/dist/src/trails/load-app.d.ts +0 -4
  154. package/dist/src/trails/load-app.d.ts.map +0 -1
  155. package/dist/src/trails/load-app.js +0 -24
  156. package/dist/src/trails/load-app.js.map +0 -1
  157. package/dist/src/trails/project.d.ts +0 -8
  158. package/dist/src/trails/project.d.ts.map +0 -1
  159. package/dist/src/trails/project.js +0 -43
  160. package/dist/src/trails/project.js.map +0 -1
  161. package/dist/src/trails/survey.d.ts +0 -31
  162. package/dist/src/trails/survey.d.ts.map +0 -1
  163. package/dist/src/trails/survey.js +0 -221
  164. package/dist/src/trails/survey.js.map +0 -1
  165. package/dist/src/trails/warden.d.ts +0 -19
  166. package/dist/src/trails/warden.d.ts.map +0 -1
  167. package/dist/src/trails/warden.js +0 -88
  168. package/dist/src/trails/warden.js.map +0 -1
  169. package/dist/tsconfig.tsbuildinfo +0 -1
  170. package/src/__tests__/create.test.ts +0 -349
  171. package/src/__tests__/guide.test.ts +0 -91
  172. package/src/__tests__/load-app.test.ts +0 -15
  173. package/src/__tests__/survey.test.ts +0 -159
  174. package/src/__tests__/warden.test.ts +0 -74
  175. package/tsconfig.json +0 -9
@@ -0,0 +1,123 @@
1
+ import { Result, ValidationError, escapeRegExp } from '@ontrails/core';
2
+ import type { Result as TrailsResult } from '@ontrails/core';
3
+ import type {
4
+ VocabularyPreserveInventoryEntry,
5
+ VocabularyRegradePlan,
6
+ } from '@ontrails/regrade';
7
+ import { deriveVocabularyFormProposals } from '@ontrails/regrade';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { z } from 'zod';
11
+
12
+ const lockShapeSchema = z
13
+ .object({
14
+ topoGraph: z
15
+ .object({
16
+ entries: z.array(z.object({ id: z.string() }).passthrough()),
17
+ library: z
18
+ .object({
19
+ exports: z.array(
20
+ z.object({ exportName: z.string() }).passthrough()
21
+ ),
22
+ })
23
+ .passthrough()
24
+ .optional(),
25
+ })
26
+ .passthrough(),
27
+ })
28
+ .passthrough();
29
+
30
+ type LockShape = z.output<typeof lockShapeSchema>;
31
+
32
+ const liveApiValues = (
33
+ lock: LockShape
34
+ ): readonly {
35
+ readonly evidence: string;
36
+ readonly value: string;
37
+ }[] => {
38
+ const values = new Map<string, string>();
39
+ for (const entry of lock.topoGraph?.entries ?? []) {
40
+ if (typeof entry.id === 'string') {
41
+ values.set(entry.id, `topo.entry:${entry.id}`);
42
+ }
43
+ }
44
+ for (const item of lock.topoGraph?.library?.exports ?? []) {
45
+ if (typeof item.exportName === 'string') {
46
+ values.set(item.exportName, `topo.library:${item.exportName}`);
47
+ }
48
+ }
49
+ return [...values.entries()]
50
+ .map(([value, evidence]) => ({ evidence, value }))
51
+ .toSorted((left, right) => left.value.localeCompare(right.value));
52
+ };
53
+
54
+ const identifierSegments = (value: string): readonly string[] =>
55
+ value
56
+ .split(/[^A-Za-z0-9]+/u)
57
+ .flatMap(
58
+ (part) => part.match(/[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+/gu) ?? []
59
+ );
60
+
61
+ const liveApiValueContainsSource = (value: string, source: string): boolean => {
62
+ if (/^[A-Za-z][A-Za-z0-9]*$/u.test(source)) {
63
+ const normalized = source.toLowerCase();
64
+ return identifierSegments(value).some(
65
+ (segment) => segment.toLowerCase() === normalized
66
+ );
67
+ }
68
+ return new RegExp(
69
+ `(?<![A-Za-z0-9_$])${escapeRegExp(source)}(?![A-Za-z0-9_$])`,
70
+ 'iu'
71
+ ).test(value);
72
+ };
73
+
74
+ export const deriveLiveApiPreserveInventory = async (
75
+ plan: VocabularyRegradePlan,
76
+ rootDir = process.cwd()
77
+ ): Promise<
78
+ TrailsResult<readonly VocabularyPreserveInventoryEntry[], ValidationError>
79
+ > => {
80
+ const lockPath = join(rootDir, 'trails.lock');
81
+ if (!existsSync(lockPath)) {
82
+ return Result.ok([]);
83
+ }
84
+ let input: unknown;
85
+ try {
86
+ input = JSON.parse(readFileSync(lockPath, 'utf8'));
87
+ } catch (error) {
88
+ return Result.err(
89
+ new ValidationError(
90
+ 'Unable to parse trails.lock for live API preserves.',
91
+ {
92
+ ...(error instanceof Error ? { cause: error } : {}),
93
+ context: { path: lockPath },
94
+ }
95
+ )
96
+ );
97
+ }
98
+ const parsed = lockShapeSchema.safeParse(input);
99
+ if (!parsed.success) {
100
+ return Result.err(
101
+ new ValidationError(
102
+ 'trails.lock does not contain a compatible TopoGraph for live API preserves.',
103
+ { context: { issues: parsed.error.issues, path: lockPath } }
104
+ )
105
+ );
106
+ }
107
+ const sourceForms = deriveVocabularyFormProposals(plan).map(
108
+ (proposal) => proposal.from
109
+ );
110
+ return Result.ok(
111
+ liveApiValues(parsed.data)
112
+ .filter(({ value }) =>
113
+ sourceForms.some((source) => liveApiValueContainsSource(value, source))
114
+ )
115
+ .map(({ evidence, value }) => ({
116
+ disposition: 'preserve-current-live-api' as const,
117
+ evidence: [evidence],
118
+ pattern: escapeRegExp(value),
119
+ reason: 'current-live-topo-api',
120
+ source: 'derived-live-api' as const,
121
+ }))
122
+ );
123
+ };
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Saved Regrade plan artifact shape plus the slug, path, and hash helpers
3
+ * shared by the `regrade` trails and the consolidated Regrade history module.
4
+ */
5
+
6
+ import { isPlainObject } from '@ontrails/core';
7
+ import { vocabularyRegradePlanSchema } from '@ontrails/regrade';
8
+ import type {
9
+ RegradeReport,
10
+ RegradeReportEntry,
11
+ VocabularyRegradePlan,
12
+ } from '@ontrails/regrade';
13
+ import { getGovernedVocabularyTransition } from '@ontrails/warden';
14
+ import { createHash } from 'node:crypto';
15
+ import { join, normalize, relative } from 'node:path';
16
+ import { z } from 'zod';
17
+
18
+ export const REGRADE_PLAN_SCHEMA_VERSION = 1;
19
+
20
+ const regradePlanProvenanceValueSchema = z.enum(['authored', 'derived']);
21
+
22
+ const regradePlanDerivationSchema = z
23
+ .object({
24
+ fileRenames: z.array(
25
+ z
26
+ .object({
27
+ evidence: z.array(z.string()),
28
+ from: z.string(),
29
+ provenance: z.literal('derived'),
30
+ status: z.literal('pending'),
31
+ to: z.string(),
32
+ })
33
+ .strict()
34
+ ),
35
+ forms: z.array(
36
+ z
37
+ .object({
38
+ from: z.string(),
39
+ kind: z.enum(['review', 'safe-rewrite']),
40
+ provenance: regradePlanProvenanceValueSchema,
41
+ reason: z.string(),
42
+ source: z.enum([
43
+ 'default-morphology',
44
+ 'plan-defer',
45
+ 'plan-override',
46
+ 'seed',
47
+ ]),
48
+ to: z.string().optional(),
49
+ })
50
+ .strict()
51
+ ),
52
+ namespaces: z.array(
53
+ z
54
+ .object({
55
+ inScope: z.number().int().nonnegative(),
56
+ namespace: z.string(),
57
+ policyClassified: z.number().int().nonnegative(),
58
+ provenance: z.literal('derived'),
59
+ })
60
+ .strict()
61
+ ),
62
+ preserves: z.array(
63
+ z
64
+ .object({
65
+ disposition: z.literal('preserve-current-live-api'),
66
+ evidence: z.array(z.string()),
67
+ pattern: z.string(),
68
+ provenance: z.literal('derived'),
69
+ reason: z.string().optional(),
70
+ })
71
+ .strict()
72
+ ),
73
+ referenceClosure: z
74
+ .object({
75
+ entries: z.array(
76
+ z
77
+ .object({
78
+ outcome: z.enum(['needs-review', 'rewrite']),
79
+ path: z.string(),
80
+ provenance: z.literal('derived'),
81
+ reason: z.string().optional(),
82
+ })
83
+ .strict()
84
+ ),
85
+ issue: z.string().optional(),
86
+ moves: z.array(
87
+ z
88
+ .object({
89
+ deferred: z.number().int().nonnegative(),
90
+ from: z.string(),
91
+ historical: z.number().int().nonnegative(),
92
+ preserved: z.number().int().nonnegative(),
93
+ provenance: z.literal('derived'),
94
+ rewritten: z.number().int().nonnegative(),
95
+ skipped: z.number().int().nonnegative(),
96
+ to: z.string(),
97
+ })
98
+ .strict()
99
+ ),
100
+ })
101
+ .strict(),
102
+ reviews: z.array(
103
+ z
104
+ .object({
105
+ evidence: z.array(
106
+ z
107
+ .object({
108
+ column: z.number().int().positive().optional(),
109
+ line: z.number().int().positive().optional(),
110
+ path: z.string(),
111
+ })
112
+ .strict()
113
+ ),
114
+ provenance: z.literal('derived'),
115
+ reason: z.string(),
116
+ status: z.literal('pending'),
117
+ value: z.string(),
118
+ })
119
+ .strict()
120
+ ),
121
+ })
122
+ .strict();
123
+
124
+ const classRegradePlanScopeSchema = z
125
+ .object({
126
+ exclude: z
127
+ .array(z.string())
128
+ .optional()
129
+ .describe('Root-relative path globs excluded from the class run'),
130
+ extensions: z
131
+ .array(z.string())
132
+ .optional()
133
+ .describe('Source file extensions scanned by the class run'),
134
+ include: z
135
+ .array(z.string())
136
+ .optional()
137
+ .describe('Root-relative path globs collected during the class run'),
138
+ })
139
+ .strict();
140
+
141
+ /**
142
+ * A saved class-mode Regrade plan: which classes run, over what scope, and
143
+ * why. The parallel payload to {@link vocabularyRegradePlanSchema} — the
144
+ * `kind` discriminant keeps existing vocabulary plan artifacts
145
+ * byte-compatible.
146
+ */
147
+ const classRegradePlanSchema = z.object({
148
+ classIds: z
149
+ .array(z.string().min(1))
150
+ .min(1)
151
+ .describe('Regrade class ids this plan runs'),
152
+ id: z.string().min(1).describe('Stable Regrade plan identifier'),
153
+ intent: z
154
+ .string()
155
+ .optional()
156
+ .describe('Human-authored migration intent for the class run'),
157
+ kind: z.literal('class').describe('Regrade plan kind'),
158
+ name: z
159
+ .string()
160
+ .min(1)
161
+ .optional()
162
+ .describe(
163
+ 'Authored transition name; keys the saved plan and consolidated history filenames'
164
+ ),
165
+ scope: classRegradePlanScopeSchema
166
+ .optional()
167
+ .describe('Collection scope for the class run'),
168
+ });
169
+
170
+ export type ClassRegradePlan = z.output<typeof classRegradePlanSchema>;
171
+
172
+ const regradePlanBodySchema = z.discriminatedUnion('kind', [
173
+ vocabularyRegradePlanSchema,
174
+ classRegradePlanSchema,
175
+ ]);
176
+
177
+ export type RegradePlanBody = VocabularyRegradePlan | ClassRegradePlan;
178
+
179
+ const regradeExpansionCandidateSchema = z.union([
180
+ z.object({
181
+ evidence: z
182
+ .array(
183
+ z.object({
184
+ column: z.number().optional(),
185
+ detail: z.string().optional(),
186
+ line: z.number().optional(),
187
+ path: z.string(),
188
+ })
189
+ )
190
+ .default([]),
191
+ kind: z.enum(['file-rename', 'form', 'namespace', 'preserve']),
192
+ provenance: regradePlanProvenanceValueSchema.default('derived'),
193
+ reason: z.string().optional(),
194
+ status: z.enum(['pending', 'rejected']).default('pending'),
195
+ suggestedClassification: z.string(),
196
+ value: z.string(),
197
+ }),
198
+ z
199
+ .object({
200
+ detail: z.string().optional(),
201
+ path: z.string(),
202
+ status: z.enum(['pending', 'rejected']).default('pending'),
203
+ })
204
+ .transform((candidate) => ({
205
+ evidence: [
206
+ {
207
+ ...(candidate.detail === undefined
208
+ ? {}
209
+ : { detail: candidate.detail }),
210
+ path: candidate.path,
211
+ },
212
+ ],
213
+ kind: 'file-rename' as const,
214
+ provenance: 'derived' as const,
215
+ ...(candidate.detail === undefined ? {} : { reason: candidate.detail }),
216
+ status: candidate.status,
217
+ suggestedClassification: 'legacy-path-candidate',
218
+ value: candidate.path,
219
+ })),
220
+ ]);
221
+
222
+ export const regradePlanArtifactSchema = z
223
+ .object({
224
+ derivation: regradePlanDerivationSchema.optional(),
225
+ expansion: z
226
+ .object({
227
+ candidates: z.array(regradeExpansionCandidateSchema).default([]),
228
+ })
229
+ .optional(),
230
+ kind: z.literal('regrade-plan'),
231
+ path: z.string(),
232
+ plan: regradePlanBodySchema,
233
+ provenance: z.object({
234
+ fields: z.record(z.string(), regradePlanProvenanceValueSchema),
235
+ }),
236
+ schemaVersion: z.literal(REGRADE_PLAN_SCHEMA_VERSION),
237
+ sourceHash: z.string(),
238
+ transitionId: z
239
+ .string()
240
+ .min(1)
241
+ .optional()
242
+ .describe(
243
+ 'Stable transition identity this plan re-runs; preserves the consolidated history spine'
244
+ ),
245
+ })
246
+ .strict();
247
+
248
+ export interface RegradePlanExpansion {
249
+ readonly candidates: readonly {
250
+ readonly evidence: readonly {
251
+ readonly column?: number | undefined;
252
+ readonly detail?: string | undefined;
253
+ readonly line?: number | undefined;
254
+ readonly path: string;
255
+ }[];
256
+ readonly kind: 'file-rename' | 'form' | 'namespace' | 'preserve';
257
+ readonly provenance: 'authored' | 'derived';
258
+ readonly reason?: string | undefined;
259
+ readonly status: 'pending' | 'rejected';
260
+ readonly suggestedClassification: string;
261
+ readonly value: string;
262
+ }[];
263
+ }
264
+
265
+ export interface RegradePlanArtifact {
266
+ readonly derivation?: RegradePlanDerivation | undefined;
267
+ readonly expansion?: RegradePlanExpansion | undefined;
268
+ readonly kind: 'regrade-plan';
269
+ readonly path: string;
270
+ readonly plan: RegradePlanBody;
271
+ readonly provenance: {
272
+ readonly fields: Readonly<Record<string, 'authored' | 'derived'>>;
273
+ };
274
+ readonly schemaVersion: typeof REGRADE_PLAN_SCHEMA_VERSION;
275
+ readonly sourceHash: string;
276
+ readonly transitionId?: string | undefined;
277
+ }
278
+
279
+ export type RegradePlanDerivation = z.output<
280
+ typeof regradePlanDerivationSchema
281
+ >;
282
+
283
+ /** A plan artifact narrowed to a vocabulary plan body. */
284
+ export type VocabularyRegradePlanArtifact = RegradePlanArtifact & {
285
+ readonly plan: VocabularyRegradePlan;
286
+ };
287
+
288
+ const regradeSlugText = (text: string): string =>
289
+ text
290
+ .toLowerCase()
291
+ .replaceAll(/[^a-z0-9]+/g, '-')
292
+ .replaceAll(/^-|-$/g, '');
293
+
294
+ const regradePlanSlug = (plan: Pick<VocabularyRegradePlan, 'from' | 'to'>) =>
295
+ regradeSlugText(`${plan.from}-to-${plan.to}`);
296
+
297
+ export const regradePlanSlugForBody = (plan: RegradePlanBody): string =>
298
+ plan.kind === 'class'
299
+ ? regradeSlugText(plan.name ?? plan.classIds.join('-'))
300
+ : (() => {
301
+ const transition =
302
+ plan.id === undefined
303
+ ? undefined
304
+ : getGovernedVocabularyTransition(plan.id);
305
+ return transition?.target.kind === 'classified'
306
+ ? regradeSlugText(transition.id)
307
+ : regradePlanSlug(plan);
308
+ })();
309
+
310
+ const normalizeRelativePath = (path: string): string =>
311
+ normalize(path).replaceAll('\\', '/');
312
+
313
+ export const rootRelativePath = (
314
+ rootDir: string,
315
+ absolutePath: string
316
+ ): string => normalizeRelativePath(relative(rootDir, absolutePath));
317
+
318
+ export const regradePlanDirectory = (rootDir: string): string =>
319
+ join(rootDir, '.trails', 'regrade');
320
+
321
+ export const regradePlanPathForPlan = (
322
+ rootDir: string,
323
+ plan: RegradePlanBody
324
+ ): string =>
325
+ join(regradePlanDirectory(rootDir), `${regradePlanSlugForBody(plan)}.json`);
326
+
327
+ const sourceHashEntryFacts = (
328
+ entries: readonly RegradeReportEntry[]
329
+ ): readonly Pick<
330
+ RegradeReportEntry,
331
+ 'classId' | 'notes' | 'outcome' | 'path' | 'reason' | 'reviewDetails'
332
+ >[] =>
333
+ entries
334
+ .filter(
335
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
336
+ )
337
+ .map(({ classId, notes, outcome, path, reason, reviewDetails }) => ({
338
+ ...(classId === undefined ? {} : { classId }),
339
+ ...(notes === undefined ? {} : { notes }),
340
+ outcome,
341
+ path,
342
+ ...(reason === undefined ? {} : { reason }),
343
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
344
+ }));
345
+
346
+ const canonicalizeJsonValue = (value: unknown): unknown => {
347
+ if (Array.isArray(value)) {
348
+ return value.map((entry) => canonicalizeJsonValue(entry));
349
+ }
350
+ if (isPlainObject(value)) {
351
+ return Object.fromEntries(
352
+ Object.keys(value)
353
+ .toSorted()
354
+ .map((key) => [key, canonicalizeJsonValue(value[key])])
355
+ );
356
+ }
357
+ return value;
358
+ };
359
+
360
+ /**
361
+ * JSON.stringify with recursively sorted object keys so structurally equal
362
+ * values serialize identically regardless of key insertion order. Arrays keep
363
+ * their authored order.
364
+ */
365
+ export const canonicalJsonStringify = (value: unknown): string =>
366
+ JSON.stringify(canonicalizeJsonValue(value));
367
+
368
+ export const isGeneratedRegradeArtifactPath = (path: string): boolean =>
369
+ /(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
370
+
371
+ const sourceHashLedgerFacts = (
372
+ report: RegradeReport,
373
+ policyMode: 'current' | 'legacy'
374
+ ): unknown => {
375
+ const ledger = report.run?.ledger;
376
+ if (ledger === undefined) {
377
+ return undefined;
378
+ }
379
+ // The active plan is written after its source hash and must not stale itself.
380
+ const occurrences = ledger.occurrences.filter(
381
+ (occurrence) =>
382
+ occurrence.scopeTier !== 'policy-classified' ||
383
+ (policyMode === 'current' &&
384
+ !isGeneratedRegradeArtifactPath(occurrence.path))
385
+ );
386
+ const forms = new Set(occurrences.map((occurrence) => occurrence.form));
387
+ return {
388
+ cycle: ledger.cycle,
389
+ forms: Object.fromEntries(
390
+ Object.entries(ledger.forms).filter(([form]) => forms.has(form))
391
+ ),
392
+ occurrences,
393
+ };
394
+ };
395
+
396
+ const regradeSourceHashFacts = (
397
+ report: RegradeReport,
398
+ policyMode: 'current' | 'legacy' = 'current',
399
+ fileEvidenceMode: 'current' | 'legacy' = 'current'
400
+ ): unknown => ({
401
+ entries: sourceHashEntryFacts(report.entries),
402
+ fileRenames: report.run?.report.fileRenames?.map(
403
+ ({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
404
+ deferred,
405
+ from,
406
+ ...(fileEvidenceMode === 'current' ? { historical, preserved } : {}),
407
+ rewritten,
408
+ ...(fileEvidenceMode === 'current' ? { skipped } : {}),
409
+ to,
410
+ })
411
+ ),
412
+ ledger: sourceHashLedgerFacts(report, policyMode),
413
+ selectedClassIds: report.selectedClassIds,
414
+ });
415
+
416
+ const hashSerializedSourceFacts = (serialized: string): string =>
417
+ createHash('sha256').update(serialized).digest('hex');
418
+
419
+ export const regradeSourceHash = (report: RegradeReport): string =>
420
+ hashSerializedSourceFacts(
421
+ canonicalJsonStringify(regradeSourceHashFacts(report))
422
+ );
423
+
424
+ /** Match only the complete current source-evidence shape used by active plans. */
425
+ export const currentRegradeSourceHashMatches = (
426
+ stampedHash: string,
427
+ report: RegradeReport
428
+ ): boolean => stampedHash === regradeSourceHash(report);
429
+
430
+ export const regradeSourceHashes = (
431
+ report: RegradeReport
432
+ ): readonly string[] => {
433
+ const facts = [
434
+ regradeSourceHashFacts(report),
435
+ regradeSourceHashFacts(report, 'current', 'legacy'),
436
+ regradeSourceHashFacts(report, 'legacy'),
437
+ regradeSourceHashFacts(report, 'legacy', 'legacy'),
438
+ ];
439
+ return [
440
+ ...new Set(
441
+ facts.flatMap((value) => [
442
+ hashSerializedSourceFacts(canonicalJsonStringify(value)),
443
+ hashSerializedSourceFacts(JSON.stringify(value)),
444
+ ])
445
+ ),
446
+ ];
447
+ };
448
+
449
+ /** Match source evidence written by any supported historical hash shape. */
450
+ export const regradeSourceHashMatches = (
451
+ stampedHash: string,
452
+ report: RegradeReport
453
+ ): boolean => regradeSourceHashes(report).includes(stampedHash);
454
+
455
+ /**
456
+ * Canonical content hash of a resolved Regrade plan body — the authored
457
+ * migration intent. Stable across key insertion order; changes on any edit to
458
+ * the plan contents.
459
+ */
460
+ export const regradePlanContentHash = (plan: RegradePlanBody): string =>
461
+ createHash('sha256').update(canonicalJsonStringify(plan)).digest('hex');