@ontrails/warden 1.0.0-beta.43 → 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,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
- GovernedVocabularyHistoryFormObservation,
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
- observation: GovernedVocabularyHistoryFormObservation
17
+ judgment: GovernedVocabularyHistoryFormJudgment
18
18
  ): boolean =>
19
- observation.reason === 'unclassified-neighbor' &&
20
- observation.verdict === 'deferred' &&
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: GovernedVocabularyHistoryFormObservation,
25
- right: GovernedVocabularyHistoryFormObservation
23
+ left: GovernedVocabularyHistoryFormJudgment,
24
+ right: GovernedVocabularyHistoryFormJudgment
26
25
  ): number =>
27
- left.path.localeCompare(right.path) ||
28
- left.line - right.line ||
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
- GovernedVocabularyHistoryFormObservation
37
+ GovernedVocabularyHistoryFormJudgment
37
38
  >();
38
39
 
39
- for (const observation of evidence.latestFormObservations) {
40
- if (!isUnknownPermutation(observation)) {
40
+ for (const judgment of evidence.latestFormJudgments) {
41
+ if (!isUnknownPermutation(judgment)) {
41
42
  continue;
42
43
  }
43
- const identity = formIdentity(observation.form, evidence);
44
+ const identity = formIdentity(judgment.form, evidence);
44
45
  const current = unknownByIdentity.get(identity);
45
- if (current === undefined || compareObservation(observation, current) < 0) {
46
- unknownByIdentity.set(identity, observation);
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(([, observation]) => ({
53
- filePath: observation.path,
54
- line: observation.line,
55
- message: `Governed transition '${evidence.transitionId}' recorded unknown vocabulary form '${observation.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.`,
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
  }));