@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,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,515 @@
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 {
8
+ regradePackageSourceExpectationSchema,
9
+ vocabularyRegradePlanSchema,
10
+ } from '@ontrails/regrade';
11
+ import type {
12
+ RegradeReport,
13
+ RegradeReportEntry,
14
+ VocabularyRegradePlan,
15
+ } from '@ontrails/regrade';
16
+ import { getGovernedVocabularyTransition } from '@ontrails/warden';
17
+ import { createHash } from 'node:crypto';
18
+ import { join, normalize, posix, relative } from 'node:path';
19
+ import { z } from 'zod';
20
+
21
+ export const REGRADE_PLAN_SCHEMA_VERSION = 1;
22
+
23
+ const regradePlanProvenanceValueSchema = z.enum(['authored', 'derived']);
24
+
25
+ const regradePlanDerivationSchema = z
26
+ .object({
27
+ fileRenames: z.array(
28
+ z
29
+ .object({
30
+ evidence: z.array(z.string()),
31
+ from: z.string(),
32
+ provenance: z.literal('derived'),
33
+ status: z.literal('pending'),
34
+ to: z.string(),
35
+ })
36
+ .strict()
37
+ ),
38
+ forms: z.array(
39
+ z
40
+ .object({
41
+ from: z.string(),
42
+ kind: z.enum(['review', 'safe-rewrite']),
43
+ provenance: regradePlanProvenanceValueSchema,
44
+ reason: z.string(),
45
+ source: z.enum([
46
+ 'default-morphology',
47
+ 'plan-defer',
48
+ 'plan-override',
49
+ 'seed',
50
+ ]),
51
+ to: z.string().optional(),
52
+ })
53
+ .strict()
54
+ ),
55
+ namespaces: z.array(
56
+ z
57
+ .object({
58
+ inScope: z.number().int().nonnegative(),
59
+ namespace: z.string(),
60
+ policyClassified: z.number().int().nonnegative(),
61
+ provenance: z.literal('derived'),
62
+ })
63
+ .strict()
64
+ ),
65
+ preserves: z.array(
66
+ z
67
+ .object({
68
+ disposition: z.literal('preserve-current-live-api'),
69
+ evidence: z.array(z.string()),
70
+ pattern: z.string(),
71
+ provenance: z.literal('derived'),
72
+ reason: z.string().optional(),
73
+ })
74
+ .strict()
75
+ ),
76
+ referenceClosure: z
77
+ .object({
78
+ entries: z.array(
79
+ z
80
+ .object({
81
+ outcome: z.enum(['needs-review', 'rewrite']),
82
+ path: z.string(),
83
+ provenance: z.literal('derived'),
84
+ reason: z.string().optional(),
85
+ })
86
+ .strict()
87
+ ),
88
+ issue: z.string().optional(),
89
+ moves: z.array(
90
+ z
91
+ .object({
92
+ deferred: z.number().int().nonnegative(),
93
+ from: z.string(),
94
+ historical: z.number().int().nonnegative(),
95
+ preserved: z.number().int().nonnegative(),
96
+ provenance: z.literal('derived'),
97
+ rewritten: z.number().int().nonnegative(),
98
+ skipped: z.number().int().nonnegative(),
99
+ to: z.string(),
100
+ })
101
+ .strict()
102
+ ),
103
+ })
104
+ .strict(),
105
+ reviews: z.array(
106
+ z
107
+ .object({
108
+ evidence: z.array(
109
+ z
110
+ .object({
111
+ column: z.number().int().positive().optional(),
112
+ line: z.number().int().positive().optional(),
113
+ path: z.string(),
114
+ })
115
+ .strict()
116
+ ),
117
+ provenance: z.literal('derived'),
118
+ reason: z.string(),
119
+ status: z.literal('pending'),
120
+ value: z.string(),
121
+ })
122
+ .strict()
123
+ ),
124
+ })
125
+ .strict();
126
+
127
+ const classRegradePlanScopeSchema = z
128
+ .object({
129
+ exclude: z
130
+ .array(z.string())
131
+ .optional()
132
+ .describe('Root-relative path globs excluded from the class run'),
133
+ extensions: z
134
+ .array(z.string())
135
+ .optional()
136
+ .describe('Source file extensions scanned by the class run'),
137
+ include: z
138
+ .array(z.string())
139
+ .optional()
140
+ .describe('Root-relative path globs collected during the class run'),
141
+ })
142
+ .strict();
143
+
144
+ export { regradePackageSourceExpectationSchema };
145
+
146
+ export const persistentPackageSourcePathIssue = (value: {
147
+ readonly kind: 'published' | 'tarball';
148
+ readonly path?: string | undefined;
149
+ }): string | undefined => {
150
+ if (value.kind !== 'tarball') {
151
+ return undefined;
152
+ }
153
+ const path = value.path ?? '';
154
+ return path.length === 0 ||
155
+ posix.isAbsolute(path) ||
156
+ /^[A-Za-z]:/u.test(path) ||
157
+ path.startsWith('\\\\') ||
158
+ path.includes('\\') ||
159
+ path.includes('\u0000') ||
160
+ path === '.' ||
161
+ posix.normalize(path) !== path ||
162
+ path.startsWith('./') ||
163
+ path === '..' ||
164
+ path.startsWith('../') ||
165
+ path.includes('/../')
166
+ ? 'Persisted Regrade package-source paths must be normalized root-relative POSIX paths.'
167
+ : undefined;
168
+ };
169
+
170
+ const persistedPackageSourceExpectationSchema =
171
+ regradePackageSourceExpectationSchema.superRefine((value, ctx) => {
172
+ const issue = persistentPackageSourcePathIssue(value);
173
+ if (issue !== undefined) {
174
+ ctx.addIssue({ code: 'custom', message: issue, path: ['path'] });
175
+ }
176
+ });
177
+
178
+ /**
179
+ * A saved class-mode Regrade plan: which classes run, over what scope, and
180
+ * why. The parallel payload to {@link vocabularyRegradePlanSchema} — the
181
+ * `kind` discriminant keeps existing vocabulary plan artifacts
182
+ * byte-compatible.
183
+ */
184
+ const classRegradePlanSchema = z.object({
185
+ classIds: z
186
+ .array(z.string().min(1))
187
+ .min(1)
188
+ .describe('Regrade class ids this plan runs'),
189
+ id: z.string().min(1).describe('Stable Regrade plan identifier'),
190
+ intent: z
191
+ .string()
192
+ .optional()
193
+ .describe('Human-authored migration intent for the class run'),
194
+ kind: z.literal('class').describe('Regrade plan kind'),
195
+ name: z
196
+ .string()
197
+ .min(1)
198
+ .optional()
199
+ .describe(
200
+ 'Authored transition name; keys the saved plan and consolidated history filenames'
201
+ ),
202
+ packageSource: persistedPackageSourceExpectationSchema
203
+ .optional()
204
+ .describe(
205
+ 'Expected source for one directly declared downstream Trails package'
206
+ ),
207
+ scope: classRegradePlanScopeSchema
208
+ .optional()
209
+ .describe('Collection scope for the class run'),
210
+ });
211
+
212
+ export type ClassRegradePlan = z.output<typeof classRegradePlanSchema>;
213
+
214
+ const regradePlanBodySchema = z.discriminatedUnion('kind', [
215
+ vocabularyRegradePlanSchema,
216
+ classRegradePlanSchema,
217
+ ]);
218
+
219
+ export type RegradePlanBody = VocabularyRegradePlan | ClassRegradePlan;
220
+
221
+ const regradeExpansionCandidateSchema = z.union([
222
+ z.object({
223
+ evidence: z
224
+ .array(
225
+ z.object({
226
+ column: z.number().optional(),
227
+ detail: z.string().optional(),
228
+ line: z.number().optional(),
229
+ path: z.string(),
230
+ })
231
+ )
232
+ .default([]),
233
+ kind: z.enum(['file-rename', 'form', 'namespace', 'preserve']),
234
+ provenance: regradePlanProvenanceValueSchema.default('derived'),
235
+ reason: z.string().optional(),
236
+ status: z.enum(['pending', 'rejected']).default('pending'),
237
+ suggestedClassification: z.string(),
238
+ value: z.string(),
239
+ }),
240
+ z
241
+ .object({
242
+ detail: z.string().optional(),
243
+ path: z.string(),
244
+ status: z.enum(['pending', 'rejected']).default('pending'),
245
+ })
246
+ .transform((candidate) => ({
247
+ evidence: [
248
+ {
249
+ ...(candidate.detail === undefined
250
+ ? {}
251
+ : { detail: candidate.detail }),
252
+ path: candidate.path,
253
+ },
254
+ ],
255
+ kind: 'file-rename' as const,
256
+ provenance: 'derived' as const,
257
+ ...(candidate.detail === undefined ? {} : { reason: candidate.detail }),
258
+ status: candidate.status,
259
+ suggestedClassification: 'legacy-path-candidate',
260
+ value: candidate.path,
261
+ })),
262
+ ]);
263
+
264
+ export const regradePlanArtifactSchema = z
265
+ .object({
266
+ derivation: regradePlanDerivationSchema.optional(),
267
+ expansion: z
268
+ .object({
269
+ candidates: z.array(regradeExpansionCandidateSchema).default([]),
270
+ })
271
+ .optional(),
272
+ kind: z.literal('regrade-plan'),
273
+ path: z.string(),
274
+ plan: regradePlanBodySchema,
275
+ provenance: z.object({
276
+ fields: z.record(z.string(), regradePlanProvenanceValueSchema),
277
+ }),
278
+ schemaVersion: z.literal(REGRADE_PLAN_SCHEMA_VERSION),
279
+ sourceHash: z.string(),
280
+ transitionId: z
281
+ .string()
282
+ .min(1)
283
+ .optional()
284
+ .describe(
285
+ 'Stable transition identity this plan re-runs; preserves the consolidated history spine'
286
+ ),
287
+ })
288
+ .strict();
289
+
290
+ export interface RegradePlanExpansion {
291
+ readonly candidates: readonly {
292
+ readonly evidence: readonly {
293
+ readonly column?: number | undefined;
294
+ readonly detail?: string | undefined;
295
+ readonly line?: number | undefined;
296
+ readonly path: string;
297
+ }[];
298
+ readonly kind: 'file-rename' | 'form' | 'namespace' | 'preserve';
299
+ readonly provenance: 'authored' | 'derived';
300
+ readonly reason?: string | undefined;
301
+ readonly status: 'pending' | 'rejected';
302
+ readonly suggestedClassification: string;
303
+ readonly value: string;
304
+ }[];
305
+ }
306
+
307
+ export interface RegradePlanArtifact {
308
+ readonly derivation?: RegradePlanDerivation | undefined;
309
+ readonly expansion?: RegradePlanExpansion | undefined;
310
+ readonly kind: 'regrade-plan';
311
+ readonly path: string;
312
+ readonly plan: RegradePlanBody;
313
+ readonly provenance: {
314
+ readonly fields: Readonly<Record<string, 'authored' | 'derived'>>;
315
+ };
316
+ readonly schemaVersion: typeof REGRADE_PLAN_SCHEMA_VERSION;
317
+ readonly sourceHash: string;
318
+ readonly transitionId?: string | undefined;
319
+ }
320
+
321
+ export type RegradePlanDerivation = z.output<
322
+ typeof regradePlanDerivationSchema
323
+ >;
324
+
325
+ /** A plan artifact narrowed to a vocabulary plan body. */
326
+ export type VocabularyRegradePlanArtifact = RegradePlanArtifact & {
327
+ readonly plan: VocabularyRegradePlan;
328
+ };
329
+
330
+ const regradeSlugText = (text: string): string =>
331
+ text
332
+ .toLowerCase()
333
+ .replaceAll(/[^a-z0-9]+/g, '-')
334
+ .replaceAll(/^-|-$/g, '');
335
+
336
+ const regradePlanSlug = (plan: Pick<VocabularyRegradePlan, 'from' | 'to'>) =>
337
+ regradeSlugText(`${plan.from}-to-${plan.to}`);
338
+
339
+ export const regradePlanSlugForBody = (plan: RegradePlanBody): string =>
340
+ plan.kind === 'class'
341
+ ? regradeSlugText(plan.name ?? plan.classIds.join('-'))
342
+ : (() => {
343
+ const transition =
344
+ plan.id === undefined
345
+ ? undefined
346
+ : getGovernedVocabularyTransition(plan.id);
347
+ return transition?.target.kind === 'classified'
348
+ ? regradeSlugText(transition.id)
349
+ : regradePlanSlug(plan);
350
+ })();
351
+
352
+ const normalizeRelativePath = (path: string): string =>
353
+ normalize(path).replaceAll('\\', '/');
354
+
355
+ export const rootRelativePath = (
356
+ rootDir: string,
357
+ absolutePath: string
358
+ ): string => normalizeRelativePath(relative(rootDir, absolutePath));
359
+
360
+ export const regradePlanDirectory = (rootDir: string): string =>
361
+ join(rootDir, '.trails', 'regrade');
362
+
363
+ export const regradePlanPathForPlan = (
364
+ rootDir: string,
365
+ plan: RegradePlanBody
366
+ ): string =>
367
+ join(regradePlanDirectory(rootDir), `${regradePlanSlugForBody(plan)}.json`);
368
+
369
+ const sourceHashEntryFacts = (
370
+ entries: readonly RegradeReportEntry[]
371
+ ): readonly Pick<
372
+ RegradeReportEntry,
373
+ 'classId' | 'notes' | 'outcome' | 'path' | 'reason' | 'reviewDetails'
374
+ >[] =>
375
+ entries
376
+ .filter(
377
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
378
+ )
379
+ .map(({ classId, notes, outcome, path, reason, reviewDetails }) => ({
380
+ ...(classId === undefined ? {} : { classId }),
381
+ ...(notes === undefined ? {} : { notes }),
382
+ outcome,
383
+ path,
384
+ ...(reason === undefined ? {} : { reason }),
385
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
386
+ }));
387
+
388
+ const canonicalizeJsonValue = (value: unknown): unknown => {
389
+ if (Array.isArray(value)) {
390
+ return value.map((entry) => canonicalizeJsonValue(entry));
391
+ }
392
+ if (isPlainObject(value)) {
393
+ return Object.fromEntries(
394
+ Object.keys(value)
395
+ .toSorted()
396
+ .map((key) => [key, canonicalizeJsonValue(value[key])])
397
+ );
398
+ }
399
+ return value;
400
+ };
401
+
402
+ /**
403
+ * JSON.stringify with recursively sorted object keys so structurally equal
404
+ * values serialize identically regardless of key insertion order. Arrays keep
405
+ * their authored order.
406
+ */
407
+ export const canonicalJsonStringify = (value: unknown): string =>
408
+ JSON.stringify(canonicalizeJsonValue(value));
409
+
410
+ export const isGeneratedRegradeArtifactPath = (path: string): boolean =>
411
+ /(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
412
+
413
+ const sourceHashLedgerFacts = (
414
+ report: RegradeReport,
415
+ policyMode: 'current' | 'legacy'
416
+ ): unknown => {
417
+ const ledger = report.run?.ledger;
418
+ if (ledger === undefined) {
419
+ return undefined;
420
+ }
421
+ // The active plan is written after its source hash and must not stale itself.
422
+ const occurrences = ledger.occurrences.filter(
423
+ (occurrence) =>
424
+ occurrence.scopeTier !== 'policy-classified' ||
425
+ (policyMode === 'current' &&
426
+ !isGeneratedRegradeArtifactPath(occurrence.path))
427
+ );
428
+ const forms = new Set(occurrences.map((occurrence) => occurrence.form));
429
+ return {
430
+ cycle: ledger.cycle,
431
+ forms: Object.fromEntries(
432
+ Object.entries(ledger.forms).filter(([form]) => forms.has(form))
433
+ ),
434
+ occurrences,
435
+ };
436
+ };
437
+
438
+ const regradeSourceHashFacts = (
439
+ report: RegradeReport,
440
+ policyMode: 'current' | 'legacy' = 'current',
441
+ fileEvidenceMode: 'current' | 'legacy' = 'current'
442
+ ): unknown => ({
443
+ entries: sourceHashEntryFacts(report.entries),
444
+ fileRenames: report.run?.report.fileRenames?.map(
445
+ ({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
446
+ deferred,
447
+ from,
448
+ ...(fileEvidenceMode === 'current' ? { historical, preserved } : {}),
449
+ rewritten,
450
+ ...(fileEvidenceMode === 'current' ? { skipped } : {}),
451
+ to,
452
+ })
453
+ ),
454
+ ledger: sourceHashLedgerFacts(report, policyMode),
455
+ ...(report.packageSource === undefined
456
+ ? {}
457
+ : {
458
+ packageSource: {
459
+ artifactSha256: report.packageSource.artifactSha256,
460
+ contentSha256: report.packageSource.contentSha256,
461
+ declaredSpecifier: report.packageSource.declaredSpecifier,
462
+ kind: report.packageSource.kind,
463
+ name: report.packageSource.name,
464
+ version: report.packageSource.version,
465
+ },
466
+ }),
467
+ selectedClassIds: report.selectedClassIds,
468
+ });
469
+
470
+ const hashSerializedSourceFacts = (serialized: string): string =>
471
+ createHash('sha256').update(serialized).digest('hex');
472
+
473
+ export const regradeSourceHash = (report: RegradeReport): string =>
474
+ hashSerializedSourceFacts(
475
+ canonicalJsonStringify(regradeSourceHashFacts(report))
476
+ );
477
+
478
+ /** Match only the complete current source-evidence shape used by active plans. */
479
+ export const currentRegradeSourceHashMatches = (
480
+ stampedHash: string,
481
+ report: RegradeReport
482
+ ): boolean => stampedHash === regradeSourceHash(report);
483
+
484
+ export const regradeSourceHashes = (
485
+ report: RegradeReport
486
+ ): readonly string[] => {
487
+ const facts = [
488
+ regradeSourceHashFacts(report),
489
+ regradeSourceHashFacts(report, 'current', 'legacy'),
490
+ regradeSourceHashFacts(report, 'legacy'),
491
+ regradeSourceHashFacts(report, 'legacy', 'legacy'),
492
+ ];
493
+ return [
494
+ ...new Set(
495
+ facts.flatMap((value) => [
496
+ hashSerializedSourceFacts(canonicalJsonStringify(value)),
497
+ hashSerializedSourceFacts(JSON.stringify(value)),
498
+ ])
499
+ ),
500
+ ];
501
+ };
502
+
503
+ /** Match source evidence written by any supported historical hash shape. */
504
+ export const regradeSourceHashMatches = (
505
+ stampedHash: string,
506
+ report: RegradeReport
507
+ ): boolean => regradeSourceHashes(report).includes(stampedHash);
508
+
509
+ /**
510
+ * Canonical content hash of a resolved Regrade plan body — the authored
511
+ * migration intent. Stable across key insertion order; changes on any edit to
512
+ * the plan contents.
513
+ */
514
+ export const regradePlanContentHash = (plan: RegradePlanBody): string =>
515
+ createHash('sha256').update(canonicalJsonStringify(plan)).digest('hex');