@ontrails/regrade 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.
@@ -0,0 +1,847 @@
1
+ import { Result, ValidationError, isPlainObject } from '@ontrails/core';
2
+ import type { Result as TrailsResult } from '@ontrails/core';
3
+ import { createHash } from 'node:crypto';
4
+ import { posix } from 'node:path';
5
+ import { z } from 'zod';
6
+
7
+ import { vocabularyDispositionValues } from './downstream/vocabulary.js';
8
+ import { regradePackageSourceExpectationSchema } from './downstream/package-source-manifest.js';
9
+
10
+ /** Canonical compact Regrade history schema. */
11
+ export const REGRADE_HISTORY_RECEIPT_SCHEMA_VERSION = 3;
12
+
13
+ const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/);
14
+ const gitObjectIdSchema = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
15
+ const countSchema = z.number().int().nonnegative();
16
+
17
+ const isMachineAbsolutePath = (value: string): boolean =>
18
+ posix.isAbsolute(value) ||
19
+ /^[A-Za-z]:/u.test(value) ||
20
+ value.startsWith('\\\\');
21
+
22
+ const rootRelativePathIssue = (value: string): string | undefined => {
23
+ if (isMachineAbsolutePath(value)) {
24
+ return 'Path-bearing receipt fields must not contain machine-absolute paths.';
25
+ }
26
+ if (value.includes('\\')) {
27
+ return 'Path-bearing receipt fields must use POSIX separators.';
28
+ }
29
+ if (value.includes('\u0000')) {
30
+ return 'Path-bearing receipt fields must be Git-resolvable text paths.';
31
+ }
32
+ if (
33
+ value.length === 0 ||
34
+ value === '.' ||
35
+ posix.normalize(value) !== value ||
36
+ value.startsWith('./') ||
37
+ value === '..' ||
38
+ value.startsWith('../') ||
39
+ value.includes('/../')
40
+ ) {
41
+ return 'Path-bearing receipt fields must be normalized root-relative paths or globs.';
42
+ }
43
+ return undefined;
44
+ };
45
+
46
+ const classRegradePlanSchema = z
47
+ .object({
48
+ classIds: z.array(z.string().min(1)).min(1),
49
+ id: z.string().min(1),
50
+ intent: z.string().optional(),
51
+ kind: z.literal('class'),
52
+ name: z.string().min(1).optional(),
53
+ packageSource: regradePackageSourceExpectationSchema
54
+ .superRefine((value, ctx) => {
55
+ if (value.kind !== 'tarball') {
56
+ return;
57
+ }
58
+ const message = rootRelativePathIssue(value.path);
59
+ if (message !== undefined) {
60
+ ctx.addIssue({ code: 'custom', message, path: ['path'] });
61
+ }
62
+ })
63
+ .optional(),
64
+ scope: z
65
+ .object({
66
+ exclude: z.array(z.string()).optional(),
67
+ extensions: z.array(z.string()).optional(),
68
+ include: z.array(z.string()).optional(),
69
+ })
70
+ .strict()
71
+ .optional(),
72
+ })
73
+ .strict();
74
+
75
+ const receiptVocabularyPreserveSchema = z
76
+ .object({
77
+ disposition: z.enum(vocabularyDispositionValues).optional(),
78
+ forms: z.array(z.string().min(1)).optional(),
79
+ paths: z.array(z.string()).optional(),
80
+ pattern: z.string(),
81
+ reason: z.string().optional(),
82
+ })
83
+ .strict();
84
+
85
+ const receiptVocabularyScopeSchema = z
86
+ .object({
87
+ exclude: z.array(z.string()).optional(),
88
+ extensions: z.array(z.string()).optional(),
89
+ ignoredDirectories: z.array(z.string()).optional(),
90
+ include: z.array(z.string()).optional(),
91
+ policyClassified: z
92
+ .array(
93
+ z
94
+ .object({
95
+ disposition: z.enum(vocabularyDispositionValues),
96
+ expectMatches: z.boolean().optional(),
97
+ paths: z.array(z.string().min(1)).min(1),
98
+ reason: z.string().min(1),
99
+ })
100
+ .strict()
101
+ )
102
+ .optional(),
103
+ teachingSurfaces: z.array(z.string().min(1)).optional(),
104
+ })
105
+ .strict();
106
+
107
+ const receiptVocabularyPlanSchema = z
108
+ .object({
109
+ caseSensitive: z.boolean().optional(),
110
+ deferForms: z.array(z.string().min(1)).optional(),
111
+ fileRenames: z
112
+ .array(
113
+ z.object({ from: z.string().min(1), to: z.string().min(1) }).strict()
114
+ )
115
+ .optional(),
116
+ from: z.string().min(1),
117
+ id: z.string().optional(),
118
+ intent: z.string().optional(),
119
+ kind: z.literal('vocabulary'),
120
+ overrides: z.record(z.string().min(1), z.string().min(1)).optional(),
121
+ preserve: z.array(receiptVocabularyPreserveSchema).optional(),
122
+ scope: receiptVocabularyScopeSchema.optional(),
123
+ to: z.string().min(1),
124
+ })
125
+ .strict();
126
+
127
+ /** Authored plan content retained by a compact receipt. */
128
+ export const regradeReceiptPlanSchema = z.discriminatedUnion('kind', [
129
+ receiptVocabularyPlanSchema,
130
+ classRegradePlanSchema,
131
+ ]);
132
+
133
+ export type RegradeReceiptPlan = z.output<typeof regradeReceiptPlanSchema>;
134
+
135
+ const regradeReceiptPlanProvenanceSchema = z
136
+ .object({
137
+ fields: z.record(z.string(), z.enum(['authored', 'derived'])),
138
+ })
139
+ .strict();
140
+
141
+ export type RegradeReceiptPlanProvenance = z.output<
142
+ typeof regradeReceiptPlanProvenanceSchema
143
+ >;
144
+
145
+ const embeddedIntentSchema = z
146
+ .object({
147
+ kind: z.literal('embedded'),
148
+ plan: regradeReceiptPlanSchema,
149
+ planContentHash: sha256Schema,
150
+ provenance: regradeReceiptPlanProvenanceSchema,
151
+ })
152
+ .strict();
153
+
154
+ const referencedIntentSchema = z
155
+ .object({
156
+ kind: z.literal('reference'),
157
+ planContentHash: sha256Schema,
158
+ })
159
+ .strict();
160
+
161
+ export const regradeFormJudgmentSchema = z
162
+ .object({
163
+ disposition: z.enum(['mapped', 'out-of-family', 'preserved', 'unresolved']),
164
+ form: z.string().min(1),
165
+ reason: z.string().min(1).optional(),
166
+ representative: z
167
+ .object({
168
+ line: z.number().int().positive(),
169
+ path: z.string().min(1),
170
+ })
171
+ .strict()
172
+ .optional(),
173
+ target: z.string().min(1).optional(),
174
+ })
175
+ .strict();
176
+
177
+ export type RegradeFormJudgment = z.output<typeof regradeFormJudgmentSchema>;
178
+
179
+ const embeddedClassifiedStateSchema = z
180
+ .object({
181
+ caseSensitive: z.boolean(),
182
+ forms: z.array(regradeFormJudgmentSchema),
183
+ kind: z.literal('embedded'),
184
+ stateHash: sha256Schema,
185
+ })
186
+ .strict();
187
+
188
+ const referencedClassifiedStateSchema = z
189
+ .object({
190
+ kind: z.literal('reference'),
191
+ stateHash: sha256Schema,
192
+ })
193
+ .strict();
194
+
195
+ const completionFactsSchema = z
196
+ .object({
197
+ counts: z
198
+ .object({
199
+ dispositions: z.record(z.string().min(1), countSchema),
200
+ matched: countSchema,
201
+ preserved: countSchema,
202
+ review: countSchema,
203
+ rewritten: countSchema,
204
+ skippedByReason: z.record(z.string().min(1), countSchema),
205
+ unknown: countSchema,
206
+ })
207
+ .strict(),
208
+ gate: z
209
+ .object({
210
+ reasons: z.array(z.string().min(1)),
211
+ remaining: countSchema,
212
+ status: z.enum(['green', 'open']),
213
+ })
214
+ .strict(),
215
+ metrics: z
216
+ .object({
217
+ filesChanged: countSchema,
218
+ formsMapped: countSchema,
219
+ occurrencesRewritten: countSchema,
220
+ })
221
+ .strict(),
222
+ })
223
+ .strict();
224
+
225
+ const evidenceKeysSchema = z
226
+ .object({
227
+ changedFiles: z.array(
228
+ z
229
+ .object({
230
+ afterBlobHash: gitObjectIdSchema,
231
+ afterPath: z.string().min(1),
232
+ beforeBlobHash: gitObjectIdSchema,
233
+ beforePath: z.string().min(1),
234
+ })
235
+ .strict()
236
+ ),
237
+ detailEvidenceHash: sha256Schema,
238
+ lockStateHash: sha256Schema,
239
+ policyHash: sha256Schema,
240
+ sourceRevision: gitObjectIdSchema,
241
+ sourceStateHash: sha256Schema,
242
+ toolVersion: z.string().min(1),
243
+ })
244
+ .strict();
245
+
246
+ const regradeRunReceiptSchema = z
247
+ .object({
248
+ classifiedState: z.discriminatedUnion('kind', [
249
+ embeddedClassifiedStateSchema,
250
+ referencedClassifiedStateSchema,
251
+ ]),
252
+ completion: completionFactsSchema,
253
+ evidence: evidenceKeysSchema,
254
+ intent: z.discriminatedUnion('kind', [
255
+ embeddedIntentSchema,
256
+ referencedIntentSchema,
257
+ ]),
258
+ project: z.object({ root: z.literal('.') }).strict(),
259
+ runId: z.string().min(1),
260
+ runKind: z.enum(['original', 'adjust', 'proof']),
261
+ timestamp: z.iso.datetime(),
262
+ transitionId: z.string().min(1),
263
+ })
264
+ .strict();
265
+
266
+ const conversionProvenanceSchema = z
267
+ .object({
268
+ convertedAt: z.iso.datetime(),
269
+ fromSchemaVersion: z.literal(2),
270
+ sourceContentHash: sha256Schema,
271
+ toolVersion: z.string().min(1),
272
+ })
273
+ .strict();
274
+
275
+ type RegradeRunReceipt = z.output<typeof regradeRunReceiptSchema>;
276
+
277
+ const addPathIssue = (
278
+ ctx: z.RefinementCtx,
279
+ value: string,
280
+ path: readonly (number | string)[]
281
+ ): void => {
282
+ const message = rootRelativePathIssue(value);
283
+ if (message !== undefined) {
284
+ ctx.addIssue({ code: 'custom', message, path: [...path] });
285
+ }
286
+ };
287
+
288
+ const validateVocabularyPlanPaths = (
289
+ plan: Extract<RegradeReceiptPlan, { readonly kind: 'vocabulary' }>,
290
+ ctx: z.RefinementCtx,
291
+ path: readonly (number | string)[]
292
+ ): void => {
293
+ for (const [index, value] of (
294
+ plan.scope?.ignoredDirectories ?? []
295
+ ).entries()) {
296
+ addPathIssue(ctx, value, [...path, 'scope', 'ignoredDirectories', index]);
297
+ }
298
+ for (const [index, policy] of (
299
+ plan.scope?.policyClassified ?? []
300
+ ).entries()) {
301
+ for (const [pathIndex, value] of policy.paths.entries()) {
302
+ addPathIssue(ctx, value, [
303
+ ...path,
304
+ 'scope',
305
+ 'policyClassified',
306
+ index,
307
+ 'paths',
308
+ pathIndex,
309
+ ]);
310
+ }
311
+ }
312
+ for (const [index, value] of (plan.scope?.teachingSurfaces ?? []).entries()) {
313
+ addPathIssue(ctx, value, [...path, 'scope', 'teachingSurfaces', index]);
314
+ }
315
+ for (const [index, rename] of (plan.fileRenames ?? []).entries()) {
316
+ addPathIssue(ctx, rename.from, [...path, 'fileRenames', index, 'from']);
317
+ addPathIssue(ctx, rename.to, [...path, 'fileRenames', index, 'to']);
318
+ }
319
+ for (const [index, preserve] of (plan.preserve ?? []).entries()) {
320
+ for (const [pathIndex, value] of (preserve.paths ?? []).entries()) {
321
+ addPathIssue(ctx, value, [
322
+ ...path,
323
+ 'preserve',
324
+ index,
325
+ 'paths',
326
+ pathIndex,
327
+ ]);
328
+ }
329
+ }
330
+ };
331
+
332
+ const validatePlanPaths = (
333
+ plan: RegradeReceiptPlan,
334
+ ctx: z.RefinementCtx,
335
+ path: readonly (number | string)[]
336
+ ): void => {
337
+ for (const [index, value] of (plan.scope?.include ?? []).entries()) {
338
+ addPathIssue(ctx, value, [...path, 'scope', 'include', index]);
339
+ }
340
+ for (const [index, value] of (plan.scope?.exclude ?? []).entries()) {
341
+ addPathIssue(ctx, value, [...path, 'scope', 'exclude', index]);
342
+ }
343
+ if (plan.kind === 'class' && plan.packageSource?.kind === 'tarball') {
344
+ addPathIssue(ctx, plan.packageSource.path, [
345
+ ...path,
346
+ 'packageSource',
347
+ 'path',
348
+ ]);
349
+ }
350
+ if (plan.kind === 'vocabulary') {
351
+ validateVocabularyPlanPaths(plan, ctx, path);
352
+ }
353
+ };
354
+
355
+ const proofClaimsNoAction = (run: RegradeRunReceipt): boolean => {
356
+ const { counts, gate, metrics } = run.completion;
357
+ return (
358
+ run.evidence.changedFiles.length === 0 &&
359
+ gate.status === 'green' &&
360
+ gate.remaining === 0 &&
361
+ gate.reasons.length === 0 &&
362
+ counts.matched === 0 &&
363
+ counts.preserved === 0 &&
364
+ counts.review === 0 &&
365
+ counts.rewritten === 0 &&
366
+ counts.unknown === 0 &&
367
+ Object.keys(counts.dispositions).length === 0 &&
368
+ Object.keys(counts.skippedByReason).length === 0 &&
369
+ metrics.filesChanged === 0 &&
370
+ metrics.formsMapped === 0 &&
371
+ metrics.occurrencesRewritten === 0
372
+ );
373
+ };
374
+
375
+ const validateProofRun = (
376
+ run: RegradeRunReceipt,
377
+ runIndex: number,
378
+ ctx: z.RefinementCtx
379
+ ): void => {
380
+ if (run.intent.kind !== 'reference') {
381
+ ctx.addIssue({
382
+ code: 'custom',
383
+ message: 'Proof receipts must hash-reference prior authored intent.',
384
+ path: ['runs', runIndex, 'intent'],
385
+ });
386
+ }
387
+ if (run.classifiedState.kind !== 'reference') {
388
+ ctx.addIssue({
389
+ code: 'custom',
390
+ message: 'Proof receipts must hash-reference prior classified state.',
391
+ path: ['runs', runIndex, 'classifiedState'],
392
+ });
393
+ }
394
+ if (!proofClaimsNoAction(run)) {
395
+ ctx.addIssue({
396
+ code: 'custom',
397
+ message:
398
+ 'Proof receipts must be green zero-actionable evidence and cannot claim changes.',
399
+ path: ['runs', runIndex],
400
+ });
401
+ }
402
+ };
403
+
404
+ const validateEmbeddedForms = (
405
+ run: RegradeRunReceipt,
406
+ runIndex: number,
407
+ ctx: z.RefinementCtx
408
+ ): void => {
409
+ if (run.classifiedState.kind !== 'embedded') {
410
+ return;
411
+ }
412
+ for (const [formIndex, form] of run.classifiedState.forms.entries()) {
413
+ if ((form.disposition === 'mapped') !== (form.target !== undefined)) {
414
+ ctx.addIssue({
415
+ code: 'custom',
416
+ message:
417
+ 'Mapped form judgments require a target; other judgments must not claim one.',
418
+ path: [
419
+ 'runs',
420
+ runIndex,
421
+ 'classifiedState',
422
+ 'forms',
423
+ formIndex,
424
+ 'target',
425
+ ],
426
+ });
427
+ }
428
+ if (form.representative !== undefined) {
429
+ addPathIssue(ctx, form.representative.path, [
430
+ 'runs',
431
+ runIndex,
432
+ 'classifiedState',
433
+ 'forms',
434
+ formIndex,
435
+ 'representative',
436
+ 'path',
437
+ ]);
438
+ }
439
+ }
440
+ };
441
+
442
+ const validateRunPaths = (
443
+ run: RegradeRunReceipt,
444
+ runIndex: number,
445
+ ctx: z.RefinementCtx
446
+ ): void => {
447
+ for (const [fileIndex, file] of run.evidence.changedFiles.entries()) {
448
+ addPathIssue(ctx, file.beforePath, [
449
+ 'runs',
450
+ runIndex,
451
+ 'evidence',
452
+ 'changedFiles',
453
+ fileIndex,
454
+ 'beforePath',
455
+ ]);
456
+ addPathIssue(ctx, file.afterPath, [
457
+ 'runs',
458
+ runIndex,
459
+ 'evidence',
460
+ 'changedFiles',
461
+ fileIndex,
462
+ 'afterPath',
463
+ ]);
464
+ }
465
+ if (run.intent.kind === 'embedded') {
466
+ validatePlanPaths(run.intent.plan, ctx, [
467
+ 'runs',
468
+ runIndex,
469
+ 'intent',
470
+ 'plan',
471
+ ]);
472
+ }
473
+ validateEmbeddedForms(run, runIndex, ctx);
474
+ };
475
+
476
+ const validateRunEvidence = (
477
+ run: RegradeRunReceipt,
478
+ runIndex: number,
479
+ ctx: z.RefinementCtx
480
+ ): void => {
481
+ const beforePaths = new Set(
482
+ run.evidence.changedFiles.map((file) => file.beforePath)
483
+ );
484
+ const afterPaths = new Set(
485
+ run.evidence.changedFiles.map((file) => file.afterPath)
486
+ );
487
+ if (
488
+ beforePaths.size !== run.evidence.changedFiles.length ||
489
+ afterPaths.size !== run.evidence.changedFiles.length
490
+ ) {
491
+ ctx.addIssue({
492
+ code: 'custom',
493
+ message:
494
+ 'Changed-file evidence must contain one transition per source and destination path.',
495
+ path: ['runs', runIndex, 'evidence', 'changedFiles'],
496
+ });
497
+ }
498
+ if (
499
+ run.completion.metrics.filesChanged !== run.evidence.changedFiles.length
500
+ ) {
501
+ ctx.addIssue({
502
+ code: 'custom',
503
+ message:
504
+ 'Completion filesChanged must equal the changed-file evidence count.',
505
+ path: ['runs', runIndex, 'completion', 'metrics', 'filesChanged'],
506
+ });
507
+ }
508
+ for (const [fileIndex, file] of run.evidence.changedFiles.entries()) {
509
+ if (
510
+ file.beforePath === file.afterPath &&
511
+ file.beforeBlobHash === file.afterBlobHash
512
+ ) {
513
+ ctx.addIssue({
514
+ code: 'custom',
515
+ message: 'Changed-file evidence must identify a content transition.',
516
+ path: ['runs', runIndex, 'evidence', 'changedFiles', fileIndex],
517
+ });
518
+ }
519
+ }
520
+
521
+ const { counts, gate, metrics } = run.completion;
522
+ const hasUnresolvedCounts = counts.review > 0 || counts.unknown > 0;
523
+ const coherentGate =
524
+ gate.status === 'green'
525
+ ? gate.remaining === 0 &&
526
+ gate.reasons.length === 0 &&
527
+ !hasUnresolvedCounts
528
+ : gate.reasons.length > 0;
529
+ if (!coherentGate) {
530
+ ctx.addIssue({
531
+ code: 'custom',
532
+ message:
533
+ 'A green completion gate must have no remaining work, reasons, review, or unknown counts; an open gate must explain its unresolved obligations.',
534
+ path: ['runs', runIndex, 'completion', 'gate'],
535
+ });
536
+ }
537
+ if (counts.rewritten !== metrics.occurrencesRewritten) {
538
+ ctx.addIssue({
539
+ code: 'custom',
540
+ message:
541
+ 'Completion rewritten count must equal occurrencesRewritten metrics.',
542
+ path: ['runs', runIndex, 'completion', 'metrics', 'occurrencesRewritten'],
543
+ });
544
+ }
545
+ };
546
+
547
+ const validateReceiptRun = (
548
+ historyId: string,
549
+ run: RegradeRunReceipt,
550
+ runIndex: number,
551
+ ctx: z.RefinementCtx
552
+ ): void => {
553
+ if (run.transitionId !== historyId) {
554
+ ctx.addIssue({
555
+ code: 'custom',
556
+ message: 'Receipt run transitionId must match the history id.',
557
+ path: ['runs', runIndex, 'transitionId'],
558
+ });
559
+ }
560
+ if (run.runKind === 'proof') {
561
+ validateProofRun(run, runIndex, ctx);
562
+ } else if (run.intent.kind !== 'embedded') {
563
+ ctx.addIssue({
564
+ code: 'custom',
565
+ message: 'Original and adjustment receipts must embed authored intent.',
566
+ path: ['runs', runIndex, 'intent'],
567
+ });
568
+ }
569
+ validateRunPaths(run, runIndex, ctx);
570
+ validateRunEvidence(run, runIndex, ctx);
571
+ };
572
+
573
+ export const regradeHistoryReceiptSchema = z
574
+ .object({
575
+ conversion: conversionProvenanceSchema.optional(),
576
+ id: z.string().min(1),
577
+ kind: z.literal('regrade-history'),
578
+ path: z.string().min(1),
579
+ runs: z.array(regradeRunReceiptSchema).min(1),
580
+ schemaVersion: z.literal(REGRADE_HISTORY_RECEIPT_SCHEMA_VERSION),
581
+ })
582
+ .strict()
583
+ .superRefine((artifact, ctx) => {
584
+ addPathIssue(ctx, artifact.path, ['path']);
585
+ if (!/^\.trails\/regrade\/history\/[^/]+\.json$/u.test(artifact.path)) {
586
+ ctx.addIssue({
587
+ code: 'custom',
588
+ message:
589
+ 'Receipt path must be a generator-owned consolidated history file.',
590
+ path: ['path'],
591
+ });
592
+ }
593
+ for (const [runIndex, run] of artifact.runs.entries()) {
594
+ validateReceiptRun(artifact.id, run, runIndex, ctx);
595
+ }
596
+ });
597
+
598
+ export type RegradeHistoryReceipt = z.output<
599
+ typeof regradeHistoryReceiptSchema
600
+ >;
601
+
602
+ export interface ResolvedRegradeHistoryReceiptRun {
603
+ readonly receipt: RegradeHistoryReceipt['runs'][number];
604
+ readonly plan: RegradeReceiptPlan;
605
+ readonly provenance: RegradeReceiptPlanProvenance;
606
+ readonly classifiedState: {
607
+ readonly caseSensitive: boolean;
608
+ readonly forms: readonly RegradeFormJudgment[];
609
+ readonly stateHash: string;
610
+ };
611
+ }
612
+
613
+ export interface ResolvedRegradeHistoryReceipt {
614
+ readonly artifact: RegradeHistoryReceipt;
615
+ readonly runs: readonly ResolvedRegradeHistoryReceiptRun[];
616
+ }
617
+
618
+ const canonicalizeJsonValue = (value: unknown): unknown => {
619
+ if (Array.isArray(value)) {
620
+ return value.map(canonicalizeJsonValue);
621
+ }
622
+ if (isPlainObject(value)) {
623
+ return Object.fromEntries(
624
+ Object.keys(value)
625
+ .toSorted()
626
+ .map((key) => [key, canonicalizeJsonValue(value[key])])
627
+ );
628
+ }
629
+ return value;
630
+ };
631
+
632
+ /** Recursively sort object keys while preserving authored array order. */
633
+ export const canonicalRegradeJson = (value: unknown): string =>
634
+ JSON.stringify(canonicalizeJsonValue(value));
635
+
636
+ /** SHA-256 content address over canonical Regrade JSON. */
637
+ export const regradeReceiptContentHash = (value: unknown): string =>
638
+ createHash('sha256').update(canonicalRegradeJson(value)).digest('hex');
639
+
640
+ export const regradeReceiptPlanContentHash = (intent: {
641
+ readonly plan: RegradeReceiptPlan;
642
+ readonly provenance: RegradeReceiptPlanProvenance;
643
+ }): string => regradeReceiptContentHash(intent);
644
+
645
+ const compareCodeUnits = (left: string, right: string): number => {
646
+ if (left < right) {
647
+ return -1;
648
+ }
649
+ if (left > right) {
650
+ return 1;
651
+ }
652
+ return 0;
653
+ };
654
+
655
+ const canonicalClassifiedState = (state: {
656
+ readonly caseSensitive: boolean;
657
+ readonly forms: readonly RegradeFormJudgment[];
658
+ }) => ({
659
+ caseSensitive: state.caseSensitive,
660
+ forms: [...state.forms].toSorted(
661
+ (left, right) =>
662
+ compareCodeUnits(left.form, right.form) ||
663
+ compareCodeUnits(left.disposition, right.disposition)
664
+ ),
665
+ });
666
+
667
+ export const regradeClassifiedStateHash = (state: {
668
+ readonly caseSensitive: boolean;
669
+ readonly forms: readonly RegradeFormJudgment[];
670
+ }): string => regradeReceiptContentHash(canonicalClassifiedState(state));
671
+
672
+ const invalidReceipt = (
673
+ message: string,
674
+ context: Readonly<Record<string, unknown>>
675
+ ): TrailsResult<never, ValidationError> =>
676
+ Result.err(new ValidationError(message, { context }));
677
+
678
+ /** Resolve all hash-referenced authored intent and classified form state. */
679
+ export const resolveRegradeHistoryReceipt = (
680
+ value: unknown
681
+ ): TrailsResult<ResolvedRegradeHistoryReceipt, ValidationError> => {
682
+ const parsed = regradeHistoryReceiptSchema.safeParse(value);
683
+ if (!parsed.success) {
684
+ return invalidReceipt('Invalid Regrade history receipt.', {
685
+ issues: parsed.error.issues,
686
+ });
687
+ }
688
+
689
+ const plans = new Map<
690
+ string,
691
+ {
692
+ readonly plan: RegradeReceiptPlan;
693
+ readonly provenance: RegradeReceiptPlanProvenance;
694
+ }
695
+ >();
696
+ const classifiedStates = new Map<
697
+ string,
698
+ {
699
+ readonly caseSensitive: boolean;
700
+ readonly forms: readonly RegradeFormJudgment[];
701
+ readonly stateHash: string;
702
+ }
703
+ >();
704
+ const runs: ResolvedRegradeHistoryReceiptRun[] = [];
705
+ const runIds = new Set<string>();
706
+
707
+ for (const [index, run] of parsed.data.runs.entries()) {
708
+ if (runIds.has(run.runId)) {
709
+ return invalidReceipt('Regrade receipt runId must be unique.', {
710
+ run: index,
711
+ runId: run.runId,
712
+ });
713
+ }
714
+ runIds.add(run.runId);
715
+ let plan: RegradeReceiptPlan | undefined;
716
+ let provenance: RegradeReceiptPlanProvenance | undefined;
717
+ if (run.intent.kind === 'embedded') {
718
+ const {
719
+ plan: embeddedPlan,
720
+ planContentHash,
721
+ provenance: embeddedProvenance,
722
+ } = run.intent;
723
+ const actualHash = regradeReceiptPlanContentHash({
724
+ plan: embeddedPlan,
725
+ provenance: embeddedProvenance,
726
+ });
727
+ if (actualHash !== planContentHash) {
728
+ return invalidReceipt('Regrade receipt plan hash mismatch.', {
729
+ actualHash,
730
+ expectedHash: planContentHash,
731
+ run: index,
732
+ });
733
+ }
734
+ plan = embeddedPlan;
735
+ provenance = embeddedProvenance;
736
+ plans.set(planContentHash, { plan, provenance });
737
+ } else {
738
+ const referencedIntent = plans.get(run.intent.planContentHash);
739
+ if (referencedIntent === undefined) {
740
+ return invalidReceipt('Broken Regrade receipt plan reference.', {
741
+ planContentHash: run.intent.planContentHash,
742
+ run: index,
743
+ });
744
+ }
745
+ ({ plan, provenance } = referencedIntent);
746
+ }
747
+
748
+ let classifiedState:
749
+ | {
750
+ readonly caseSensitive: boolean;
751
+ readonly forms: readonly RegradeFormJudgment[];
752
+ readonly stateHash: string;
753
+ }
754
+ | undefined;
755
+ if (run.classifiedState.kind === 'embedded') {
756
+ const embeddedState = run.classifiedState;
757
+ const uniqueForms = new Set(
758
+ embeddedState.forms.map((form) =>
759
+ embeddedState.caseSensitive ? form.form : form.form.toLowerCase()
760
+ )
761
+ );
762
+ if (uniqueForms.size !== embeddedState.forms.length) {
763
+ return invalidReceipt(
764
+ 'Regrade receipt classified state contains duplicate forms.',
765
+ { run: index }
766
+ );
767
+ }
768
+ const state = {
769
+ caseSensitive: embeddedState.caseSensitive,
770
+ forms: embeddedState.forms,
771
+ };
772
+ const actualHash = regradeClassifiedStateHash(state);
773
+ if (actualHash !== embeddedState.stateHash) {
774
+ return invalidReceipt(
775
+ 'Regrade receipt classified state hash mismatch.',
776
+ {
777
+ actualHash,
778
+ expectedHash: embeddedState.stateHash,
779
+ run: index,
780
+ }
781
+ );
782
+ }
783
+ classifiedState = { ...state, stateHash: actualHash };
784
+ classifiedStates.set(actualHash, classifiedState);
785
+ } else {
786
+ classifiedState = classifiedStates.get(run.classifiedState.stateHash);
787
+ if (classifiedState === undefined) {
788
+ return invalidReceipt(
789
+ 'Broken Regrade receipt classified state reference.',
790
+ { run: index, stateHash: run.classifiedState.stateHash }
791
+ );
792
+ }
793
+ }
794
+
795
+ runs.push({ classifiedState, plan, provenance, receipt: run });
796
+ }
797
+
798
+ return Result.ok({ artifact: parsed.data, runs });
799
+ };
800
+
801
+ const canonicalReceiptArtifact = (
802
+ artifact: RegradeHistoryReceipt
803
+ ): RegradeHistoryReceipt => ({
804
+ ...artifact,
805
+ runs: artifact.runs.map((run) => ({
806
+ ...run,
807
+ classifiedState:
808
+ run.classifiedState.kind === 'reference'
809
+ ? run.classifiedState
810
+ : {
811
+ ...run.classifiedState,
812
+ forms: canonicalClassifiedState(run.classifiedState).forms,
813
+ },
814
+ completion: {
815
+ ...run.completion,
816
+ gate: {
817
+ ...run.completion.gate,
818
+ reasons: [...new Set(run.completion.gate.reasons)].toSorted(),
819
+ },
820
+ },
821
+ evidence: {
822
+ ...run.evidence,
823
+ changedFiles: [...run.evidence.changedFiles].toSorted(
824
+ (left, right) =>
825
+ compareCodeUnits(left.beforePath, right.beforePath) ||
826
+ compareCodeUnits(left.afterPath, right.afterPath)
827
+ ),
828
+ },
829
+ })),
830
+ });
831
+
832
+ /** Parse, validate, resolve, and emit canonical generator-owned receipt bytes. */
833
+ export const serializeRegradeHistoryReceipt = (
834
+ value: unknown
835
+ ): TrailsResult<string, ValidationError> => {
836
+ const resolved = resolveRegradeHistoryReceipt(value);
837
+ if (resolved.isErr()) {
838
+ return resolved;
839
+ }
840
+ return Result.ok(
841
+ `${JSON.stringify(
842
+ canonicalizeJsonValue(canonicalReceiptArtifact(resolved.value.artifact)),
843
+ null,
844
+ 2
845
+ )}\n`
846
+ );
847
+ };