@vantageos/backend-doctor 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,7 +81,7 @@ A `could-not-judge` rule is an **abstention** — a human verdict is owed, never
81
81
 
82
82
  Every input is pinned and, where it is a vendored document, proven byte-identical to its upstream git blob (an un-hashable vendored doc is an authority granted to oneself).
83
83
 
84
- **The single authority for which standard fixture is active is the loader constant** `STANDARD_FILE` in [`src/detectors/standard/_doc.ts`](./src/detectors/standard/_doc.ts) — currently `backend-standard-51ec7af1.md` (48 rules, canonical source: ElPi-Corp `analysis/vantagepeers/backend-standard/backend-standard.md`). Everything else is provenance, NOT the active pin: `PINS.txt` and any other `fixtures/backend-standard-<sha>.md` still in the tree (e.g. the superseded `backend-standard-a9d7877b.md`, 47 rules — it lacks R-48) are kept only to show what was scored before. If any of them disagrees with `STANDARD_FILE`, `STANDARD_FILE` wins. Read the active pin from `STANDARD_FILE`, never from `PINS.txt` or a number typed elsewhere — following a stale `PINS.txt` would silently score the superseded 47-rule document and never surface R-48. See [`PINS.txt`](./PINS.txt) for the engine, backend snapshot, and MCP-family doc + VR inventory pins.
84
+ **The single authority for which standard fixture is active is the loader constant** `STANDARD_FILE` in [`src/detectors/standard/_doc.ts`](./src/detectors/standard/_doc.ts) — currently `backend-standard-4265226e.md` (49 rules — adds R-50, org isolation on a subscribed read is a typed empty result, never an uncaught throw; canonical source: ElPi-Corp `analysis/vantagepeers/backend-standard/backend-standard.md`). Everything else is provenance, NOT the active pin: `PINS.txt` and any other `fixtures/backend-standard-<sha>.md` still in the tree (e.g. the superseded `backend-standard-a9d7877b.md`, 47 rules — it lacks R-48) are kept only to show what was scored before. If any of them disagrees with `STANDARD_FILE`, `STANDARD_FILE` wins. Read the active pin from `STANDARD_FILE`, never from `PINS.txt` or a number typed elsewhere — following a stale `PINS.txt` would silently score the superseded 47-rule document and never surface R-48. See [`PINS.txt`](./PINS.txt) for the engine, backend snapshot, and MCP-family doc + VR inventory pins.
85
85
 
86
86
  ## Discipline (how a detector is proven)
87
87
 
package/dist/cli.js CHANGED
@@ -8,17 +8,20 @@ import { execFileSync } from "node:child_process";
8
8
  import { readFileSync } from "node:fs";
9
9
  import { dirname, join, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
- import { runDoctor, renderTable } from "@vantageos/mcp-doctor";
12
- import { REGISTRY } from "./registry.js";
11
+ import { renderTable, runDoctor } from "@vantageos/mcp-doctor";
13
12
  import { coverage, coverageSummary } from "./coverage.js";
13
+ import { checkStandardReadable } from "./detectors/standard/_doc.js";
14
14
  import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults } from "./not-checkable.js";
15
- import { tally, tallyLine, verdict } from "./tally.js";
15
+ import { REGISTRY } from "./registry.js";
16
16
  import { writeReport } from "./report.js";
17
+ import { tally, tallyLine, verdict } from "./tally.js";
17
18
  const here = dirname(fileURLToPath(import.meta.url));
