@ontrails/trails 1.0.0-beta.45 → 1.0.0-beta.46
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 +21 -0
- package/README.md +1 -1
- package/package.json +16 -16
- package/src/cli.ts +13 -0
- package/src/regrade/audit.ts +118 -33
- package/src/regrade/history.ts +423 -425
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/plan-artifact.ts +0 -3
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +445 -0
- package/src/regrade/source-transaction.ts +121 -11
- package/src/run-regrade-progress.ts +47 -0
- package/src/trails/create-scaffold.ts +1 -0
- package/src/trails/regrade.ts +965 -236
|
@@ -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
|
+
}
|
|
@@ -427,9 +427,6 @@ export const currentRegradeSourceHashMatches = (
|
|
|
427
427
|
report: RegradeReport
|
|
428
428
|
): boolean => stampedHash === regradeSourceHash(report);
|
|
429
429
|
|
|
430
|
-
export const legacyRegradeSourceHash = (report: RegradeReport): string =>
|
|
431
|
-
hashSerializedSourceFacts(JSON.stringify(regradeSourceHashFacts(report)));
|
|
432
|
-
|
|
433
430
|
export const regradeSourceHashes = (
|
|
434
431
|
report: RegradeReport
|
|
435
432
|
): readonly string[] => {
|
|
@@ -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
|
+
};
|