@dzhechkov/harness-core 0.4.4 → 0.5.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 (97) hide show
  1. package/.dz-manifest.json +283 -103
  2. package/LICENSE +21 -0
  3. package/README.md +73 -5
  4. package/dist/agents-policy.d.ts +67 -0
  5. package/dist/agents-policy.d.ts.map +1 -0
  6. package/dist/agents-policy.js +258 -0
  7. package/dist/agents-policy.js.map +1 -0
  8. package/dist/codex-hooks-assets.d.ts +47 -0
  9. package/dist/codex-hooks-assets.d.ts.map +1 -0
  10. package/dist/codex-hooks-assets.js +287 -0
  11. package/dist/codex-hooks-assets.js.map +1 -0
  12. package/dist/codex-hooks-verify.d.ts +74 -0
  13. package/dist/codex-hooks-verify.d.ts.map +1 -0
  14. package/dist/codex-hooks-verify.js +140 -0
  15. package/dist/codex-hooks-verify.js.map +1 -0
  16. package/dist/codex-hooks.d.ts +258 -0
  17. package/dist/codex-hooks.d.ts.map +1 -0
  18. package/dist/codex-hooks.js +391 -0
  19. package/dist/codex-hooks.js.map +1 -0
  20. package/dist/discrimination-gate.d.ts +88 -15
  21. package/dist/discrimination-gate.d.ts.map +1 -1
  22. package/dist/discrimination-gate.js +343 -51
  23. package/dist/discrimination-gate.js.map +1 -1
  24. package/dist/feature-adr-checkpoints.d.ts +22 -0
  25. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +42 -0
  27. package/dist/feature-adr-checkpoints.js.map +1 -1
  28. package/dist/feature-adr-routing.d.ts +196 -5
  29. package/dist/feature-adr-routing.d.ts.map +1 -1
  30. package/dist/feature-adr-routing.js +538 -54
  31. package/dist/feature-adr-routing.js.map +1 -1
  32. package/dist/guard.d.ts +13 -0
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +25 -1
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/loop-blobs.generated.js +2 -2
  41. package/dist/loop-blobs.generated.js.map +1 -1
  42. package/dist/managed-hooks.d.ts +76 -0
  43. package/dist/managed-hooks.d.ts.map +1 -0
  44. package/dist/managed-hooks.js +89 -0
  45. package/dist/managed-hooks.js.map +1 -0
  46. package/dist/mutation-gate.d.ts +14 -0
  47. package/dist/mutation-gate.d.ts.map +1 -1
  48. package/dist/mutation-gate.js +25 -2
  49. package/dist/mutation-gate.js.map +1 -1
  50. package/dist/operations.d.ts +153 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +560 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/parity.d.ts +38 -1
  55. package/dist/parity.d.ts.map +1 -1
  56. package/dist/parity.js +78 -5
  57. package/dist/parity.js.map +1 -1
  58. package/dist/recall-usage.d.ts +53 -0
  59. package/dist/recall-usage.d.ts.map +1 -1
  60. package/dist/recall-usage.js +125 -2
  61. package/dist/recall-usage.js.map +1 -1
  62. package/dist/setup.d.ts.map +1 -1
  63. package/dist/setup.js +14 -26
  64. package/dist/setup.js.map +1 -1
  65. package/dist/shell-veto-policy.d.ts +53 -0
  66. package/dist/shell-veto-policy.d.ts.map +1 -0
  67. package/dist/shell-veto-policy.js +103 -0
  68. package/dist/shell-veto-policy.js.map +1 -0
  69. package/dist/skills.d.ts +86 -1
  70. package/dist/skills.d.ts.map +1 -1
  71. package/dist/skills.js +116 -1
  72. package/dist/skills.js.map +1 -1
  73. package/dist/targets.d.ts +75 -0
  74. package/dist/targets.d.ts.map +1 -1
  75. package/dist/targets.js +160 -0
  76. package/dist/targets.js.map +1 -1
  77. package/package.json +20 -19
  78. package/sbom.json +552 -102
  79. package/src/agents-policy.ts +338 -0
  80. package/src/codex-hooks-assets.ts +291 -0
  81. package/src/codex-hooks-verify.ts +184 -0
  82. package/src/codex-hooks.ts +571 -0
  83. package/src/discrimination-gate.ts +456 -58
  84. package/src/feature-adr-checkpoints.ts +38 -0
  85. package/src/feature-adr-routing.ts +642 -75
  86. package/src/guard.ts +36 -1
  87. package/src/index.ts +118 -2
  88. package/src/loop-blobs.generated.ts +2 -2
  89. package/src/managed-hooks.ts +129 -0
  90. package/src/mutation-gate.ts +24 -2
  91. package/src/operations.ts +719 -28
  92. package/src/parity.ts +120 -6
  93. package/src/recall-usage.ts +184 -1
  94. package/src/setup.ts +26 -27
  95. package/src/shell-veto-policy.ts +119 -0
  96. package/src/skills.ts +174 -1
  97. package/src/targets.ts +189 -0
@@ -1,3 +1,16 @@
1
+ // EPOCH: before the wave1-instrument-repair release, DISCRIMINATES_VIA_ERROR could mask absent files,
2
+ // both-rev load errors, and tip-red tests, and DISCRIMINATES could be minted without execution evidence;
3
+ // the 47 pre-epoch reports under features/ are historical and are not relabeled.
4
+ //
5
+ // Read that sentence as a dating stamp on every §42 verdict this repo has ever recorded. The pre-epoch
6
+ // gate classified an OUTCOME VALUE ('fail' ⇒ DISCRIMINATES) with no proof that a runner ever executed
7
+ // the named test: MEASURED pre-fix, `dz discrimination-check --test <a real test> --runner false --base
8
+ // HEAD --json` returned `"aggregate": "DISCRIMINATES"` (features/wave1-instrument-repair/07_code_changes/
9
+ // acid-red-runs.md, acid A6), and a file that never existed returned `DISCRIMINATES_VIA_ERROR` (acid A1).
10
+ // This module now gates every trust verdict on RECOGNIZED PROCESS-OUTPUT EVIDENCE (ADR-001): the runner's
11
+ // output must match a measured shape AND name the target. Scope honesty: that bar is "a recognized runner
12
+ // demonstrably executed the named test", NOT resistance to an output-imitating runner.
13
+ //
1
14
  // §42 test-discrimination gate for feature-adr Step-8.
