@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,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;