@ontrails/trails 1.0.0-beta.41 → 1.0.0-beta.43

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.
@@ -10,6 +10,7 @@ import type {
10
10
  RegradeReportEntry,
11
11
  VocabularyRegradePlan,
12
12
  } from '@ontrails/regrade';
13
+ import { getGovernedVocabularyTransition } from '@ontrails/warden';
13
14
  import { createHash } from 'node:crypto';
14
15
  import { join, normalize, relative } from 'node:path';
15
16
  import { z } from 'zod';
@@ -18,6 +19,108 @@ export const REGRADE_PLAN_SCHEMA_VERSION = 1;
18
19
 
19
20
  const regradePlanProvenanceValueSchema = z.enum(['authored', 'derived']);
20
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
+
21
124
  const classRegradePlanScopeSchema = z
22
125
  .object({
23
126
  exclude: z
@@ -86,6 +189,7 @@ const regradeExpansionCandidateSchema = z.union([
86
189
  )
87
190
  .default([]),
88
191
  kind: z.enum(['file-rename', 'form', 'namespace', 'preserve']),
192
+ provenance: regradePlanProvenanceValueSchema.default('derived'),
89
193
  reason: z.string().optional(),
90
194
  status: z.enum(['pending', 'rejected']).default('pending'),
91
195
  suggestedClassification: z.string(),
@@ -107,6 +211,7 @@ const regradeExpansionCandidateSchema = z.union([
107
211
  },
108
212
  ],
109
213
  kind: 'file-rename' as const,
214
+ provenance: 'derived' as const,
110
215
  ...(candidate.detail === undefined ? {} : { reason: candidate.detail }),
111
216
  status: candidate.status,
112
217
  suggestedClassification: 'legacy-path-candidate',
@@ -116,6 +221,7 @@ const regradeExpansionCandidateSchema = z.union([
116
221
 
117
222
  export const regradePlanArtifactSchema = z
118
223
  .object({
224
+ derivation: regradePlanDerivationSchema.optional(),
119
225
  expansion: z
120
226
  .object({
121
227
  candidates: z.array(regradeExpansionCandidateSchema).default([]),
@@ -148,6 +254,7 @@ export interface RegradePlanExpansion {
148
254
  readonly path: string;
149
255
  }[];
150
256
  readonly kind: 'file-rename' | 'form' | 'namespace' | 'preserve';
257
+ readonly provenance: 'authored' | 'derived';
151
258
  readonly reason?: string | undefined;
152
259
  readonly status: 'pending' | 'rejected';
153
260
  readonly suggestedClassification: string;
@@ -156,6 +263,7 @@ export interface RegradePlanExpansion {
156
263
  }
157
264
 
158
265
  export interface RegradePlanArtifact {
266
+ readonly derivation?: RegradePlanDerivation | undefined;
159
267
  readonly expansion?: RegradePlanExpansion | undefined;
160
268
  readonly kind: 'regrade-plan';
161
269
  readonly path: string;
@@ -168,6 +276,10 @@ export interface RegradePlanArtifact {
168
276
  readonly transitionId?: string | undefined;
169
277
  }
170
278
 
279
+ export type RegradePlanDerivation = z.output<
280
+ typeof regradePlanDerivationSchema
281
+ >;
282
+
171
283
  /** A plan artifact narrowed to a vocabulary plan body. */
172
284
  export type VocabularyRegradePlanArtifact = RegradePlanArtifact & {
173
285
  readonly plan: VocabularyRegradePlan;
@@ -185,7 +297,15 @@ const regradePlanSlug = (plan: Pick<VocabularyRegradePlan, 'from' | 'to'>) =>
185
297
  export const regradePlanSlugForBody = (plan: RegradePlanBody): string =>
186
298
  plan.kind === 'class'
187
299
  ? regradeSlugText(plan.name ?? plan.classIds.join('-'))
188
- : regradePlanSlug(plan);
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
+ })();
189
309
 
190
310
  const normalizeRelativePath = (path: string): string =>
191
311
  normalize(path).replaceAll('\\', '/');
@@ -245,9 +365,51 @@ const canonicalizeJsonValue = (value: unknown): unknown => {
245
365
  export const canonicalJsonStringify = (value: unknown): string =>
246
366
  JSON.stringify(canonicalizeJsonValue(value));
247
367
 
248
- const regradeSourceHashFacts = (report: RegradeReport): unknown => ({
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 => ({
249
401
  entries: sourceHashEntryFacts(report.entries),
250
- ledger: report.run?.ledger,
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),
251
413
  selectedClassIds: report.selectedClassIds,
252
414
  });
253
415
 
@@ -259,22 +421,39 @@ export const regradeSourceHash = (report: RegradeReport): string =>
259
421
  canonicalJsonStringify(regradeSourceHashFacts(report))
260
422
  );
261
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
+
262
430
  export const legacyRegradeSourceHash = (report: RegradeReport): string =>
263
431
  hashSerializedSourceFacts(JSON.stringify(regradeSourceHashFacts(report)));
264
432
 
265
- /** Match source evidence written before canonical JSON hashing. */
266
- export const regradeSourceHashMatches = (
267
- stampedHash: string,
433
+ export const regradeSourceHashes = (
268
434
  report: RegradeReport
269
- ): boolean =>
270
- stampedHash === regradeSourceHash(report) ||
271
- stampedHash === legacyRegradeSourceHash(report);
435
+ ): readonly string[] => {
436
+ const facts = [
437
+ regradeSourceHashFacts(report),
438
+ regradeSourceHashFacts(report, 'current', 'legacy'),
439
+ regradeSourceHashFacts(report, 'legacy'),
440
+ regradeSourceHashFacts(report, 'legacy', 'legacy'),
441
+ ];
442
+ return [
443
+ ...new Set(
444
+ facts.flatMap((value) => [
445
+ hashSerializedSourceFacts(canonicalJsonStringify(value)),
446
+ hashSerializedSourceFacts(JSON.stringify(value)),
447
+ ])
448
+ ),
449
+ ];
450
+ };
272
451
 
273
- export const regradeSourceHashes = (
452
+ /** Match source evidence written by any supported historical hash shape. */
453
+ export const regradeSourceHashMatches = (
454
+ stampedHash: string,
274
455
  report: RegradeReport
275
- ): readonly string[] => [
276
- ...new Set([regradeSourceHash(report), legacyRegradeSourceHash(report)]),
277
- ];
456
+ ): boolean => regradeSourceHashes(report).includes(stampedHash);
278
457
 
279
458
  /**
280
459
  * Canonical content hash of a resolved Regrade plan body — the authored
@@ -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,75 @@
1
+ import { InternalError, Result, deriveSafePath } from '@ontrails/core';
2
+ import type { Result as TrailsResult } from '@ontrails/core';
3
+ import type { RegradeReport } from '@ontrails/regrade';
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+
6
+ export interface RegradeSourceSnapshot {
7
+ readonly absolutePath: string;
8
+ readonly source: Uint8Array;
9
+ }
10
+
11
+ export const snapshotRegradeSources = (params: {
12
+ readonly reports: readonly (RegradeReport | null)[];
13
+ readonly rootDir: string;
14
+ }): TrailsResult<readonly RegradeSourceSnapshot[], Error> => {
15
+ const paths = new Set(
16
+ params.reports.flatMap(
17
+ (report) =>
18
+ report?.entries
19
+ .filter(
20
+ (entry) =>
21
+ entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
22
+ )
23
+ .map((entry) => entry.path) ?? []
24
+ )
25
+ );
26
+ const snapshots: RegradeSourceSnapshot[] = [];
27
+ try {
28
+ for (const path of paths) {
29
+ const absolutePath = deriveSafePath(params.rootDir, path);
30
+ if (absolutePath.isErr()) {
31
+ return absolutePath;
32
+ }
33
+ snapshots.push({
34
+ absolutePath: absolutePath.value,
35
+ source: readFileSync(absolutePath.value),
36
+ });
37
+ }
38
+ } catch (error) {
39
+ return Result.err(
40
+ new InternalError(
41
+ 'Failed to snapshot Regrade sources before apply.',
42
+ error instanceof Error ? { cause: error } : {}
43
+ )
44
+ );
45
+ }
46
+ return Result.ok(snapshots);
47
+ };
48
+
49
+ const rollbackRegradeSources = (
50
+ snapshots: readonly RegradeSourceSnapshot[]
51
+ ): Error | null => {
52
+ try {
53
+ for (const snapshot of snapshots) {
54
+ writeFileSync(snapshot.absolutePath, snapshot.source);
55
+ }
56
+ return null;
57
+ } catch (error) {
58
+ return error instanceof Error ? error : new Error(String(error));
59
+ }
60
+ };
61
+
62
+ export const regradeApplyErrorAfterRollback = (
63
+ error: Error,
64
+ snapshots: readonly RegradeSourceSnapshot[]
65
+ ): TrailsResult<never, Error> => {
66
+ const rollbackError = rollbackRegradeSources(snapshots);
67
+ return rollbackError === null
68
+ ? Result.err(error)
69
+ : Result.err(
70
+ new InternalError('Regrade apply failed and source rollback failed.', {
71
+ cause: error,
72
+ context: { rollbackError: rollbackError.message },
73
+ })
74
+ );
75
+ };