18
19
  /** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
19
20
  function targetCommit(target) {
20
21
  try {
21
- return execFileSync("git", ["-C", target, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
22
+ return execFileSync("git", ["-C", target, "rev-parse", "HEAD"], {
23
+ encoding: "utf8",
24
+ }).trim();
22
25
  }
23
26
  catch {
24
27
  return "not a git repo";
@@ -41,6 +44,15 @@ function toolVersion() {
41
44
  }
42
45
  function main() {
43
46
  const target = resolve(process.argv[2] ?? process.cwd());
47
+ // Instrument self-check, BEFORE any judging: a backend-doctor run reads its
48
+ // pinned standard fixture as material. If that fixture is absent/unreadable the
49
+ // tool cannot judge — REFUSE with exit 2 (could-not-judge) naming what it could
50
+ // not read, never exit 1 (which reads as "a violation was found"). Eta #12 REVISE.
51
+ const readable = checkStandardReadable();
52
+ if (!readable.ok) {
53
+ console.error(readable.refusal);
54
+ process.exit(2);
55
+ }
44
56
  // Every derived rule carries EXACTLY ONE result: the executable detectors plus
45
57
  // one could-not-judge abstention per NOT_MECHANICALLY_CHECKABLE rule (R-48's
46
58
  // pending detector) — so the tally's rule total equals the 48 derived rules and
@@ -54,7 +66,7 @@ function main() {
54
66
  const cov = coverage();
55
67
  console.log(coverageSummary(cov));
56
68
  console.log("");
57
- console.log(`NOT MECHANICALLY CHECKABLE (${cov.notCheckable.length}) — a human decides, or it governs a document; NOT a clean bill:`);
69
+ console.log(`NOT MECHANICALLY CHECKABLE (${cov.notCheckable.length}) — NAMED, OWNED gaps (detector owned by another task), NOT 'a human decides'; NOT a clean bill:`);
58
70
  for (const id of cov.notCheckable) {
59
71
  console.log(` ${id} — ${NOT_MECHANICALLY_CHECKABLE[id]}`);
60
72
  }
@@ -62,6 +74,10 @@ function main() {
62
74
  // DERIVED here (no human counts rows) and the verdict maps to a TRUTHFUL exit
63
75
  // code — >=1 critical or a hard violation exits 1, a human-verdict-owed
64
76
  // abstention exits 2, approve exits 0. See src/tally.ts + src/severity.ts.
77
+ // The tally is over ALL 48 derived rules: the executable detectors' results
78
+ // PLUS the one could-not-judge abstention per NOT_MECHANICALLY_CHECKABLE rule
79
+ // (R-48) appended above — so `results` already carries exactly one result per
80
+ // rule and `tally` reconciles against deriveRuleIds() (48), nothing dropped.
65
81
  const t = tally(results);
66
82
  const v = verdict(t, results);
67
83
  console.log("");
@@ -1,9 +1,10 @@
1
1
  /** The standard is VENDORED at the pin as a fixture — DATA at 51ec7af1, a
2
2
  * comparison target on the same footing as the oracle CSV. deriveRuleIds no
3
3
  * longer shells out to any local repo, so it is CI-portable. Re-pinned from the
4
- * stale 47-rule freeze (a9d7877b) to the canonical 48-rule standard (adds R-48,
5
- * authority-totality) so coverage cannot pretend 47 is the whole document. */
6
- export declare const STANDARD_SHA = "51ec7af1bc84239aefd751d16389c65af6082c9f";
4
+ * canonical 48-rule standard (51ec7af1) to 2bb402f7, which closes R-7/R-8/R-37/R-40
5
+ * as a written-justification-token control (§23) — the paired standard PR the four
6
+ * decisive detectors below track. Still 48 rules; the pin moves forward with them. */
7
+ export declare const STANDARD_SHA = "4265226eb6698c7dfbae3ced77df564e877908ad";
7
8
  export declare const VENDORED_STANDARD_PATH: string;
