@ontrails/warden 1.0.0-beta.42 → 1.0.0-beta.43
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 +55 -0
- package/package.json +10 -10
- package/src/adapter-check.ts +1 -1
- package/src/cli.ts +81 -5
- package/src/index.ts +17 -4
- package/src/regrade-history.ts +599 -0
- package/src/rules/cli-command-route-coherence.ts +6 -6
- package/src/rules/draft-visible-debt.ts +1 -1
- package/src/rules/duplicate-exported-symbol.ts +4 -43
- package/src/rules/governed-symbol-residue.ts +146 -53
- package/src/rules/governed-vocabulary-permutation-watch.ts +76 -0
- package/src/rules/index.ts +17 -6
- package/src/rules/layer-field-name-drift.ts +2 -2
- package/src/rules/{library-projection-coherence.ts → library-render-coherence.ts} +11 -14
- package/src/rules/metadata.ts +51 -14
- package/src/rules/no-retired-cross-vocabulary.ts +0 -1
- package/src/rules/{owner-projection-parity.ts → owner-render-parity.ts} +18 -18
- package/src/rules/public-internal-deep-imports.ts +1 -3
- package/src/rules/public-output-schema.ts +1 -1
- package/src/rules/registry-names.ts +6 -4
- package/src/rules/retired-vocabulary.ts +775 -41
- package/src/rules/surface-overlay-coherence.ts +1 -1
- package/src/rules/trail-versioning-source.ts +1 -1
- package/src/rules/trailhead-override-divergence.ts +4 -4
- package/src/rules/types.ts +51 -5
- package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
- package/src/trails/index.ts +3 -2
- package/src/trails/layer-field-name-drift.trail.ts +1 -1
- package/src/trails/{library-projection-coherence.trail.ts → library-render-coherence.trail.ts} +6 -6
- package/src/trails/{owner-projection-parity.trail.ts → owner-render-parity.trail.ts} +4 -4
- package/src/trails/public-output-schema.trail.ts +1 -1
- package/src/trails/run.ts +44 -2
- package/src/trails/schema.ts +41 -0
- package/src/trails/wrap-rule.ts +44 -2
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getGovernedVocabularyTransition,
|
|
8
|
+
governedVocabularyHistoryProvenanceSchema,
|
|
9
|
+
listGovernedVocabularyTransitions,
|
|
10
|
+
} from './rules/retired-vocabulary.js';
|
|
11
|
+
import type {
|
|
12
|
+
GovernedVocabularyHistoryEvidence,
|
|
13
|
+
GovernedVocabularyHistoryIssue,
|
|
14
|
+
} from './rules/types.js';
|
|
15
|
+
|
|
16
|
+
const planBodySchema = z.discriminatedUnion('kind', [
|
|
17
|
+
z
|
|
18
|
+
.object({
|
|
19
|
+
caseSensitive: z.boolean().optional(),
|
|
20
|
+
from: z.string().min(1),
|
|
21
|
+
id: z.string().optional(),
|
|
22
|
+
kind: z.literal('vocabulary'),
|
|
23
|
+
to: z.string().min(1),
|
|
24
|
+
})
|
|
25
|
+
.passthrough(),
|
|
26
|
+
z
|
|
27
|
+
.object({
|
|
28
|
+
classIds: z.array(z.string().min(1)).min(1),
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
kind: z.literal('class'),
|
|
31
|
+
})
|
|
32
|
+
.passthrough(),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const planArtifactSchema = z
|
|
36
|
+
.object({
|
|
37
|
+
derivation: z.unknown().optional(),
|
|
38
|
+
expansion: z.unknown().optional(),
|
|
39
|
+
kind: z.literal('regrade-plan'),
|
|
40
|
+
path: z.string().min(1),
|
|
41
|
+
plan: planBodySchema,
|
|
42
|
+
provenance: z.object({
|
|
43
|
+
fields: z.record(z.string(), z.enum(['authored', 'derived'])),
|
|
44
|
+
}),
|
|
45
|
+
schemaVersion: z.literal(1),
|
|
46
|
+
sourceHash: z.string().min(1),
|
|
47
|
+
transitionId: z.string().min(1).optional(),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
|
|
51
|
+
const historyRunSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
completionReport: z.unknown().optional(),
|
|
54
|
+
completionReportHash: z.string().optional(),
|
|
55
|
+
lockHashAtRun: z.string().min(1),
|
|
56
|
+
plan: planArtifactSchema,
|
|
57
|
+
planContentHash: z.string().min(1),
|
|
58
|
+
provenance: governedVocabularyHistoryProvenanceSchema.optional(),
|
|
59
|
+
report: z.unknown(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
|
|
63
|
+
const historyArtifactSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
id: z.string().min(1),
|
|
66
|
+
kind: z.literal('regrade-history'),
|
|
67
|
+
path: z.string().min(1),
|
|
68
|
+
runs: z.array(historyRunSchema).min(1),
|
|
69
|
+
schemaVersion: z.literal(2),
|
|
70
|
+
})
|
|
71
|
+
.strict();
|
|
72
|
+
|
|
73
|
+
const reportEntrySchema = z
|
|
74
|
+
.object({
|
|
75
|
+
classId: z.string().optional(),
|
|
76
|
+
notes: z.array(z.string()).optional(),
|
|
77
|
+
outcome: z.enum(['needs-review', 'no-op', 'rewrite', 'skip']),
|
|
78
|
+
path: z.string(),
|
|
79
|
+
reason: z.string().optional(),
|
|
80
|
+
reviewDetails: z.unknown().optional(),
|
|
81
|
+
})
|
|
82
|
+
.passthrough();
|
|
83
|
+
|
|
84
|
+
const vocabularyDispositionSchema = z.enum([
|
|
85
|
+
'code-context-out-of-engine',
|
|
86
|
+
'docs-only',
|
|
87
|
+
'explicit-preserve',
|
|
88
|
+
'forward-pointer',
|
|
89
|
+
'historical-by-policy',
|
|
90
|
+
'ignored-by-scope',
|
|
91
|
+
'in-family-modified',
|
|
92
|
+
'in-family-unresolved',
|
|
93
|
+
'out-of-family',
|
|
94
|
+
'preserve-current-live-api',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const historyOccurrenceSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
disposition: vocabularyDispositionSchema.optional(),
|
|
100
|
+
form: z.string(),
|
|
101
|
+
line: z.number().int().positive().optional(),
|
|
102
|
+
path: z.string(),
|
|
103
|
+
reason: z.string().optional(),
|
|
104
|
+
scopeTier: z.string().nullish(),
|
|
105
|
+
verdict: z.enum(['applied', 'deferred', 'modified', 'skipped']).optional(),
|
|
106
|
+
})
|
|
107
|
+
.passthrough()
|
|
108
|
+
.superRefine((occurrence, ctx) => {
|
|
109
|
+
const modernOccurrence =
|
|
110
|
+
occurrence.disposition !== undefined &&
|
|
111
|
+
occurrence.line !== undefined &&
|
|
112
|
+
occurrence.reason !== undefined &&
|
|
113
|
+
occurrence.verdict !== undefined;
|
|
114
|
+
if (
|
|
115
|
+
modernOccurrence &&
|
|
116
|
+
occurrence.scopeTier !== undefined &&
|
|
117
|
+
occurrence.scopeTier !== null &&
|
|
118
|
+
occurrence.scopeTier !== 'in-scope' &&
|
|
119
|
+
occurrence.scopeTier !== 'policy-classified'
|
|
120
|
+
) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: 'custom',
|
|
123
|
+
message: 'Modern Regrade occurrences use a recognized scope tier.',
|
|
124
|
+
path: ['scopeTier'],
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const governedObservationSchema = historyOccurrenceSchema.safeExtend({
|
|
130
|
+
disposition: vocabularyDispositionSchema,
|
|
131
|
+
line: z.number().int().positive(),
|
|
132
|
+
reason: z.string(),
|
|
133
|
+
scopeTier: z.enum(['in-scope', 'policy-classified']).nullish(),
|
|
134
|
+
verdict: z.enum(['applied', 'deferred', 'modified', 'skipped']),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const reportSchema = z
|
|
138
|
+
.object({
|
|
139
|
+
apply: z.object({ applied: z.number() }).passthrough().optional(),
|
|
140
|
+
entries: z.array(reportEntrySchema),
|
|
141
|
+
matched: z.number(),
|
|
142
|
+
review: z.number(),
|
|
143
|
+
rewritten: z.number(),
|
|
144
|
+
root: z.string(),
|
|
145
|
+
run: z
|
|
146
|
+
.object({
|
|
147
|
+
ledger: z
|
|
148
|
+
.object({
|
|
149
|
+
cycle: z.unknown(),
|
|
150
|
+
forms: z.record(z.string(), z.unknown()),
|
|
151
|
+
occurrences: z.array(historyOccurrenceSchema),
|
|
152
|
+
})
|
|
153
|
+
.passthrough(),
|
|
154
|
+
report: z
|
|
155
|
+
.object({
|
|
156
|
+
fileRenames: z
|
|
157
|
+
.array(
|
|
158
|
+
z
|
|
159
|
+
.object({
|
|
160
|
+
deferred: z.number(),
|
|
161
|
+
from: z.string(),
|
|
162
|
+
historical: z.number().optional(),
|
|
163
|
+
preserved: z.number().optional(),
|
|
164
|
+
rewritten: z.number(),
|
|
165
|
+
skipped: z.number().optional(),
|
|
166
|
+
to: z.string(),
|
|
167
|
+
})
|
|
168
|
+
.passthrough()
|
|
169
|
+
)
|
|
170
|
+
.optional(),
|
|
171
|
+
})
|
|
172
|
+
.passthrough(),
|
|
173
|
+
})
|
|
174
|
+
.passthrough()
|
|
175
|
+
.optional(),
|
|
176
|
+
scan: z.object({
|
|
177
|
+
byDirectory: z.array(z.unknown()),
|
|
178
|
+
byExtension: z.array(z.unknown()),
|
|
179
|
+
files: z.object({
|
|
180
|
+
matched: z.number(),
|
|
181
|
+
scanned: z.number(),
|
|
182
|
+
skipped: z.number(),
|
|
183
|
+
}),
|
|
184
|
+
skippedByReason: z.record(z.string(), z.number()),
|
|
185
|
+
}),
|
|
186
|
+
scanned: z.number(),
|
|
187
|
+
selectedClassIds: z.array(z.string()),
|
|
188
|
+
skipped: z.number(),
|
|
189
|
+
skipsByReason: z.record(z.string(), z.number()),
|
|
190
|
+
unknownClassIds: z.array(z.string()),
|
|
191
|
+
})
|
|
192
|
+
.passthrough();
|
|
193
|
+
|
|
194
|
+
const governedHistoryRunSchema = historyRunSchema.extend({
|
|
195
|
+
completionReport: reportSchema,
|
|
196
|
+
completionReportHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
197
|
+
lockHashAtRun: z.string().regex(/^[0-9a-f]{64}$/),
|
|
198
|
+
planContentHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
199
|
+
report: reportSchema,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
type GovernedHistoryRun = z.infer<typeof governedHistoryRunSchema>;
|
|
203
|
+
type HistoryRun = z.infer<typeof historyRunSchema>;
|
|
204
|
+
|
|
205
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
206
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
207
|
+
|
|
208
|
+
const canonicalizeJsonValue = (value: unknown): unknown => {
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
return value.map(canonicalizeJsonValue);
|
|
211
|
+
}
|
|
212
|
+
if (isPlainObject(value)) {
|
|
213
|
+
return Object.fromEntries(
|
|
214
|
+
Object.keys(value)
|
|
215
|
+
.toSorted()
|
|
216
|
+
.map((key) => [key, canonicalizeJsonValue(value[key])])
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const hash = (value: string): string =>
|
|
223
|
+
createHash('sha256').update(value).digest('hex');
|
|
224
|
+
|
|
225
|
+
const isGeneratedRegradeArtifact = (path: string): boolean =>
|
|
226
|
+
/(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
|
|
227
|
+
|
|
228
|
+
const sourceHashFacts = (
|
|
229
|
+
report: z.infer<typeof reportSchema>,
|
|
230
|
+
policyMode: 'current' | 'legacy' = 'current',
|
|
231
|
+
fileEvidenceMode: 'current' | 'legacy' = 'current'
|
|
232
|
+
): unknown => {
|
|
233
|
+
const ledger = report.run?.ledger;
|
|
234
|
+
const occurrences = ledger?.occurrences.filter(
|
|
235
|
+
(occurrence) =>
|
|
236
|
+
occurrence.scopeTier !== 'policy-classified' ||
|
|
237
|
+
(policyMode === 'current' && !isGeneratedRegradeArtifact(occurrence.path))
|
|
238
|
+
);
|
|
239
|
+
const forms = new Set(occurrences?.map((occurrence) => occurrence.form));
|
|
240
|
+
return {
|
|
241
|
+
entries: report.entries
|
|
242
|
+
.filter(
|
|
243
|
+
(entry) =>
|
|
244
|
+
entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
245
|
+
)
|
|
246
|
+
.map(({ classId, notes, outcome, path, reason, reviewDetails }) => ({
|
|
247
|
+
...(classId === undefined ? {} : { classId }),
|
|
248
|
+
...(notes === undefined ? {} : { notes }),
|
|
249
|
+
outcome,
|
|
250
|
+
path,
|
|
251
|
+
...(reason === undefined ? {} : { reason }),
|
|
252
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
253
|
+
})),
|
|
254
|
+
fileRenames: report.run?.report.fileRenames?.map((rename) => ({
|
|
255
|
+
deferred: rename.deferred,
|
|
256
|
+
from: rename.from,
|
|
257
|
+
...(fileEvidenceMode === 'current'
|
|
258
|
+
? {
|
|
259
|
+
historical: rename.historical ?? 0,
|
|
260
|
+
preserved: rename.preserved ?? 0,
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
rewritten: rename.rewritten,
|
|
264
|
+
...(fileEvidenceMode === 'current'
|
|
265
|
+
? { skipped: rename.skipped ?? 0 }
|
|
266
|
+
: {}),
|
|
267
|
+
to: rename.to,
|
|
268
|
+
})),
|
|
269
|
+
ledger:
|
|
270
|
+
ledger === undefined || occurrences === undefined || forms === undefined
|
|
271
|
+
? undefined
|
|
272
|
+
: {
|
|
273
|
+
cycle: ledger.cycle,
|
|
274
|
+
forms: Object.fromEntries(
|
|
275
|
+
Object.entries(ledger.forms).filter(([form]) => forms.has(form))
|
|
276
|
+
),
|
|
277
|
+
occurrences,
|
|
278
|
+
},
|
|
279
|
+
selectedClassIds: report.selectedClassIds,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const sourceHashes = (report: z.infer<typeof reportSchema>): Set<string> => {
|
|
284
|
+
const facts = [
|
|
285
|
+
sourceHashFacts(report),
|
|
286
|
+
sourceHashFacts(report, 'current', 'legacy'),
|
|
287
|
+
sourceHashFacts(report, 'legacy'),
|
|
288
|
+
sourceHashFacts(report, 'legacy', 'legacy'),
|
|
289
|
+
];
|
|
290
|
+
return new Set(
|
|
291
|
+
facts.flatMap((value) => [
|
|
292
|
+
hash(JSON.stringify(canonicalizeJsonValue(value))),
|
|
293
|
+
hash(JSON.stringify(value)),
|
|
294
|
+
])
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
interface RawGovernedHistoryRun {
|
|
299
|
+
readonly completionReport?: z.infer<typeof reportSchema>;
|
|
300
|
+
readonly report: z.infer<typeof reportSchema>;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const rawEvidenceReports = (
|
|
304
|
+
run: GovernedHistoryRun,
|
|
305
|
+
rawRun: RawGovernedHistoryRun | undefined
|
|
306
|
+
): {
|
|
307
|
+
readonly completionReport: z.infer<typeof reportSchema>;
|
|
308
|
+
readonly report: z.infer<typeof reportSchema>;
|
|
309
|
+
} => ({
|
|
310
|
+
completionReport:
|
|
311
|
+
rawRun?.completionReport ?? rawRun?.report ?? run.completionReport,
|
|
312
|
+
report: rawRun?.report ?? run.report,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const validatesDeterministicEvidence = (
|
|
316
|
+
run: GovernedHistoryRun,
|
|
317
|
+
rawRun: RawGovernedHistoryRun | undefined,
|
|
318
|
+
transitionId: string
|
|
319
|
+
): boolean => {
|
|
320
|
+
const { provenance } = run;
|
|
321
|
+
const { completionReport, report } = rawEvidenceReports(run, rawRun);
|
|
322
|
+
const provenanceValid =
|
|
323
|
+
provenance === undefined ||
|
|
324
|
+
(provenance.disposition ===
|
|
325
|
+
(run.report.review > 0 ? 'review-follow-up' : 'applied-clean') &&
|
|
326
|
+
provenance.kind === 'governed-vocabulary' &&
|
|
327
|
+
provenance.planContentHash === run.planContentHash &&
|
|
328
|
+
provenance.reviewPending === run.report.review &&
|
|
329
|
+
provenance.safeApplied ===
|
|
330
|
+
(run.report.apply?.applied ?? run.report.rewritten) &&
|
|
331
|
+
sourceHashes(completionReport).has(provenance.sourceHashAfter) &&
|
|
332
|
+
sourceHashes(report).has(provenance.sourceHashBefore) &&
|
|
333
|
+
provenance.transitionId === transitionId);
|
|
334
|
+
return (
|
|
335
|
+
hash(JSON.stringify(canonicalizeJsonValue(run.plan.plan))) ===
|
|
336
|
+
run.planContentHash &&
|
|
337
|
+
sourceHashes(report).has(run.lockHashAtRun) &&
|
|
338
|
+
sourceHashes(completionReport).has(run.completionReportHash) &&
|
|
339
|
+
provenanceValid
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export interface GovernedVocabularyHistoryIndex {
|
|
344
|
+
readonly byTransitionId: ReadonlyMap<
|
|
345
|
+
string,
|
|
346
|
+
GovernedVocabularyHistoryEvidence
|
|
347
|
+
>;
|
|
348
|
+
readonly issues: readonly GovernedVocabularyHistoryIssue[];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const normalizePath = (value: string): string => value.replaceAll('\\', '/');
|
|
352
|
+
|
|
353
|
+
const latestRunFacts = (
|
|
354
|
+
run: HistoryRun | undefined
|
|
355
|
+
): Pick<
|
|
356
|
+
GovernedVocabularyHistoryEvidence,
|
|
357
|
+
'caseSensitive' | 'latestFormObservations'
|
|
358
|
+
> => {
|
|
359
|
+
if (run === undefined || run.plan.plan.kind !== 'vocabulary') {
|
|
360
|
+
return { caseSensitive: false, latestFormObservations: [] };
|
|
361
|
+
}
|
|
362
|
+
const report = reportSchema.safeParse(run.completionReport ?? run.report);
|
|
363
|
+
if (!report.success) {
|
|
364
|
+
return {
|
|
365
|
+
caseSensitive: run.plan.plan.caseSensitive === true,
|
|
366
|
+
latestFormObservations: [],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
caseSensitive: run.plan.plan.caseSensitive === true,
|
|
371
|
+
latestFormObservations: (report.data.run?.ledger.occurrences ?? []).flatMap(
|
|
372
|
+
(occurrence) => {
|
|
373
|
+
const observation = governedObservationSchema.safeParse(occurrence);
|
|
374
|
+
if (!observation.success) {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
const { disposition, form, line, path, reason, scopeTier, verdict } =
|
|
378
|
+
observation.data;
|
|
379
|
+
return [
|
|
380
|
+
{
|
|
381
|
+
disposition,
|
|
382
|
+
form,
|
|
383
|
+
line,
|
|
384
|
+
path,
|
|
385
|
+
reason,
|
|
386
|
+
...(scopeTier === undefined || scopeTier === null
|
|
387
|
+
? {}
|
|
388
|
+
: { scopeTier }),
|
|
389
|
+
verdict,
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
}
|
|
393
|
+
),
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
type HistoryFileResult =
|
|
398
|
+
| {
|
|
399
|
+
readonly kind: 'evidence';
|
|
400
|
+
readonly value: GovernedVocabularyHistoryEvidence;
|
|
401
|
+
}
|
|
402
|
+
| { readonly kind: 'ignore' }
|
|
403
|
+
| { readonly kind: 'issue'; readonly value: GovernedVocabularyHistoryIssue };
|
|
404
|
+
|
|
405
|
+
const historyIssue = (
|
|
406
|
+
path: string,
|
|
407
|
+
message: string,
|
|
408
|
+
transitionId?: string
|
|
409
|
+
): HistoryFileResult => ({
|
|
410
|
+
kind: 'issue',
|
|
411
|
+
value: {
|
|
412
|
+
message,
|
|
413
|
+
path,
|
|
414
|
+
...(transitionId === undefined ? {} : { transitionId }),
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const governedTransitionForRun = (run: z.infer<typeof historyRunSchema>) => {
|
|
419
|
+
const { plan } = run.plan;
|
|
420
|
+
if (plan.kind !== 'vocabulary') {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const transitionIds = [
|
|
424
|
+
plan.id,
|
|
425
|
+
run.provenance?.transitionId,
|
|
426
|
+
run.plan.transitionId,
|
|
427
|
+
];
|
|
428
|
+
for (const transitionId of transitionIds) {
|
|
429
|
+
if (transitionId === undefined) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const transition = getGovernedVocabularyTransition(transitionId);
|
|
433
|
+
if (transition !== undefined) {
|
|
434
|
+
return transition;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return listGovernedVocabularyTransitions().find(
|
|
438
|
+
(transition) =>
|
|
439
|
+
transition.from === plan.from &&
|
|
440
|
+
(transition.target.kind === 'single'
|
|
441
|
+
? transition.target.to === plan.to
|
|
442
|
+
: transition.target.options.some((option) => option.to === plan.to))
|
|
443
|
+
);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const loadHistoryFile = (rootDir: string, name: string): HistoryFileResult => {
|
|
447
|
+
const absolutePath = join(rootDir, '.trails', 'regrade', 'history', name);
|
|
448
|
+
const observedPath = normalizePath(relative(rootDir, absolutePath));
|
|
449
|
+
let json: unknown;
|
|
450
|
+
try {
|
|
451
|
+
json = JSON.parse(readFileSync(absolutePath, 'utf8'));
|
|
452
|
+
} catch {
|
|
453
|
+
return historyIssue(
|
|
454
|
+
observedPath,
|
|
455
|
+
'Committed Regrade history is not valid JSON.'
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const parsed = historyArtifactSchema.safeParse(json);
|
|
459
|
+
if (!parsed.success) {
|
|
460
|
+
return historyIssue(
|
|
461
|
+
observedPath,
|
|
462
|
+
'Committed Regrade history has an invalid evidence shape.'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (normalizePath(parsed.data.path) !== observedPath) {
|
|
466
|
+
return historyIssue(
|
|
467
|
+
observedPath,
|
|
468
|
+
'Committed Regrade history path does not match its observed file.'
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const latestRun = parsed.data.runs.at(-1);
|
|
473
|
+
const transition =
|
|
474
|
+
latestRun === undefined ? undefined : governedTransitionForRun(latestRun);
|
|
475
|
+
if (transition === undefined) {
|
|
476
|
+
return { kind: 'ignore' };
|
|
477
|
+
}
|
|
478
|
+
const allRunsMatchTransition = parsed.data.runs.every((run) => {
|
|
479
|
+
const runPlan = run.plan.plan;
|
|
480
|
+
if (
|
|
481
|
+
runPlan.kind !== 'vocabulary' ||
|
|
482
|
+
governedTransitionForRun(run)?.id !== transition.id ||
|
|
483
|
+
runPlan.from !== transition.from
|
|
484
|
+
) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
return transition.target.kind === 'single'
|
|
488
|
+
? transition.target.to === runPlan.to
|
|
489
|
+
: transition.target.options.some((option) => option.to === runPlan.to);
|
|
490
|
+
});
|
|
491
|
+
if (!allRunsMatchTransition) {
|
|
492
|
+
return historyIssue(
|
|
493
|
+
observedPath,
|
|
494
|
+
'Committed Regrade history does not match its governed registry transition.',
|
|
495
|
+
transition.id
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const governedRuns = parsed.data.runs.map((run) =>
|
|
499
|
+
governedHistoryRunSchema.safeParse(run)
|
|
500
|
+
);
|
|
501
|
+
const rawRuns = (
|
|
502
|
+
json as {
|
|
503
|
+
readonly runs: readonly RawGovernedHistoryRun[];
|
|
504
|
+
}
|
|
505
|
+
).runs;
|
|
506
|
+
if (
|
|
507
|
+
transition.provenance.mode === 'regrade-history' &&
|
|
508
|
+
(governedRuns.some((run) => !run.success) ||
|
|
509
|
+
governedRuns.some(
|
|
510
|
+
(run, index) =>
|
|
511
|
+
run.success &&
|
|
512
|
+
!validatesDeterministicEvidence(
|
|
513
|
+
run.data,
|
|
514
|
+
rawRuns[index],
|
|
515
|
+
transition.id
|
|
516
|
+
)
|
|
517
|
+
))
|
|
518
|
+
) {
|
|
519
|
+
return historyIssue(
|
|
520
|
+
observedPath,
|
|
521
|
+
'Committed Regrade history has invalid deterministic run evidence.',
|
|
522
|
+
transition.id
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (
|
|
526
|
+
parsed.data.runs.some(
|
|
527
|
+
(run) =>
|
|
528
|
+
run.provenance !== undefined &&
|
|
529
|
+
run.provenance.transitionId !== transition.id
|
|
530
|
+
)
|
|
531
|
+
) {
|
|
532
|
+
return historyIssue(
|
|
533
|
+
observedPath,
|
|
534
|
+
'Committed Regrade history provenance names a different governed transition.',
|
|
535
|
+
transition.id
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
if (
|
|
539
|
+
transition.provenance.mode === 'regrade-history' &&
|
|
540
|
+
parsed.data.runs.some((run) => run.provenance === undefined)
|
|
541
|
+
) {
|
|
542
|
+
return historyIssue(
|
|
543
|
+
observedPath,
|
|
544
|
+
'Committed Regrade history lacks required governed provenance.',
|
|
545
|
+
transition.id
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
const provenance = parsed.data.runs.at(-1)?.provenance;
|
|
549
|
+
const facts = latestRunFacts(parsed.data.runs.at(-1));
|
|
550
|
+
return {
|
|
551
|
+
kind: 'evidence',
|
|
552
|
+
value: {
|
|
553
|
+
...facts,
|
|
554
|
+
id: parsed.data.id,
|
|
555
|
+
path: parsed.data.path,
|
|
556
|
+
...(provenance === undefined ? {} : { provenance }),
|
|
557
|
+
runCount: parsed.data.runs.length,
|
|
558
|
+
transitionId: transition.id,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
export const loadGovernedVocabularyHistory = (
|
|
564
|
+
rootDir: string
|
|
565
|
+
): GovernedVocabularyHistoryIndex => {
|
|
566
|
+
const directory = join(rootDir, '.trails', 'regrade', 'history');
|
|
567
|
+
if (!existsSync(directory)) {
|
|
568
|
+
return { byTransitionId: new Map(), issues: [] };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const byTransitionId = new Map<string, GovernedVocabularyHistoryEvidence>();
|
|
572
|
+
const issues: GovernedVocabularyHistoryIssue[] = [];
|
|
573
|
+
const files = readdirSync(directory)
|
|
574
|
+
.filter((name) => name.endsWith('.json'))
|
|
575
|
+
.toSorted();
|
|
576
|
+
|
|
577
|
+
for (const name of files) {
|
|
578
|
+
const result = loadHistoryFile(rootDir, name);
|
|
579
|
+
if (result.kind === 'ignore') {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (result.kind === 'issue') {
|
|
583
|
+
issues.push(result.value);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (byTransitionId.has(result.value.transitionId)) {
|
|
587
|
+
issues.push({
|
|
588
|
+
message:
|
|
589
|
+
'Multiple committed Regrade histories claim the same governed transition.',
|
|
590
|
+
path: result.value.path,
|
|
591
|
+
transitionId: result.value.transitionId,
|
|
592
|
+
});
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
byTransitionId.set(result.value.transitionId, result.value);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return { byTransitionId, issues };
|
|
599
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveTrailCliCommandRendering } from '@ontrails/core';
|
|
2
2
|
import type { CliCommandRoute, Topo } from '@ontrails/core';
|
|
3
3
|
import type { TopoGraph } from '@ontrails/topography';
|
|
4
4
|
|
|
@@ -21,13 +21,13 @@ const renderPath = (path: readonly string[]): string => path.join(' ');
|
|
|
21
21
|
const claimLabel = (claim: RouteClaim): string =>
|
|
22
22
|
`${claim.kind} route for trail "${claim.trailId}" (${claim.source})`;
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const buildDerivationDiagnostic = (
|
|
25
25
|
trailId: string,
|
|
26
26
|
error: unknown
|
|
27
27
|
): WardenDiagnostic => ({
|
|
28
28
|
filePath: TOPO_FILE,
|
|
29
29
|
line: 1,
|
|
30
|
-
message: `CLI command route
|
|
30
|
+
message: `CLI command route rendering for trail "${trailId}" is invalid: ${
|
|
31
31
|
error instanceof Error ? error.message : String(error)
|
|
32
32
|
}`,
|
|
33
33
|
rule: RULE_NAME,
|
|
@@ -75,8 +75,8 @@ const collectTopoClaims = (
|
|
|
75
75
|
|
|
76
76
|
for (const trail of topo.list()) {
|
|
77
77
|
try {
|
|
78
|
-
const
|
|
79
|
-
for (const route of
|
|
78
|
+
const rendering = deriveTrailCliCommandRendering(trail);
|
|
79
|
+
for (const route of rendering.routes) {
|
|
80
80
|
claims.push({
|
|
81
81
|
kind: route.kind,
|
|
82
82
|
path: route.path,
|
|
@@ -85,7 +85,7 @@ const collectTopoClaims = (
|
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
} catch (error: unknown) {
|
|
88
|
-
diagnostics.push(
|
|
88
|
+
diagnostics.push(buildDerivationDiagnostic(trail.id, error));
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -63,7 +63,7 @@ const collectDraftVisibleDebtDiagnostics = (
|
|
|
63
63
|
*
|
|
64
64
|
* Severity is intentionally `warn`, not `error`. The hard rejection layer for
|
|
65
65
|
* draft state leaking into established outputs is `validateEstablishedTopo` at
|
|
66
|
-
* runtime — it blocks topo compile, surface
|
|
66
|
+
* runtime — it blocks topo compile, surface rendering, and lockfile writes.
|
|
67
67
|
* This rule surfaces the debt for human reviewers without duplicating that layer.
|
|
68
68
|
*/
|
|
69
69
|
export const draftVisibleDebt: WardenRule = {
|
|
@@ -54,52 +54,13 @@ const ALLOWED_DUPLICATE_EXPORT_GROUPS: readonly {
|
|
|
54
54
|
'ObserveConfig',
|
|
55
55
|
'ObserveInput',
|
|
56
56
|
],
|
|
57
|
-
reason: '@ontrails/
|
|
58
|
-
workspaceNames: ['@ontrails/core', '@ontrails/
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
names: [
|
|
62
|
-
'DEFAULT_MEMORY_SINK_MAX_RECORDS',
|
|
63
|
-
'MemorySinkOptions',
|
|
64
|
-
'MemoryTraceSink',
|
|
65
|
-
'createBoundedMemorySink',
|
|
66
|
-
'createMemorySink',
|
|
67
|
-
],
|
|
68
|
-
reason:
|
|
69
|
-
'@ontrails/tracing compatibility memory sink mirrors @ontrails/observe',
|
|
70
|
-
workspaceNames: ['@ontrails/observe', '@ontrails/tracing'],
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
names: [
|
|
74
|
-
'ActivationTraceRecordName',
|
|
75
|
-
'NOOP_SINK',
|
|
76
|
-
'SignalTraceRecordName',
|
|
77
|
-
'TRACE_CONTEXT_KEY',
|
|
78
|
-
'TraceFn',
|
|
79
|
-
'clearTraceSink',
|
|
80
|
-
'createActivationTraceRecord',
|
|
81
|
-
'createSignalTraceRecord',
|
|
82
|
-
'createTraceRecord',
|
|
83
|
-
'getTraceContext',
|
|
84
|
-
'getTraceSink',
|
|
85
|
-
'registerTraceSink',
|
|
86
|
-
'traceContextFromRecord',
|
|
87
|
-
'writeActivationTraceRecord',
|
|
88
|
-
'writeSignalTraceRecord',
|
|
89
|
-
],
|
|
90
|
-
reason:
|
|
91
|
-
'@ontrails/tracing mirrors core tracing contracts for compatibility',
|
|
92
|
-
workspaceNames: ['@ontrails/core', '@ontrails/tracing'],
|
|
57
|
+
reason: '@ontrails/observability mirrors core observability contracts',
|
|
58
|
+
workspaceNames: ['@ontrails/core', '@ontrails/observability'],
|
|
93
59
|
},
|
|
94
60
|
{
|
|
95
61
|
names: ['TraceContext', 'TraceRecord', 'TraceSink'],
|
|
96
|
-
reason:
|
|
97
|
-
|
|
98
|
-
workspaceNames: [
|
|
99
|
-
'@ontrails/core',
|
|
100
|
-
'@ontrails/observe',
|
|
101
|
-
'@ontrails/tracing',
|
|
102
|
-
],
|
|
62
|
+
reason: '@ontrails/observability mirrors core trace contracts',
|
|
63
|
+
workspaceNames: ['@ontrails/core', '@ontrails/observability'],
|
|
103
64
|
},
|
|
104
65
|
{
|
|
105
66
|
names: ['AuthError', 'PermitError'],
|