@ontrails/warden 1.0.0-beta.45 → 1.0.0-beta.47
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 +31 -0
- package/package.json +10 -10
- package/src/cli.ts +97 -62
- package/src/index.ts +1 -1
- package/src/regrade-history.ts +54 -465
- package/src/regrade-receipt.ts +484 -0
- package/src/rules/governed-symbol-residue.ts +4 -0
- package/src/rules/governed-vocabulary-permutation-watch.ts +20 -19
- package/src/rules/index.ts +1 -1
- package/src/rules/public-export-example-coverage.ts +25 -37
- package/src/rules/retired-vocabulary.ts +40 -0
- package/src/rules/types.ts +11 -8
- package/src/trails/schema.ts +11 -8
- package/src/workspaces.ts +71 -6
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/);
|
|
6
|
+
const gitObjectIdSchema = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
|
|
7
|
+
const countSchema = z.number().int().nonnegative();
|
|
8
|
+
const vocabularyDispositionValues = [
|
|
9
|
+
'code-context-out-of-engine',
|
|
10
|
+
'docs-only',
|
|
11
|
+
'explicit-preserve',
|
|
12
|
+
'forward-pointer',
|
|
13
|
+
'historical-by-policy',
|
|
14
|
+
'ignored-by-scope',
|
|
15
|
+
'in-family-modified',
|
|
16
|
+
'in-family-unresolved',
|
|
17
|
+
'out-of-family',
|
|
18
|
+
'preserve-current-live-api',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
const scopeSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
exclude: z.array(z.string()).optional(),
|
|
24
|
+
extensions: z.array(z.string()).optional(),
|
|
25
|
+
include: z.array(z.string()).optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
const vocabularyScopeSchema = scopeSchema.extend({
|
|
30
|
+
ignoredDirectories: z.array(z.string()).optional(),
|
|
31
|
+
policyClassified: z
|
|
32
|
+
.array(
|
|
33
|
+
z
|
|
34
|
+
.object({
|
|
35
|
+
disposition: z.enum(vocabularyDispositionValues),
|
|
36
|
+
expectMatches: z.boolean().optional(),
|
|
37
|
+
paths: z.array(z.string().min(1)).min(1),
|
|
38
|
+
reason: z.string().min(1),
|
|
39
|
+
})
|
|
40
|
+
.strict()
|
|
41
|
+
)
|
|
42
|
+
.optional(),
|
|
43
|
+
teachingSurfaces: z.array(z.string().min(1)).optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const vocabularyPlanSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
caseSensitive: z.boolean().optional(),
|
|
49
|
+
deferForms: z.array(z.string().min(1)).optional(),
|
|
50
|
+
fileRenames: z
|
|
51
|
+
.array(
|
|
52
|
+
z.object({ from: z.string().min(1), to: z.string().min(1) }).strict()
|
|
53
|
+
)
|
|
54
|
+
.optional(),
|
|
55
|
+
from: z.string().min(1),
|
|
56
|
+
id: z.string().optional(),
|
|
57
|
+
intent: z.string().optional(),
|
|
58
|
+
kind: z.literal('vocabulary'),
|
|
59
|
+
overrides: z.record(z.string().min(1), z.string().min(1)).optional(),
|
|
60
|
+
preserve: z
|
|
61
|
+
.array(
|
|
62
|
+
z
|
|
63
|
+
.object({
|
|
64
|
+
disposition: z.enum(vocabularyDispositionValues).optional(),
|
|
65
|
+
forms: z.array(z.string().min(1)).optional(),
|
|
66
|
+
paths: z.array(z.string()).optional(),
|
|
67
|
+
pattern: z.string(),
|
|
68
|
+
reason: z.string().optional(),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
)
|
|
72
|
+
.optional(),
|
|
73
|
+
scope: vocabularyScopeSchema.optional(),
|
|
74
|
+
to: z.string().min(1),
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
|
|
78
|
+
const classPlanSchema = z
|
|
79
|
+
.object({
|
|
80
|
+
classIds: z.array(z.string().min(1)).min(1),
|
|
81
|
+
id: z.string().min(1),
|
|
82
|
+
intent: z.string().optional(),
|
|
83
|
+
kind: z.literal('class'),
|
|
84
|
+
name: z.string().min(1).optional(),
|
|
85
|
+
scope: scopeSchema.optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
|
|
89
|
+
const planSchema = z.discriminatedUnion('kind', [
|
|
90
|
+
vocabularyPlanSchema,
|
|
91
|
+
classPlanSchema,
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const planProvenanceSchema = z
|
|
95
|
+
.object({
|
|
96
|
+
fields: z.record(z.string(), z.enum(['authored', 'derived'])),
|
|
97
|
+
})
|
|
98
|
+
.strict();
|
|
99
|
+
|
|
100
|
+
const formJudgmentSchema = z
|
|
101
|
+
.object({
|
|
102
|
+
disposition: z.enum(['mapped', 'out-of-family', 'preserved', 'unresolved']),
|
|
103
|
+
form: z.string().min(1),
|
|
104
|
+
reason: z.string().min(1).optional(),
|
|
105
|
+
representative: z
|
|
106
|
+
.object({ line: z.number().int().positive(), path: z.string().min(1) })
|
|
107
|
+
.strict()
|
|
108
|
+
.optional(),
|
|
109
|
+
target: z.string().min(1).optional(),
|
|
110
|
+
})
|
|
111
|
+
.strict();
|
|
112
|
+
|
|
113
|
+
const completionSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
counts: z
|
|
116
|
+
.object({
|
|
117
|
+
dispositions: z.record(z.string().min(1), countSchema),
|
|
118
|
+
matched: countSchema,
|
|
119
|
+
preserved: countSchema,
|
|
120
|
+
review: countSchema,
|
|
121
|
+
rewritten: countSchema,
|
|
122
|
+
skippedByReason: z.record(z.string().min(1), countSchema),
|
|
123
|
+
unknown: countSchema,
|
|
124
|
+
})
|
|
125
|
+
.strict(),
|
|
126
|
+
gate: z
|
|
127
|
+
.object({
|
|
128
|
+
reasons: z.array(z.string().min(1)),
|
|
129
|
+
remaining: countSchema,
|
|
130
|
+
status: z.enum(['green', 'open']),
|
|
131
|
+
})
|
|
132
|
+
.strict(),
|
|
133
|
+
metrics: z
|
|
134
|
+
.object({
|
|
135
|
+
filesChanged: countSchema,
|
|
136
|
+
formsMapped: countSchema,
|
|
137
|
+
occurrencesRewritten: countSchema,
|
|
138
|
+
})
|
|
139
|
+
.strict(),
|
|
140
|
+
})
|
|
141
|
+
.strict();
|
|
142
|
+
|
|
143
|
+
const runSchema = z
|
|
144
|
+
.object({
|
|
145
|
+
classifiedState: z.discriminatedUnion('kind', [
|
|
146
|
+
z
|
|
147
|
+
.object({
|
|
148
|
+
caseSensitive: z.boolean(),
|
|
149
|
+
forms: z.array(formJudgmentSchema),
|
|
150
|
+
kind: z.literal('embedded'),
|
|
151
|
+
stateHash: sha256Schema,
|
|
152
|
+
})
|
|
153
|
+
.strict(),
|
|
154
|
+
z
|
|
155
|
+
.object({ kind: z.literal('reference'), stateHash: sha256Schema })
|
|
156
|
+
.strict(),
|
|
157
|
+
]),
|
|
158
|
+
completion: completionSchema,
|
|
159
|
+
evidence: z
|
|
160
|
+
.object({
|
|
161
|
+
changedFiles: z.array(
|
|
162
|
+
z
|
|
163
|
+
.object({
|
|
164
|
+
afterBlobHash: gitObjectIdSchema,
|
|
165
|
+
afterPath: z.string().min(1),
|
|
166
|
+
beforeBlobHash: gitObjectIdSchema,
|
|
167
|
+
beforePath: z.string().min(1),
|
|
168
|
+
})
|
|
169
|
+
.strict()
|
|
170
|
+
),
|
|
171
|
+
detailEvidenceHash: sha256Schema,
|
|
172
|
+
lockStateHash: sha256Schema,
|
|
173
|
+
policyHash: sha256Schema,
|
|
174
|
+
sourceRevision: gitObjectIdSchema,
|
|
175
|
+
sourceStateHash: sha256Schema,
|
|
176
|
+
toolVersion: z.string().min(1),
|
|
177
|
+
})
|
|
178
|
+
.strict(),
|
|
179
|
+
intent: z.discriminatedUnion('kind', [
|
|
180
|
+
z
|
|
181
|
+
.object({
|
|
182
|
+
kind: z.literal('embedded'),
|
|
183
|
+
plan: planSchema,
|
|
184
|
+
planContentHash: sha256Schema,
|
|
185
|
+
provenance: planProvenanceSchema,
|
|
186
|
+
})
|
|
187
|
+
.strict(),
|
|
188
|
+
z
|
|
189
|
+
.object({ kind: z.literal('reference'), planContentHash: sha256Schema })
|
|
190
|
+
.strict(),
|
|
191
|
+
]),
|
|
192
|
+
project: z.object({ root: z.literal('.') }).strict(),
|
|
193
|
+
runId: z.string().min(1),
|
|
194
|
+
runKind: z.enum(['original', 'adjust', 'proof']),
|
|
195
|
+
timestamp: z.iso.datetime(),
|
|
196
|
+
transitionId: z.string().min(1),
|
|
197
|
+
})
|
|
198
|
+
.strict();
|
|
199
|
+
|
|
200
|
+
const receiptSchema = z
|
|
201
|
+
.object({
|
|
202
|
+
conversion: z
|
|
203
|
+
.object({
|
|
204
|
+
convertedAt: z.iso.datetime(),
|
|
205
|
+
fromSchemaVersion: z.literal(2),
|
|
206
|
+
sourceContentHash: sha256Schema,
|
|
207
|
+
toolVersion: z.string().min(1),
|
|
208
|
+
})
|
|
209
|
+
.strict()
|
|
210
|
+
.optional(),
|
|
211
|
+
id: z.string().min(1),
|
|
212
|
+
kind: z.literal('regrade-history'),
|
|
213
|
+
path: z.string().min(1),
|
|
214
|
+
runs: z.array(runSchema).min(1),
|
|
215
|
+
schemaVersion: z.literal(3),
|
|
216
|
+
})
|
|
217
|
+
.strict();
|
|
218
|
+
|
|
219
|
+
type Receipt = z.output<typeof receiptSchema>;
|
|
220
|
+
type Plan = z.output<typeof planSchema>;
|
|
221
|
+
type FormJudgment = z.output<typeof formJudgmentSchema>;
|
|
222
|
+
|
|
223
|
+
export interface WardenResolvedReceipt {
|
|
224
|
+
readonly artifact: Receipt;
|
|
225
|
+
readonly runs: readonly {
|
|
226
|
+
readonly caseSensitive: boolean;
|
|
227
|
+
readonly forms: readonly FormJudgment[];
|
|
228
|
+
readonly plan: Plan;
|
|
229
|
+
}[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
233
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
234
|
+
|
|
235
|
+
const canonicalize = (value: unknown): unknown => {
|
|
236
|
+
if (Array.isArray(value)) {
|
|
237
|
+
return value.map(canonicalize);
|
|
238
|
+
}
|
|
239
|
+
if (!isPlainObject(value)) {
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
return Object.fromEntries(
|
|
243
|
+
Object.keys(value)
|
|
244
|
+
.toSorted()
|
|
245
|
+
.map((key) => [key, canonicalize(value[key])])
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const contentHash = (value: unknown): string =>
|
|
250
|
+
createHash('sha256')
|
|
251
|
+
.update(JSON.stringify(canonicalize(value)))
|
|
252
|
+
.digest('hex');
|
|
253
|
+
|
|
254
|
+
const compareCodeUnits = (left: string, right: string): number => {
|
|
255
|
+
if (left < right) {
|
|
256
|
+
return -1;
|
|
257
|
+
}
|
|
258
|
+
if (left > right) {
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
return 0;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const stateHash = (state: {
|
|
265
|
+
readonly caseSensitive: boolean;
|
|
266
|
+
readonly forms: readonly FormJudgment[];
|
|
267
|
+
}): string =>
|
|
268
|
+
contentHash({
|
|
269
|
+
caseSensitive: state.caseSensitive,
|
|
270
|
+
forms: [...state.forms].toSorted(
|
|
271
|
+
(left, right) =>
|
|
272
|
+
compareCodeUnits(left.form, right.form) ||
|
|
273
|
+
compareCodeUnits(left.disposition, right.disposition)
|
|
274
|
+
),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const validRootRelativePath = (value: string): boolean =>
|
|
278
|
+
value.length > 0 &&
|
|
279
|
+
value !== '.' &&
|
|
280
|
+
!posix.isAbsolute(value) &&
|
|
281
|
+
!/^[A-Za-z]:[\\/]/u.test(value) &&
|
|
282
|
+
!value.startsWith('\\\\') &&
|
|
283
|
+
!value.includes('\\') &&
|
|
284
|
+
!value.includes('\u0000') &&
|
|
285
|
+
posix.normalize(value) === value &&
|
|
286
|
+
!value.startsWith('./') &&
|
|
287
|
+
value !== '..' &&
|
|
288
|
+
!value.startsWith('../') &&
|
|
289
|
+
!value.includes('/../');
|
|
290
|
+
|
|
291
|
+
const planPaths = (plan: Plan): readonly string[] => {
|
|
292
|
+
const common = [
|
|
293
|
+
...(plan.scope?.include ?? []),
|
|
294
|
+
...(plan.scope?.exclude ?? []),
|
|
295
|
+
];
|
|
296
|
+
if (plan.kind === 'class') {
|
|
297
|
+
return common;
|
|
298
|
+
}
|
|
299
|
+
return [
|
|
300
|
+
...common,
|
|
301
|
+
...(plan.scope?.ignoredDirectories ?? []),
|
|
302
|
+
...(plan.scope?.teachingSurfaces ?? []),
|
|
303
|
+
...(plan.scope?.policyClassified ?? []).flatMap((item) => item.paths),
|
|
304
|
+
...(plan.fileRenames ?? []).flatMap((rename) => [rename.from, rename.to]),
|
|
305
|
+
...(plan.preserve ?? []).flatMap((item) => item.paths ?? []),
|
|
306
|
+
];
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const proofIsMinimal = (run: Receipt['runs'][number]): boolean => {
|
|
310
|
+
const { counts, gate, metrics } = run.completion;
|
|
311
|
+
return (
|
|
312
|
+
run.intent.kind === 'reference' &&
|
|
313
|
+
run.classifiedState.kind === 'reference' &&
|
|
314
|
+
run.evidence.changedFiles.length === 0 &&
|
|
315
|
+
gate.status === 'green' &&
|
|
316
|
+
gate.remaining === 0 &&
|
|
317
|
+
gate.reasons.length === 0 &&
|
|
318
|
+
counts.matched === 0 &&
|
|
319
|
+
counts.preserved === 0 &&
|
|
320
|
+
counts.review === 0 &&
|
|
321
|
+
counts.rewritten === 0 &&
|
|
322
|
+
counts.unknown === 0 &&
|
|
323
|
+
Object.keys(counts.dispositions).length === 0 &&
|
|
324
|
+
Object.keys(counts.skippedByReason).length === 0 &&
|
|
325
|
+
metrics.filesChanged === 0 &&
|
|
326
|
+
metrics.formsMapped === 0 &&
|
|
327
|
+
metrics.occurrencesRewritten === 0
|
|
328
|
+
);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
type ReceiptRun = Receipt['runs'][number];
|
|
332
|
+
interface ResolvedState {
|
|
333
|
+
readonly caseSensitive: boolean;
|
|
334
|
+
readonly forms: readonly FormJudgment[];
|
|
335
|
+
}
|
|
336
|
+
type Resolution<T> =
|
|
337
|
+
| { readonly ok: true; readonly value: T }
|
|
338
|
+
| { readonly error: string; readonly ok: false };
|
|
339
|
+
|
|
340
|
+
const validateRunEvidence = (run: ReceiptRun): string | undefined => {
|
|
341
|
+
const beforePaths = new Set<string>();
|
|
342
|
+
const afterPaths = new Set<string>();
|
|
343
|
+
for (const file of run.evidence.changedFiles) {
|
|
344
|
+
if (
|
|
345
|
+
!validRootRelativePath(file.beforePath) ||
|
|
346
|
+
!validRootRelativePath(file.afterPath) ||
|
|
347
|
+
beforePaths.has(file.beforePath) ||
|
|
348
|
+
afterPaths.has(file.afterPath) ||
|
|
349
|
+
(file.beforePath === file.afterPath &&
|
|
350
|
+
file.beforeBlobHash === file.afterBlobHash)
|
|
351
|
+
) {
|
|
352
|
+
return 'invalid changed-file evidence';
|
|
353
|
+
}
|
|
354
|
+
beforePaths.add(file.beforePath);
|
|
355
|
+
afterPaths.add(file.afterPath);
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
run.completion.metrics.filesChanged !== run.evidence.changedFiles.length
|
|
359
|
+
) {
|
|
360
|
+
return 'changed-file count mismatch';
|
|
361
|
+
}
|
|
362
|
+
const { counts, gate, metrics } = run.completion;
|
|
363
|
+
const hasUnresolvedCounts = counts.review > 0 || counts.unknown > 0;
|
|
364
|
+
const gateIsCoherent =
|
|
365
|
+
gate.status === 'green'
|
|
366
|
+
? gate.remaining === 0 &&
|
|
367
|
+
gate.reasons.length === 0 &&
|
|
368
|
+
!hasUnresolvedCounts
|
|
369
|
+
: gate.reasons.length > 0;
|
|
370
|
+
if (counts.rewritten !== metrics.occurrencesRewritten || !gateIsCoherent) {
|
|
371
|
+
return 'incoherent completion facts';
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const resolvePlan = (
|
|
377
|
+
run: ReceiptRun,
|
|
378
|
+
plans: Map<string, Plan>
|
|
379
|
+
): Resolution<Plan> => {
|
|
380
|
+
if (run.intent.kind === 'reference') {
|
|
381
|
+
const plan = plans.get(run.intent.planContentHash);
|
|
382
|
+
return plan === undefined
|
|
383
|
+
? { error: 'broken plan reference', ok: false }
|
|
384
|
+
: { ok: true, value: plan };
|
|
385
|
+
}
|
|
386
|
+
if (
|
|
387
|
+
contentHash({
|
|
388
|
+
plan: run.intent.plan,
|
|
389
|
+
provenance: run.intent.provenance,
|
|
390
|
+
}) !== run.intent.planContentHash ||
|
|
391
|
+
planPaths(run.intent.plan).some((path) => !validRootRelativePath(path))
|
|
392
|
+
) {
|
|
393
|
+
return { error: 'invalid embedded plan', ok: false };
|
|
394
|
+
}
|
|
395
|
+
plans.set(run.intent.planContentHash, run.intent.plan);
|
|
396
|
+
return { ok: true, value: run.intent.plan };
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const resolveState = (
|
|
400
|
+
run: ReceiptRun,
|
|
401
|
+
states: Map<string, ResolvedState>
|
|
402
|
+
): Resolution<ResolvedState> => {
|
|
403
|
+
if (run.classifiedState.kind === 'reference') {
|
|
404
|
+
const state = states.get(run.classifiedState.stateHash);
|
|
405
|
+
return state === undefined
|
|
406
|
+
? { error: 'broken classified state reference', ok: false }
|
|
407
|
+
: { ok: true, value: state };
|
|
408
|
+
}
|
|
409
|
+
const { caseSensitive, forms } = run.classifiedState;
|
|
410
|
+
const identities = new Set(
|
|
411
|
+
forms.map((form) => (caseSensitive ? form.form : form.form.toLowerCase()))
|
|
412
|
+
);
|
|
413
|
+
if (
|
|
414
|
+
identities.size !== forms.length ||
|
|
415
|
+
forms.some(
|
|
416
|
+
(form) =>
|
|
417
|
+
(form.disposition === 'mapped') !== (form.target !== undefined) ||
|
|
418
|
+
(form.representative !== undefined &&
|
|
419
|
+
!validRootRelativePath(form.representative.path))
|
|
420
|
+
)
|
|
421
|
+
) {
|
|
422
|
+
return { error: 'invalid classified form state', ok: false };
|
|
423
|
+
}
|
|
424
|
+
const state = { caseSensitive, forms };
|
|
425
|
+
if (stateHash(state) !== run.classifiedState.stateHash) {
|
|
426
|
+
return { error: 'classified state hash mismatch', ok: false };
|
|
427
|
+
}
|
|
428
|
+
states.set(run.classifiedState.stateHash, state);
|
|
429
|
+
return { ok: true, value: state };
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** Validate and resolve only the compact receipt facts consumed by Warden. */
|
|
433
|
+
export const resolveReceiptForWarden = (
|
|
434
|
+
value: unknown
|
|
435
|
+
): { readonly error?: string; readonly value?: WardenResolvedReceipt } => {
|
|
436
|
+
const parsed = receiptSchema.safeParse(value);
|
|
437
|
+
if (!parsed.success) {
|
|
438
|
+
return { error: 'invalid evidence shape' };
|
|
439
|
+
}
|
|
440
|
+
const artifact = parsed.data;
|
|
441
|
+
if (
|
|
442
|
+
!validRootRelativePath(artifact.path) ||
|
|
443
|
+
!/^\.trails\/regrade\/history\/[^/]+\.json$/u.test(artifact.path)
|
|
444
|
+
) {
|
|
445
|
+
return { error: 'invalid receipt path' };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const plans = new Map<string, Plan>();
|
|
449
|
+
const states = new Map<string, ResolvedState>();
|
|
450
|
+
const runIds = new Set<string>();
|
|
451
|
+
const runs: WardenResolvedReceipt['runs'][number][] = [];
|
|
452
|
+
|
|
453
|
+
for (const run of artifact.runs) {
|
|
454
|
+
if (run.transitionId !== artifact.id || runIds.has(run.runId)) {
|
|
455
|
+
return { error: 'invalid transition or duplicate run identity' };
|
|
456
|
+
}
|
|
457
|
+
runIds.add(run.runId);
|
|
458
|
+
if (run.runKind === 'proof' && !proofIsMinimal(run)) {
|
|
459
|
+
return { error: 'invalid proof receipt' };
|
|
460
|
+
}
|
|
461
|
+
if (run.runKind !== 'proof' && run.intent.kind !== 'embedded') {
|
|
462
|
+
return { error: 'non-proof receipt must embed intent' };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const evidenceError = validateRunEvidence(run);
|
|
466
|
+
if (evidenceError !== undefined) {
|
|
467
|
+
return { error: evidenceError };
|
|
468
|
+
}
|
|
469
|
+
const plan = resolvePlan(run, plans);
|
|
470
|
+
if (!plan.ok) {
|
|
471
|
+
return { error: plan.error };
|
|
472
|
+
}
|
|
473
|
+
const state = resolveState(run, states);
|
|
474
|
+
if (!state.ok) {
|
|
475
|
+
return { error: state.error };
|
|
476
|
+
}
|
|
477
|
+
runs.push({
|
|
478
|
+
caseSensitive: state.value.caseSensitive,
|
|
479
|
+
forms: state.value.forms,
|
|
480
|
+
plan: plan.value,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return { value: { artifact, runs } };
|
|
484
|
+
};
|
|
@@ -286,6 +286,10 @@ const reviewReasonFor = (
|
|
|
286
286
|
}
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
if (match.rename.safety === 'review') {
|
|
290
|
+
return 'requires semantic classification';
|
|
291
|
+
}
|
|
292
|
+
|
|
289
293
|
if (shorthandIdentifiers.has(match.from)) {
|
|
290
294
|
return 'participates in an authored shorthand property';
|
|
291
295
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
GovernedVocabularyHistoryEvidence,
|
|
3
|
-
|
|
3
|
+
GovernedVocabularyHistoryFormJudgment,
|
|
4
4
|
ProjectAwareWardenRule,
|
|
5
5
|
ProjectContext,
|
|
6
6
|
WardenDiagnostic,
|
|
@@ -14,18 +14,19 @@ const formIdentity = (
|
|
|
14
14
|
): string => (evidence.caseSensitive ? form : form.toLowerCase());
|
|
15
15
|
|
|
16
16
|
const isUnknownPermutation = (
|
|
17
|
-
|
|
17
|
+
judgment: GovernedVocabularyHistoryFormJudgment
|
|
18
18
|
): boolean =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
(observation.scopeTier === undefined || observation.scopeTier === 'in-scope');
|
|
19
|
+
judgment.disposition === 'unresolved' &&
|
|
20
|
+
judgment.reason === 'unclassified-neighbor';
|
|
22
21
|
|
|
23
22
|
const compareObservation = (
|
|
24
|
-
left:
|
|
25
|
-
right:
|
|
23
|
+
left: GovernedVocabularyHistoryFormJudgment,
|
|
24
|
+
right: GovernedVocabularyHistoryFormJudgment
|
|
26
25
|
): number =>
|
|
27
|
-
left.path.localeCompare(
|
|
28
|
-
|
|
26
|
+
(left.representative?.path ?? '').localeCompare(
|
|
27
|
+
right.representative?.path ?? ''
|
|
28
|
+
) ||
|
|
29
|
+
(left.representative?.line ?? 1) - (right.representative?.line ?? 1) ||
|
|
29
30
|
left.form.localeCompare(right.form);
|
|
30
31
|
|
|
31
32
|
const diagnosticsForHistory = (
|
|
@@ -33,26 +34,26 @@ const diagnosticsForHistory = (
|
|
|
33
34
|
): readonly WardenDiagnostic[] => {
|
|
34
35
|
const unknownByIdentity = new Map<
|
|
35
36
|
string,
|
|
36
|
-
|
|
37
|
+
GovernedVocabularyHistoryFormJudgment
|
|
37
38
|
>();
|
|
38
39
|
|
|
39
|
-
for (const
|
|
40
|
-
if (!isUnknownPermutation(
|
|
40
|
+
for (const judgment of evidence.latestFormJudgments) {
|
|
41
|
+
if (!isUnknownPermutation(judgment)) {
|
|
41
42
|
continue;
|
|
42
43
|
}
|
|
43
|
-
const identity = formIdentity(
|
|
44
|
+
const identity = formIdentity(judgment.form, evidence);
|
|
44
45
|
const current = unknownByIdentity.get(identity);
|
|
45
|
-
if (current === undefined || compareObservation(
|
|
46
|
-
unknownByIdentity.set(identity,
|
|
46
|
+
if (current === undefined || compareObservation(judgment, current) < 0) {
|
|
47
|
+
unknownByIdentity.set(identity, judgment);
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
return [...unknownByIdentity.entries()]
|
|
51
52
|
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
52
|
-
.map(([,
|
|
53
|
-
filePath:
|
|
54
|
-
line:
|
|
55
|
-
message: `Governed transition '${evidence.transitionId}' recorded unknown vocabulary form '${
|
|
53
|
+
.map(([, judgment]) => ({
|
|
54
|
+
filePath: judgment.representative?.path ?? evidence.path,
|
|
55
|
+
line: judgment.representative?.line ?? 1,
|
|
56
|
+
message: `Governed transition '${evidence.transitionId}' recorded unknown vocabulary form '${judgment.form}' in committed Regrade history '${evidence.path}' (${evidence.id}). Add the form and run an incremental plan, or classify it as out-of-family or preserved.`,
|
|
56
57
|
rule: RULE_NAME,
|
|
57
58
|
severity: 'warn' as const,
|
|
58
59
|
}));
|
package/src/rules/index.ts
CHANGED
|
@@ -81,7 +81,7 @@ import { webhookRouteCollision } from './webhook-route-collision.js';
|
|
|
81
81
|
|
|
82
82
|
export type {
|
|
83
83
|
GovernedVocabularyHistoryEvidence,
|
|
84
|
-
|
|
84
|
+
GovernedVocabularyHistoryFormJudgment,
|
|
85
85
|
GovernedVocabularyHistoryIssue,
|
|
86
86
|
WardenFix,
|
|
87
87
|
WardenFixCapability,
|
|
@@ -44,8 +44,9 @@ import {
|
|
|
44
44
|
getNodeValue,
|
|
45
45
|
offsetToLine,
|
|
46
46
|
parse,
|
|
47
|
+
parseWithDiagnostics,
|
|
47
48
|
} from '@ontrails/source';
|
|
48
|
-
import type { AstNode } from '@ontrails/source';
|
|
49
|
+
import type { AstNode, SourceComment } from '@ontrails/source';
|
|
49
50
|
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
50
51
|
|
|
51
52
|
const RULE_NAME = 'public-export-example-coverage';
|
|
@@ -379,44 +380,19 @@ const declarationNameMatches = (
|
|
|
379
380
|
return false;
|
|
380
381
|
};
|
|
381
382
|
|
|
382
|
-
/**
|
|
383
|
-
* Comment texts found in an inter-statement trivia gap. The gap between two
|
|
384
|
-
* top-level statements contains only whitespace and comments, so a small
|
|
385
|
-
* line/block comment scan recovers the same comment set TypeScript's
|
|
386
|
-
* `getLeadingCommentRanges` returns for the statement's full start.
|
|
387
|
-
*/
|
|
388
|
-
const collectCommentTexts = (gapText: string): readonly string[] => {
|
|
389
|
-
const comments: string[] = [];
|
|
390
|
-
let index = 0;
|
|
391
|
-
while (index < gapText.length - 1) {
|
|
392
|
-
if (gapText[index] === '/' && gapText[index + 1] === '/') {
|
|
393
|
-
const lineEnd = gapText.indexOf('\n', index);
|
|
394
|
-
const stop = lineEnd === -1 ? gapText.length : lineEnd;
|
|
395
|
-
comments.push(gapText.slice(index, stop));
|
|
396
|
-
index = stop + 1;
|
|
397
|
-
} else if (gapText[index] === '/' && gapText[index + 1] === '*') {
|
|
398
|
-
const blockEnd = gapText.indexOf('*/', index + 2);
|
|
399
|
-
const stop = blockEnd === -1 ? gapText.length : blockEnd + 2;
|
|
400
|
-
comments.push(gapText.slice(index, stop));
|
|
401
|
-
index = stop;
|
|
402
|
-
} else {
|
|
403
|
-
index += 1;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return comments;
|
|
407
|
-
};
|
|
408
|
-
|
|
409
383
|
const EXAMPLE_TAG_PATTERN = /@example\b/;
|
|
384
|
+
const LINE_TERMINATOR_PATTERN = /[\n\r\u2028\u2029]/u;
|
|
410
385
|
|
|
411
386
|
/**
|
|
412
387
|
* True when the exported declaration named `importedName` in `sourceText`
|
|
413
|
-
* carries a leading comment containing `@example`.
|
|
414
|
-
*
|
|
415
|
-
*
|
|
388
|
+
* carries a leading comment containing `@example`. Parser-native comments in
|
|
389
|
+
* the trivia gap between the preceding top-level statement's end (or file
|
|
390
|
+
* start) and the matching export statement are considered leading.
|
|
416
391
|
*/
|
|
417
392
|
const hasLeadingExampleForExport = (
|
|
418
393
|
sourceText: string,
|
|
419
394
|
ast: AstNode,
|
|
395
|
+
comments: readonly SourceComment[],
|
|
420
396
|
importedName: string
|
|
421
397
|
): boolean => {
|
|
422
398
|
const body = programBody(ast);
|
|
@@ -429,9 +405,16 @@ const hasLeadingExampleForExport = (
|
|
|
429
405
|
continue;
|
|
430
406
|
}
|
|
431
407
|
const previous = body[statementIndex - 1];
|
|
432
|
-
const
|
|
433
|
-
return
|
|
434
|
-
|
|
408
|
+
const gapStart = previous?.end ?? 0;
|
|
409
|
+
return comments.some(
|
|
410
|
+
(comment) =>
|
|
411
|
+
gapStart <= comment.start &&
|
|
412
|
+
comment.end <= statement.start &&
|
|
413
|
+
(previous === undefined ||
|
|
414
|
+
LINE_TERMINATOR_PATTERN.test(
|
|
415
|
+
sourceText.slice(gapStart, comment.start)
|
|
416
|
+
)) &&
|
|
417
|
+
EXAMPLE_TAG_PATTERN.test(comment.value)
|
|
435
418
|
);
|
|
436
419
|
}
|
|
437
420
|
return false;
|
|
@@ -468,8 +451,8 @@ const coverageDiagnosticsForSpecifier = (
|
|
|
468
451
|
),
|
|
469
452
|
];
|
|
470
453
|
}
|
|
471
|
-
const
|
|
472
|
-
if (!
|
|
454
|
+
const parsedSource = parseWithDiagnostics(sourcePath, sourceText);
|
|
455
|
+
if (!parsedSource.ast || parsedSource.diagnostics.length > 0) {
|
|
473
456
|
return [
|
|
474
457
|
diagnostic(
|
|
475
458
|
sourceCode,
|
|
@@ -481,7 +464,12 @@ const coverageDiagnosticsForSpecifier = (
|
|
|
481
464
|
];
|
|
482
465
|
}
|
|
483
466
|
if (
|
|
484
|
-
hasLeadingExampleForExport(
|
|
467
|
+
hasLeadingExampleForExport(
|
|
468
|
+
sourceText,
|
|
469
|
+
parsedSource.ast,
|
|
470
|
+
parsedSource.comments,
|
|
471
|
+
specifier.importedName
|
|
472
|
+
)
|
|
485
473
|
) {
|
|
486
474
|
return [];
|
|
487
475
|
}
|