@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.
- package/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- package/src/versions.ts +31 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidated, append-only Regrade transition history. One file per
|
|
3
|
+
* transition at `.trails/regrade/history/<transition>.json`; each apply
|
|
4
|
+
* appends a run entry stamped with the plan content hash and the lock hash
|
|
5
|
+
* observed at that run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { InternalError, Result, ValidationError } from '@ontrails/core';
|
|
9
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
10
|
+
import { resolveRegradeHistoryReceipt } from '@ontrails/regrade';
|
|
11
|
+
import type {
|
|
12
|
+
RegradeFormJudgment,
|
|
13
|
+
RegradeReport,
|
|
14
|
+
ResolvedRegradeHistoryReceipt,
|
|
15
|
+
} from '@ontrails/regrade';
|
|
16
|
+
import {
|
|
17
|
+
getGovernedVocabularyTransition,
|
|
18
|
+
listGovernedVocabularyTransitions,
|
|
19
|
+
} from '@ontrails/warden';
|
|
20
|
+
import type { GovernedVocabularyHistoryProvenance } from '@ontrails/warden';
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
regradePlanContentHash,
|
|
35
|
+
regradePlanDirectory,
|
|
36
|
+
regradePlanSlugForBody,
|
|
37
|
+
regradeSourceHash,
|
|
38
|
+
rootRelativePath,
|
|
39
|
+
} from './plan-artifact.js';
|
|
40
|
+
import type { RegradePlanArtifact, RegradePlanBody } from './plan-artifact.js';
|
|
41
|
+
import {
|
|
42
|
+
buildRegradeHistoryReceipt,
|
|
43
|
+
resolveRegradeSourceRevision,
|
|
44
|
+
serializeRegradeHistoryReceipt,
|
|
45
|
+
} from './receipt-history.js';
|
|
46
|
+
import type { RegradeChangedFileEvidence } from './receipt-history.js';
|
|
47
|
+
|
|
48
|
+
const resolvedReceipt = Symbol('resolvedReceipt');
|
|
49
|
+
|
|
50
|
+
export const writeRegradeHistoryFileAtomically = (params: {
|
|
51
|
+
readonly absolutePath: string;
|
|
52
|
+
readonly content: string;
|
|
53
|
+
readonly diagnosticPath: string;
|
|
54
|
+
readonly replace?: typeof renameSync | undefined;
|
|
55
|
+
}): TrailsResult<void, InternalError> => {
|
|
56
|
+
const temporaryPath = join(
|
|
57
|
+
dirname(params.absolutePath),
|
|
58
|
+
`.${randomUUID()}.regrade-history.tmp`
|
|
59
|
+
);
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dirname(params.absolutePath), { recursive: true });
|
|
62
|
+
writeFileSync(temporaryPath, params.content);
|
|
63
|
+
(params.replace ?? renameSync)(temporaryPath, params.absolutePath);
|
|
64
|
+
return Result.ok();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
try {
|
|
67
|
+
rmSync(temporaryPath, { force: true });
|
|
68
|
+
} catch {
|
|
69
|
+
// Preserve the primary persistence failure.
|
|
70
|
+
}
|
|
71
|
+
return Result.err(
|
|
72
|
+
new InternalError('Failed to atomically write Regrade history.', {
|
|
73
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
74
|
+
context: { path: params.diagnosticPath },
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const consumeActiveRegradePlanAfterHistoryWrite = (params: {
|
|
81
|
+
readonly absoluteHistoryPath: string;
|
|
82
|
+
readonly absolutePlanPath: string;
|
|
83
|
+
readonly historyPath: string;
|
|
84
|
+
readonly planPath: string;
|
|
85
|
+
readonly priorHistoryBytes?: string | undefined;
|
|
86
|
+
readonly remove?: ((path: string) => void) | undefined;
|
|
87
|
+
readonly replace?: typeof renameSync | undefined;
|
|
88
|
+
}): TrailsResult<void, InternalError> => {
|
|
89
|
+
const remove =
|
|
90
|
+
params.remove ?? ((path: string) => rmSync(path, { force: true }));
|
|
91
|
+
const removeNewHistory = (): Error | undefined => {
|
|
92
|
+
try {
|
|
93
|
+
remove(params.absoluteHistoryPath);
|
|
94
|
+
return undefined;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
remove(params.absolutePlanPath);
|
|
101
|
+
return Result.ok();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
let rollbackError: Error | undefined;
|
|
104
|
+
if (params.priorHistoryBytes === undefined) {
|
|
105
|
+
rollbackError = removeNewHistory();
|
|
106
|
+
} else {
|
|
107
|
+
const restored = writeRegradeHistoryFileAtomically({
|
|
108
|
+
absolutePath: params.absoluteHistoryPath,
|
|
109
|
+
content: params.priorHistoryBytes,
|
|
110
|
+
diagnosticPath: params.historyPath,
|
|
111
|
+
replace: params.replace,
|
|
112
|
+
});
|
|
113
|
+
if (restored.isErr()) {
|
|
114
|
+
rollbackError = restored.error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Result.err(
|
|
118
|
+
new InternalError('Failed to remove active Regrade plan.', {
|
|
119
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
120
|
+
context: {
|
|
121
|
+
history: params.historyPath,
|
|
122
|
+
plan: params.planPath,
|
|
123
|
+
...(rollbackError === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { historyRollback: rollbackError.message }),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
interface RegradeHistoryRun {
|
|
132
|
+
readonly completionReport: RegradeReport;
|
|
133
|
+
readonly completionReportHash: string;
|
|
134
|
+
readonly lockHashAtRun: string;
|
|
135
|
+
readonly plan: RegradePlanArtifact;
|
|
136
|
+
readonly planContentHash: string;
|
|
137
|
+
readonly report: RegradeReport;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RegradeHistoryArtifact {
|
|
141
|
+
readonly [resolvedReceipt]?: ResolvedRegradeHistoryReceipt;
|
|
142
|
+
readonly id: string;
|
|
143
|
+
readonly kind: 'regrade-history';
|
|
144
|
+
readonly path: string;
|
|
145
|
+
readonly runs: readonly RegradeHistoryRun[];
|
|
146
|
+
readonly schemaVersion: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const receiptDisposition = (
|
|
150
|
+
disposition: RegradeFormJudgment['disposition']
|
|
151
|
+
) => {
|
|
152
|
+
switch (disposition) {
|
|
153
|
+
case 'mapped': {
|
|
154
|
+
return {
|
|
155
|
+
disposition: 'in-family-modified' as const,
|
|
156
|
+
verdict: 'applied' as const,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
case 'out-of-family': {
|
|
160
|
+
return {
|
|
161
|
+
disposition: 'out-of-family' as const,
|
|
162
|
+
verdict: 'skipped' as const,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
case 'preserved': {
|
|
166
|
+
return {
|
|
167
|
+
disposition: 'explicit-preserve' as const,
|
|
168
|
+
verdict: 'skipped' as const,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
case 'unresolved': {
|
|
172
|
+
return {
|
|
173
|
+
disposition: 'in-family-unresolved' as const,
|
|
174
|
+
verdict: 'deferred' as const,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
default: {
|
|
178
|
+
const exhaustive: never = disposition;
|
|
179
|
+
return exhaustive;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const reportForReceiptRun = (
|
|
185
|
+
run: ResolvedRegradeHistoryReceipt['runs'][number]
|
|
186
|
+
): RegradeReport => {
|
|
187
|
+
const plan = run.plan as RegradePlanBody;
|
|
188
|
+
const { completion } = run.receipt;
|
|
189
|
+
const entries = run.receipt.evidence.changedFiles.map((file) => ({
|
|
190
|
+
outcome: 'rewrite' as const,
|
|
191
|
+
path: file.afterPath,
|
|
192
|
+
}));
|
|
193
|
+
const skipped = Object.values(completion.counts.skippedByReason).reduce(
|
|
194
|
+
(sum, count) => sum + count,
|
|
195
|
+
0
|
|
196
|
+
);
|
|
197
|
+
const base: RegradeReport = {
|
|
198
|
+
apply: {
|
|
199
|
+
applied: completion.counts.rewritten,
|
|
200
|
+
filesChanged: completion.metrics.filesChanged,
|
|
201
|
+
review: completion.counts.review,
|
|
202
|
+
skipped,
|
|
203
|
+
unknown: completion.counts.unknown,
|
|
204
|
+
},
|
|
205
|
+
entries,
|
|
206
|
+
matched: completion.counts.matched,
|
|
207
|
+
review: completion.counts.review,
|
|
208
|
+
rewritten: completion.counts.rewritten,
|
|
209
|
+
root: '.',
|
|
210
|
+
scan: {
|
|
211
|
+
byDirectory: [],
|
|
212
|
+
byExtension: [],
|
|
213
|
+
files: {
|
|
214
|
+
matched: completion.counts.matched,
|
|
215
|
+
scanned: 0,
|
|
216
|
+
skipped,
|
|
217
|
+
},
|
|
218
|
+
skippedByReason: completion.counts.skippedByReason,
|
|
219
|
+
},
|
|
220
|
+
scanned: 0,
|
|
221
|
+
selectedClassIds:
|
|
222
|
+
plan.kind === 'class'
|
|
223
|
+
? plan.classIds
|
|
224
|
+
: [plan.id ?? `vocabulary:${plan.from}->${plan.to}`],
|
|
225
|
+
skipped,
|
|
226
|
+
skipsByReason: completion.counts.skippedByReason,
|
|
227
|
+
unknownClassIds: [],
|
|
228
|
+
};
|
|
229
|
+
if (plan.kind !== 'vocabulary') {
|
|
230
|
+
return base;
|
|
231
|
+
}
|
|
232
|
+
const occurrences = run.classifiedState.forms.map((form, index) => {
|
|
233
|
+
const mapped = receiptDisposition(form.disposition);
|
|
234
|
+
return {
|
|
235
|
+
column: 1,
|
|
236
|
+
context: '',
|
|
237
|
+
...mapped,
|
|
238
|
+
end: 0,
|
|
239
|
+
form: form.form,
|
|
240
|
+
line: form.representative?.line ?? 1,
|
|
241
|
+
path: form.representative?.path ?? run.receipt.transitionId,
|
|
242
|
+
reason: form.reason ?? form.disposition,
|
|
243
|
+
...(form.target === undefined ? {} : { replacement: form.target }),
|
|
244
|
+
scopeTier: 'in-scope' as const,
|
|
245
|
+
start: index,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
...base,
|
|
250
|
+
run: {
|
|
251
|
+
ledger: {
|
|
252
|
+
cycle: 1,
|
|
253
|
+
forms: Object.fromEntries(
|
|
254
|
+
occurrences.map((occurrence) => [occurrence.form, occurrence.verdict])
|
|
255
|
+
),
|
|
256
|
+
occurrences,
|
|
257
|
+
},
|
|
258
|
+
plan,
|
|
259
|
+
report: {
|
|
260
|
+
applied: completion.counts.rewritten,
|
|
261
|
+
deferred: completion.counts.review,
|
|
262
|
+
dispositions: completion.counts.dispositions,
|
|
263
|
+
filesChanged: completion.metrics.filesChanged,
|
|
264
|
+
gate: {
|
|
265
|
+
...completion.gate,
|
|
266
|
+
remainingByDisposition: {},
|
|
267
|
+
},
|
|
268
|
+
modified: completion.counts.rewritten,
|
|
269
|
+
open: completion.gate.remaining,
|
|
270
|
+
scopeTiers: { 'in-scope': occurrences.length, 'policy-classified': 0 },
|
|
271
|
+
skipped,
|
|
272
|
+
teachingSurfaces: { expected: [], missing: [], touched: [] },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const projectReceiptHistory = (
|
|
279
|
+
receipt: ResolvedRegradeHistoryReceipt
|
|
280
|
+
): RegradeHistoryArtifact => ({
|
|
281
|
+
[resolvedReceipt]: receipt,
|
|
282
|
+
id: receipt.artifact.id,
|
|
283
|
+
kind: 'regrade-history',
|
|
284
|
+
path: receipt.artifact.path,
|
|
285
|
+
runs: receipt.runs.map((run) => ({
|
|
286
|
+
completionReport: reportForReceiptRun(run),
|
|
287
|
+
completionReportHash: run.receipt.evidence.sourceStateHash,
|
|
288
|
+
lockHashAtRun: run.receipt.evidence.lockStateHash,
|
|
289
|
+
plan: {
|
|
290
|
+
kind: 'regrade-plan',
|
|
291
|
+
path: `.trails/regrade/plans/${receipt.artifact.path.split('/').at(-1) ?? 'receipt'}`,
|
|
292
|
+
plan: run.plan as RegradePlanBody,
|
|
293
|
+
provenance: run.provenance,
|
|
294
|
+
schemaVersion: 1,
|
|
295
|
+
sourceHash: run.receipt.evidence.sourceStateHash,
|
|
296
|
+
transitionId: receipt.artifact.id,
|
|
297
|
+
},
|
|
298
|
+
planContentHash: run.receipt.intent.planContentHash,
|
|
299
|
+
report: reportForReceiptRun(run),
|
|
300
|
+
})),
|
|
301
|
+
schemaVersion: receipt.artifact.schemaVersion,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
export interface RegradeHistorySummary {
|
|
305
|
+
readonly id: string;
|
|
306
|
+
readonly path: string;
|
|
307
|
+
readonly provenance?: GovernedVocabularyHistoryProvenance;
|
|
308
|
+
readonly schemaVersion: number;
|
|
309
|
+
readonly status: 'applied' | 'replay';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export const regradeHistoryPathForPlan = (
|
|
313
|
+
rootDir: string,
|
|
314
|
+
plan: RegradePlanBody
|
|
315
|
+
): string =>
|
|
316
|
+
join(
|
|
317
|
+
regradePlanDirectory(rootDir),
|
|
318
|
+
'history',
|
|
319
|
+
`${regradePlanSlugForBody(plan)}.json`
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
export const readRegradeHistoryArtifact = (
|
|
323
|
+
path: string
|
|
324
|
+
): TrailsResult<RegradeHistoryArtifact, InternalError | ValidationError> => {
|
|
325
|
+
if (!existsSync(path)) {
|
|
326
|
+
return Result.err(
|
|
327
|
+
new ValidationError(`Regrade history "${path}" not found.`)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
let parsedJson: unknown;
|
|
331
|
+
try {
|
|
332
|
+
parsedJson = JSON.parse(readFileSync(path, 'utf8'));
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return Result.err(
|
|
335
|
+
new InternalError('Failed to read Regrade history artifact.', {
|
|
336
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
337
|
+
context: { path },
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const receipt = resolveRegradeHistoryReceipt(parsedJson);
|
|
342
|
+
if (receipt.isErr()) {
|
|
343
|
+
return receipt;
|
|
344
|
+
}
|
|
345
|
+
const observedPath = path.replaceAll('\\', '/');
|
|
346
|
+
const embeddedPath = receipt.value.artifact.path;
|
|
347
|
+
if (
|
|
348
|
+
observedPath !== embeddedPath &&
|
|
349
|
+
!observedPath.endsWith(`/${embeddedPath}`)
|
|
350
|
+
) {
|
|
351
|
+
return Result.err(
|
|
352
|
+
new ValidationError(
|
|
353
|
+
'Regrade history path does not match its observed file.',
|
|
354
|
+
{
|
|
355
|
+
context: { embeddedPath, observedPath },
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return Result.ok(projectReceiptHistory(receipt.value));
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Deterministic transition identity minted at the first recorded run and
|
|
365
|
+
* preserved for the life of the consolidated history file.
|
|
366
|
+
*/
|
|
367
|
+
export const mintTransitionId = (
|
|
368
|
+
slug: string,
|
|
369
|
+
planContentHash: string,
|
|
370
|
+
lockHashAtRun: string
|
|
371
|
+
): string =>
|
|
372
|
+
createHash('sha256')
|
|
373
|
+
.update(`${slug}\n${planContentHash}\n${lockHashAtRun}`)
|
|
374
|
+
.digest('hex')
|
|
375
|
+
.slice(0, 12);
|
|
376
|
+
|
|
377
|
+
const targetIncludesPlanTarget = (
|
|
378
|
+
transition: NonNullable<ReturnType<typeof getGovernedVocabularyTransition>>,
|
|
379
|
+
to: string
|
|
380
|
+
): boolean =>
|
|
381
|
+
transition.target.kind === 'single'
|
|
382
|
+
? transition.target.to === to
|
|
383
|
+
: transition.target.options.some((option) => option.to === to);
|
|
384
|
+
|
|
385
|
+
const governedTransitionForPlan = (
|
|
386
|
+
plan: Extract<RegradePlanBody, { kind: 'vocabulary' }>
|
|
387
|
+
) => {
|
|
388
|
+
const idTransition =
|
|
389
|
+
plan.id === undefined
|
|
390
|
+
? undefined
|
|
391
|
+
: getGovernedVocabularyTransition(plan.id);
|
|
392
|
+
return (
|
|
393
|
+
idTransition ??
|
|
394
|
+
listGovernedVocabularyTransitions().find(
|
|
395
|
+
(transition) => transition.from === plan.from
|
|
396
|
+
)
|
|
397
|
+
);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
export const validateGovernedRegradePlan = (
|
|
401
|
+
artifact: RegradePlanArtifact
|
|
402
|
+
): TrailsResult<void, ValidationError> => {
|
|
403
|
+
const { plan } = artifact;
|
|
404
|
+
if (plan.kind !== 'vocabulary') {
|
|
405
|
+
return Result.ok();
|
|
406
|
+
}
|
|
407
|
+
const transition = governedTransitionForPlan(plan);
|
|
408
|
+
if (transition === undefined) {
|
|
409
|
+
return Result.ok();
|
|
410
|
+
}
|
|
411
|
+
if (
|
|
412
|
+
transition.from === plan.from &&
|
|
413
|
+
targetIncludesPlanTarget(transition, plan.to)
|
|
414
|
+
) {
|
|
415
|
+
return Result.ok();
|
|
416
|
+
}
|
|
417
|
+
return Result.err(
|
|
418
|
+
new ValidationError(
|
|
419
|
+
'Governed Regrade plan does not match its registry transition.',
|
|
420
|
+
{
|
|
421
|
+
context: {
|
|
422
|
+
plan: { from: plan.from, id: plan.id, to: plan.to },
|
|
423
|
+
registry: { from: transition.from, id: transition.id },
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/** Receipts are validated and hash-resolved during read. */
|
|
431
|
+
export const verifyRegradeHistoryRuns = (
|
|
432
|
+
artifact: RegradeHistoryArtifact
|
|
433
|
+
): TrailsResult<{ readonly runs: number }, ValidationError> =>
|
|
434
|
+
Result.ok({ runs: artifact.runs.length });
|
|
435
|
+
|
|
436
|
+
const appendReceiptHistoryRun = (params: {
|
|
437
|
+
readonly absolutePath: string;
|
|
438
|
+
readonly artifact: RegradePlanArtifact;
|
|
439
|
+
readonly changedFiles: readonly RegradeChangedFileEvidence[];
|
|
440
|
+
readonly completedReport: RegradeReport;
|
|
441
|
+
readonly current: RegradeHistoryArtifact | undefined;
|
|
442
|
+
readonly lockHashAtRun: string;
|
|
443
|
+
readonly planContentHash: string;
|
|
444
|
+
readonly relativePath: string;
|
|
445
|
+
readonly report: RegradeReport;
|
|
446
|
+
readonly rootDir: string;
|
|
447
|
+
readonly sourceRevision: string;
|
|
448
|
+
}): TrailsResult<RegradeHistorySummary, Error> => {
|
|
449
|
+
if (
|
|
450
|
+
params.current !== undefined &&
|
|
451
|
+
params.artifact.transitionId !== undefined &&
|
|
452
|
+
params.artifact.transitionId !== params.current.id
|
|
453
|
+
) {
|
|
454
|
+
return Result.err(
|
|
455
|
+
new ValidationError(
|
|
456
|
+
'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
|
|
457
|
+
{
|
|
458
|
+
context: {
|
|
459
|
+
history: params.current.id,
|
|
460
|
+
path: params.relativePath,
|
|
461
|
+
plan: params.artifact.transitionId,
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const currentLastRun = params.current?.runs.at(-1);
|
|
468
|
+
if (
|
|
469
|
+
params.current !== undefined &&
|
|
470
|
+
params.artifact.transitionId === undefined &&
|
|
471
|
+
currentLastRun !== undefined &&
|
|
472
|
+
currentLastRun.plan.plan.id !== params.artifact.plan.id
|
|
473
|
+
) {
|
|
474
|
+
return Result.err(
|
|
475
|
+
new ValidationError(
|
|
476
|
+
'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
|
|
477
|
+
{
|
|
478
|
+
context: {
|
|
479
|
+
history: currentLastRun.plan.plan.id,
|
|
480
|
+
path: params.relativePath,
|
|
481
|
+
plan: params.artifact.plan.id,
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const transitionId =
|
|
488
|
+
params.current?.id ??
|
|
489
|
+
params.artifact.transitionId ??
|
|
490
|
+
mintTransitionId(
|
|
491
|
+
regradePlanSlugForBody(params.artifact.plan),
|
|
492
|
+
params.planContentHash,
|
|
493
|
+
params.lockHashAtRun
|
|
494
|
+
);
|
|
495
|
+
const receipt = buildRegradeHistoryReceipt({
|
|
496
|
+
artifact: params.artifact,
|
|
497
|
+
changedFiles: params.changedFiles,
|
|
498
|
+
completedReport: params.completedReport,
|
|
499
|
+
historyPath: params.relativePath,
|
|
500
|
+
...(params.current?.[resolvedReceipt] === undefined
|
|
501
|
+
? {}
|
|
502
|
+
: { prior: params.current[resolvedReceipt] }),
|
|
503
|
+
report: params.report,
|
|
504
|
+
rootDir: params.rootDir,
|
|
505
|
+
sourceRevision: params.sourceRevision,
|
|
506
|
+
transitionId,
|
|
507
|
+
});
|
|
508
|
+
if (receipt.isErr()) {
|
|
509
|
+
return receipt;
|
|
510
|
+
}
|
|
511
|
+
const serialized = serializeRegradeHistoryReceipt(receipt.value);
|
|
512
|
+
if (serialized.isErr()) {
|
|
513
|
+
return serialized;
|
|
514
|
+
}
|
|
515
|
+
const written = writeRegradeHistoryFileAtomically({
|
|
516
|
+
absolutePath: params.absolutePath,
|
|
517
|
+
content: serialized.value,
|
|
518
|
+
diagnosticPath: params.relativePath,
|
|
519
|
+
});
|
|
520
|
+
if (written.isErr()) {
|
|
521
|
+
return written;
|
|
522
|
+
}
|
|
523
|
+
const lastRun = receipt.value.runs.at(-1);
|
|
524
|
+
return Result.ok({
|
|
525
|
+
id: receipt.value.id,
|
|
526
|
+
path: receipt.value.path,
|
|
527
|
+
schemaVersion: receipt.value.schemaVersion,
|
|
528
|
+
status: lastRun?.runKind === 'proof' ? 'replay' : 'applied',
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Append one applied run to the transition's consolidated history file. A
|
|
534
|
+
* Unchanged intent and classified state append a compact reference-only proof
|
|
535
|
+
* and surface `status: 'replay'`.
|
|
536
|
+
*/
|
|
537
|
+
export const appendRegradeHistoryRun = (params: {
|
|
538
|
+
readonly artifact: RegradePlanArtifact;
|
|
539
|
+
readonly changedFiles?: readonly RegradeChangedFileEvidence[];
|
|
540
|
+
readonly completedReport?: RegradeReport;
|
|
541
|
+
readonly report: RegradeReport;
|
|
542
|
+
readonly rootDir: string;
|
|
543
|
+
readonly sourceRevision?: string;
|
|
544
|
+
}): TrailsResult<RegradeHistorySummary, Error> => {
|
|
545
|
+
const absolutePath = regradeHistoryPathForPlan(
|
|
546
|
+
params.rootDir,
|
|
547
|
+
params.artifact.plan
|
|
548
|
+
);
|
|
549
|
+
const relativePath = rootRelativePath(params.rootDir, absolutePath);
|
|
550
|
+
const planContentHash = regradePlanContentHash(params.artifact.plan);
|
|
551
|
+
const lockHashAtRun = regradeSourceHash(params.report);
|
|
552
|
+
const completionReport = params.completedReport ?? params.report;
|
|
553
|
+
|
|
554
|
+
let current: RegradeHistoryArtifact | undefined;
|
|
555
|
+
if (existsSync(absolutePath)) {
|
|
556
|
+
const existing = readRegradeHistoryArtifact(absolutePath);
|
|
557
|
+
if (existing.isErr()) {
|
|
558
|
+
return existing;
|
|
559
|
+
}
|
|
560
|
+
current = existing.value;
|
|
561
|
+
}
|
|
562
|
+
const sourceRevision =
|
|
563
|
+
params.sourceRevision === undefined
|
|
564
|
+
? resolveRegradeSourceRevision(params.rootDir)
|
|
565
|
+
: Result.ok(params.sourceRevision);
|
|
566
|
+
if (sourceRevision.isErr()) {
|
|
567
|
+
return sourceRevision;
|
|
568
|
+
}
|
|
569
|
+
return appendReceiptHistoryRun({
|
|
570
|
+
absolutePath,
|
|
571
|
+
artifact: params.artifact,
|
|
572
|
+
changedFiles: params.changedFiles ?? [],
|
|
573
|
+
completedReport: completionReport,
|
|
574
|
+
current,
|
|
575
|
+
lockHashAtRun,
|
|
576
|
+
planContentHash,
|
|
577
|
+
relativePath,
|
|
578
|
+
report: params.report,
|
|
579
|
+
rootDir: params.rootDir,
|
|
580
|
+
sourceRevision: sourceRevision.value,
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const hasPathSeparator = (value: string): boolean =>
|
|
585
|
+
value.includes('/') || value.includes('\\');
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Resolve an opaque transition id to its validated consolidated history file.
|
|
589
|
+
* Path and filename references are rejected; the operator never selects the
|
|
590
|
+
* generator-owned receipt filename.
|
|
591
|
+
*/
|
|
592
|
+
export const resolveRegradeHistoryPath = (
|
|
593
|
+
rootDir: string,
|
|
594
|
+
ref: string
|
|
595
|
+
): TrailsResult<string, Error> => {
|
|
596
|
+
if (hasPathSeparator(ref) || isAbsolute(ref)) {
|
|
597
|
+
return Result.err(
|
|
598
|
+
new ValidationError(
|
|
599
|
+
`Regrade history reference "${ref}" must be an opaque transition id.`
|
|
600
|
+
)
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const historyDir = join(regradePlanDirectory(rootDir), 'history');
|
|
604
|
+
if (!existsSync(historyDir)) {
|
|
605
|
+
return Result.err(
|
|
606
|
+
new ValidationError(`No Regrade history for transition "${ref}" found.`)
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const matches: string[] = [];
|
|
610
|
+
for (const name of readdirSync(historyDir).toSorted()) {
|
|
611
|
+
if (!name.endsWith('.json')) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const path = join(historyDir, name);
|
|
615
|
+
const history = readRegradeHistoryArtifact(path);
|
|
616
|
+
if (history.isErr()) {
|
|
617
|
+
return history;
|
|
618
|
+
}
|
|
619
|
+
if (history.value.id === ref) {
|
|
620
|
+
matches.push(path);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (matches.length === 0) {
|
|
624
|
+
return Result.err(
|
|
625
|
+
new ValidationError(`No Regrade history for transition "${ref}" found.`)
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (matches.length > 1) {
|
|
629
|
+
return Result.err(
|
|
630
|
+
new ValidationError(
|
|
631
|
+
`Multiple Regrade histories claim transition "${ref}".`
|
|
632
|
+
)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
return Result.ok(matches[0] as string);
|
|
636
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ProgressCallback, Result } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export const regradeLifecyclePhaseSchema = z.object({
|
|
5
|
+
durationMs: z.number().int().nonnegative(),
|
|
6
|
+
name: z.string().min(1),
|
|
7
|
+
status: z.literal('completed'),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const regradeLifecycleSchema = z.object({
|
|
11
|
+
durationMs: z.number().int().nonnegative(),
|
|
12
|
+
phases: z.array(regradeLifecyclePhaseSchema),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type RegradeLifecycle = z.output<typeof regradeLifecycleSchema>;
|
|
16
|
+
|
|
17
|
+
interface RegradeLifecycleTrackerOptions {
|
|
18
|
+
readonly now?: (() => number) | undefined;
|
|
19
|
+
readonly progress?: ProgressCallback | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const elapsedMilliseconds = (startedAt: number, finishedAt: number): number =>
|
|
23
|
+
Math.max(0, Math.round(finishedAt - startedAt));
|
|
24
|
+
|
|
25
|
+
const phaseLabel = (name: string): string => name.replaceAll('-', ' ');
|
|
26
|
+
|
|
27
|
+
export class RegradeLifecycleTracker {
|
|
28
|
+
readonly #now: () => number;
|
|
29
|
+
readonly #phases: RegradeLifecycle['phases'][number][] = [];
|
|
30
|
+
readonly #progress: ProgressCallback | undefined;
|
|
31
|
+
readonly #startedAt: number;
|
|
32
|
+
|
|
33
|
+
constructor(options: RegradeLifecycleTrackerOptions = {}) {
|
|
34
|
+
this.#now = options.now ?? Date.now;
|
|
35
|
+
this.#progress = options.progress;
|
|
36
|
+
this.#startedAt = this.#now();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async run<T, E extends Error>(
|
|
40
|
+
name: string,
|
|
41
|
+
operation: () => Promise<Result<T, E>> | Result<T, E>
|
|
42
|
+
): Promise<Result<T, E>> {
|
|
43
|
+
const startedAt = this.#now();
|
|
44
|
+
this.#progress?.({
|
|
45
|
+
message: `Regrade: ${phaseLabel(name)}`,
|
|
46
|
+
ts: new Date().toISOString(),
|
|
47
|
+
type: 'start',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const result = await operation();
|
|
51
|
+
const durationMs = elapsedMilliseconds(startedAt, this.#now());
|
|
52
|
+
if (result.isErr()) {
|
|
53
|
+
this.#progress?.({
|
|
54
|
+
message: `Regrade: ${phaseLabel(name)} failed (${durationMs} ms)`,
|
|
55
|
+
ts: new Date().toISOString(),
|
|
56
|
+
type: 'error',
|
|
57
|
+
});
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.#phases.push({ durationMs, name, status: 'completed' });
|
|
62
|
+
this.#progress?.({
|
|
63
|
+
message: `Regrade: ${phaseLabel(name)} complete (${durationMs} ms)`,
|
|
64
|
+
ts: new Date().toISOString(),
|
|
65
|
+
type: 'complete',
|
|
66
|
+
});
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
summary(): RegradeLifecycle {
|
|
71
|
+
return {
|
|
72
|
+
durationMs: elapsedMilliseconds(this.#startedAt, this.#now()),
|
|
73
|
+
phases: [...this.#phases],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|