2
15
  //
3
16
  // Grounded in cve-bench/scripts/evaluate.mjs (RuvNet), whose §42 loop proves a regression test actually
@@ -12,18 +25,99 @@
12
25
  // pure-engine + injected-data shape as challenge-panel.ts and routing-outcomes.ts, so it is deterministic and
13
26
  // unit-testable without a git repo or a test runner.
14
27
 
28
+ import { classifyRunFailure, countFailingTests, detectRunnerKind } from './mutation-gate.js';
29
+
15
30
  /** The outcome of running one property test in the base (pre-feature) worktree. */
16
31
  export type BaseOutcome =
17
32
  | 'pass' // green at base → the test does NOT depend on the feature diff
18
33
  | 'fail' // red at base by assertion → the test catches the defect (good)
19
- | 'error'; // could not load/compile at base (e.g. imports feature code that does not exist yet)
34
+ | 'error' // could not load/compile at base (e.g. imports feature code that does not exist yet)
35
+ | 'absent'; // the named check is not a regular file in the working tree (stat+isFile, pre-worktree)
20
36
 
21
- /** Per-property verdict after classification. */
37
+ /** The outcome of the TIP control run — the same test against the LIVE tree (feature present). */
38
+ export type TipOutcome = 'pass' | 'fail' | 'error';
39
+
40
+ /** Per-property verdict after classification. Seven values (ADR-001) — the pre-epoch four collapsed
41
+ * absence, both-rev breakage, and a tip-red test into `DISCRIMINATES_VIA_ERROR`'s near-pass. */
22
42
  export type DiscriminationVerdict =
23
- | 'DISCRIMINATES' // failed at base (red) proven to catch the defect
24
- | 'DISCRIMINATES_VIA_ERROR' // errored at base inferred (the test cannot even load without the feature)
25
- | 'NON_DISCRIMINATING' // passed at base (green) does NOT catch the defect: a false green
26
- | 'CANNOT_ISOLATE'; // no runnable property test could be isolated folds into "property untested"
43
+ | 'DISCRIMINATES' // failed by assertion at base, WITH recognized execution evidence
44
+ | 'DISCRIMINATES_VIA_ERROR' // evidenced load error at base + evidenced pass at tip inferred dependence
45
+ | 'NON_DISCRIMINATING' // evidenced pass at base — a proven false green
46
+ | 'CANNOT_ISOLATE' // no ESTABLISHED observation; the row's `reason` says which bar failed
47
+ | 'TEST_FILE_ABSENT' // the named check is not a regular file (its evidence IS the layer-1 stat)
48
+ | 'LOAD_ERROR_AT_BOTH_REVS' // errored at base AND at tip — the instrument could not execute it at all
49
+ | 'FAILS_AT_TIP'; // errored at base, assertion-red at tip — the feature's own test is red WITH the feature
50
+
51
+ /** Runner identity for the evidence model. ADR-001's spelling is normative for the PUBLISHED type;
52
+ * mutation-gate's internal `RunnerKind` says `'unknown'` and is mapped here (H2) rather than renamed —
53
+ * mutation-gate stays untouched beyond the `detectRunnerKind` extraction. */
54
+ export type EvidenceRunner = 'vitest' | 'node-test' | 'unrecognised';
55
+
56
+ /** How the run went, as read off the output shape (mutation-gate's `RunFailureClassification.kind`
57
+ * plus `'none'` for a green run — `classifyRunFailure` classifies RED runs only, H3). */
58
+ export type EvidenceFailureKind = 'assertions' | 'file-load' | 'none' | 'unrecognised';
59
+
60
+ /** RECOGNIZED PROCESS-OUTPUT EVIDENCE for one run. Present in TYPE optionally (old callers still
61
+ * compile) but required in EFFECT for every trust verdict — that asymmetry IS the ADR-001 fix. */
62
+ export interface ExecutionEvidence {
63
+ /** null = timeout / spawn failure. No evidence bar accepts null ⇒ reason 'timeout'. */
64
+ readonly exitCode: number | null;
65
+ readonly runner: EvidenceRunner;
66
+ readonly failureKind: EvidenceFailureKind;
67
+ /** parsed from the runner's own summary; null = unparseable (never assumed to be ≥ 1). */
68
+ readonly testsExecuted: number | null;
69
+ /** target identity: the named test file appears in the recognized output. */
70
+ readonly targetSeen: boolean;
71
+ /** the classifier's evidence string when it has one (quotable in 08_qe_report.md, K7). */
72
+ readonly evidenceLine?: string;
73
+ }
74
+
75
+ /** Why a row degraded to CANNOT_ISOLATE. Typed so the operator repair is machine-derivable — the
76
+ * pre-epoch gate had one untyped bucket that read like "meh, no test", hiding a broken instrument. */
77
+ export type CannotIsolateReason =
78
+ | 'no-execution-evidence'
79
+ | 'unrecognised-runner-output'
80
+ | 'no-tests-executed'
81
+ | 'inconsistent-evidence'
82
+ | 'tip-control-missing'
83
+ | 'tip-evidence-missing'
84
+ | 'timeout';
85
+
86
+ /**
87
+ * The CLOSED verdict vocabulary, as DATA (QE F6).
88
+ *
89
+ * Derived from an exhaustive `Record<DiscriminationVerdict, true>`, so the exactness is enforced by
90
+ * `tsc` at BUILD time in both directions: adding a union member fails with "Property '<new>' is
91
+ * missing", removing one fails with "does not exist in type". A runtime sweep can only show which
92
+ * values are REACHABLE — it can never see an eighth value that nothing happens to construct, which
93
+ * is exactly the hole the ADR-002 Confirmation-1 gate had. This list closes it and is exported so
94
+ * the release gate can compare "declared" against "reachable" instead of a hand-written literal.
95
+ *
96
+ * (The test directory is excluded from this package's tsconfig, so a `satisfies` written in a test
97
+ * file would never be compiled. The guard has to live here to be real.)
98
+ */
99
+ const DISCRIMINATION_VERDICT_SET: Record<DiscriminationVerdict, true> = {
100
+ DISCRIMINATES: true,
101
+ DISCRIMINATES_VIA_ERROR: true,
102
+ NON_DISCRIMINATING: true,
103
+ CANNOT_ISOLATE: true,
104
+ TEST_FILE_ABSENT: true,
105
+ LOAD_ERROR_AT_BOTH_REVS: true,
106
+ FAILS_AT_TIP: true,
107
+ };
108
+ export const DISCRIMINATION_VERDICTS = Object.keys(DISCRIMINATION_VERDICT_SET) as readonly DiscriminationVerdict[];
109
+
110
+ /** The closed degradation vocabulary, same exhaustive-record discipline. */
111
+ const CANNOT_ISOLATE_REASON_SET: Record<CannotIsolateReason, true> = {
112
+ 'no-execution-evidence': true,
113
+ 'unrecognised-runner-output': true,
114
+ 'no-tests-executed': true,
115
+ 'inconsistent-evidence': true,
116
+ 'tip-control-missing': true,
117
+ 'tip-evidence-missing': true,
118
+ timeout: true,
119
+ };
120
+ export const CANNOT_ISOLATE_REASONS = Object.keys(CANNOT_ISOLATE_REASON_SET) as readonly CannotIsolateReason[];
27
121
 
