@vantageos/backend-doctor 0.1.0 → 0.1.1

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[];
@@ -0,0 +1,127 @@
1
+ import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // A PUBLIC reactive read opener. `\bquery\(` does NOT match `internalQuery(`
3
+ // (the 'query' there is preceded by a word char, so the \b fails) nor `mutation(`.
4
+ const PUBLIC_QUERY_OPEN = /\bquery\s*\(/;
5
+ // exclude the httpAction/action openers and internal variants explicitly for safety.
6
+ const NON_QUERY_OPEN = /\b(?:internalQuery|internalMutation|mutation|action|httpAction)\s*\(/;
7
+ const EXPORT_NAME = /export\s+const\s+([A-Za-z0-9_]+)\s*=/;
8
+ // An ORG-ISOLATION THROW: a throw keyed on the caller having no org/identity, or a
9
+ // helper that throws on the missing-org path. This is the behaviour a subscribed
10
+ // client receives as an uncaught Server Error.
11
+ const ORG_THROW = /throw\b[^;]*\b(?:NO_ORG|ORG_REQUIRED|RBAC_DENIED|no org|missing org|organization is required|not in (?:an )?org)/i;
12
+ const ORG_THROW_HELPER = /\b(?:requireWorkspaceId|requireOrgId|requireOrgSlug|getOrgIdOrThrow|ensureCurrentOrg|ensureCurrent)\s*\(/;
13
+ // The NON-throwing, fail-closed org resolver (VP's `withOrgScope` returns a typed
14
+ // empty scope on no identity — never throws) OR a documented isolation contract
15
+ // OR an explicit typed-empty return on the no-org branch. Any of these means the
16
+ // subscribing client always receives a shape it can render.
17
+ const SAFE_RESOLVER = /\bwithOrgScope\s*\(/;
18
+ const CONTRACT_MARKER = /isolation-contract:|client-safe-empty|@client-must-not-subscribe/i;
19
+ /** delimit a function body from its opener at index i by matching braces. */
20
+ function queryBlock(lines, i) {
21
+ let depth = 0;
22
+ let seenOpen = false;
23
+ const body = [];
24
+ for (let j = i; j < lines.length; j++) {
25
+ const l = lines[j];
26
+ body.push(l);
27
+ for (const ch of l.raw) {
28
+ if (ch === "{") {
29
+ depth += 1;
30
+ seenOpen = true;
31
+ }
32
+ else if (ch === "}") {
33
+ depth -= 1;
34
+ }
35
+ }
36
+ if (seenOpen && depth <= 0)
37
+ return { lines: body, unterminated: false };
38
+ }
39
+ return { lines: body, unterminated: seenOpen };
40
+ }
41
+ export function scanR50(files) {
42
+ const scan = emptyScan();
43
+ for (const f of files) {
44
+ if (!isJudgeable(f))
45
+ continue;
46
+ const lines = f.lines;
47
+ for (let i = 0; i < lines.length; i++) {
48
+ if (!code(lines[i]))
49
+ continue;
50
+ if (!PUBLIC_QUERY_OPEN.test(lines[i].raw))
51
+ continue;
52
+ // a line whose only match is a non-query opener (mutation/action/internal*)
53
+ // is not R-50's subject — skip it even if it also contains "query" prose.
54
+ if (NON_QUERY_OPEN.test(lines[i].raw) && !/\bquery\s*\(/.test(lines[i].raw.replace(NON_QUERY_OPEN, "")))
55
+ continue;
56
+ const fn = EXPORT_NAME.exec(lines[i].raw)?.[1] ??
57
+ (i > 0 ? EXPORT_NAME.exec(lines[i - 1].raw)?.[1] : undefined) ??
58
+ "<anonymous>";
59
+ const block = queryBlock(lines, i);
60
+ i += block.lines.length - 1;
61
+ if (block.unterminated) {
62
+ scan.cnj.push({
63
+ rel: f.rel,
64
+ n: lines[Math.min(i, lines.length - 1)].n,
65
+ note: `${fn}: a public query block could not be delimited (unbalanced braces) — whether its no-org path throws or returns a typed empty is not statically decidable; a human must judge (never a silent green)`,
66
+ });
67
+ continue;
68
+ }
69
+ const blockText = block.lines
70
+ .filter(code)
71
+ .map((l) => l.raw)
72
+ .join("\n");
73
+ // The org-throw and the resolver are CODE (read from executing lines only,
74
+ // so a commented-out throw does not count). The contract marker is, by
75
+ // definition, a COMMENT — so it is read from the full block text INCLUDING
76
+ // comment lines, not the code-only blockText.
77
+ const blockTextWithComments = block.lines
78
+ .map((l) => l.raw)
79
+ .join("\n");
80
+ const throwsOnNoOrg = ORG_THROW.test(blockText) || ORG_THROW_HELPER.test(blockText);
81
+ const hasResolver = SAFE_RESOLVER.test(blockText);
82
+ const hasMarker = CONTRACT_MARKER.test(blockTextWithComments);
83
+ if (!throwsOnNoOrg && !hasResolver && !hasMarker) {
84
+ // query does neither org-isolation throw nor fail-closed resolve nor
85
+ // declare a contract — not R-50's subject (too ambiguous to judge
86
+ // mechanically; never a violation).
87
+ continue;
88
+ }
89
+ // PRECEDENCE (Eta #13 REVISE seam): the THROW wins over a fail-closed
90
+ // resolver. `withOrgScope` returning a typed empty scope is a property of
91
+ // the RESOLVER, not of the handler — a handler that calls withOrgScope AND
92
+ // then `throw`s on the no-org path still fires that throw into the
93
+ // subscription, so the resolver's presence must NOT clear it. Only a
94
+ // documented CONTRACT_MARKER — a CLAIM the reviewer checks, never a proof —
95
+ // clears a throwing handler (a declared "the layout must not subscribe").
96
+ if (hasMarker) {
97
+ scan.conform.push({
98
+ rel: f.rel,
99
+ n: block.lines[0].n,
100
+ note: `${fn}: public query carries a documented isolation-contract marker (a declared divergence — a CLAIM the reviewer verifies, not a proof of behaviour) — accepted, the layout is asserted not to subscribe on the no-org path`,
101
+ });
102
+ }
103
+ else if (throwsOnNoOrg) {
104
+ scan.violate.push({
105
+ rel: f.rel,
106
+ n: block.lines[0].n,
107
+ note: `${fn}: public query throws on the missing-org path (org-keyed ConvexError or a throw-on-no-org helper) with no documented contract${hasResolver ? " — a `withOrgScope` resolver is ALSO present, but a fail-closed resolver returning a typed empty scope does NOT rescue an explicit throw that still fires into the subscription (Eta #13 seam)" : ""}. A subscribing client receives an uncaught Server Error. Return a typed empty/unauthorized shape (do not throw), or document that the layout must not subscribe`,
108
+ });
109
+ }
110
+ else {
111
+ // hasResolver, no throw, no marker — the fail-closed resolver is the
112
+ // whole no-org handling; the subscribing client receives a typed shape.
113
+ scan.conform.push({
114
+ rel: f.rel,
115
+ n: block.lines[0].n,
116
+ note: `${fn}: public query resolves org via the fail-closed non-throwing resolver (\`withOrgScope\`) and does NOT throw on the no-org path — the subscribing client receives a typed shape, not an uncaught throw`,
117
+ });
118
+ }
119
+ }
120
+ }
121
+ return scan;
122
+ }
123
+ export const R50 = srcDetector("R-50", "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw", "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch", scanR50, "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.");
124
+ /** In the form the orchestrator spreads into REGISTRY. */
125
+ export const SOURCE_ISOLATION_DETECTORS = [
126
+ { detector: R50, ruleId: "R-50", standardPin: SOURCE_STANDARD_PIN },
127
+ ];
@@ -1,11 +1,26 @@
1
1
  import type { Detector, DetectorResult } from "../../engine-types.js";
2
2
  /** The vendored-fixture basename used as the anchor file for every doc site. */
3
- export declare const STANDARD_FILE = "backend-standard-51ec7af1.md";
3
+ export declare const STANDARD_FILE = "backend-standard-4265226e.md";
4
4
  export declare const VENDORED_STANDARD_PATH: string;
5
5
  /** The standard all four are pinned against (short hash a9d7877b). */
6
6
  export declare const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A714/\u00A711/\u00A710/\u00A73";
7
7
  /** Read the vendored standard's raw text. Injectable for tests. */
8
8
  export declare function readStandard(path?: string): string;
9
+ /**
10
+ * The instrument's own readability check. A backend-doctor run reads its pinned
11
+ * standard fixture as material; if that fixture is ABSENT or UNREADABLE the tool
12
+ * has nothing to judge against, and letting `readFileSync` throw exits the CLI
13
+ * with code 1 — indistinguishable from "a violation was found" (Eta #12 REVISE).
14
+ * This returns a REFUSAL the CLI maps to exit 2 (could-not-judge) with a line
15
+ * NAMING what it could not read, mirroring the fleet leak_guard.py form. `ok`
16
+ * means the instrument is readable; a run may then legitimately pass or fail a pole.
17
+ */
18
+ export declare function checkStandardReadable(path?: string): {
19
+ ok: true;
20
+ } | {
21
+ ok: false;
22
+ refusal: string;
23
+ };
9
24
  export interface DocLine {
10
25
  /** 1-based line number in the vendored file. */
11
26
  n: number;
@@ -2,7 +2,7 @@
2
2
  // detectors that read THE STANDARD ITSELF as their material — R-42, R-15, R-32,
3
3
  // R-39. Unlike the source-tree detectors (which read convex/ via `tree.files`)
4
4
  // and the oracle detectors (which read the CSV), these read the VENDORED standard
5
- // fixture `fixtures/backend-standard-a9d7877b.md` — the material at the pin. The
5
+ // fixture `fixtures/backend-standard-4265226e.md` — the material at the pin. The
6
6
  // engine's contract types are DERIVED from engine-types.ts, never re-spelled.
7
7
  //
8
8
  // THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
@@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  /** The vendored-fixture basename used as the anchor file for every doc site. */
16
- export const STANDARD_FILE = "backend-standard-51ec7af1.md";
16
+ export const STANDARD_FILE = "backend-standard-4265226e.md";
17
17
  /** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
18
18
  * Resolved locally (not via coverage.ts) so this module carries no import cycle
19
19
  * with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
@@ -25,6 +25,33 @@ export const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-
25
25
  export function readStandard(path = VENDORED_STANDARD_PATH) {
26
26
  return readFileSync(path, "utf8");
27
27
  }
28
+ /**
29
+ * The instrument's own readability check. A backend-doctor run reads its pinned
30
+ * standard fixture as material; if that fixture is ABSENT or UNREADABLE the tool
31
+ * has nothing to judge against, and letting `readFileSync` throw exits the CLI
32
+ * with code 1 — indistinguishable from "a violation was found" (Eta #12 REVISE).
33
+ * This returns a REFUSAL the CLI maps to exit 2 (could-not-judge) with a line
34
+ * NAMING what it could not read, mirroring the fleet leak_guard.py form. `ok`
35
+ * means the instrument is readable; a run may then legitimately pass or fail a pole.
36
+ */
37
+ export function checkStandardReadable(path = VENDORED_STANDARD_PATH) {
38
+ try {
39
+ const text = readFileSync(path, "utf8");
40
+ if (text.trim().length === 0) {
41
+ return {
42
+ ok: false,
43
+ refusal: `REFUSING TO JUDGE: standard fixture ${STANDARD_FILE} is EMPTY — the instrument cannot read its own pin (${path})`,
44
+ };
45
+ }
46
+ return { ok: true };
47
+ }
48
+ catch {
49
+ return {
50
+ ok: false,
51
+ refusal: `REFUSING TO JUDGE: standard fixture ${STANDARD_FILE} is ABSENT or UNREADABLE — the instrument cannot read its own pin (${path})`,
52
+ };
53
+ }
54
+ }
28
55
  /** Split the standard text into 1-based numbered lines. */
29
56
  export function toLines(text) {
30
57
  return text.split("\n").map((raw, i) => ({ n: i + 1, raw }));
@@ -16,7 +16,7 @@
16
16
  // gentler word — it was emptied.
17
17
  import { STANDARD_FILE } from "./detectors/standard/_doc.js";
18
18
  export const NOT_MECHANICALLY_CHECKABLE = {
19
- "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 51ec7af1 so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
19
+ "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 4265226e so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
20
20
  };
21
21
  /**
22
22
  * One `could-not-judge` DetectorResult per NOT_MECHANICALLY_CHECKABLE rule, so
@@ -1,6 +1,6 @@
1
1
  import type { Detector } from "./engine-types.js";
2
2
  /** The standard both pins the whole wave against. */
3
- export declare const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp)";
3
+ export declare const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ 4265226eb6698c7dfbae3ced77df564e877908ad (elpiarthera/ElPi-Corp)";
4
4
  export interface RegistryEntry {
5
5
  detector: Detector;
6
6
  ruleId: string;