@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,446 @@
|
|
|
1
|
+
import { InternalError, Result, ValidationError } from '@ontrails/core';
|
|
2
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
3
|
+
import {
|
|
4
|
+
canonicalRegradeJson,
|
|
5
|
+
regradeClassifiedStateHash,
|
|
6
|
+
regradeHistoryReceiptSchema,
|
|
7
|
+
regradeReceiptContentHash,
|
|
8
|
+
regradeReceiptPlanContentHash,
|
|
9
|
+
regradeReceiptPlanSchema,
|
|
10
|
+
resolveRegradeHistoryReceipt,
|
|
11
|
+
serializeRegradeHistoryReceipt,
|
|
12
|
+
} from '@ontrails/regrade';
|
|
13
|
+
import type {
|
|
14
|
+
RegradeFormJudgment,
|
|
15
|
+
RegradeHistoryReceipt,
|
|
16
|
+
RegradeReport,
|
|
17
|
+
ResolvedRegradeHistoryReceipt,
|
|
18
|
+
} from '@ontrails/regrade';
|
|
19
|
+
import { listGovernedVocabularyTransitions } from '@ontrails/warden';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
import { trailsPackageVersion } from '../versions.js';
|
|
26
|
+
import { regradeSourceHash } from './plan-artifact.js';
|
|
27
|
+
import type { RegradePlanArtifact } from './plan-artifact.js';
|
|
28
|
+
|
|
29
|
+
export interface RegradeChangedFileBefore {
|
|
30
|
+
readonly afterPath: string;
|
|
31
|
+
readonly beforeBlobHash: string;
|
|
32
|
+
readonly beforePath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RegradeChangedFileEvidence extends RegradeChangedFileBefore {
|
|
36
|
+
readonly afterBlobHash: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const gitBlobHash = (
|
|
40
|
+
rootDir: string,
|
|
41
|
+
content: Uint8Array
|
|
42
|
+
): TrailsResult<string, InternalError> => {
|
|
43
|
+
const result = spawnSync('git', ['-C', rootDir, 'hash-object', '--stdin'], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
input: content,
|
|
46
|
+
});
|
|
47
|
+
const hash = result.stdout.trim();
|
|
48
|
+
if (result.status !== 0 || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(hash)) {
|
|
49
|
+
return Result.err(
|
|
50
|
+
new InternalError(
|
|
51
|
+
'Failed to derive Git blob identity for Regrade receipt.',
|
|
52
|
+
{
|
|
53
|
+
context: { rootDir, stderr: result.stderr.trim() },
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return Result.ok(hash);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const fileBlobHash = (
|
|
62
|
+
rootDir: string,
|
|
63
|
+
relativePath: string
|
|
64
|
+
): TrailsResult<string, InternalError> => {
|
|
65
|
+
const absolutePath = join(rootDir, relativePath);
|
|
66
|
+
if (!existsSync(absolutePath)) {
|
|
67
|
+
return Result.err(
|
|
68
|
+
new InternalError('Changed file is missing for Regrade receipt.', {
|
|
69
|
+
context: { path: relativePath },
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
let content: Uint8Array;
|
|
74
|
+
try {
|
|
75
|
+
content = readFileSync(absolutePath);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return Result.err(
|
|
78
|
+
new InternalError('Failed to read changed file for Regrade receipt.', {
|
|
79
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
80
|
+
context: { path: relativePath },
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return gitBlobHash(rootDir, content);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const changedPathPairs = (
|
|
88
|
+
artifact: RegradePlanArtifact,
|
|
89
|
+
report: RegradeReport
|
|
90
|
+
): readonly { readonly afterPath: string; readonly beforePath: string }[] => {
|
|
91
|
+
const renames =
|
|
92
|
+
artifact.plan.kind === 'vocabulary'
|
|
93
|
+
? new Map(
|
|
94
|
+
(artifact.plan.fileRenames ?? []).map((rename) => [
|
|
95
|
+
rename.from,
|
|
96
|
+
rename.to,
|
|
97
|
+
])
|
|
98
|
+
)
|
|
99
|
+
: new Map<string, string>();
|
|
100
|
+
const pairs = new Map<
|
|
101
|
+
string,
|
|
102
|
+
{ readonly afterPath: string; readonly beforePath: string }
|
|
103
|
+
>();
|
|
104
|
+
for (const entry of report.entries) {
|
|
105
|
+
if (entry.outcome !== 'rewrite' && entry.outcome !== 'needs-review') {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const beforePath = entry.path;
|
|
109
|
+
const afterPath = renames.get(beforePath) ?? beforePath;
|
|
110
|
+
pairs.set(`${beforePath}\u0000${afterPath}`, { afterPath, beforePath });
|
|
111
|
+
}
|
|
112
|
+
return [...pairs.values()].toSorted(
|
|
113
|
+
(left, right) =>
|
|
114
|
+
left.beforePath.localeCompare(right.beforePath) ||
|
|
115
|
+
left.afterPath.localeCompare(right.afterPath)
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const captureRegradeChangedFilesBefore = (params: {
|
|
120
|
+
readonly artifact: RegradePlanArtifact;
|
|
121
|
+
readonly report: RegradeReport;
|
|
122
|
+
readonly rootDir: string;
|
|
123
|
+
}): TrailsResult<readonly RegradeChangedFileBefore[], InternalError> => {
|
|
124
|
+
const captures: RegradeChangedFileBefore[] = [];
|
|
125
|
+
for (const pair of changedPathPairs(params.artifact, params.report)) {
|
|
126
|
+
const beforeBlobHash = fileBlobHash(params.rootDir, pair.beforePath);
|
|
127
|
+
if (beforeBlobHash.isErr()) {
|
|
128
|
+
return beforeBlobHash;
|
|
129
|
+
}
|
|
130
|
+
captures.push({ ...pair, beforeBlobHash: beforeBlobHash.value });
|
|
131
|
+
}
|
|
132
|
+
return Result.ok(captures);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const completeRegradeChangedFiles = (params: {
|
|
136
|
+
readonly before: readonly RegradeChangedFileBefore[];
|
|
137
|
+
readonly rootDir: string;
|
|
138
|
+
}): TrailsResult<readonly RegradeChangedFileEvidence[], InternalError> => {
|
|
139
|
+
const completed: RegradeChangedFileEvidence[] = [];
|
|
140
|
+
for (const capture of params.before) {
|
|
141
|
+
const afterBlobHash = fileBlobHash(params.rootDir, capture.afterPath);
|
|
142
|
+
if (afterBlobHash.isErr()) {
|
|
143
|
+
return afterBlobHash;
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
capture.beforePath === capture.afterPath &&
|
|
147
|
+
capture.beforeBlobHash === afterBlobHash.value
|
|
148
|
+
) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
completed.push({ ...capture, afterBlobHash: afterBlobHash.value });
|
|
152
|
+
}
|
|
153
|
+
return Result.ok(completed);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const formDisposition = (
|
|
157
|
+
occurrence: NonNullable<RegradeReport['run']>['ledger']['occurrences'][number]
|
|
158
|
+
): RegradeFormJudgment['disposition'] => {
|
|
159
|
+
if (occurrence.verdict === 'applied' || occurrence.verdict === 'modified') {
|
|
160
|
+
return 'mapped';
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
occurrence.verdict === 'deferred' ||
|
|
164
|
+
occurrence.disposition === 'in-family-unresolved'
|
|
165
|
+
) {
|
|
166
|
+
return 'unresolved';
|
|
167
|
+
}
|
|
168
|
+
if (occurrence.disposition === 'out-of-family') {
|
|
169
|
+
return 'out-of-family';
|
|
170
|
+
}
|
|
171
|
+
return 'preserved';
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const formJudgments = (
|
|
175
|
+
report: RegradeReport
|
|
176
|
+
): readonly RegradeFormJudgment[] => {
|
|
177
|
+
const caseSensitive = report.run?.plan.caseSensitive === true;
|
|
178
|
+
const byIdentity = new Map<string, RegradeFormJudgment>();
|
|
179
|
+
for (const occurrence of report.run?.ledger.occurrences ?? []) {
|
|
180
|
+
const disposition = formDisposition(occurrence);
|
|
181
|
+
const judgment: RegradeFormJudgment = {
|
|
182
|
+
disposition,
|
|
183
|
+
form: occurrence.form,
|
|
184
|
+
reason: occurrence.reason,
|
|
185
|
+
representative: { line: occurrence.line, path: occurrence.path },
|
|
186
|
+
...(disposition === 'mapped' && occurrence.replacement !== undefined
|
|
187
|
+
? { target: occurrence.replacement }
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
const identity = caseSensitive
|
|
191
|
+
? occurrence.form
|
|
192
|
+
: occurrence.form.toLowerCase();
|
|
193
|
+
const current = byIdentity.get(identity);
|
|
194
|
+
if (current === undefined || disposition === 'unresolved') {
|
|
195
|
+
byIdentity.set(identity, judgment);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...byIdentity.values()];
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const resolveRegradeSourceRevision = (
|
|
202
|
+
rootDir: string
|
|
203
|
+
): TrailsResult<string, InternalError> => {
|
|
204
|
+
const result = spawnSync('git', ['-C', rootDir, 'rev-parse', 'HEAD'], {
|
|
205
|
+
encoding: 'utf8',
|
|
206
|
+
});
|
|
207
|
+
const revision = result.stdout.trim();
|
|
208
|
+
if (
|
|
209
|
+
result.status !== 0 ||
|
|
210
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(revision)
|
|
211
|
+
) {
|
|
212
|
+
return Result.err(
|
|
213
|
+
new InternalError(
|
|
214
|
+
'Failed to resolve source revision for Regrade receipt.',
|
|
215
|
+
{
|
|
216
|
+
context: { rootDir, stderr: result.stderr.trim() },
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return Result.ok(revision);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const validateRegradeReceiptPlan = (
|
|
225
|
+
artifact: RegradePlanArtifact
|
|
226
|
+
): TrailsResult<RegradePlanArtifact, ValidationError> => {
|
|
227
|
+
const parsed = regradeReceiptPlanSchema.safeParse(artifact.plan);
|
|
228
|
+
if (!parsed.success) {
|
|
229
|
+
return Result.err(
|
|
230
|
+
new ValidationError('Invalid authored plan for Regrade receipt.', {
|
|
231
|
+
context: { issues: parsed.error.issues },
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return Result.ok(artifact);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const completionFacts = (
|
|
239
|
+
report: RegradeReport,
|
|
240
|
+
completedReport: RegradeReport,
|
|
241
|
+
changedFiles: readonly RegradeChangedFileEvidence[],
|
|
242
|
+
forms: readonly RegradeFormJudgment[]
|
|
243
|
+
) => {
|
|
244
|
+
const vocabularyGate = completedReport.run?.report.gate;
|
|
245
|
+
const remaining =
|
|
246
|
+
vocabularyGate?.remaining ??
|
|
247
|
+
completedReport.review + completedReport.unknownClassIds.length;
|
|
248
|
+
const reasons =
|
|
249
|
+
vocabularyGate?.reasons ??
|
|
250
|
+
(remaining === 0 ? [] : ['review-or-unknown-work-remains']);
|
|
251
|
+
const dispositions = report.run?.report.dispositions ?? {};
|
|
252
|
+
const preserved = forms.filter(
|
|
253
|
+
(form) => form.disposition === 'preserved'
|
|
254
|
+
).length;
|
|
255
|
+
const rewritten = report.apply?.applied ?? report.rewritten;
|
|
256
|
+
return {
|
|
257
|
+
counts: {
|
|
258
|
+
dispositions,
|
|
259
|
+
matched: report.matched,
|
|
260
|
+
preserved,
|
|
261
|
+
review: report.review,
|
|
262
|
+
rewritten,
|
|
263
|
+
skippedByReason: report.skipsByReason,
|
|
264
|
+
unknown: report.unknownClassIds.length,
|
|
265
|
+
},
|
|
266
|
+
gate: {
|
|
267
|
+
reasons,
|
|
268
|
+
remaining,
|
|
269
|
+
status:
|
|
270
|
+
remaining === 0 && reasons.length === 0
|
|
271
|
+
? ('green' as const)
|
|
272
|
+
: ('open' as const),
|
|
273
|
+
},
|
|
274
|
+
metrics: {
|
|
275
|
+
filesChanged: changedFiles.length,
|
|
276
|
+
formsMapped: forms.filter((form) => form.disposition === 'mapped').length,
|
|
277
|
+
occurrencesRewritten: rewritten,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const proofCompletionFacts = () => ({
|
|
283
|
+
counts: {
|
|
284
|
+
dispositions: {},
|
|
285
|
+
matched: 0,
|
|
286
|
+
preserved: 0,
|
|
287
|
+
review: 0,
|
|
288
|
+
rewritten: 0,
|
|
289
|
+
skippedByReason: {},
|
|
290
|
+
unknown: 0,
|
|
291
|
+
},
|
|
292
|
+
gate: { reasons: [], remaining: 0, status: 'green' as const },
|
|
293
|
+
metrics: { filesChanged: 0, formsMapped: 0, occurrencesRewritten: 0 },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const receiptRunKind = (
|
|
297
|
+
proof: boolean,
|
|
298
|
+
hasPrior: boolean
|
|
299
|
+
): 'adjust' | 'original' | 'proof' => {
|
|
300
|
+
if (proof) {
|
|
301
|
+
return 'proof';
|
|
302
|
+
}
|
|
303
|
+
if (hasPrior) {
|
|
304
|
+
return 'adjust';
|
|
305
|
+
}
|
|
306
|
+
return 'original';
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export const buildRegradeHistoryReceipt = (params: {
|
|
310
|
+
readonly artifact: RegradePlanArtifact;
|
|
311
|
+
readonly changedFiles: readonly RegradeChangedFileEvidence[];
|
|
312
|
+
readonly completedReport: RegradeReport;
|
|
313
|
+
readonly historyPath: string;
|
|
314
|
+
readonly prior?: ResolvedRegradeHistoryReceipt;
|
|
315
|
+
readonly report: RegradeReport;
|
|
316
|
+
readonly rootDir: string;
|
|
317
|
+
readonly sourceRevision: string;
|
|
318
|
+
readonly transitionId: string;
|
|
319
|
+
}): TrailsResult<RegradeHistoryReceipt, Error> => {
|
|
320
|
+
const parsedPlan = regradeReceiptPlanSchema.safeParse(params.artifact.plan);
|
|
321
|
+
if (!parsedPlan.success) {
|
|
322
|
+
return Result.err(
|
|
323
|
+
new ValidationError('Invalid authored plan for Regrade receipt.', {
|
|
324
|
+
context: { issues: parsedPlan.error.issues },
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const planContentHash = regradeReceiptPlanContentHash({
|
|
329
|
+
plan: parsedPlan.data,
|
|
330
|
+
provenance: params.artifact.provenance,
|
|
331
|
+
});
|
|
332
|
+
const forms = formJudgments(params.report);
|
|
333
|
+
const caseSensitive =
|
|
334
|
+
parsedPlan.data.kind === 'vocabulary' &&
|
|
335
|
+
parsedPlan.data.caseSensitive === true;
|
|
336
|
+
const stateHash = regradeClassifiedStateHash({ caseSensitive, forms });
|
|
337
|
+
const last = params.prior?.runs.at(-1);
|
|
338
|
+
const stateUnchanged = last?.classifiedState.stateHash === stateHash;
|
|
339
|
+
const planMatchesLast =
|
|
340
|
+
last !== undefined &&
|
|
341
|
+
last.receipt.intent.planContentHash === planContentHash;
|
|
342
|
+
const effectiveForms = stateUnchanged ? last.classifiedState.forms : forms;
|
|
343
|
+
const effectiveStateHash = stateUnchanged
|
|
344
|
+
? last.classifiedState.stateHash
|
|
345
|
+
: stateHash;
|
|
346
|
+
const completion = completionFacts(
|
|
347
|
+
params.report,
|
|
348
|
+
params.completedReport,
|
|
349
|
+
params.changedFiles,
|
|
350
|
+
effectiveForms
|
|
351
|
+
);
|
|
352
|
+
const proof =
|
|
353
|
+
planMatchesLast &&
|
|
354
|
+
stateUnchanged &&
|
|
355
|
+
params.changedFiles.length === 0 &&
|
|
356
|
+
params.report.review === 0 &&
|
|
357
|
+
params.report.unknownClassIds.length === 0 &&
|
|
358
|
+
completion.gate.status === 'green';
|
|
359
|
+
const timestamp = new Date().toISOString();
|
|
360
|
+
const runId = createHash('sha256')
|
|
361
|
+
.update(
|
|
362
|
+
canonicalRegradeJson({
|
|
363
|
+
planContentHash,
|
|
364
|
+
sourceRevision: params.sourceRevision,
|
|
365
|
+
timestamp,
|
|
366
|
+
transitionId: params.transitionId,
|
|
367
|
+
})
|
|
368
|
+
)
|
|
369
|
+
.digest('hex')
|
|
370
|
+
.slice(0, 16);
|
|
371
|
+
const runKind = receiptRunKind(proof, params.prior !== undefined);
|
|
372
|
+
const run = {
|
|
373
|
+
classifiedState: stateUnchanged
|
|
374
|
+
? ({ kind: 'reference', stateHash: effectiveStateHash } as const)
|
|
375
|
+
: ({
|
|
376
|
+
caseSensitive,
|
|
377
|
+
forms: effectiveForms,
|
|
378
|
+
kind: 'embedded',
|
|
379
|
+
stateHash: effectiveStateHash,
|
|
380
|
+
} as const),
|
|
381
|
+
completion: proof ? proofCompletionFacts() : completion,
|
|
382
|
+
evidence: {
|
|
383
|
+
changedFiles: params.changedFiles,
|
|
384
|
+
detailEvidenceHash: regradeReceiptContentHash(params.completedReport),
|
|
385
|
+
lockStateHash: regradeSourceHash(params.report),
|
|
386
|
+
policyHash: regradeReceiptContentHash(
|
|
387
|
+
listGovernedVocabularyTransitions()
|
|
388
|
+
),
|
|
389
|
+
sourceRevision: params.sourceRevision,
|
|
390
|
+
sourceStateHash: regradeSourceHash(params.completedReport),
|
|
391
|
+
toolVersion: trailsPackageVersion,
|
|
392
|
+
},
|
|
393
|
+
intent: proof
|
|
394
|
+
? ({ kind: 'reference', planContentHash } as const)
|
|
395
|
+
: ({
|
|
396
|
+
kind: 'embedded',
|
|
397
|
+
plan: parsedPlan.data,
|
|
398
|
+
planContentHash,
|
|
399
|
+
provenance: params.artifact.provenance,
|
|
400
|
+
} as const),
|
|
401
|
+
project: { root: '.' as const },
|
|
402
|
+
runId,
|
|
403
|
+
runKind,
|
|
404
|
+
timestamp,
|
|
405
|
+
transitionId: params.transitionId,
|
|
406
|
+
};
|
|
407
|
+
const receipt = {
|
|
408
|
+
conversion: params.prior?.artifact.conversion,
|
|
409
|
+
id: params.transitionId,
|
|
410
|
+
kind: 'regrade-history',
|
|
411
|
+
path: params.historyPath,
|
|
412
|
+
runs: [...(params.prior?.artifact.runs ?? []), run],
|
|
413
|
+
schemaVersion: 3,
|
|
414
|
+
};
|
|
415
|
+
const parsed = regradeHistoryReceiptSchema.safeParse(receipt);
|
|
416
|
+
if (!parsed.success) {
|
|
417
|
+
return Result.err(
|
|
418
|
+
new ValidationError('Invalid Regrade history receipt.', {
|
|
419
|
+
context: { issues: parsed.error.issues, path: params.historyPath },
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return Result.ok(parsed.data);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
export const readRegradeReceipt = (
|
|
427
|
+
path: string
|
|
428
|
+
): TrailsResult<
|
|
429
|
+
ResolvedRegradeHistoryReceipt,
|
|
430
|
+
InternalError | ValidationError
|
|
431
|
+
> => {
|
|
432
|
+
let value: unknown;
|
|
433
|
+
try {
|
|
434
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return Result.err(
|
|
437
|
+
new InternalError('Failed to read Regrade history receipt.', {
|
|
438
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
439
|
+
context: { path },
|
|
440
|
+
})
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return resolveRegradeHistoryReceipt(value);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export { serializeRegradeHistoryReceipt };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
Result,
|
|
4
|
+
ValidationError,
|
|
5
|
+
deriveSafePath,
|
|
6
|
+
} from '@ontrails/core';
|
|
7
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
8
|
+
import type { RegradeReport } from '@ontrails/regrade';
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import type { Stats } from 'node:fs';
|
|
18
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
19
|
+
|
|
20
|
+
export interface RegradeSourceSnapshot {
|
|
21
|
+
readonly absolutePath: string;
|
|
22
|
+
readonly mode: number | null;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly source: Uint8Array | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const inspectRegradeMutationPath = (params: {
|
|
28
|
+
readonly absolutePath: string;
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly rootDir: string;
|
|
31
|
+
}): TrailsResult<Stats | null, Error> => {
|
|
32
|
+
const root = resolve(params.rootDir);
|
|
33
|
+
const segments = relative(root, params.absolutePath)
|
|
34
|
+
.split(sep)
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
let currentPath = root;
|
|
37
|
+
let leafStats: Stats | null = null;
|
|
38
|
+
for (const segment of segments) {
|
|
39
|
+
currentPath = join(currentPath, segment);
|
|
40
|
+
try {
|
|
41
|
+
leafStats = lstatSync(currentPath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
44
|
+
return Result.ok(null);
|
|
45
|
+
}
|
|
46
|
+
return Result.err(
|
|
47
|
+
new InternalError(
|
|
48
|
+
'Failed to inspect a Regrade mutation path before apply.',
|
|
49
|
+
{
|
|
50
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
51
|
+
context: { path: params.path },
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (leafStats.isSymbolicLink()) {
|
|
57
|
+
return Result.err(
|
|
58
|
+
new ValidationError(
|
|
59
|
+
'Regrade apply cannot transactionally mutate through symbolic links.',
|
|
60
|
+
{ context: { path: params.path } }
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Result.ok(leafStats);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const snapshotRegradeSources = (params: {
|
|
69
|
+
readonly optionalPaths?: readonly string[] | undefined;
|
|
70
|
+
readonly reports: readonly (RegradeReport | null)[];
|
|
71
|
+
readonly rootDir: string;
|
|
72
|
+
}): TrailsResult<readonly RegradeSourceSnapshot[], Error> => {
|
|
73
|
+
const requiredPaths = new Set(
|
|
74
|
+
params.reports.flatMap(
|
|
75
|
+
(report) =>
|
|
76
|
+
report?.entries
|
|
77
|
+
.filter(
|
|
78
|
+
(entry) =>
|
|
79
|
+
entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
80
|
+
)
|
|
81
|
+
.map((entry) => entry.path) ?? []
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
const paths = new Map([...requiredPaths].map((path) => [path, false]));
|
|
85
|
+
for (const path of params.optionalPaths ?? []) {
|
|
86
|
+
if (!paths.has(path)) {
|
|
87
|
+
paths.set(path, true);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const snapshots: RegradeSourceSnapshot[] = [];
|
|
91
|
+
try {
|
|
92
|
+
for (const [path, optional] of paths) {
|
|
93
|
+
const absolutePath = deriveSafePath(params.rootDir, path);
|
|
94
|
+
if (absolutePath.isErr()) {
|
|
95
|
+
return absolutePath;
|
|
96
|
+
}
|
|
97
|
+
const inspected = inspectRegradeMutationPath({
|
|
98
|
+
absolutePath: absolutePath.value,
|
|
99
|
+
path,
|
|
100
|
+
rootDir: params.rootDir,
|
|
101
|
+
});
|
|
102
|
+
if (inspected.isErr()) {
|
|
103
|
+
return inspected;
|
|
104
|
+
}
|
|
105
|
+
const stats = inspected.value;
|
|
106
|
+
if (optional && stats === null) {
|
|
107
|
+
snapshots.push({
|
|
108
|
+
absolutePath: absolutePath.value,
|
|
109
|
+
mode: null,
|
|
110
|
+
path,
|
|
111
|
+
source: null,
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (stats === null) {
|
|
116
|
+
return Result.err(
|
|
117
|
+
new InternalError(
|
|
118
|
+
'Failed to snapshot a missing Regrade source before apply.',
|
|
119
|
+
{ context: { path } }
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
snapshots.push({
|
|
124
|
+
absolutePath: absolutePath.value,
|
|
125
|
+
mode: stats.mode % 0o1_0000,
|
|
126
|
+
path,
|
|
127
|
+
source: readFileSync(absolutePath.value),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return Result.err(
|
|
132
|
+
new InternalError(
|
|
133
|
+
'Failed to snapshot Regrade sources before apply.',
|
|
134
|
+
error instanceof Error ? { cause: error } : {}
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return Result.ok(snapshots);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const rollbackRegradeSources = (
|
|
142
|
+
snapshots: readonly RegradeSourceSnapshot[]
|
|
143
|
+
): Error | null => {
|
|
144
|
+
const failures: { readonly message: string; readonly path: string }[] = [];
|
|
145
|
+
for (const snapshot of snapshots) {
|
|
146
|
+
try {
|
|
147
|
+
if (snapshot.source === null) {
|
|
148
|
+
rmSync(snapshot.absolutePath, { force: true });
|
|
149
|
+
} else {
|
|
150
|
+
mkdirSync(dirname(snapshot.absolutePath), { recursive: true });
|
|
151
|
+
writeFileSync(snapshot.absolutePath, snapshot.source);
|
|
152
|
+
if (snapshot.mode !== null) {
|
|
153
|
+
chmodSync(snapshot.absolutePath, snapshot.mode);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
failures.push({
|
|
158
|
+
message:
|
|
159
|
+
(error as NodeJS.ErrnoException).code ??
|
|
160
|
+
(error instanceof Error ? error.name : 'Unknown rollback failure'),
|
|
161
|
+
path: snapshot.path,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return failures.length === 0
|
|
166
|
+
? null
|
|
167
|
+
: new Error(
|
|
168
|
+
`Failed to restore Regrade sources: ${JSON.stringify(failures)}`
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const regradeApplyErrorAfterRollback = (
|
|
173
|
+
error: Error,
|
|
174
|
+
snapshots: readonly RegradeSourceSnapshot[]
|
|
175
|
+
): TrailsResult<never, Error> => {
|
|
176
|
+
const rollbackError = rollbackRegradeSources(snapshots);
|
|
177
|
+
return rollbackError === null
|
|
178
|
+
? Result.err(error)
|
|
179
|
+
: Result.err(
|
|
180
|
+
new InternalError('Regrade apply failed and source rollback failed.', {
|
|
181
|
+
cause: error,
|
|
182
|
+
context: { rollbackError: rollbackError.message },
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const releaseBindingKindValues = ['native', 'adapter'] as const;
|
|
2
|
+
export type ReleaseBindingKind = (typeof releaseBindingKindValues)[number];
|
|
3
|
+
|
|
4
|
+
export const releaseBindingPlacementValues = [
|
|
5
|
+
'same-package',
|
|
6
|
+
'subpath',
|
|
7
|
+
'extracted',
|
|
8
|
+
] as const;
|
|
9
|
+
export type ReleaseBindingPlacement =
|
|
10
|
+
(typeof releaseBindingPlacementValues)[number];
|
|
11
|
+
|
|
12
|
+
export const releaseBindingCapabilityValues = [
|
|
13
|
+
'pack-check',
|
|
14
|
+
'publish',
|
|
15
|
+
'registry-preflight',
|
|
16
|
+
] as const;
|
|
17
|
+
export type ReleaseBindingCapability =
|
|
18
|
+
(typeof releaseBindingCapabilityValues)[number];
|
|
19
|
+
|
|
20
|
+
export interface ReleaseBindingDescriptor {
|
|
21
|
+
readonly boundary: 'foreign' | 'trails-owned';
|
|
22
|
+
readonly capabilities: readonly ReleaseBindingCapability[];
|
|
23
|
+
readonly description: string;
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly kind: ReleaseBindingKind;
|
|
26
|
+
readonly placement: ReleaseBindingPlacement;
|
|
27
|
+
readonly runtime: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const nativeBunPackBinding = {
|
|
31
|
+
boundary: 'trails-owned',
|
|
32
|
+
capabilities: ['pack-check'],
|
|
33
|
+
description:
|
|
34
|
+
'Native Bun release binding for Trails-owned package packing and validation.',
|
|
35
|
+
id: 'release.binding.native-bun',
|
|
36
|
+
kind: 'native',
|
|
37
|
+
placement: 'same-package',
|
|
38
|
+
runtime: 'bun',
|
|
39
|
+
} satisfies ReleaseBindingDescriptor;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @deprecated Use `nativeBunPackBinding`. This alias preserves the exported
|
|
43
|
+
* name while correcting its descriptor to the Bun-owned pack boundary.
|
|
44
|
+
*/
|
|
45
|
+
export const nativeBunReleaseBinding = Object.freeze({
|
|
46
|
+
...nativeBunPackBinding,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const npmReleaseAdapterBinding = {
|
|
50
|
+
boundary: 'foreign',
|
|
51
|
+
capabilities: ['publish', 'registry-preflight'],
|
|
52
|
+
description:
|
|
53
|
+
'Same-package npm adapter binding for trusted publication, registry preflight, and lockstep recovery.',
|
|
54
|
+
id: 'release.binding.npm',
|
|
55
|
+
kind: 'adapter',
|
|
56
|
+
placement: 'same-package',
|
|
57
|
+
runtime: 'npm',
|
|
58
|
+
} satisfies ReleaseBindingDescriptor;
|