8
9
  /**
9
10
  * Derive the full rule list R-1..R-42 from the VENDORED standard fixture —
package/dist/coverage.js CHANGED
@@ -15,10 +15,11 @@ const here = dirname(fileURLToPath(import.meta.url));
15
15
  /** The standard is VENDORED at the pin as a fixture — DATA at 51ec7af1, a
16
16
  * comparison target on the same footing as the oracle CSV. deriveRuleIds no
17
17
  * longer shells out to any local repo, so it is CI-portable. Re-pinned from the
18
- * stale 47-rule freeze (a9d7877b) to the canonical 48-rule standard (adds R-48,
19
- * authority-totality) so coverage cannot pretend 47 is the whole document. */
20
- export const STANDARD_SHA = "51ec7af1bc84239aefd751d16389c65af6082c9f";
21
- export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-51ec7af1.md");
18
+ * canonical 48-rule standard (51ec7af1) to 2bb402f7, which closes R-7/R-8/R-37/R-40
19
+ * as a written-justification-token control (§23) — the paired standard PR the four
20
+ * decisive detectors below track. Still 48 rules; the pin moves forward with them. */
21
+ export const STANDARD_SHA = "4265226eb6698c7dfbae3ced77df564e877908ad";
22
+ export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-4265226e.md");
22
23
  /**
23
24
  * Derive the full rule list R-1..R-42 from the VENDORED standard fixture —
24
25
  * never typed, never a git show against a local repo. Reads the `**R-NN`
@@ -6,16 +6,17 @@
6
6
  // Axis-2 (divergence WITHIN one backend) is R-37; Axis-1 (BETWEEN two backends)
7
7
  // is R-40 — it fires the moment a second backend's inventory exists.
8
8
  //
9
- // This is a JUDGEMENT rule with a mechanical half. Like R-7/R-8/R-37 the
10
- // instrument performs the mechanical half IN FULL — it names every concrete
11
- // between-backend divergence site (which shared field, which value on VP vs
12
- // which on VR) — and returns `could-not-judge`: whether each divergence carries
13
- // a SOUND written justification is the human half. It never claims `present`
14
- // (a clean bill) nor `absent` (does-not-apply) while real divergences exist.
9
+ // The instrument performs the mechanical compare IN FULL — it names every
10
+ // concrete between-backend divergence site (which shared field, which value on
11
+ // VP vs which on VR) — and DECIDES: a divergence with no written justification
12
+ // token is `non-conforming` (the two backends owe either agreement or a recorded
13
+ // justification); a run where every divergence carries a written justification
14
+ // token, or where the two agree on every shared field, is `present`. The
15
+ // disposition is the token's PRESENCE, never a human's opinion of its soundness.
16
+ // could-not-judge is reserved for an unreadable oracle (a malformed VR/VP CSV),
17
+ // never for "a person should weigh the divergence".
15
18
  //
16
- // Both inputs are IN HAND (VP oracle + VR oracle, both vendored + hash-checked),
17
- // so this is NOT the input-absent could-not-judge — it is the human-verdict-owed
18
- // state, the acceptance property of a comparative judgement rule.
19
+ // Both inputs are IN HAND (VP oracle + VR oracle, both vendored + hash-checked).
19
20
  import { readFileSync } from "node:fs";
20
21
  import { dirname, join } from "node:path";
21
22
  import { fileURLToPath } from "node:url";
@@ -28,7 +29,7 @@ export const VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day
28
29
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
29
30
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
30
31
  * import cycle. */
31
- export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-51ec7af1.md");
32
+ export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-4265226e.md");
32
33
  /** Load any backend inventory CSV generically from its OWN header — the shared
33
34
  * fields are DERIVED from these real headers, never a prose illustration. */
34
35
  export function loadInventory(path) {
@@ -176,22 +177,20 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
176
177
  if (schema.vrOnlyColumns.length)
177
178
  schemaBits.push(`VR-only column(s): ${schema.vrOnlyColumns.join(", ")}`);
178
179
  const schemaFinding = schemaBits.length
179
- ? `Between-schema divergence (R-14-class): ${schemaBits.join("; ")} — a shared field absent on one backend; a human must judge whether the schema difference is justified.`
180
+ ? `Between-schema divergence (R-14-class): ${schemaBits.join("; ")} — a shared field absent on one backend, with no written justification recorded; non-conforming until the schema difference carries a written justification.`
180
181
  : "No between-schema column divergence.";
181
182
  const totalValueDivergences = sites.reduce((n, s) => n + s.vpOnly.length + s.vrOnly.length, 0);
182
183
  const breakdown = `[shared-field-sites=${sites.length} value-divergences=${totalValueDivergences} schema-col-divergences=${schema.vpOnlyColumns.length + schema.vrOnlyColumns.length}]`;
183
184
  if (sites.length === 0 && schemaBits.length === 0) {
184
- // The two backends agree on every shared field's vocabulary — still not a
185
- // clean bill: R-40's disposition (is each divergence justified?) is human,
186
- // and here there is simply nothing owed. The instrument abstains rather
187
- // than claim the rule held.
188
- const reason = `R-40: the VP and VR inventories agree on every shared field's closed vocabulary and share every column; the mechanical between-backend compare is exhausted with no divergence. Confirming the contract is human. ${breakdown}`;
185
+ // The two backends agree on every shared field's vocabulary and share
186
+ // every column — a decisive PASS. The disposition is mechanical (nothing
187
+ // diverges, so no justification is owed); the instrument does not abstain.
188
+ const reason = `R-40: the VP and VR inventories agree on every shared field's closed vocabulary and share every column; the mechanical between-backend compare is exhausted with no divergence. Present — decided by the compare, not a human verdict. ${breakdown}`;
189
189
  return {
190
190
  id: R40_ID,
191
191
  anchor: R40_ANCHOR,
192
- state: "could-not-judge",
192
+ state: "present",
193
193
  verdict: reason,
194
- reason,
195
194
  };
196
195
  }
197
196
  const examples = sites
@@ -208,13 +207,12 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
208
207
  return `${s.field}: VP-only {${vp || "—"}} vs VR-only {${vr || "—"}}`;
209
208
  })
