@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,571 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
isPlainObject,
|
|
4
|
+
matchesAnyPathGlob,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
Result,
|
|
7
|
+
ValidationError,
|
|
8
|
+
} from '@ontrails/core';
|
|
9
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
10
|
+
import {
|
|
11
|
+
listVocabularyRegradeAuditPlansFromRegistry,
|
|
12
|
+
regradeReportOutput,
|
|
13
|
+
runVocabularyRegrade,
|
|
14
|
+
vocabularyDispositionValues,
|
|
15
|
+
vocabularyRegradePlanForInput,
|
|
16
|
+
vocabularyRegradeTransitionForInput,
|
|
17
|
+
} from '@ontrails/regrade';
|
|
18
|
+
import type { VocabularyRegradePlan } from '@ontrails/regrade';
|
|
19
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { basename, extname, join } from 'node:path';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
import { deriveLiveApiPreserveInventory } from './live-api-preserve.js';
|
|
24
|
+
import {
|
|
25
|
+
readRegradeHistoryArtifact,
|
|
26
|
+
regradeHistoryPathForPlan,
|
|
27
|
+
} from './history.js';
|
|
28
|
+
import { rootRelativePath } from './plan-artifact.js';
|
|
29
|
+
|
|
30
|
+
const sourceCommentExtensions = [
|
|
31
|
+
'.cjs',
|
|
32
|
+
'.cts',
|
|
33
|
+
'.js',
|
|
34
|
+
'.jsx',
|
|
35
|
+
'.mjs',
|
|
36
|
+
'.mts',
|
|
37
|
+
'.ts',
|
|
38
|
+
'.tsx',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export const regradeAuditInputSchema = z.object({
|
|
42
|
+
failOnOpen: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.default(false)
|
|
45
|
+
.describe('Fail when a completed transition has current-tree residue'),
|
|
46
|
+
includeEntries: z
|
|
47
|
+
.enum(['actionable', 'all'])
|
|
48
|
+
.default('actionable')
|
|
49
|
+
.describe('Report entry detail to include for each audited transition'),
|
|
50
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
51
|
+
transitionIds: z
|
|
52
|
+
.array(z.string().min(1))
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Governed transition ids to audit; defaults to applied history'),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const regradeAuditTransitionSchema = z.object({
|
|
58
|
+
report: z
|
|
59
|
+
.object({
|
|
60
|
+
dispositions: z
|
|
61
|
+
.partialRecord(
|
|
62
|
+
z.enum(vocabularyDispositionValues),
|
|
63
|
+
z.number().int().nonnegative()
|
|
64
|
+
)
|
|
65
|
+
.describe('Current source occurrence counts by classification'),
|
|
66
|
+
entries: regradeReportOutput.shape.entries
|
|
67
|
+
.readonly()
|
|
68
|
+
.describe('Actionable file-level residue details'),
|
|
69
|
+
occurrences: z
|
|
70
|
+
.number()
|
|
71
|
+
.int()
|
|
72
|
+
.nonnegative()
|
|
73
|
+
.describe('Total classified current-source occurrences'),
|
|
74
|
+
open: z
|
|
75
|
+
.number()
|
|
76
|
+
.int()
|
|
77
|
+
.nonnegative()
|
|
78
|
+
.describe('Unresolved current-source occurrences'),
|
|
79
|
+
scanned: z.number().int().nonnegative().describe('Files scanned'),
|
|
80
|
+
skippedByReason: z
|
|
81
|
+
.record(z.string(), z.number().int().nonnegative())
|
|
82
|
+
.describe('Collection skips grouped by machine-readable reason'),
|
|
83
|
+
status: z.enum(['green', 'open']).describe('Transition audit status'),
|
|
84
|
+
})
|
|
85
|
+
.describe('Concise current-tree audit of the latest applied plan'),
|
|
86
|
+
source: z.string().describe('Committed Regrade history artifact'),
|
|
87
|
+
transitionId: z.string().describe('Stable Regrade transition identity'),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const regradeAuditOutputSchema = z.object({
|
|
91
|
+
gate: z.object({
|
|
92
|
+
open: z
|
|
93
|
+
.number()
|
|
94
|
+
.int()
|
|
95
|
+
.nonnegative()
|
|
96
|
+
.describe('Current-tree unresolved occurrence count'),
|
|
97
|
+
status: z
|
|
98
|
+
.enum(['green', 'open'])
|
|
99
|
+
.describe('Whether every audited transition remains complete'),
|
|
100
|
+
}),
|
|
101
|
+
transitions: z
|
|
102
|
+
.array(regradeAuditTransitionSchema)
|
|
103
|
+
.describe('Applied vocabulary transitions audited against current source'),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export type RegradeAuditInput = z.output<typeof regradeAuditInputSchema>;
|
|
107
|
+
export type RegradeAuditOutput = z.output<typeof regradeAuditOutputSchema>;
|
|
108
|
+
|
|
109
|
+
interface RegradeAuditCandidate {
|
|
110
|
+
readonly plan: VocabularyRegradePlan;
|
|
111
|
+
readonly source: string;
|
|
112
|
+
readonly transitionId: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read the governed transition id from legacy history or v3 embedded intent.
|
|
117
|
+
*
|
|
118
|
+
* @internal
|
|
119
|
+
*/
|
|
120
|
+
export const historyTransitionId = (
|
|
121
|
+
rawHistory: unknown
|
|
122
|
+
): string | undefined => {
|
|
123
|
+
if (!isPlainObject(rawHistory)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const { id, runs } = rawHistory;
|
|
127
|
+
const historyRuns = Array.isArray(runs) ? runs : [];
|
|
128
|
+
const latestRun = historyRuns.at(-1);
|
|
129
|
+
const planArtifact = isPlainObject(latestRun) ? latestRun['plan'] : undefined;
|
|
130
|
+
const plan = isPlainObject(planArtifact) ? planArtifact['plan'] : undefined;
|
|
131
|
+
const planId = isPlainObject(plan) ? plan['id'] : undefined;
|
|
132
|
+
if (typeof planId === 'string') {
|
|
133
|
+
return planId;
|
|
134
|
+
}
|
|
135
|
+
for (const run of historyRuns.toReversed()) {
|
|
136
|
+
const intent = isPlainObject(run) ? run['intent'] : undefined;
|
|
137
|
+
const intentPlan = isPlainObject(intent) ? intent['plan'] : undefined;
|
|
138
|
+
const intentPlanId = isPlainObject(intentPlan)
|
|
139
|
+
? intentPlan['id']
|
|
140
|
+
: undefined;
|
|
141
|
+
if (typeof intentPlanId === 'string') {
|
|
142
|
+
return intentPlanId;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return typeof id === 'string' ? id : undefined;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const selectedHistoryFileNamesFor = (
|
|
149
|
+
selectedTransitionIds: ReadonlySet<string> | null,
|
|
150
|
+
registryPlansById: ReadonlyMap<string, VocabularyRegradePlan>
|
|
151
|
+
): ReadonlySet<string> =>
|
|
152
|
+
new Set(
|
|
153
|
+
selectedTransitionIds === null
|
|
154
|
+
? []
|
|
155
|
+
: [...selectedTransitionIds].flatMap((transitionId) => {
|
|
156
|
+
const plan = registryPlansById.get(transitionId);
|
|
157
|
+
return plan === undefined
|
|
158
|
+
? []
|
|
159
|
+
: [basename(regradeHistoryPathForPlan('.', plan))];
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const historyIsSelected = (
|
|
164
|
+
rawHistory: unknown,
|
|
165
|
+
historyFile: string,
|
|
166
|
+
selectedTransitionIds: ReadonlySet<string> | null,
|
|
167
|
+
selectedHistoryFileNames: ReadonlySet<string>
|
|
168
|
+
): boolean =>
|
|
169
|
+
selectedTransitionIds === null ||
|
|
170
|
+
selectedTransitionIds.has(historyTransitionId(rawHistory) ?? '') ||
|
|
171
|
+
selectedHistoryFileNames.has(basename(historyFile));
|
|
172
|
+
|
|
173
|
+
export const mergeAuditPlan = (
|
|
174
|
+
latestPlan: VocabularyRegradePlan,
|
|
175
|
+
registryPlan: VocabularyRegradePlan | undefined
|
|
176
|
+
): VocabularyRegradePlan => {
|
|
177
|
+
if (registryPlan === undefined) {
|
|
178
|
+
return latestPlan;
|
|
179
|
+
}
|
|
180
|
+
const policyClassified = [
|
|
181
|
+
...(latestPlan.scope?.policyClassified ?? []),
|
|
182
|
+
...(registryPlan.scope?.policyClassified ?? []),
|
|
183
|
+
];
|
|
184
|
+
const teachingSurfaces = [
|
|
185
|
+
...new Set([
|
|
186
|
+
...(latestPlan.scope?.teachingSurfaces ?? []),
|
|
187
|
+
...(registryPlan.scope?.teachingSurfaces ?? []),
|
|
188
|
+
]),
|
|
189
|
+
].toSorted();
|
|
190
|
+
const scope =
|
|
191
|
+
latestPlan.scope === undefined && registryPlan.scope === undefined
|
|
192
|
+
? undefined
|
|
193
|
+
: {
|
|
194
|
+
...registryPlan.scope,
|
|
195
|
+
...latestPlan.scope,
|
|
196
|
+
...(policyClassified.length === 0 ? {} : { policyClassified }),
|
|
197
|
+
...(teachingSurfaces.length === 0 ? {} : { teachingSurfaces }),
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
...registryPlan,
|
|
201
|
+
...latestPlan,
|
|
202
|
+
preserve: [
|
|
203
|
+
...(latestPlan.preserve ?? []),
|
|
204
|
+
...(registryPlan.preserve ?? []),
|
|
205
|
+
],
|
|
206
|
+
...(scope === undefined ? {} : { scope }),
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const readRegradeAuditCandidates = (
|
|
211
|
+
rootDir: string,
|
|
212
|
+
selectedTransitionIds: ReadonlySet<string> | null
|
|
213
|
+
): TrailsResult<readonly RegradeAuditCandidate[], Error> => {
|
|
214
|
+
const historyDir = join(rootDir, '.trails', 'regrade', 'history');
|
|
215
|
+
if (!existsSync(historyDir)) {
|
|
216
|
+
return Result.ok([]);
|
|
217
|
+
}
|
|
218
|
+
let historyFiles: readonly string[];
|
|
219
|
+
try {
|
|
220
|
+
historyFiles = readdirSync(historyDir, { withFileTypes: true })
|
|
221
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
222
|
+
.map((entry) => join(historyDir, entry.name))
|
|
223
|
+
.toSorted();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return Result.err(
|
|
226
|
+
new InternalError('Failed to list Regrade history artifacts.', {
|
|
227
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
228
|
+
context: { historyDir },
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const registryPlansById = new Map(
|
|
233
|
+
listVocabularyRegradeAuditPlansFromRegistry().flatMap((plan) =>
|
|
234
|
+
plan.id === undefined ? [] : [[plan.id, plan] as const]
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
const selectedHistoryFileNames = selectedHistoryFileNamesFor(
|
|
238
|
+
selectedTransitionIds,
|
|
239
|
+
registryPlansById
|
|
240
|
+
);
|
|
241
|
+
const selectedHistoriesHaveCanonicalFileNames =
|
|
242
|
+
selectedTransitionIds !== null &&
|
|
243
|
+
selectedHistoryFileNames.size === selectedTransitionIds.size;
|
|
244
|
+
const candidates: RegradeAuditCandidate[] = [];
|
|
245
|
+
for (const historyFile of historyFiles) {
|
|
246
|
+
if (
|
|
247
|
+
selectedHistoriesHaveCanonicalFileNames &&
|
|
248
|
+
!selectedHistoryFileNames.has(basename(historyFile))
|
|
249
|
+
) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
let rawHistory: unknown;
|
|
253
|
+
try {
|
|
254
|
+
rawHistory = JSON.parse(readFileSync(historyFile, 'utf8'));
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return Result.err(
|
|
257
|
+
new InternalError('Failed to read Regrade history audit plan.', {
|
|
258
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
259
|
+
context: { historyFile },
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
!historyIsSelected(
|
|
265
|
+
rawHistory,
|
|
266
|
+
historyFile,
|
|
267
|
+
selectedTransitionIds,
|
|
268
|
+
selectedHistoryFileNames
|
|
269
|
+
)
|
|
270
|
+
) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const history = readRegradeHistoryArtifact(historyFile);
|
|
274
|
+
if (history.isErr()) {
|
|
275
|
+
return history;
|
|
276
|
+
}
|
|
277
|
+
const latestPlan = history.value.runs.at(-1)?.plan.plan as
|
|
278
|
+
| VocabularyRegradePlan
|
|
279
|
+
| undefined;
|
|
280
|
+
if (latestPlan?.kind !== 'vocabulary') {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const transitionId = latestPlan.id ?? history.value.id;
|
|
284
|
+
if (
|
|
285
|
+
selectedTransitionIds !== null &&
|
|
286
|
+
!selectedTransitionIds.has(transitionId)
|
|
287
|
+
) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const registryPlan =
|
|
291
|
+
registryPlansById.get(transitionId) ??
|
|
292
|
+
vocabularyRegradePlanForInput(latestPlan.from, latestPlan.to) ??
|
|
293
|
+
undefined;
|
|
294
|
+
candidates.push({
|
|
295
|
+
plan: mergeAuditPlan(latestPlan, registryPlan),
|
|
296
|
+
source: rootRelativePath(rootDir, historyFile),
|
|
297
|
+
transitionId,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return Result.ok(
|
|
301
|
+
candidates.toSorted((left, right) =>
|
|
302
|
+
left.transitionId.localeCompare(right.transitionId)
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
type VocabularyPolicyClassified = NonNullable<
|
|
308
|
+
NonNullable<VocabularyRegradePlan['scope']>['policyClassified']
|
|
309
|
+
>;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Select full evidence for classified paths and comments elsewhere.
|
|
313
|
+
*
|
|
314
|
+
* @internal
|
|
315
|
+
*/
|
|
316
|
+
export const sourceKindForRegradeAuditPath = (
|
|
317
|
+
path: string,
|
|
318
|
+
policyClassified: VocabularyPolicyClassified | undefined
|
|
319
|
+
): 'all' | 'comments' => {
|
|
320
|
+
const isPolicyClassified = policyClassified?.some((policy) =>
|
|
321
|
+
matchesAnyPathGlob(path, policy.paths)
|
|
322
|
+
);
|
|
323
|
+
return sourceCommentExtensions.includes(
|
|
324
|
+
extname(path) as (typeof sourceCommentExtensions)[number]
|
|
325
|
+
) && !isPolicyClassified
|
|
326
|
+
? 'comments'
|
|
327
|
+
: 'all';
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export const projectPolicyClassifiedForMarkdownAudit = (
|
|
331
|
+
plan: VocabularyRegradePlan,
|
|
332
|
+
includeCodeComments = false
|
|
333
|
+
): VocabularyPolicyClassified | undefined => {
|
|
334
|
+
const auditedExtensions = new Set<string>([
|
|
335
|
+
'.md',
|
|
336
|
+
'.mdx',
|
|
337
|
+
...(includeCodeComments ? sourceCommentExtensions : []),
|
|
338
|
+
]);
|
|
339
|
+
return plan.scope?.policyClassified
|
|
340
|
+
?.map((policy) => ({
|
|
341
|
+
...policy,
|
|
342
|
+
paths: policy.paths.filter(
|
|
343
|
+
(path) =>
|
|
344
|
+
!plan.scope?.exclude?.some(
|
|
345
|
+
(excludedPath) =>
|
|
346
|
+
excludedPath === path || matchesAnyPathGlob(path, [excludedPath])
|
|
347
|
+
)
|
|
348
|
+
),
|
|
349
|
+
}))
|
|
350
|
+
.filter((policy) => policy.paths.length > 0)
|
|
351
|
+
.map((policy) => {
|
|
352
|
+
const hasAuditablePath = policy.paths.some((path) => {
|
|
353
|
+
const extension = extname(path);
|
|
354
|
+
if (auditedExtensions.has(extension)) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
const terminalPattern = basename(path);
|
|
358
|
+
const trailingWildcards = terminalPattern.match(/[*?]+$/)?.[0];
|
|
359
|
+
const canSelectAuditedExtension =
|
|
360
|
+
extension === '' &&
|
|
361
|
+
trailingWildcards !== undefined &&
|
|
362
|
+
[...auditedExtensions].some(
|
|
363
|
+
(auditedExtension) =>
|
|
364
|
+
trailingWildcards.includes('*') ||
|
|
365
|
+
(trailingWildcards.length >= auditedExtension.length &&
|
|
366
|
+
(terminalPattern.length > trailingWildcards.length ||
|
|
367
|
+
trailingWildcards.length > auditedExtension.length))
|
|
368
|
+
);
|
|
369
|
+
return (
|
|
370
|
+
includeCodeComments &&
|
|
371
|
+
(canSelectAuditedExtension ||
|
|
372
|
+
[...auditedExtensions].some((auditedExtension) =>
|
|
373
|
+
matchesAnyPathGlob(auditedExtension, [extension])
|
|
374
|
+
))
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
if (policy.expectMatches !== true || hasAuditablePath) {
|
|
378
|
+
return policy;
|
|
379
|
+
}
|
|
380
|
+
// An extension-filtered audit cannot prove evidence for a path whose
|
|
381
|
+
// authored pattern does not identify one of the audited file families.
|
|
382
|
+
const { expectMatches: _, ...markdownPolicy } = policy;
|
|
383
|
+
return markdownPolicy;
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
export const projectExcludesForMarkdownAudit = (
|
|
388
|
+
plan: VocabularyRegradePlan,
|
|
389
|
+
policyClassified: VocabularyPolicyClassified | undefined
|
|
390
|
+
): readonly string[] | undefined =>
|
|
391
|
+
plan.scope?.exclude?.filter(
|
|
392
|
+
(excludedPath) =>
|
|
393
|
+
!policyClassified?.some((policy) =>
|
|
394
|
+
policy.paths.some((path) => matchesAnyPathGlob(excludedPath, [path]))
|
|
395
|
+
)
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
export const projectIncludesForMarkdownAudit = (
|
|
399
|
+
plan: VocabularyRegradePlan,
|
|
400
|
+
policyClassified: VocabularyPolicyClassified | undefined
|
|
401
|
+
): readonly string[] | undefined => {
|
|
402
|
+
if (plan.scope?.include === undefined) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
return [
|
|
406
|
+
...new Set([
|
|
407
|
+
...plan.scope.include,
|
|
408
|
+
...(policyClassified?.flatMap((policy) => policy.paths) ?? []),
|
|
409
|
+
...(plan.scope.teachingSurfaces ?? []),
|
|
410
|
+
]),
|
|
411
|
+
].toSorted();
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const runRegradeAuditCandidate = async (
|
|
415
|
+
candidate: RegradeAuditCandidate,
|
|
416
|
+
input: RegradeAuditInput,
|
|
417
|
+
rootDir: string
|
|
418
|
+
): Promise<TrailsResult<RegradeAuditOutput['transitions'][number], Error>> => {
|
|
419
|
+
const includeCodeComments =
|
|
420
|
+
vocabularyRegradeTransitionForInput(candidate.plan.from, candidate.plan.to)
|
|
421
|
+
?.target.kind === 'classified';
|
|
422
|
+
const policyClassified = projectPolicyClassifiedForMarkdownAudit(
|
|
423
|
+
candidate.plan,
|
|
424
|
+
includeCodeComments
|
|
425
|
+
);
|
|
426
|
+
const exclude = projectExcludesForMarkdownAudit(
|
|
427
|
+
candidate.plan,
|
|
428
|
+
policyClassified
|
|
429
|
+
);
|
|
430
|
+
const include = projectIncludesForMarkdownAudit(
|
|
431
|
+
candidate.plan,
|
|
432
|
+
policyClassified
|
|
433
|
+
);
|
|
434
|
+
const auditPlan: VocabularyRegradePlan = {
|
|
435
|
+
...candidate.plan,
|
|
436
|
+
scope: {
|
|
437
|
+
...candidate.plan.scope,
|
|
438
|
+
extensions: [
|
|
439
|
+
'.md',
|
|
440
|
+
'.mdx',
|
|
441
|
+
...(includeCodeComments ? sourceCommentExtensions : []),
|
|
442
|
+
],
|
|
443
|
+
...(exclude === undefined ? {} : { exclude }),
|
|
444
|
+
...(include === undefined ? {} : { include }),
|
|
445
|
+
...(policyClassified === undefined ? {} : { policyClassified }),
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
const preserveResult = await deriveLiveApiPreserveInventory(
|
|
449
|
+
auditPlan,
|
|
450
|
+
rootDir
|
|
451
|
+
);
|
|
452
|
+
if (preserveResult.isErr()) {
|
|
453
|
+
return preserveResult;
|
|
454
|
+
}
|
|
455
|
+
const reportResult = runVocabularyRegrade({
|
|
456
|
+
apply: false,
|
|
457
|
+
includeEntries: input.includeEntries,
|
|
458
|
+
plan: auditPlan,
|
|
459
|
+
...(preserveResult.value.length === 0
|
|
460
|
+
? {}
|
|
461
|
+
: { preserveInventory: preserveResult.value }),
|
|
462
|
+
root: rootDir,
|
|
463
|
+
...(includeCodeComments
|
|
464
|
+
? {
|
|
465
|
+
sourceKindForPath: (path: string) =>
|
|
466
|
+
sourceKindForRegradeAuditPath(path, policyClassified),
|
|
467
|
+
}
|
|
468
|
+
: {}),
|
|
469
|
+
});
|
|
470
|
+
if (reportResult.isErr()) {
|
|
471
|
+
return reportResult;
|
|
472
|
+
}
|
|
473
|
+
if (reportResult.value === null) {
|
|
474
|
+
return Result.err(
|
|
475
|
+
new NotFoundError('Regrade audit root could not be collected.', {
|
|
476
|
+
context: { rootDir },
|
|
477
|
+
})
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const { run } = reportResult.value;
|
|
481
|
+
if (run === undefined) {
|
|
482
|
+
return Result.err(
|
|
483
|
+
new InternalError('Regrade audit did not produce a vocabulary run.', {
|
|
484
|
+
context: { transitionId: candidate.transitionId },
|
|
485
|
+
})
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const output = regradeAuditTransitionSchema.safeParse({
|
|
489
|
+
report: {
|
|
490
|
+
dispositions: run.report.dispositions,
|
|
491
|
+
entries: reportResult.value.entries,
|
|
492
|
+
occurrences: run.ledger.occurrences.length,
|
|
493
|
+
open: run.report.open,
|
|
494
|
+
scanned: reportResult.value.scanned,
|
|
495
|
+
skippedByReason: reportResult.value.skipsByReason,
|
|
496
|
+
status: run.report.gate.status,
|
|
497
|
+
},
|
|
498
|
+
source: candidate.source,
|
|
499
|
+
transitionId: candidate.transitionId,
|
|
500
|
+
});
|
|
501
|
+
if (!output.success) {
|
|
502
|
+
return Result.err(
|
|
503
|
+
new InternalError('Regrade audit produced an invalid transition.', {
|
|
504
|
+
context: {
|
|
505
|
+
issues: output.error.issues,
|
|
506
|
+
transitionId: candidate.transitionId,
|
|
507
|
+
},
|
|
508
|
+
})
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return Result.ok(output.data);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
export const auditRegradeHistory = async (
|
|
515
|
+
input: RegradeAuditInput,
|
|
516
|
+
rootDir: string
|
|
517
|
+
): Promise<TrailsResult<RegradeAuditOutput, Error>> => {
|
|
518
|
+
const selectedTransitionIds =
|
|
519
|
+
input.transitionIds === undefined ? null : new Set(input.transitionIds);
|
|
520
|
+
const candidatesResult = readRegradeAuditCandidates(
|
|
521
|
+
rootDir,
|
|
522
|
+
selectedTransitionIds
|
|
523
|
+
);
|
|
524
|
+
if (candidatesResult.isErr()) {
|
|
525
|
+
return candidatesResult;
|
|
526
|
+
}
|
|
527
|
+
const candidates = candidatesResult.value;
|
|
528
|
+
if (selectedTransitionIds !== null) {
|
|
529
|
+
const auditedTransitionIds = new Set(
|
|
530
|
+
candidates.map((candidate) => candidate.transitionId)
|
|
531
|
+
);
|
|
532
|
+
const missingTransitionIds = [...selectedTransitionIds].filter(
|
|
533
|
+
(transitionId) => !auditedTransitionIds.has(transitionId)
|
|
534
|
+
);
|
|
535
|
+
if (missingTransitionIds.length > 0) {
|
|
536
|
+
return Result.err(
|
|
537
|
+
new ValidationError(
|
|
538
|
+
'Selected Regrade audit transitions do not have committed history.',
|
|
539
|
+
{ context: { transitionIds: missingTransitionIds.toSorted() } }
|
|
540
|
+
)
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const transitions: RegradeAuditOutput['transitions'][number][] = [];
|
|
545
|
+
for (const candidate of candidates) {
|
|
546
|
+
const transitionResult = await runRegradeAuditCandidate(
|
|
547
|
+
candidate,
|
|
548
|
+
input,
|
|
549
|
+
rootDir
|
|
550
|
+
);
|
|
551
|
+
if (transitionResult.isErr()) {
|
|
552
|
+
return transitionResult;
|
|
553
|
+
}
|
|
554
|
+
transitions.push(transitionResult.value);
|
|
555
|
+
}
|
|
556
|
+
const open = transitions.reduce(
|
|
557
|
+
(total, transition) => total + transition.report.open,
|
|
558
|
+
0
|
|
559
|
+
);
|
|
560
|
+
return Result.ok({
|
|
561
|
+
gate: {
|
|
562
|
+
open,
|
|
563
|
+
status: transitions.some(
|
|
564
|
+
(transition) => transition.report.status === 'open'
|
|
565
|
+
)
|
|
566
|
+
? 'open'
|
|
567
|
+
: 'green',
|
|
568
|
+
},
|
|
569
|
+
transitions,
|
|
570
|
+
});
|
|
571
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { loadTrailsConfigValue } from '@ontrails/config';
|
|
2
|
+
import {
|
|
3
|
+
InternalError,
|
|
4
|
+
Result,
|
|
5
|
+
ValidationError,
|
|
6
|
+
pathScopeSchema,
|
|
7
|
+
} from '@ontrails/core';
|
|
8
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
export const regradeConfigSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
scope: pathScopeSchema.optional(),
|
|
14
|
+
})
|
|
15
|
+
.default({});
|
|
16
|
+
|
|
17
|
+
export type RegradeConfig = z.output<typeof regradeConfigSchema>;
|
|
18
|
+
|
|
19
|
+
interface RegradeConfigLoadResult {
|
|
20
|
+
readonly config?: RegradeConfig;
|
|
21
|
+
readonly configPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ResolvableConfig {
|
|
25
|
+
readonly resolve: (options?: {
|
|
26
|
+
readonly cwd?: string;
|
|
27
|
+
readonly env?: Record<string, string | undefined>;
|
|
28
|
+
}) => unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
32
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
|
|
34
|
+
const isResultLike = (
|
|
35
|
+
value: unknown
|
|
36
|
+
): value is {
|
|
37
|
+
readonly error?: unknown;
|
|
38
|
+
readonly isErr: () => boolean;
|
|
39
|
+
readonly isOk: () => boolean;
|
|
40
|
+
readonly value?: unknown;
|
|
41
|
+
} =>
|
|
42
|
+
isRecord(value) &&
|
|
43
|
+
typeof value['isOk'] === 'function' &&
|
|
44
|
+
typeof value['isErr'] === 'function';
|
|
45
|
+
|
|
46
|
+
const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
|
|
47
|
+
isRecord(value) && typeof value['resolve'] === 'function';
|
|
48
|
+
|
|
49
|
+
const errorMessage = (error: unknown): string =>
|
|
50
|
+
error instanceof Error ? error.message : String(error);
|
|
51
|
+
|
|
52
|
+
const extractRegradeConfig = (
|
|
53
|
+
value: unknown
|
|
54
|
+
): TrailsResult<RegradeConfig | undefined, ValidationError> => {
|
|
55
|
+
if (!(isRecord(value) && 'regrade' in value)) {
|
|
56
|
+
return Result.ok();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parsed = regradeConfigSchema.safeParse(value['regrade']);
|
|
60
|
+
if (parsed.success) {
|
|
61
|
+
return Result.ok(parsed.data);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Result.err(
|
|
65
|
+
new ValidationError('Invalid regrade config in Trails config file.', {
|
|
66
|
+
context: { issues: parsed.error.issues },
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const loadRegradeConfig = async ({
|
|
72
|
+
configPath,
|
|
73
|
+
env = {},
|
|
74
|
+
rootDir,
|
|
75
|
+
}: {
|
|
76
|
+
readonly configPath?: string | undefined;
|
|
77
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
78
|
+
readonly rootDir: string;
|
|
79
|
+
}): Promise<
|
|
80
|
+
TrailsResult<RegradeConfigLoadResult, InternalError | ValidationError>
|
|
81
|
+
> => {
|
|
82
|
+
try {
|
|
83
|
+
const loaded = await loadTrailsConfigValue({
|
|
84
|
+
configPath,
|
|
85
|
+
rootDir,
|
|
86
|
+
});
|
|
87
|
+
const exported = loaded.value;
|
|
88
|
+
if (exported === undefined) {
|
|
89
|
+
return Result.ok({});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isResolvableConfig(exported)) {
|
|
93
|
+
const resolved = await exported.resolve({ cwd: rootDir, env });
|
|
94
|
+
if (isResultLike(resolved)) {
|
|
95
|
+
if (resolved.isErr()) {
|
|
96
|
+
return Result.err(
|
|
97
|
+
new InternalError(
|
|
98
|
+
`Failed to resolve regrade config: ${errorMessage(resolved.error)}`
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const configResult = extractRegradeConfig(resolved.value);
|
|
103
|
+
if (configResult.isErr()) {
|
|
104
|
+
return configResult;
|
|
105
|
+
}
|
|
106
|
+
return Result.ok({
|
|
107
|
+
...(configResult.value === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: { config: configResult.value }),
|
|
110
|
+
...(loaded.configPath === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { configPath: loaded.configPath }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const configResult = extractRegradeConfig(resolved);
|
|
117
|
+
if (configResult.isErr()) {
|
|
118
|
+
return configResult;
|
|
119
|
+
}
|
|
120
|
+
return Result.ok({
|
|
121
|
+
...(configResult.value === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { config: configResult.value }),
|
|
124
|
+
...(loaded.configPath === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: { configPath: loaded.configPath }),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const configResult = extractRegradeConfig(exported);
|
|
131
|
+
if (configResult.isErr()) {
|
|
132
|
+
return configResult;
|
|
133
|
+
}
|
|
134
|
+
return Result.ok({
|
|
135
|
+
...(configResult.value === undefined
|
|
136
|
+
? {}
|
|
137
|
+
: { config: configResult.value }),
|
|
138
|
+
...(loaded.configPath === undefined
|
|
139
|
+
? {}
|
|
140
|
+
: { configPath: loaded.configPath }),
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return Result.err(
|
|
144
|
+
new InternalError(
|
|
145
|
+
`Failed to load regrade config: ${errorMessage(error)}`,
|
|
146
|
+
{
|
|
147
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
};
|