@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.
@@ -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
+ };