210
209
  .join("; ");
211
- const reason = `R-40: ${sites.length} shared field(s) diverge BETWEEN VP and VR on their closed vocabulary — the mechanical compare names WHICH FIELD and WHICH value on VP vs VR; a human must judge whether each between-backend divergence carries a SOUND written justification. This is NOT a clean bill. ${schemaFinding} ${breakdown} Field sites: ${examples}.`;
210
+ const reason = `R-40: ${sites.length} shared field(s) diverge BETWEEN VP and VR on their closed vocabulary — the mechanical compare names WHICH FIELD and WHICH value on VP vs VR, and none carries a written between-backend justification; non-conforming until each divergence is reconciled or carries a recorded justification. ${schemaFinding} ${breakdown} Field sites: ${examples}.`;
212
211
  return {
213
212
  id: R40_ID,
214
213
  anchor: `${R40_ANCHOR} field=${sites[0]?.field ?? "schema"}`,
215
- state: "could-not-judge",
214
+ state: "non-conforming",
216
215
  verdict: reason,
217
- reason,
218
216
  };
219
217
  },
220
218
  };
@@ -1,6 +1,7 @@
1
1
  import type { Detector } from "../engine-types.js";
2
2
  import { type OracleRow } from "../oracle.js";
3
3
  import type { RulePredicate } from "./shared.js";
4
+ export declare function hasJustificationToken(r: OracleRow): boolean;
4
5
  export declare const r7: RulePredicate;
5
6
  export declare const r8: RulePredicate;
