@ontrails/warden 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.
Files changed (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -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
+ };