28
122
  /** A property test mapped from the ADR Confirmation `Required automated check`. */
29
123
  export interface PropertyTestRef {
@@ -33,6 +127,16 @@ export interface PropertyTestRef {
33
127
  readonly name?: string;
34
128
  }
35
129
 
130
+ /** One observed row fed back by the executor. */
131
+ export interface ClassifyResultRow {
132
+ readonly file: string;
133
+ readonly name?: string;
134
+ readonly outcome: BaseOutcome;
135
+ readonly evidence?: ExecutionEvidence;
136
+ readonly tipOutcome?: TipOutcome;
137
+ readonly tipEvidence?: ExecutionEvidence;
138
+ }
139
+
36
140
  export interface DiscriminationPlanInput {
37
141
  /** the git ref of pre-feature HEAD — the "base" the property test must fail against. */
38
142
  readonly baseRef: string;
@@ -65,28 +169,54 @@ export interface DiscriminationPlan {
65
169
  export interface ClassifyInput {
66
170
  readonly propertyTests: readonly PropertyTestRef[];
67
171
  /** observed base outcome per target file (+optional name), same identity the plan used. */
68
- readonly results: readonly { readonly file: string; readonly name?: string; readonly outcome: BaseOutcome }[];
172
+ readonly results: readonly ClassifyResultRow[];
69
173
  }
70
174
 
71
175
  export interface PerTestVerdict {
72
176
  readonly file: string;
73
177
  readonly name?: string;
74
178
  readonly verdict: DiscriminationVerdict;
179
+ /** present iff verdict === 'CANNOT_ISOLATE'. */
180
+ readonly reason?: CannotIsolateReason;
75
181
  }
76
182
 
77
183
  export interface DiscriminationFinding {
78
- /** high only for a proven false green; the rest are advisory. Never a hard blocker (owner decides). */
184
+ /** high for every unestablished/false-green state; info only for the VIA_ERROR inference.
185
+ * Never a hard blocker (dz's rule — a false gate kills trust; the owner decides). */
79
186
  readonly severity: 'high' | 'info';
187
+ /** which non-clean verdict this finding reports. */
188
+ readonly verdict: DiscriminationVerdict;
189
+ /** the targets carrying that verdict. */
190
+ readonly files: readonly string[];
80
191
  readonly title: string;
192
+ /** names THIS verdict's operator action, so nothing rides on the primaryAction tie-break. */
81
193
  readonly detail: string;
82
194
  }
83
195
 
196
+ /** The INSTRUMENT axis, orthogonal to the product axis a single scalar was forced to carry. */
197
+ export type MeasurementValid = boolean | 'partial';
198
+
199
+ /** The single most urgent operator repair, derived from the worst-ranked verdict present. */
200
+ export type PrimaryAction =
201
+ | 'none'
202
+ | 'strengthen-test'
203
+ | 'create-missing-test'
204
+ | 'fix-runner-invocation'
205
+ | 'fix-red-feature-test'
206
+ | 'map-a-test';
207
+
84
208
  export interface DiscriminationResult {
85
209
  readonly perTest: readonly PerTestVerdict[];
86
- /** the gate's overall verdict (worst case across targets). */
210
+ /** compat scalar: worst-of via RANK. A total order can only answer "worst thing present"
211
+ * everything it destroys travels in findings[] / measurementValid / primaryAction. */
87
212
  readonly aggregate: DiscriminationVerdict;
88
- /** the Step-8 finding to fold into 08_qe_report.md null when everything discriminates cleanly. */
213
+ /** @deprecated compat alias for ONE releasealways `findings[0] ?? null` (worst first).
214
+ * Removal in the next minor is a recorded release obligation (ADR-002 Decision item 6). */
89
215
  readonly finding: DiscriminationFinding | null;
216
+ /** one per distinct non-clean verdict present, worst-first. */
217
+ readonly findings: readonly DiscriminationFinding[];
218
+ readonly measurementValid: MeasurementValid;
219
+ readonly primaryAction: PrimaryAction;
90
220
  }
91
221
 
92
222
  /** git-ref-safe characters only; rejects shell metacharacters and traversal that would break out of a token. */
@@ -157,82 +287,350 @@ export function planDiscriminationCheck(input: DiscriminationPlanInput): Discrim
157
287
  return { runnable: true, baseRef, targets, rejected, commands };
158
288
  }
159
289
 
160
- function verdictFor(outcome: BaseOutcome | undefined): DiscriminationVerdict {
161
- if (outcome === 'fail') return 'DISCRIMINATES';
162
- if (outcome === 'error') return 'DISCRIMINATES_VIA_ERROR';
163
- if (outcome === 'pass') return 'NON_DISCRIMINATING';
164
- return 'CANNOT_ISOLATE'; // no observed result for this target
290
+
291
+ // ── Evidence production (ADR-001's root-disease fix) ─────────────────────────────────────────
292
+
293
+ /** ANSI SGR sequences. vitest colours its summary even when piped (MEASURED), which would hide the
294
+ * target path and the green summary from every check below. Terminal encoding, not a runner shape —
295
+ * the runner-shape regex family stays single-sourced in mutation-gate.ts (ADR-001 driver). */
296
+ // eslint-disable-next-line no-control-regex
297
+ const SGR = /\x1b\[[0-9;]*m/g;
298
+
299
+ /**
300
+ * Tests EXECUTED in a GREEN run, from the runner's own summary. The green counterpart of
301
+ * mutation-gate's `countFailingTests`: `classifyRunFailure` classifies RED runs only (its green
302
+ * branch returns a red-worded `'unrecognised'`, mutation-gate.ts:404-411), so the ADR's pass bar
303
+ * — "≥ 1 test actually executed" — cannot be answered by delegating a green run to it (H3).
304
+ * Returns null when nothing parses; null NEVER counts as ≥ 1 (a `--passWithNoTests` / `--runner true`
305
+ * green is not an established false green — misdiagnosing it sends the operator to strengthen a test
306
+ * when the RUNNER is broken).
307
+ */
308
+ function countExecutedTests(rawOutput: string): number | null {
309
+ const output = rawOutput.replace(SGR, '');
310
+ // node --test / TAP: `# pass N` is authoritative; `# tests N` is the total when pass is absent.
311
+ const tapPass = /^#\s*pass\s+(\d+)\s*$/m.exec(output);
312
+ if (tapPass && tapPass[1] !== undefined) return Number(tapPass[1]);
313
+ const tapTests = /^#\s*tests\s+(\d+)\s*$/m.exec(output);
314
+ if (tapTests && tapTests[1] !== undefined) return Number(tapTests[1]);
315
+ // vitest: the `Tests` line, NOT `Test Files` (a file count under-reports the executed tests).
316
+ const vitest = /^\s*Tests\s+[^|\n]*?(\d+)\s+passed/m.exec(output);
317
+ if (vitest && vitest[1] !== undefined) return Number(vitest[1]);
318
+ return null;
319
+ }
320
+
321
+ /** mutation-gate's internal `'unknown'` is the PUBLISHED `'unrecognised'` here (H2). */
322
+ function toEvidenceRunner(kind: 'node-test' | 'vitest' | 'unknown'): EvidenceRunner {
323
+ return kind === 'unknown' ? 'unrecognised' : kind;
324
+ }
325
+
326
+ /**
327
+ * Classify ONE captured run into RECOGNIZED PROCESS-OUTPUT EVIDENCE. This is the function the
328
+ * pre-epoch gate did not have: it answers "did a runner I can read demonstrably execute THIS file?"
329
+ * instead of trusting the caller's outcome word. Three branches, exactly as ADR-001 specifies:
330
+ *
331
+ * exitCode === null the run timed out or never spawned — nothing is known. No bar accepts it,
332
+ * so the row degrades to CANNOT_ISOLATE reason 'timeout' (same posture as
333
+ * mutation-gate.ts:549 — a hung runner is a loud non-answer, never a pass).
334
+ * exitCode === 0 green: runner from the output SHAPE, failureKind 'none', testsExecuted from
335
+ * the green summary.
336
+ * exitCode !== 0 red: delegate to classifyRunFailure — the SAME classifier that replaced
337
+ * mutation-gate's ad-hoc regexes, so load-vs-assert is decided once per repo.
338
+ *
339
+ * `targetSeen` is a plain substring test over the SGR-stripped output: the bar is target IDENTITY
340
+ * ("the output names the file we asked about"), not authorship. No resistance to an output-imitating
341
+ * runner is claimed (ADR-001 Consequences, hole b) — the honest, narrowed contract.
342
+ */
343
+ export function classifyExecutionEvidence(
344
+ rawOutput: string,
345
+ exitCode: number | null,
346
+ targetFile: string,
347
+ ): ExecutionEvidence {
348
+ if (exitCode === null) {
349
+ return { exitCode: null, runner: 'unrecognised', failureKind: 'unrecognised', testsExecuted: null, targetSeen: false };
350
+ }
351
+ const raw = typeof rawOutput === 'string' ? rawOutput : '';
352
+ const targetSeen = typeof targetFile === 'string' && targetFile.length > 0 && raw.replace(SGR, '').includes(targetFile);
353
+
354
+ if (exitCode === 0) {
355
+ return {
356
+ exitCode,
357
+ runner: toEvidenceRunner(detectRunnerKind(raw)),
358
+ failureKind: 'none',
359
+ testsExecuted: countExecutedTests(raw),
360
+ targetSeen,
361
+ };
362
+ }
363
+
364
+ const red = classifyRunFailure(raw);
365
+ const base = {
366
+ exitCode,
367
+ runner: toEvidenceRunner(red.runner),
368
+ failureKind: red.kind,
369
+ testsExecuted: countFailingTests(raw),
370
+ targetSeen,
371
+ } as const;
372
+ // exactOptionalPropertyTypes: an absent evidence line is OMITTED, never set to undefined.
373
+ return red.evidence !== undefined ? { ...base, evidenceLine: red.evidence } : base;
374
+ }
375
+
376
+ // ── The evidence gate (ADR-001's EVIDENCED predicate, defined exactly once) ──────────────────
377
+
378
+ type GateResult = { readonly evidenced: true } | { readonly evidenced: false; readonly reason: CannotIsolateReason };
379
+
380
+ const EVIDENCED: GateResult = { evidenced: true };
381
+ function degraded(reason: CannotIsolateReason): GateResult {
382
+ return { evidenced: false, reason };
383
+ }
384
+
385
+ /**
386
+ * Is this observation EVIDENCED? Applied to the base observation and (independently) to the tip
387
+ * control, BEFORE any verdict combination — uniform precedence, uniquely implementable: no verdict,
388
+ * trust OR alarm, fires from an unevidenced runner constituent.
389
+ *
390
+ * PARSE-NEVER-SYNTHESIZE: an outcome value CONTRADICTED by its own evidence (`fail` + `file-load`,
391
+ * `error` + `assertions`, `pass` + a non-zero exit, and — symmetrically — any RED outcome with
392
+ * exit 0) is REJECTED as 'inconsistent-evidence' and never
393
+ * reinterpreted to the evidence-implied outcome. Guessing which half to trust would re-open exactly
394
+ * the value-trusting hole this rewrite closes — a caller whose value and observation disagree is a
395
+ * broken executor, and a broken executor is a non-answer.
396
+ */
397
+ function gateObservation(outcome: 'pass' | 'fail' | 'error', evidence: ExecutionEvidence | undefined): GateResult {
398
+ if (!evidence || typeof evidence !== 'object') return degraded('no-execution-evidence');
399
+ if (evidence.exitCode === null) return degraded('timeout');
400
+ if (evidence.runner === 'unrecognised') return degraded('unrecognised-runner-output');
401
+
402
+ if (outcome === 'pass') {
403
+ if (evidence.exitCode !== 0) return degraded('inconsistent-evidence');
404
+ if (evidence.failureKind !== 'none') return degraded('inconsistent-evidence');
405
+ // null testsExecuted is NOT ≥ 1: an unparseable summary proves no test ran (driver 3).
406
+ if (evidence.testsExecuted === null || evidence.testsExecuted < 1) return degraded('no-tests-executed');
407
+ if (!evidence.targetSeen) return degraded('no-execution-evidence');
408
+ return EVIDENCED;
409
+ }
410
+
411
+ if (outcome === 'fail') {
412
+ // QE F1: a RED outcome with exit 0 is an inconsistent tuple, symmetrically with `pass` + a
413
+ // non-zero exit. The exit code is the runner's own primary verdict; a caller reporting "it
414
+ // failed" over a process that exited SUCCESSFULLY is a broken executor, and accepting it let a
415
+ // fabricated evidence object mint DISCRIMINATES (MEASURED: `{outcome:'fail', evidence:
416
+ // {exitCode:0, runner:'vitest', failureKind:'assertions', testsExecuted:1, targetSeen:true}}`
417
+ // returned aggregate DISCRIMINATES, measurementValid true).
418
+ if (evidence.exitCode === 0) return degraded('inconsistent-evidence');
419
+ if (evidence.failureKind === 'file-load' || evidence.failureKind === 'none') return degraded('inconsistent-evidence');
420
+ // a RECOGNIZED runner whose red output carries no classifiable failure: the redness has no
421
+ // readable source, so it is the same non-answer as an unreadable runner.
422
+ if (evidence.failureKind === 'unrecognised') return degraded('unrecognised-runner-output');
423
+ if (!evidence.targetSeen) return degraded('no-execution-evidence');
424
+ return EVIDENCED;
425
+ }
426
+
427
+ // outcome === 'error' — the bar is recognized FILE-LOAD redness. targetSeen is not required: a
428
+ // module that dies at import can legitimately be reported by the importing suite's name.
429
+ // QE F1: same exit-code symmetry as the fail branch — a run that exited 0 did not error.
430
+ if (evidence.exitCode === 0) return degraded('inconsistent-evidence');
431
+ if (evidence.failureKind === 'assertions' || evidence.failureKind === 'none') return degraded('inconsistent-evidence');
432
+ if (evidence.failureKind === 'unrecognised') return degraded('unrecognised-runner-output');
433
+ return EVIDENCED;
434
+ }
435
+
436
+ // ── Verdict combination — the ADR-001 base×tip matrix, verbatim ──────────────────────────────
437
+
438
+ interface RowVerdict {
439
+ readonly verdict: DiscriminationVerdict;
440
+ readonly reason?: CannotIsolateReason;
441
+ }
442
+
443
+ function cannotIsolate(reason: CannotIsolateReason): RowVerdict {
444
+ return { verdict: 'CANNOT_ISOLATE', reason };
165
445
  }
166
446
 
167
- /** Severity rank for aggregation — a proven false green is the worst outcome the gate reports. */
447
+ /**
448
+ * One row → one verdict. Reading order IS the matrix's precedence: absence (layer-1 stat, the one
449
+ * principled evidence exemption — no runner ran, so none is applicable), then the base gate, then
450
+ * — only for an EVIDENCED base error — the tip control.
451
+ *
452
+ * The tip is consulted ONLY under an evidenced base error. Without a classified tip redness,
453
+ * LOAD_ERROR_AT_BOTH_REVS and FAILS_AT_TIP cannot be told apart, so claiming either would synthesize.
454
+ */
455
+ function classifyRow(row: ClassifyResultRow | undefined): RowVerdict {
456
+ if (row === undefined) return cannotIsolate('no-execution-evidence');
457
+ if (row.outcome === 'absent') return { verdict: 'TEST_FILE_ABSENT' };
458
+
459
+ const baseGate = gateObservation(row.outcome, row.evidence);
460
+ if (!baseGate.evidenced) return cannotIsolate(baseGate.reason);
461
+
462
+ if (row.outcome === 'fail') return { verdict: 'DISCRIMINATES' };
463
+ if (row.outcome === 'pass') return { verdict: 'NON_DISCRIMINATING' };
464
+
465
+ // EVIDENCED base error — the only cell where the tip control decides.
466
+ if (row.tipOutcome === undefined) return cannotIsolate('tip-control-missing');
467
+ const tipGate = gateObservation(row.tipOutcome, row.tipEvidence);
468
+ if (!tipGate.evidenced) {
469
+ // a tip that TIMED OUT is named as such; every other unevidenced tip is 'tip-evidence-missing'.
470
+ return cannotIsolate(tipGate.reason === 'timeout' ? 'timeout' : 'tip-evidence-missing');
471
+ }
472
+ if (row.tipOutcome === 'pass') return { verdict: 'DISCRIMINATES_VIA_ERROR' };
473
+ if (row.tipOutcome === 'error') return { verdict: 'LOAD_ERROR_AT_BOTH_REVS' };
474
+ return { verdict: 'FAILS_AT_TIP' };
475
+ }
476
+
477
+ /**
478
+ * Severity rank for the compat aggregate. The unestablished band's ordering (5 > 4 > 3 > 2) is
479
+ * unchanged from the original decision: absence of the instrument's subject (a broken promise —
480
+ * the historically exploited channel) outranks an active misconfiguration, which outranks positive
481
+ * evidence of a currently-red named check, which outranks mere absence of an observation.
482
+ */
168
483
  const RANK: Record<DiscriminationVerdict, number> = {
169
- NON_DISCRIMINATING: 3,
484
+ NON_DISCRIMINATING: 6,
485
+ TEST_FILE_ABSENT: 5,
486
+ LOAD_ERROR_AT_BOTH_REVS: 4,
487
+ FAILS_AT_TIP: 3,
170
488
  CANNOT_ISOLATE: 2,
171
489
  DISCRIMINATES_VIA_ERROR: 1,
172
490
  DISCRIMINATES: 0,
173
491
  };
174
492
 
493
+ /** Verdicts that mean the instrument MEASURED (even when the news is bad). */
494
+ const ESTABLISHED: readonly DiscriminationVerdict[] = ['DISCRIMINATES', 'DISCRIMINATES_VIA_ERROR', 'NON_DISCRIMINATING'];
495
+
496
+ const SEVERITY_OF: Record<DiscriminationVerdict, 'high' | 'info'> = {
497
+ NON_DISCRIMINATING: 'high',
498
+ TEST_FILE_ABSENT: 'high',
499
+ LOAD_ERROR_AT_BOTH_REVS: 'high',
500
+ FAILS_AT_TIP: 'high',
501
+ CANNOT_ISOLATE: 'high', // was info pre-epoch: a degraded reading that reads advisory gets ignored.
502
+ DISCRIMINATES_VIA_ERROR: 'info',
503
+ DISCRIMINATES: 'info',
504
+ };
505
+
506
+ const TITLE_OF: Record<DiscriminationVerdict, string> = {
507
+ NON_DISCRIMINATING: 'non-discriminating property test (false green)',
508
+ TEST_FILE_ABSENT: 'named property test is not a regular file',
509
+ LOAD_ERROR_AT_BOTH_REVS: 'property test could not execute at EITHER rev (zero signal)',
510
+ FAILS_AT_TIP: "the feature's own property test is RED with the feature present",
511
+ CANNOT_ISOLATE: 'discrimination could not be established (degraded reading)',
512
+ DISCRIMINATES_VIA_ERROR: 'discrimination inferred via load error',
513
+ DISCRIMINATES: '',
514
+ };
515
+
516
+ const ACTION_OF: Record<DiscriminationVerdict, PrimaryAction> = {
517
+ NON_DISCRIMINATING: 'strengthen-test',
518
+ TEST_FILE_ABSENT: 'create-missing-test',
519
+ LOAD_ERROR_AT_BOTH_REVS: 'fix-runner-invocation',
520
+ FAILS_AT_TIP: 'fix-red-feature-test',
521
+ CANNOT_ISOLATE: 'fix-runner-invocation',
522
+ DISCRIMINATES_VIA_ERROR: 'none',
523
+ DISCRIMINATES: 'none',
524
+ };
525
+
526
+ function describe(p: PerTestVerdict): string {
527
+ const named = p.name ? `${p.file} (${p.name})` : p.file;
528
+ return p.reason ? `${named} [reason: ${p.reason}]` : named;
529
+ }
530
+
531
+ function detailFor(verdict: DiscriminationVerdict, rows: readonly PerTestVerdict[], extra: readonly string[]): string {
532
+ const list = rows.map(describe).join(', ');
533
+ const tail = extra.length > 0 ? ` ${extra.join(' ')}` : '';
534
+ switch (verdict) {
535
+ case 'NON_DISCRIMINATING':
536
+ return `${rows.length} property test(s) PASS at pre-feature base WITH execution evidence — they do not exercise the ADR safety property and would stay green if the fix regressed: ${list}. Action: strengthen-test (make the assertion fail without the feature diff). (Advisory — the pipeline continues; the owner decides.)`;
537
+ case 'TEST_FILE_ABSENT':
538
+ return `the named check is not a regular file in the working tree (stat + isFile, before any worktree): ${list}.${tail} Action: create-missing-test — an ADR Confirmation naming a file that does not exist is a broken promise, never a pass.`;
539
+ case 'LOAD_ERROR_AT_BOTH_REVS':
540
+ return `the test errored at the pre-feature base AND at tip: ${list}. The instrument could not execute it at either rev, so the reading carries ZERO signal about discrimination. Action: fix-runner-invocation (wrong runner for this file type, or a broken config).`;
541
+ case 'FAILS_AT_TIP':
542
+ return `the test errored at base and fails BY ASSERTION at tip: ${list}. The feature's own property test is red with the feature present, so discrimination is unestablishable. Action: fix-red-feature-test — grade the feature code accordingly.`;
543
+ case 'CANNOT_ISOLATE':
544
+ return `no ESTABLISHED observation for: ${list}. A degraded reading is not a pass and not a failure — it means the instrument did not measure. Action: map-a-test when no test was mapped/ran, otherwise fix-runner-invocation.`;
545
+ case 'DISCRIMINATES_VIA_ERROR':
546
+ return `the property test could not LOAD at pre-feature base and PASSES at tip, both with execution evidence: ${list}. Discrimination is INFERRED from that pair, not proven by an assertion — acceptable, but a green-path assertion would be stronger.`;
547
+ default:
548
+ return '';
549
+ }
550
+ }
551
+
175
552
  /**
176
- * Classify observed base outcomes into per-test and aggregate verdicts + a Step-8 finding. A green-at-base
177
- * property test is the load-bearing failure this gate exists to catch: it is surfaced as a HIGH finding but
178
- * NEVER auto-aborts (dz's rule — a false gate kills trust; the owner decides). CANNOT_ISOLATE folds into the
179
- * existing "property untested" finding rather than minting a new severity concept.
553
+ * Classify observed base outcomes (+ evidence + the tip control) into per-test verdicts, the compat
554
+ * aggregate, the full findings[] list, and the two orthogonal axes.
555
+ *
556
+ * Every constituent is evidence-gated FIRST; verdict combination is consulted only over EVIDENCED
557
+ * constituents. Old callers that pass bare outcome values (no evidence, no tipOutcome) therefore
558
+ * degrade to a loud CANNOT_ISOLATE — never to a near-pass and never to a false DISCRIMINATES. That
559
+ * runtime behavior change for compiling callers is the ADR-002 headline, not a side effect.
180
560
  */
181
561
  export function classifyDiscrimination(input: ClassifyInput): DiscriminationResult {
182
562
  const results = Array.isArray(input.results) ? input.results : [];
183
- const byKey = new Map<string, BaseOutcome>();
563
+ const byKey = new Map<string, ClassifyResultRow>();
184
564
  for (const r of results) {
185
- if (r && typeof r.file === 'string') byKey.set(`${r.file}|${r.name ?? ''}`, r.outcome);
565
+ if (r && typeof r.file === 'string') byKey.set(`${r.file}|${r.name ?? ''}`, r);
186
566
  }
187
567
 
188
568
  const propertyTests = Array.isArray(input.propertyTests) ? input.propertyTests : [];
189
569
  if (propertyTests.length === 0) {
190
- return {
191
- perTest: [],
192
- aggregate: 'CANNOT_ISOLATE',
193
- finding: {
194
- severity: 'info',
195
- title: 'discrimination gate: no property test to check',
196
- detail: 'No test was mapped to the ADR safety property, so discrimination could not be evaluated — this is the existing "property untested" finding.',
197
- },
570
+ // No mapped test at all: the instrument has no subject. HIGH, because a degraded reading that
571
+ // renders as advisory is the one an operator skips.
572
+ const finding: DiscriminationFinding = {
573
+ severity: 'high',
574
+ verdict: 'CANNOT_ISOLATE',
575
+ files: [],
576
+ title: 'discrimination gate: no property test to check',
577
+ detail:
578
+ 'No test was mapped to the ADR safety property, so discrimination could not be evaluated — this is the existing "property untested" finding. Action: map-a-test.',
198
579
  };
580
+ return { perTest: [], aggregate: 'CANNOT_ISOLATE', finding, findings: [finding], measurementValid: false, primaryAction: 'map-a-test' };
199
581
  }
200
582
 
583
+ let missingRow = false;
201
584
  const perTest: PerTestVerdict[] = propertyTests.map((t) => {
202
585
  const file = typeof t.file === 'string' ? t.file : '';
203
586
  const name = typeof t.name === 'string' ? t.name : undefined;
204
- const outcome = byKey.get(`${file}|${name ?? ''}`);
205
- const verdict = verdictFor(outcome);
206
- return name !== undefined ? { file, name, verdict } : { file, verdict };
587
+ const row = byKey.get(`${file}|${name ?? ''}`);
588
+ if (row === undefined) missingRow = true;
589
+ const rv = classifyRow(row);
590
+ // exactOptionalPropertyTypes: `name`/`reason` are OMITTED when absent, never set to undefined.
591
+ const base = name !== undefined ? { file, name, verdict: rv.verdict } : { file, verdict: rv.verdict };
592
+ return rv.reason !== undefined ? { ...base, reason: rv.reason } : base;
207
593
  });
208
594
 
209
595
  let aggregate: DiscriminationVerdict = 'DISCRIMINATES';
210
596
  for (const p of perTest) if (RANK[p.verdict] > RANK[aggregate]) aggregate = p.verdict;
211
597
 
212
- const falseGreens = perTest.filter((p) => p.verdict === 'NON_DISCRIMINATING');
213
- let finding: DiscriminationFinding | null = null;
214
- if (falseGreens.length > 0) {
215
- finding = {
216
- severity: 'high',
217
- title: 'non-discriminating property test (false green)',
218
- detail:
219
- `${falseGreens.length} property test(s) PASS at pre-feature base — they do not exercise the ADR safety property and would stay green even if the fix regressed: ` +
220
- falseGreens.map((p) => (p.name ? `${p.file} (${p.name})` : p.file)).join(', ') +
221
- '. Strengthen the assertion so it fails without the feature diff. (Advisory — the pipeline continues; the owner decides.)',
222
- };
223
- } else if (aggregate === 'CANNOT_ISOLATE') {
224
- finding = {
225
- severity: 'info',
226
- title: 'discrimination gate: could not run a mapped test',
227
- detail: 'One or more mapped property tests produced no observed base outcome — folds into the existing "property untested" finding.',
228
- };
229
- } else if (aggregate === 'DISCRIMINATES_VIA_ERROR') {
230
- finding = {
231
- severity: 'info',
232
- title: 'discrimination inferred via load error',
233
- detail: 'The property test could not load at pre-feature base (it references feature code that does not exist yet), so discrimination is INFERRED, not proven by an assertion. Acceptable, but a green-path assertion would be stronger.',
598
+ // findings[]: ONE per distinct non-clean verdict present, worst-first. The scalar aggregate can
599
+ // only name the worst state; a corpus with a false green AND an absent file must report BOTH.
600
+ const distinct = [...new Set(perTest.map((p) => p.verdict))]
601
+ .filter((v) => v !== 'DISCRIMINATES')
602
+ .sort((a, b) => RANK[b] - RANK[a]);
603
+ const findings: DiscriminationFinding[] = distinct.map((verdict) => {
604
+ const rows = perTest.filter((p) => p.verdict === verdict);
605
+ // the CLI reports a directory-at-the-path out of band (absent rows never consult the evidence
606
+ // gate, matrix row 1), so its `not-a-regular-file` note rides into the finding detail here.
607
+ const extra =
608
+ verdict === 'TEST_FILE_ABSENT'
609
+ ? [
610
+ ...new Set(
611
+ rows
612
+ .map((p) => byKey.get(`${p.file}|${p.name ?? ''}`)?.evidence?.evidenceLine)
613
+ .filter((l): l is string => typeof l === 'string' && l.length > 0),
614
+ ),
615
+ ]
616
+ : [];
617
+ return {
618
+ severity: SEVERITY_OF[verdict],
619
+ verdict,
620
+ files: rows.map((p) => p.file),
621
+ title: TITLE_OF[verdict],
622
+ detail: detailFor(verdict, rows, extra),
234
623
  };
235
- }
624
+ });
625
+
626
+ const established = perTest.filter((p) => ESTABLISHED.includes(p.verdict)).length;
627
+ const measurementValid: MeasurementValid =
628
+ established === perTest.length ? true : established === 0 ? false : 'partial';
629
+
630
+ // primaryAction: the worst-ranked verdict's action. CANNOT_ISOLATE splits — a target with no
631
+ // mapped/observed row is a MAPPING repair; every other degradation is an INVOCATION repair.
632
+ const primaryAction: PrimaryAction =
633
+ aggregate === 'CANNOT_ISOLATE' && missingRow ? 'map-a-test' : ACTION_OF[aggregate];
236
634
 
237
- return { perTest, aggregate, finding };
635
+ return { perTest, aggregate, finding: findings[0] ?? null, findings, measurementValid, primaryAction };
238
636
  }
@@ -509,3 +509,41 @@ export function trainingPairBackfillCmd(repoAbs: string, slug: string, stage: st
509
509
  '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi'
510
510
  );
511
511
  }
512
+
513
+ // ── ADR-003 Condition 3: the code-stage persist ALLOWLIST ────────────────────────────────────
514
+
515
+ /**
516
+ * May the code stage's result be checkpointed?
517
+ *
518
+ * Pre-epoch this was a DENYLIST inlined in the workflow — `!/genuinely-not-landed/.test(landedNote)`
519
+ * — which is fail-OPEN by construction: every state that is not that one string persists, including
520
+ * a dead probe. MEASURED pre-fix: `node -e "console.log(!/genuinely-not-landed/.test('(landed-probe
521
+ * failed)'))"` prints `true`, i.e. a run whose barrier never answered was checkpointed as landed.
522
+ *
523
+ * The replacement is an ALLOWLIST with exactly two admitted states, and `barrierRequired` is what
524
+ * makes it non-forgeable: a codex run cannot LABEL itself `'synchronous'` past the gate, and a
525
+ * Claude run cannot claim a barrier verdict it never ran. `barrierRequired` arrives as a BOOLEAN
526
+ * (`needsCodeLandedBarrier(coderUsed)` at the call site) purely to avoid a routing↔checkpoints
527
+ * module cycle — H6.
528
+ */
529
+ export function codeCheckpointPersistAllowed(landingStatus: unknown, barrierRequired: boolean): boolean {
530
+ if (landingStatus === 'landed') return barrierRequired === true
531
+ if (landingStatus === 'synchronous') return barrierRequired === false
532
+ return false
533
+ }
534
+
535
+ /**
536
+ * Composite-shape validity for a code-stage checkpoint entry (consumer #3): the pre-existing shape
537
+ * checks PLUS the landing fields. An entry written before this protocol carries no `landingStatus`
538
+ * and no `landingProtocol`, so it reads as NO CHECKPOINT and the stage re-runs — the belt to R6's
539
+ * hash-token invalidation, in case a hash somehow matches.
540
+ */
541
+ export function codeStageResultShapeValid(r: unknown): boolean {
542
+ if (!r || typeof r !== 'object') return false
543
+ const v = r as { code?: unknown; coderUsed?: unknown; landedNote?: unknown; landingStatus?: unknown; landingProtocol?: unknown }
544
+ if (!v.code || typeof v.code !== 'object') return false
545
+ if (typeof v.coderUsed !== 'string') return false
546
+ if (typeof v.landedNote !== 'string') return false
547
+ if (v.landingProtocol !== 2) return false
548
+ return v.landingStatus === 'landed' || v.landingStatus === 'genuinely-not-landed' || v.landingStatus === 'inconclusive' || v.landingStatus === 'synchronous'
549
+ }