6
7
  export interface DivergenceSite {
@@ -10,6 +11,11 @@ export interface DivergenceSite {
10
11
  valA: string;
11
12
  toolB: string;
12
13
  valB: string;
14
+ /** MECHANICAL disposition: the divergence carries a written per-tool
15
+ * justification token on at least one of the two diverging rows. A justified
16
+ * divergence is `present`; an unjustified one is `non-conforming`. This is the
17
+ * pole that replaced "a human judges whether the justification is sound". */
18
+ justified: boolean;
13
19
  }
14
20
  /**
15
21
  * Group the inventory rows by table and, per shared field, name ONE diverging
@@ -1,4 +1,29 @@
1
1
  import { isInventoryRow, readOracle, } from "../oracle.js";
2
+ // A written justification token turns a flagged divergence from a DEFECT the
3
+ // author owes into a recorded, accepted divergence. It is the mechanical pole
4
+ // that replaced "a human decides whether the divergence is sound": the control
5
+ // reads whether the token is PRESENT in the inventory (the `rbac_adjustment_needed`
6
+ // remediation cell), never whether a person judges it sound. `CORRIGER`/`REVOIR`
7
+ // are remediation-owed markers (R-28) — a fix still owed, NOT a justification —
8
+ // so they do not satisfy this predicate; only an explicit JUSTIFIÉ/JUSTIFIED/
9
+ // STATELESS/ACCEPTED token does.
10
+ export function hasJustificationToken(r) {
11
+ const cell = (r.by.rbac_adjustment_needed ?? "").trim();
12
+ // Anchored on the DOCUMENTED shape: the standard writes the token with a
13
+ // trailing colon (`JUSTIFIÉ:` / `JUSTIFIED:` / `STATELESS:`), at a word
14
+ // boundary. Two failures a bare substring match let through, both closed here:
15
+ // • a LEADING-letter negation — "UNJUSTIFIED" contains "JUSTIFIED", so the
16
+ // leading `(?<![A-Za-zÀ-ÿ-])` (not preceded by a letter OR a hyphen — a
17
+ // hyphen-joined negation like French `non-justifié:` clears a letter-only
18
+ // lookbehind, Eta #12 round-2) rejects it; and R-37's own output prints
19
+ // "N UNJUSTIFIED divergence(s)".
20
+ // • a token with NO colon — "not justified", "justified by design" — cleared
21
+ // the guard though the standard requires the colon; the trailing `\s*:`
22
+ // now rejects it. Only the recorded token form (`TOKEN:`) is a justification.
23
+ // No trailing \b before the colon: an accented final letter (É in JUSTIFIÉ) is
24
+ // not an ASCII word char, so \b would not match after it — the colon is the anchor.
25
+ return /(?<![A-Za-zÀ-ÿ-])(JUSTIFI(É|E|ED)|STATELESS|ACCEPT(É|E|ED)|WONTFIX)\s*:/i.test(cell);
26
+ }
2
27
  // ---- R-7 read/write coherence divergence (INCOHERENT* table-aggregate flag) -
3
28
  // §18: "`INCOHERENT*` flag (mechanical); divergence justified (human)". The
4
29
  // oracle's `rbac_coherence_table` carries the audit's table-aggregate coherence
@@ -9,14 +34,23 @@ import { isInventoryRow, readOracle, } from "../oracle.js";
9
34
  export const r7 = {
10
35
  ruleId: "R-7",
11
36
  id: "R-7 read/write coherence divergence",
12
- check: "row carries an `INCOHERENT*` coherence flag (mechanical); whether the read/write divergence is justified is human",
37
+ check: "row carries an `INCOHERENT*` coherence flag with NO written justification token in its remediation cell → non-conforming; a `COHERENT*` verdict, or an `INCOHERENT*` carrying a `JUSTIFIÉ:`/`JUSTIFIED:` token, → present (the machine decides on the token's PRESENCE, never on a person's opinion of its soundness)",
13
38
  classify: (r) => {
14
39
  const v = r.by.rbac_coherence_table;
15
- if (/^INCOHERENT/i.test(v))
40
+ if (/^INCOHERENT/i.test(v)) {
41
+ // The disposition is MECHANICAL: an incoherence publishes only with a
42
+ // written justification token recorded in the inventory (the same
43
+ // remediation cell R-28 governs). No token → non-conforming; the row owes
44
+ // a fix or a written justification, and asserting neither is the defect.
45
+ // could-not-judge is NOT emitted here — it is reserved for a row the
46
+ // parser could not read (handled upstream by the malformed branch).
47
+ if (hasJustificationToken(r))
48
+ return { kind: "conform" };
16
49
  return {
17
- kind: "cnj",
18
- note: `rbac_coherence_table="${v}" — a read/write incoherence; a human must record whether this divergence is justified or the tool corrected`,
50
+ kind: "violate",
51
+ note: `rbac_coherence_table="${v}" — a read/write incoherence with no written justification token (JUSTIFIÉ:/JUSTIFIED:) in rbac_adjustment_needed; it owes a fix or a recorded justification`,
19
52
  };
53
+ }
20
54
  if (/^COHERENT/i.test(v))
21
55
  return { kind: "conform" };
22
56
  return { kind: "na" }; // no coherence verdict recorded on this row (e.g. n/a)
@@ -30,7 +64,7 @@ export const r7 = {
30
64
  export const r8 = {
31
65
  ruleId: "R-8",
32
66
  id: "R-8 zero scope check on a data op",
33
- check: "empty/`AUCUN` scope enforcement on a row carrying a data op (mechanical); whether the stateless justification is sound is human",
67
+ check: "empty/`AUCUN` scope enforcement on a row carrying a data op, with NO written stateless/read-nothing justification token → non-conforming; a genuine scope check, or a no-scope row carrying a `JUSTIFIÉ:`/`STATELESS:` token, → present (the machine decides on the token's PRESENCE, never on a person's opinion of soundness)",
34
68
  classify: (r) => {
35
69
  const scope = r.by.scope_enforcement.trim();
36
70
  const crud = r.by.crud.trim();
@@ -38,11 +72,18 @@ export const r8 = {
38
72
  if (!hasDataOp)
39
73
  return { kind: "na" }; // no data op — R-8 does not apply
40
74
  const noScope = scope === "" || /AUCUN/i.test(scope);
41
- if (noScope)
75
+ if (noScope) {
76
+ // MECHANICAL disposition: a zero-scope data op publishes only with an
77
+ // explicit written stateless/read-nothing justification token in its
78
+ // remediation cell. No token → non-conforming. could-not-judge is reserved
79
+ // for an unreadable row, never for "a person should weigh soundness".
80
+ if (hasJustificationToken(r) || /STATELESS/i.test(scope))
81
+ return { kind: "conform" };
42
82
  return {
43
- kind: "cnj",
44
- note: `scope_enforcement="${scope || "<empty>"}" on a data op crud="${crud}" — no scope check; a human must judge whether the stateless justification is sound`,
83
+ kind: "violate",
84
+ note: `scope_enforcement="${scope || "<empty>"}" on a data op crud="${crud}" — no scope check and no written stateless justification token (JUSTIFIÉ:/STATELESS:)`,
45
85
  };
86
+ }
46
87
  return { kind: "conform" }; // a genuine scope check is recorded
47
88
  },
48
89
  };
@@ -104,6 +145,7 @@ export function findDivergences(rows) {
104
145
  valA: first.by[col],
105
146
  toolB: neighbour.by.outil,
106
147
  valB: neighbour.by[col],
148
+ justified: hasJustificationToken(first) || hasJustificationToken(neighbour),
107
149
  });
108
150
  }
109
151
  }
@@ -117,32 +159,35 @@ export function makeR37(rowsProvider = readOracle) {
117
159
  return {
118
160
  id: R37_ID,
119
161
  run: () => {
120
- const sites = findDivergences(rowsProvider());
162
+ const all = findDivergences(rowsProvider());
163
+ // MECHANICAL disposition: an unjustified divergence is the defect; a
164
+ // divergence carrying a written per-tool justification token is accepted.
165
+ // The instrument decides present/non-conforming — it never abstains on
166
+ // "a human should weigh soundness". could-not-judge would appear only if
167
+ // the oracle itself were unreadable (there is no such branch to reach here
168
+ // because a malformed row cannot form a filled pair).
169
+ const sites = all.filter((s) => !s.justified);
121
170
  const tables = new Set(sites.map((s) => s.table));
122
- const breakdown = `[filled-neighbour-divergences=${sites.length} across tables=${tables.size}]`;
171
+ const breakdown = `[filled-neighbour-divergences=${all.length} unjustified=${sites.length} justified=${all.length - sites.length} across tables=${tables.size}]`;
123
172
  if (sites.length === 0) {
124
- // No divergence anywhere — still not a clean bill: R-37's disposition is
125
- // human, so the instrument abstains rather than claim the rule held.
126
- const reason = `R-37: no filled-neighbour divergence found across the oracle's per-table groups; the mechanical compare is exhausted, but confirming the per-tool contract is human. ${breakdown}`;
173
+ const reason = `R-37: no UNJUSTIFIED filled-neighbour divergence across the oracle's per-table groups (${all.length} divergence(s), all carrying a written per-tool justification token, or none present). The mechanical compare is exhaustive and the disposition is decided by the token's presence, not a human verdict. ${breakdown}`;
127
174
  return {
128
175
  id: R37_ID,
129
176
  anchor: R37_ANCHOR,
130
- state: "could-not-judge",
177
+ state: "present",
131
178
  verdict: reason,
132
- reason,
133
179
  };
134
180
  }
135
181
  const examples = sites
136
182
  .slice(0, 5)
137
183
  .map((s) => `${s.table}.${s.field}: ${s.toolA} "${s.valA.slice(0, 32)}" vs ${s.toolB} "${s.valB.slice(0, 32)}"`)
138
184
  .join("; ");
139
- const reason = `R-37: ${sites.length} filled-neighbour divergence(s) across ${tables.size} tables — the mechanical compare names WHICH TWO TOOLS diverge on a shared contract field; a human must judge whether each per-tool justification is sound. This is NOT a clean bill. ${breakdown} Examples: ${examples}.`;
185
+ const reason = `R-37: ${sites.length} UNJUSTIFIED filled-neighbour divergence(s) across ${tables.size} tables — the mechanical compare names WHICH TWO TOOLS diverge on a shared contract field, and none carries a written per-tool justification token (JUSTIFIÉ:/JUSTIFIED: in rbac_adjustment_needed); each owes a fix or a recorded justification. This IS a violation, not an abstention. ${breakdown} Examples: ${examples}.`;
140
186
  return {
141
187
  id: R37_ID,
142
188
  anchor: `oracle:vp-by-tool-day158.csv table=${sites[0].table}`,
143
- state: "could-not-judge",
189
+ state: "non-conforming",
144
190
  verdict: reason,
145
- reason,
146
191
  };
147
192
  },
148
193
  };
@@ -0,0 +1,6 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare function scanR50(files: SrcFile[]): Scan;
4
+ export declare const R50: Detector;
5
+ /** In the form the orchestrator spreads into REGISTRY. */
6
+ export declare const SOURCE_ISOLATION_DETECTORS: SourceDetectorEntry[];