@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,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAstIdentifierRenameClass,
|
|
3
|
+
deriveFileRenameCandidates,
|
|
4
|
+
deriveVocabularyFormProposals,
|
|
5
|
+
runFileRenameRegrade,
|
|
6
|
+
runRegrade,
|
|
7
|
+
} from '@ontrails/regrade';
|
|
8
|
+
import type {
|
|
9
|
+
RegradeReport,
|
|
10
|
+
VocabularyPreserveInventoryEntry,
|
|
11
|
+
VocabularyRegradePlan,
|
|
12
|
+
} from '@ontrails/regrade';
|
|
13
|
+
|
|
14
|
+
import { isGeneratedRegradeArtifactPath } from './plan-artifact.js';
|
|
15
|
+
import type {
|
|
16
|
+
RegradePlanArtifact,
|
|
17
|
+
RegradePlanDerivation,
|
|
18
|
+
} from './plan-artifact.js';
|
|
19
|
+
|
|
20
|
+
const provenanceForForm = (
|
|
21
|
+
source: RegradePlanDerivation['forms'][number]['source'],
|
|
22
|
+
fields: RegradePlanArtifact['provenance']['fields']
|
|
23
|
+
): 'authored' | 'derived' => {
|
|
24
|
+
if (source === 'seed') {
|
|
25
|
+
return 'authored';
|
|
26
|
+
}
|
|
27
|
+
if (source === 'plan-override') {
|
|
28
|
+
return fields['overrides'] ?? 'derived';
|
|
29
|
+
}
|
|
30
|
+
if (source === 'plan-defer') {
|
|
31
|
+
return fields['deferForms'] ?? 'derived';
|
|
32
|
+
}
|
|
33
|
+
return 'derived';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const namespaceCensus = (
|
|
37
|
+
report: RegradeReport
|
|
38
|
+
): RegradePlanDerivation['namespaces'] => {
|
|
39
|
+
const namespaces = new Map<
|
|
40
|
+
string,
|
|
41
|
+
{ inScope: number; policyClassified: number }
|
|
42
|
+
>();
|
|
43
|
+
for (const occurrence of report.run?.ledger.occurrences ?? []) {
|
|
44
|
+
if (isGeneratedRegradeArtifactPath(occurrence.path)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const namespace = occurrence.path.split('/')[0] ?? occurrence.path;
|
|
48
|
+
const counts = namespaces.get(namespace) ?? {
|
|
49
|
+
inScope: 0,
|
|
50
|
+
policyClassified: 0,
|
|
51
|
+
};
|
|
52
|
+
if (occurrence.scopeTier === 'policy-classified') {
|
|
53
|
+
counts.policyClassified += 1;
|
|
54
|
+
} else {
|
|
55
|
+
counts.inScope += 1;
|
|
56
|
+
}
|
|
57
|
+
namespaces.set(namespace, counts);
|
|
58
|
+
}
|
|
59
|
+
return [...namespaces.entries()]
|
|
60
|
+
.map(([namespace, counts]) => ({
|
|
61
|
+
...counts,
|
|
62
|
+
namespace,
|
|
63
|
+
provenance: 'derived' as const,
|
|
64
|
+
}))
|
|
65
|
+
.toSorted((left, right) => left.namespace.localeCompare(right.namespace));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const observedReviews = (
|
|
69
|
+
report: RegradeReport
|
|
70
|
+
): RegradePlanDerivation['reviews'] => {
|
|
71
|
+
const reviews = new Map<string, RegradePlanDerivation['reviews'][number]>();
|
|
72
|
+
for (const occurrence of report.run?.ledger.occurrences ?? []) {
|
|
73
|
+
if (
|
|
74
|
+
isGeneratedRegradeArtifactPath(occurrence.path) ||
|
|
75
|
+
(occurrence.verdict !== 'deferred' &&
|
|
76
|
+
occurrence.disposition !== 'code-context-out-of-engine' &&
|
|
77
|
+
occurrence.disposition !== 'in-family-unresolved')
|
|
78
|
+
) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const key = `${occurrence.form}\0${occurrence.reason}`;
|
|
82
|
+
const current = reviews.get(key);
|
|
83
|
+
const evidence = {
|
|
84
|
+
column: occurrence.column,
|
|
85
|
+
line: occurrence.line,
|
|
86
|
+
path: occurrence.path,
|
|
87
|
+
};
|
|
88
|
+
reviews.set(key, {
|
|
89
|
+
evidence:
|
|
90
|
+
current === undefined ? [evidence] : [...current.evidence, evidence],
|
|
91
|
+
provenance: 'derived',
|
|
92
|
+
reason: occurrence.reason,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
value: occurrence.form,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return [...reviews.values()]
|
|
98
|
+
.map((review) => ({
|
|
99
|
+
...review,
|
|
100
|
+
evidence: review.evidence.toSorted((left, right) =>
|
|
101
|
+
left.path === right.path
|
|
102
|
+
? (left.line ?? 0) - (right.line ?? 0)
|
|
103
|
+
: left.path.localeCompare(right.path)
|
|
104
|
+
),
|
|
105
|
+
}))
|
|
106
|
+
.toSorted((left, right) => left.value.localeCompare(right.value));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const identifierReviews = (params: {
|
|
110
|
+
readonly plan: VocabularyRegradePlan;
|
|
111
|
+
readonly rootDir: string;
|
|
112
|
+
}): RegradePlanDerivation['reviews'] => {
|
|
113
|
+
const { scope } = params.plan;
|
|
114
|
+
const sourceForms = [
|
|
115
|
+
...new Map(
|
|
116
|
+
deriveVocabularyFormProposals(params.plan).map((form) => [
|
|
117
|
+
form.from,
|
|
118
|
+
form,
|
|
119
|
+
])
|
|
120
|
+
).values(),
|
|
121
|
+
];
|
|
122
|
+
const collection = {
|
|
123
|
+
...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
|
|
124
|
+
extensions: scope?.extensions ?? ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
|
|
125
|
+
...(scope?.include === undefined ? {} : { include: scope.include }),
|
|
126
|
+
};
|
|
127
|
+
const entries: RegradeReport['entries'][number][] = [];
|
|
128
|
+
for (const form of sourceForms) {
|
|
129
|
+
const report = runRegrade({
|
|
130
|
+
classes: [
|
|
131
|
+
createAstIdentifierRenameClass({
|
|
132
|
+
from: form.from,
|
|
133
|
+
id: `derived-plan-identifiers:${form.from}->${form.to ?? params.plan.to}`,
|
|
134
|
+
match: 'identifier-segment',
|
|
135
|
+
reviewAllMatches: true,
|
|
136
|
+
to: form.to ?? params.plan.to,
|
|
137
|
+
}),
|
|
138
|
+
],
|
|
139
|
+
collection,
|
|
140
|
+
root: params.rootDir,
|
|
141
|
+
});
|
|
142
|
+
if (report.isErr()) {
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
evidence: [],
|
|
146
|
+
provenance: 'derived',
|
|
147
|
+
reason: `identifier-review-scan-failed: ${report.error.message}`,
|
|
148
|
+
status: 'pending',
|
|
149
|
+
value: form.from,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
entries.push(...(report.value?.entries ?? []));
|
|
154
|
+
}
|
|
155
|
+
return entries
|
|
156
|
+
.flatMap((entry) =>
|
|
157
|
+
(entry.reviewDetails ?? []).flatMap((detail) =>
|
|
158
|
+
detail.symbol === undefined
|
|
159
|
+
? []
|
|
160
|
+
: [
|
|
161
|
+
{
|
|
162
|
+
evidence: [
|
|
163
|
+
{
|
|
164
|
+
...(detail.span?.column === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { column: detail.span.column }),
|
|
167
|
+
...(detail.span?.line === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: { line: detail.span.line }),
|
|
170
|
+
path: entry.path,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
provenance: 'derived' as const,
|
|
174
|
+
reason: detail.reason,
|
|
175
|
+
status: 'pending' as const,
|
|
176
|
+
value: detail.symbol,
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
.toSorted((left, right) => left.value.localeCompare(right.value));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const mergeReviews = (
|
|
185
|
+
...groups: readonly RegradePlanDerivation['reviews'][]
|
|
186
|
+
): RegradePlanDerivation['reviews'] => {
|
|
187
|
+
const merged = new Map<string, RegradePlanDerivation['reviews'][number]>();
|
|
188
|
+
for (const review of groups.flat()) {
|
|
189
|
+
const key = `${review.value}\0${review.reason}`;
|
|
190
|
+
const current = merged.get(key);
|
|
191
|
+
merged.set(key, {
|
|
192
|
+
...review,
|
|
193
|
+
evidence: [...(current?.evidence ?? []), ...review.evidence]
|
|
194
|
+
.filter(
|
|
195
|
+
(entry, index, evidence) =>
|
|
196
|
+
evidence.findIndex(
|
|
197
|
+
(candidate) =>
|
|
198
|
+
candidate.path === entry.path &&
|
|
199
|
+
candidate.line === entry.line &&
|
|
200
|
+
candidate.column === entry.column
|
|
201
|
+
) === index
|
|
202
|
+
)
|
|
203
|
+
.toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return [...merged.values()].toSorted((left, right) =>
|
|
207
|
+
left.value === right.value
|
|
208
|
+
? left.reason.localeCompare(right.reason)
|
|
209
|
+
: left.value.localeCompare(right.value)
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const referenceClosure = (params: {
|
|
214
|
+
readonly candidates: ReturnType<typeof deriveFileRenameCandidates>;
|
|
215
|
+
readonly plan: VocabularyRegradePlan;
|
|
216
|
+
readonly rootDir: string;
|
|
217
|
+
}): RegradePlanDerivation['referenceClosure'] => {
|
|
218
|
+
if (params.candidates.length === 0) {
|
|
219
|
+
return { entries: [], moves: [] };
|
|
220
|
+
}
|
|
221
|
+
const result = runFileRenameRegrade({
|
|
222
|
+
excludeGeneratedArtifacts: true,
|
|
223
|
+
renames: params.candidates,
|
|
224
|
+
root: params.rootDir,
|
|
225
|
+
...(params.plan.scope === undefined ? {} : { scope: params.plan.scope }),
|
|
226
|
+
});
|
|
227
|
+
if (result.isErr()) {
|
|
228
|
+
return { entries: [], issue: result.error.message, moves: [] };
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
entries: result.value.report.entries
|
|
232
|
+
.filter(
|
|
233
|
+
(
|
|
234
|
+
entry
|
|
235
|
+
): entry is typeof entry & {
|
|
236
|
+
readonly outcome: 'needs-review' | 'rewrite';
|
|
237
|
+
} => entry.outcome === 'needs-review' || entry.outcome === 'rewrite'
|
|
238
|
+
)
|
|
239
|
+
.map((entry) => ({
|
|
240
|
+
outcome: entry.outcome,
|
|
241
|
+
path: entry.path,
|
|
242
|
+
provenance: 'derived' as const,
|
|
243
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
244
|
+
}))
|
|
245
|
+
.toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
246
|
+
moves: result.value.evidence.map(
|
|
247
|
+
({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
|
|
248
|
+
deferred,
|
|
249
|
+
from,
|
|
250
|
+
historical,
|
|
251
|
+
preserved,
|
|
252
|
+
provenance: 'derived' as const,
|
|
253
|
+
rewritten,
|
|
254
|
+
skipped,
|
|
255
|
+
to,
|
|
256
|
+
})
|
|
257
|
+
),
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const deriveRegradePlanDerivation = (params: {
|
|
262
|
+
readonly plan: VocabularyRegradePlan;
|
|
263
|
+
readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
|
|
264
|
+
readonly provenance: RegradePlanArtifact['provenance'];
|
|
265
|
+
readonly report: RegradeReport;
|
|
266
|
+
readonly rootDir: string;
|
|
267
|
+
}): RegradePlanDerivation => {
|
|
268
|
+
const candidates = deriveFileRenameCandidates({
|
|
269
|
+
plan: params.plan,
|
|
270
|
+
root: params.rootDir,
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
fileRenames: candidates.map((candidate) => ({
|
|
274
|
+
...candidate,
|
|
275
|
+
evidence: [...candidate.evidence],
|
|
276
|
+
provenance: 'derived' as const,
|
|
277
|
+
status: 'pending' as const,
|
|
278
|
+
})),
|
|
279
|
+
forms: deriveVocabularyFormProposals(params.plan).map((form) => ({
|
|
280
|
+
...form,
|
|
281
|
+
provenance: provenanceForForm(form.source, params.provenance.fields),
|
|
282
|
+
})),
|
|
283
|
+
namespaces: namespaceCensus(params.report),
|
|
284
|
+
preserves: params.preserveInventory.map((entry) => ({
|
|
285
|
+
disposition: 'preserve-current-live-api' as const,
|
|
286
|
+
evidence: [...entry.evidence],
|
|
287
|
+
pattern: entry.pattern,
|
|
288
|
+
provenance: 'derived' as const,
|
|
289
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
290
|
+
})),
|
|
291
|
+
referenceClosure: referenceClosure({
|
|
292
|
+
candidates,
|
|
293
|
+
plan: params.plan,
|
|
294
|
+
rootDir: params.rootDir,
|
|
295
|
+
}),
|
|
296
|
+
reviews: mergeReviews(
|
|
297
|
+
observedReviews(params.report),
|
|
298
|
+
identifierReviews({ plan: params.plan, rootDir: params.rootDir })
|
|
299
|
+
),
|
|
300
|
+
};
|
|
301
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { InternalError, Result, ValidationError } from '@ontrails/core';
|
|
2
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
3
|
+
import {
|
|
4
|
+
regradeReceiptContentHash,
|
|
5
|
+
regradeReceiptPlanContentHash,
|
|
6
|
+
regradeReceiptPlanSchema,
|
|
7
|
+
} from '@ontrails/regrade';
|
|
8
|
+
import type {
|
|
9
|
+
PreparedRegradeRunIdentity,
|
|
10
|
+
RegradeClass,
|
|
11
|
+
} from '@ontrails/regrade';
|
|
12
|
+
import { listGovernedVocabularyTransitions } from '@ontrails/warden';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { trailsPackageVersion } from '../versions.js';
|
|
18
|
+
import { canonicalJsonStringify } from './plan-artifact.js';
|
|
19
|
+
import type { RegradePlanArtifact } from './plan-artifact.js';
|
|
20
|
+
|
|
21
|
+
const ignoredLockDirectories = new Set([
|
|
22
|
+
'.agents',
|
|
23
|
+
'.git',
|
|
24
|
+
'.turbo',
|
|
25
|
+
'dist',
|
|
26
|
+
'node_modules',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const normalizePath = (path: string): string => path.split(sep).join('/');
|
|
30
|
+
|
|
31
|
+
const compareCodeUnits = (left: string, right: string): number => {
|
|
32
|
+
if (left < right) {
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
if (left > right) {
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const loaderForModuleExtension = (
|
|
42
|
+
extension: string
|
|
43
|
+
): 'js' | 'jsx' | 'ts' | 'tsx' => {
|
|
44
|
+
if (extension === '.tsx') {
|
|
45
|
+
return 'tsx';
|
|
46
|
+
}
|
|
47
|
+
if (extension === '.jsx') {
|
|
48
|
+
return 'jsx';
|
|
49
|
+
}
|
|
50
|
+
return ['.cts', '.mts', '.ts'].includes(extension) ? 'ts' : 'js';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const collectLockPaths = (rootDir: string): readonly string[] => {
|
|
54
|
+
const paths: string[] = [];
|
|
55
|
+
const visit = (directory: string): void => {
|
|
56
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
if (!ignoredLockDirectories.has(entry.name)) {
|
|
59
|
+
visit(join(directory, entry.name));
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (entry.isFile() && entry.name === 'trails.lock') {
|
|
64
|
+
paths.push(join(directory, entry.name));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
visit(rootDir);
|
|
69
|
+
return paths.toSorted(compareCodeUnits);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const lockStateHash = (
|
|
73
|
+
rootDir: string
|
|
74
|
+
): TrailsResult<string, InternalError> => {
|
|
75
|
+
try {
|
|
76
|
+
const locks = collectLockPaths(rootDir).map((absolutePath) => ({
|
|
77
|
+
contentHash: createHash('sha256')
|
|
78
|
+
.update(readFileSync(absolutePath))
|
|
79
|
+
.digest('hex'),
|
|
80
|
+
path: normalizePath(relative(rootDir, absolutePath)),
|
|
81
|
+
}));
|
|
82
|
+
return Result.ok(regradeReceiptContentHash(locks));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return Result.err(
|
|
85
|
+
new InternalError('Failed to derive Regrade lock identity.', {
|
|
86
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
87
|
+
context: { rootDir },
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const projectRuleStateHash = (
|
|
94
|
+
rootDir: string
|
|
95
|
+
): TrailsResult<string, InternalError> => {
|
|
96
|
+
try {
|
|
97
|
+
const root = realpathSync(rootDir);
|
|
98
|
+
const entryPaths: string[] = [];
|
|
99
|
+
const rulesFile = join(root, '.trails/rules.ts');
|
|
100
|
+
const rulesDirectory = join(root, '.trails/rules');
|
|
101
|
+
if (existsSync(rulesFile)) {
|
|
102
|
+
entryPaths.push(rulesFile);
|
|
103
|
+
}
|
|
104
|
+
if (existsSync(rulesDirectory)) {
|
|
105
|
+
for (const entry of readdirSync(rulesDirectory, {
|
|
106
|
+
withFileTypes: true,
|
|
107
|
+
})) {
|
|
108
|
+
if (
|
|
109
|
+
entry.isFile() &&
|
|
110
|
+
entry.name.endsWith('.ts') &&
|
|
111
|
+
!entry.name.endsWith('.test.ts') &&
|
|
112
|
+
!entry.name.startsWith('_')
|
|
113
|
+
) {
|
|
114
|
+
entryPaths.push(join(rulesDirectory, entry.name));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const paths = new Set<string>();
|
|
120
|
+
const visitModule = (absolutePath: string): void => {
|
|
121
|
+
if (paths.has(absolutePath)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
paths.add(absolutePath);
|
|
125
|
+
const extension = extname(absolutePath);
|
|
126
|
+
if (
|
|
127
|
+
![
|
|
128
|
+
'.cjs',
|
|
129
|
+
'.cts',
|
|
130
|
+
'.js',
|
|
131
|
+
'.jsx',
|
|
132
|
+
'.mjs',
|
|
133
|
+
'.mts',
|
|
134
|
+
'.ts',
|
|
135
|
+
'.tsx',
|
|
136
|
+
].includes(extension)
|
|
137
|
+
) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const imports = new Bun.Transpiler({
|
|
141
|
+
loader: loaderForModuleExtension(extension),
|
|
142
|
+
}).scanImports(readFileSync(absolutePath, 'utf8'));
|
|
143
|
+
for (const imported of imports) {
|
|
144
|
+
const resolved = Bun.resolveSync(imported.path, dirname(absolutePath));
|
|
145
|
+
const relativePath = relative(root, resolved);
|
|
146
|
+
if (
|
|
147
|
+
isAbsolute(resolved) &&
|
|
148
|
+
relativePath !== '..' &&
|
|
149
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
150
|
+
!relativePath.split(sep).includes('node_modules')
|
|
151
|
+
) {
|
|
152
|
+
visitModule(resolved);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
for (const entryPath of entryPaths) {
|
|
157
|
+
visitModule(entryPath);
|
|
158
|
+
}
|
|
159
|
+
return Result.ok(
|
|
160
|
+
regradeReceiptContentHash(
|
|
161
|
+
[...paths].toSorted(compareCodeUnits).map((absolutePath) => ({
|
|
162
|
+
contentHash: createHash('sha256')
|
|
163
|
+
.update(readFileSync(absolutePath))
|
|
164
|
+
.digest('hex'),
|
|
165
|
+
path: normalizePath(relative(rootDir, absolutePath)),
|
|
166
|
+
}))
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return Result.err(
|
|
171
|
+
new InternalError('Failed to derive Regrade policy identity.', {
|
|
172
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
173
|
+
context: { rootDir },
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the command-local reuse identity from receipt-aligned facts.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* const identity = preparedRegradeRunIdentity({ artifact, rootDir: '.' });
|
|
185
|
+
* if (identity.isOk()) console.log(identity.value.planContentHash);
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export const preparedRegradeRunIdentity = (params: {
|
|
189
|
+
readonly artifact: RegradePlanArtifact;
|
|
190
|
+
readonly classIds?: readonly string[] | undefined;
|
|
191
|
+
readonly classes?: readonly RegradeClass[] | undefined;
|
|
192
|
+
readonly includeEntries: 'actionable' | 'all';
|
|
193
|
+
readonly rootDir: string;
|
|
194
|
+
}): TrailsResult<PreparedRegradeRunIdentity, Error> => {
|
|
195
|
+
const parsedPlan = regradeReceiptPlanSchema.safeParse(params.artifact.plan);
|
|
196
|
+
if (!parsedPlan.success) {
|
|
197
|
+
return Result.err(
|
|
198
|
+
new InternalError('Failed to derive Regrade plan identity.', {
|
|
199
|
+
context: { issues: parsedPlan.error.issues },
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const lockHash = lockStateHash(params.rootDir);
|
|
204
|
+
if (lockHash.isErr()) {
|
|
205
|
+
return lockHash;
|
|
206
|
+
}
|
|
207
|
+
const usesProjectRules = parsedPlan.data.kind === 'class';
|
|
208
|
+
const policyStateHash = usesProjectRules
|
|
209
|
+
? projectRuleStateHash(params.rootDir)
|
|
210
|
+
: Result.ok(null);
|
|
211
|
+
if (policyStateHash.isErr()) {
|
|
212
|
+
return policyStateHash;
|
|
213
|
+
}
|
|
214
|
+
return Result.ok({
|
|
215
|
+
lockStateHash: lockHash.value,
|
|
216
|
+
planContentHash: regradeReceiptPlanContentHash({
|
|
217
|
+
plan: parsedPlan.data,
|
|
218
|
+
provenance: params.artifact.provenance,
|
|
219
|
+
}),
|
|
220
|
+
policyHash: regradeReceiptContentHash({
|
|
221
|
+
classIds: [...(params.classIds ?? [])].toSorted(),
|
|
222
|
+
classes: (params.classes ?? []).map((regradeClass) => ({
|
|
223
|
+
describe: regradeClass.describe,
|
|
224
|
+
id: regradeClass.id,
|
|
225
|
+
scanTargets: regradeClass.scanTargets ?? null,
|
|
226
|
+
})),
|
|
227
|
+
...(policyStateHash.value === null
|
|
228
|
+
? {}
|
|
229
|
+
: { projectRulesHash: policyStateHash.value }),
|
|
230
|
+
transitions: listGovernedVocabularyTransitions(),
|
|
231
|
+
}),
|
|
232
|
+
scopeHash: regradeReceiptContentHash({
|
|
233
|
+
includeEntries: params.includeEntries,
|
|
234
|
+
scope: params.artifact.plan.scope ?? null,
|
|
235
|
+
}),
|
|
236
|
+
toolVersion: trailsPackageVersion,
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Reject an active plan that changed after its prepared evaluation. */
|
|
241
|
+
export const validatePreparedRegradePlanArtifact = (params: {
|
|
242
|
+
readonly current: RegradePlanArtifact;
|
|
243
|
+
readonly currentPath: string;
|
|
244
|
+
readonly expected: RegradePlanArtifact;
|
|
245
|
+
readonly expectedPath: string;
|
|
246
|
+
}): TrailsResult<void, ValidationError> => {
|
|
247
|
+
if (
|
|
248
|
+
params.currentPath !== params.expectedPath ||
|
|
249
|
+
canonicalJsonStringify(params.current) !==
|
|
250
|
+
canonicalJsonStringify(params.expected)
|
|
251
|
+
) {
|
|
252
|
+
return Result.err(
|
|
253
|
+
new ValidationError('Regrade plan changed during apply preflight.', {
|
|
254
|
+
context: { plan: params.expected.path },
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return Result.ok();
|
|
259
|
+
};
|