@vantageos/backend-doctor 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +91 -8
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/detectors/oracle-axis1.d.ts +5 -3
  12. package/dist/detectors/oracle-axis1.js +24 -15
  13. package/dist/detectors/oracle-axis2.js +5 -6
  14. package/dist/detectors/predicates.d.ts +3 -0
  15. package/dist/detectors/predicates.js +81 -16
  16. package/dist/detectors/shared.js +5 -5
  17. package/dist/detectors/source/backfill.js +9 -1
  18. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  19. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  20. package/dist/detectors/source/identity.d.ts +51 -2
  21. package/dist/detectors/source/identity.js +815 -14
  22. package/dist/detectors/source/isolation.d.ts +11 -1
  23. package/dist/detectors/source/isolation.js +106 -25
  24. package/dist/detectors/source/jobs.js +165 -2
  25. package/dist/detectors/source/readbudget.d.ts +1 -0
  26. package/dist/detectors/source/readbudget.js +67 -4
  27. package/dist/detectors/source/security.d.ts +16 -0
  28. package/dist/detectors/source/security.js +225 -7
  29. package/dist/detectors/source/write-isolation.d.ts +8 -1
  30. package/dist/detectors/source/write-isolation.js +64 -30
  31. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  32. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  33. package/dist/detectors/structural.js +14 -10
  34. package/dist/oracle.d.ts +92 -3
  35. package/dist/oracle.js +229 -12
  36. package/dist/registry.d.ts +7 -0
  37. package/dist/registry.js +48 -6
  38. package/dist/report.d.ts +11 -1
  39. package/dist/report.js +15 -2
  40. package/dist/state-rank.d.ts +5 -0
  41. package/dist/state-rank.js +9 -0
  42. package/dist/tally.js +1 -7
  43. package/dist/target.d.ts +32 -0
  44. package/dist/target.js +173 -0
  45. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  46. package/fixtures/cr1-classes/surface.ts +206 -0
  47. package/fixtures/cr1-empty/nothing.ts +1 -0
  48. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  49. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  50. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  51. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  52. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  53. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  54. package/package.json +2 -2
package/dist/oracle.d.ts CHANGED
@@ -1,5 +1,91 @@
1
- /** Absolute path to the pinned oracle fixture. */
2
- export declare const ORACLE_PATH: string;
1
+ import type { DetectorResult } from "./engine-types.js";
2
+ /** The vendored day158 snapshot. It is a FIXTURE for this tool's own tests and
3
+ * for an operator who names it explicitly with `--oracle`. It is NEVER a default:
4
+ * nothing in the run path reads it unless a caller passes this path in. */
5
+ export declare const FIXTURE_ORACLE_PATH: string;
6
+ export declare const ORACLE_DIR = ".backend-doctor";
7
+ export declare const ORACLE_NAME = "vp-by-tool.csv";
8
+ export declare const VR_ORACLE_NAME = "vr-by-tool.csv";
9
+ export interface OracleFile {
10
+ status: "found";
11
+ path: string;
12
+ /** where the path came from: "flag" or "convention:<dir>". */
13
+ origin: string;
14
+ }
15
+ export interface OracleMissing {
16
+ status: "absent";
17
+ /** every path that was looked at, in order. */
18
+ searched: string[];
19
+ origin: string;
20
+ }
21
+ export type OracleLocation = OracleFile | OracleMissing;
22
+ export interface OracleSelection {
23
+ target: string;
24
+ vp: OracleLocation;
25
+ vr: OracleLocation;
26
+ /** the writer-tier side-car R-10 reads (see WRITER_TIER_NAME). Optional so a
27
+ * caller that binds only the two oracles keeps working: when undefined, the
28
+ * side-car is derived beside the bound vp oracle at read time. */
29
+ writerTier?: OracleLocation;
30
+ }
31
+ /** The convention dirs for `target`, in search order, never above the repo root. */
32
+ export declare function oracleDirs(target: string): string[];
33
+ /** Derive the oracle for `target`. Pure over the filesystem; never reads this tool's own directory. */
34
+ export declare function resolveOracle(target: string, opts?: {
35
+ oracle?: string;
36
+ vrOracle?: string;
37
+ writerTier?: string;
38
+ }): OracleSelection;
39
+ export declare const WRITER_TIER_NAME = "vp-writer-tier.csv";
40
+ export declare const WRITER_TIER_COLUMNS: readonly ["outil", "writer_tier", "writer_gate"];
41
+ export interface WriterTierEntry {
42
+ /** 1-based data-row index in the side-car. */
43
+ n: number;
44
+ outil: string;
45
+ writer_tier: string;
46
+ writer_gate: string;
47
+ }
48
+ export type WriterTierTable = {
49
+ status: "found";
50
+ path: string;
51
+ byTool: Map<string, WriterTierEntry[]>;
52
+ } | {
53
+ status: "absent";
54
+ searched: string[];
55
+ origin: string;
56
+ } | {
57
+ status: "unreadable";
58
+ path: string;
59
+ reason: string;
60
+ };
61
+ /** Parse one side-car. A missing required column or a row whose cell count
62
+ * differs from the header makes the WHOLE file unreadable — never a partial pass. */
63
+ export declare function readWriterTierFile(path: string): WriterTierTable;
64
+ /** The side-car bound for THIS run. Throws OracleAbsentError when no vp oracle is
65
+ * bound (R-10 then refuses like every oracle-backed rule); otherwise returns the
66
+ * table, or an `absent` record naming every path that was looked at. */
67
+ export declare function readWriterTier(): WriterTierTable;
68
+ /** One human line naming which oracle files a run used and where each came from. */
69
+ export declare function describeOracle(sel: OracleSelection): string;
70
+ /** Raised when an oracle-backed rule is asked to judge and no oracle describes the tree. */
71
+ export declare class OracleAbsentError extends Error {
72
+ readonly which: "vp" | "vr";
73
+ readonly searched: string[];
74
+ constructor(which: "vp" | "vr", searched: string[], detail?: string);
75
+ }
76
+ /** Bind the oracle for this run. The CLI calls it once with the target's resolution. */
77
+ export declare function activateOracle(sel: OracleSelection | null): void;
78
+ /** The oracle currently bound, or null. */
79
+ export declare function activeOracle(): OracleSelection | null;
80
+ /** Path of the bound VP oracle, or throws OracleAbsentError. */
81
+ export declare function requireOracle(): OracleFile;
82
+ /** Path of the bound VR oracle, or throws OracleAbsentError. */
83
+ export declare function requireVrOracle(): OracleFile;
84
+ /** `oracle:<file>` naming the bound oracle in a result anchor, or `oracle:ABSENT`. */
85
+ export declare function oracleLabel(): string;
86
+ /** Run an oracle-backed detector body. An absent oracle is could-not-judge —
87
+ * never present, never non-conforming. Anything else propagates untouched. */
88
+ export declare function refuseWithoutOracle(id: string, body: () => DetectorResult): DetectorResult;
3
89
  /** The eighteen audit columns, in file order. §2 of the standard maps contract
4
90
  * fields onto these column names. */
5
91
  export declare const ORACLE_COLUMNS: readonly ["table", "outil", "T2_verdict", "T2_composant_cible", "crud", "operation_detail", "search_mecanisme", "surface", "rbac_qui", "scope_enforcement", "namespace_ou_org", "tokens_estime_contexte", "doublon_alias", "rbac_coherence_table", "rbac_adjustment_needed", "table_purpose", "table_conserver_supprimer", "statut_suppression"];
@@ -23,7 +109,10 @@ export interface OracleRow {
23
109
  export declare function parseCsv(text: string): string[][];
24
110
  /** Build typed rows from raw parsed cells (header already removed). */
25
111
  export declare function toRows(dataRows: string[][]): OracleRow[];
26
- /** Memoized read of the real pinned oracle fixture. */
112
+ /** Memoized read of one oracle CSV, keyed by path. */
113
+ export declare function readOracleFile(path: string): OracleRow[];
114
+ /** The rows of the oracle bound for THIS run (derived from the target).
115
+ * Throws OracleAbsentError when none is bound or none was found. */
27
116
  export declare function readOracle(): OracleRow[];
28
117
  /**
29
118
  * A real inventory row is one tool/table entry. The audit's trailing `TOTAL`
package/dist/oracle.js CHANGED
@@ -5,12 +5,222 @@
5
5
  // The tree the engine's loadTree builds is .ts-only and does NOT contain this
6
6
  // CSV, so every first-wave detector ignores `tree` and consults this memoized
7
7
  // reader instead.
8
- import { readFileSync } from "node:fs";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { basename, dirname, join, resolve } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
10
- import { dirname, join } from "node:path";
11
+ import { resolveTarget } from "./target.js";
11
12
  const here = dirname(fileURLToPath(import.meta.url));
12
- /** Absolute path to the pinned oracle fixture. */
13
- export const ORACLE_PATH = join(here, "..", "fixtures", "vp-by-tool-day158.csv");
13
+ /** The vendored day158 snapshot. It is a FIXTURE for this tool's own tests and
14
+ * for an operator who names it explicitly with `--oracle`. It is NEVER a default:
15
+ * nothing in the run path reads it unless a caller passes this path in. */
16
+ export const FIXTURE_ORACLE_PATH = join(here, "..", "fixtures", "vp-by-tool-day158.csv");
17
+ // ---------------------------------------------------------------------------
18
+ // ORACLE RESOLUTION — the oracle describes the TREE UNDER JUDGEMENT, so it is
19
+ // found from the target the CLI was given, never from this tool's install dir.
20
+ //
21
+ // Rule (first hit wins, every candidate is listed in the refusal when none hits):
22
+ // 1. an explicit path (`--oracle <file>` / `--vr-oracle <file>`). An explicit
23
+ // path that does not exist is a REFUSAL, never a silent fall-through.
24
+ // 2. `<convexDir>/.backend-doctor/<name>`, then `<repoRoot>/.backend-doctor/<name>`,
25
+ // then `<target>/.backend-doctor/<name>` when the argument is neither — where
26
+ // repoRoot/convexDir come from resolveTarget (src/target.ts), so `convex` and
27
+ // `.` search the SAME list. Nothing ABOVE the repository root is ever read: a
28
+ // `.backend-doctor/` in a parent directory describes some other tree.
29
+ // where <name> is `vp-by-tool.csv` (the target backend's own inventory) and
30
+ // `vr-by-tool.csv` (the second backend R-40 compares against).
31
+ // No oracle found => the oracle-backed rules return could-not-judge.
32
+ // ---------------------------------------------------------------------------
33
+ export const ORACLE_DIR = ".backend-doctor";
34
+ export const ORACLE_NAME = "vp-by-tool.csv";
35
+ export const VR_ORACLE_NAME = "vr-by-tool.csv";
36
+ /** The convention dirs for `target`, in search order, never above the repo root. */
37
+ export function oracleDirs(target) {
38
+ const scope = resolveTarget(target);
39
+ const dirs = [
40
+ ...(scope.convexDir ? [join(scope.convexDir, ORACLE_DIR)] : []),
41
+ join(scope.repoRoot, ORACLE_DIR),
42
+ join(scope.input, ORACLE_DIR),
43
+ ];
44
+ return [...new Set(dirs)];
45
+ }
46
+ function locate(target, name, explicit) {
47
+ if (explicit !== undefined) {
48
+ const p = resolve(explicit);
49
+ return existsSync(p)
50
+ ? { status: "found", path: p, origin: "flag" }
51
+ : { status: "absent", searched: [p], origin: "flag (explicit path does not exist)" };
52
+ }
53
+ const dirs = oracleDirs(target);
54
+ const searched = dirs.map((d) => join(d, name));
55
+ for (let i = 0; i < searched.length; i++) {
56
+ if (existsSync(searched[i])) {
57
+ return { status: "found", path: searched[i], origin: `convention:${dirs[i]}` };
58
+ }
59
+ }
60
+ return { status: "absent", searched, origin: "convention" };
61
+ }
62
+ /** Derive the oracle for `target`. Pure over the filesystem; never reads this tool's own directory. */
63
+ export function resolveOracle(target, opts = {}) {
64
+ const t = resolve(target);
65
+ const vp = locate(t, ORACLE_NAME, opts.oracle);
66
+ return {
67
+ target: t,
68
+ vp,
69
+ vr: locate(t, VR_ORACLE_NAME, opts.vrOracle),
70
+ writerTier: locateWriterTier(vp, opts.writerTier),
71
+ };
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // WRITER-TIER SIDE-CAR — R-10's material.
75
+ //
76
+ // §2 splits authority into reader (R-5) and writer (R-10). The oracle's eighteen
77
+ // columns carry ONE authority column (`rbac_qui`, the reader), and a 19th column
78
+ // would make every row of every 18-column oracle malformed under toRows (measured
79
+ // on vantage-peers: 108/108). So the writer tier travels in a SIDE-CAR beside the
80
+ // oracle, joined by tool name (`outil`). Header-addressed, so a producer may add
81
+ // columns; the three below are required.
82
+ //
83
+ // Resolution: an explicit `--writer-tier <file>` (missing => absent, never a
84
+ // fall-through), else `<dirname(vp oracle)>/vp-writer-tier.csv`. A side-car that
85
+ // is absent or unreadable makes every WRITE row could-not-judge, naming the file.
86
+ // ---------------------------------------------------------------------------
87
+ export const WRITER_TIER_NAME = "vp-writer-tier.csv";
88
+ export const WRITER_TIER_COLUMNS = ["outil", "writer_tier", "writer_gate"];
89
+ function locateWriterTier(vp, explicit) {
90
+ if (explicit !== undefined) {
91
+ const p = resolve(explicit);
92
+ return existsSync(p)
93
+ ? { status: "found", path: p, origin: "flag" }
94
+ : { status: "absent", searched: [p], origin: "flag (explicit path does not exist)" };
95
+ }
96
+ if (vp.status === "absent")
97
+ return { status: "absent", searched: [], origin: "no vp oracle to sit beside" };
98
+ const p = join(dirname(vp.path), WRITER_TIER_NAME);
99
+ return existsSync(p)
100
+ ? { status: "found", path: p, origin: `beside-oracle:${dirname(vp.path)}` }
101
+ : { status: "absent", searched: [p], origin: "beside-oracle" };
102
+ }
103
+ const writerCache = new Map();
104
+ /** Parse one side-car. A missing required column or a row whose cell count
105
+ * differs from the header makes the WHOLE file unreadable — never a partial pass. */
106
+ export function readWriterTierFile(path) {
107
+ const hit = writerCache.get(path);
108
+ if (hit)
109
+ return hit;
110
+ const raw = parseCsv(readFileSync(path, "utf8")).filter((r) => !(r.length === 1 && r[0] === ""));
111
+ const [header = [], ...data] = raw;
112
+ const cols = header.map((h) => h.trim());
113
+ const missing = WRITER_TIER_COLUMNS.filter((c) => !cols.includes(c));
114
+ let table;
115
+ if (missing.length > 0) {
116
+ table = { status: "unreadable", path, reason: `header lacks required column(s) ${missing.join(", ")}` };
117
+ }
118
+ else {
119
+ const bad = data.findIndex((cells) => cells.length !== cols.length);
120
+ if (bad !== -1) {
121
+ table = {
122
+ status: "unreadable",
123
+ path,
124
+ reason: `row ${bad + 1} has ${data[bad].length} cells, header has ${cols.length}`,
125
+ };
126
+ }
127
+ else {
128
+ const at = (c) => cols.indexOf(c);
129
+ const byTool = new Map();
130
+ data.forEach((cells, i) => {
131
+ const e = {
132
+ n: i + 1,
133
+ outil: cells[at("outil")].trim(),
134
+ writer_tier: cells[at("writer_tier")].trim(),
135
+ writer_gate: cells[at("writer_gate")].trim(),
136
+ };
137
+ byTool.set(e.outil, [...(byTool.get(e.outil) ?? []), e]);
138
+ });
139
+ table = { status: "found", path, byTool };
140
+ }
141
+ }
142
+ writerCache.set(path, table);
143
+ return table;
144
+ }
145
+ /** The side-car bound for THIS run. Throws OracleAbsentError when no vp oracle is
146
+ * bound (R-10 then refuses like every oracle-backed rule); otherwise returns the
147
+ * table, or an `absent` record naming every path that was looked at. */
148
+ export function readWriterTier() {
149
+ const vp = requireOracle();
150
+ const loc = active?.writerTier ?? locateWriterTier(vp, undefined);
151
+ if (loc.status === "absent")
152
+ return { status: "absent", searched: loc.searched, origin: loc.origin };
153
+ return readWriterTierFile(loc.path);
154
+ }
155
+ /** One human line naming which oracle files a run used and where each came from. */
156
+ export function describeOracle(sel) {
157
+ const one = (label, l) => l.status === "found"
158
+ ? `${label}=${l.path} (from ${l.origin})`
159
+ : `${label}=ABSENT (${l.origin}; searched: ${l.searched.join(", ")})`;
160
+ const wt = sel.writerTier ? `; ${one("writer-tier", sel.writerTier)}` : "";
161
+ return `oracle for target ${sel.target}: ${one("vp", sel.vp)}; ${one("vr", sel.vr)}${wt}`;
162
+ }
163
+ /** Raised when an oracle-backed rule is asked to judge and no oracle describes the tree. */
164
+ export class OracleAbsentError extends Error {
165
+ which;
166
+ searched;
167
+ constructor(which, searched, detail) {
168
+ super(`no ${which === "vp" ? "target-backend" : "second-backend (VR)"} oracle describes this tree — ${detail ?? `searched: ${searched.join(", ")}`}`);
169
+ this.which = which;
170
+ this.searched = searched;
171
+ this.name = "OracleAbsentError";
172
+ }
173
+ }
174
+ let active = null;
175
+ /** Bind the oracle for this run. The CLI calls it once with the target's resolution. */
176
+ export function activateOracle(sel) {
177
+ active = sel;
178
+ }
179
+ /** The oracle currently bound, or null. */
180
+ export function activeOracle() {
181
+ return active;
182
+ }
183
+ function requireLocation(which) {
184
+ if (!active) {
185
+ throw new OracleAbsentError(which, [], "no target was bound (activateOracle never called), so no oracle was derived");
186
+ }
187
+ const l = active[which];
188
+ if (l.status === "absent")
189
+ throw new OracleAbsentError(which, l.searched);
190
+ return l;
191
+ }
192
+ /** Path of the bound VP oracle, or throws OracleAbsentError. */
193
+ export function requireOracle() {
194
+ return requireLocation("vp");
195
+ }
196
+ /** Path of the bound VR oracle, or throws OracleAbsentError. */
197
+ export function requireVrOracle() {
198
+ return requireLocation("vr");
199
+ }
200
+ /** `oracle:<file>` naming the bound oracle in a result anchor, or `oracle:ABSENT`. */
201
+ export function oracleLabel() {
202
+ const l = active?.vp;
203
+ return l && l.status === "found" ? `oracle:${basename(l.path)}` : "oracle:ABSENT";
204
+ }
205
+ /** Run an oracle-backed detector body. An absent oracle is could-not-judge —
206
+ * never present, never non-conforming. Anything else propagates untouched. */
207
+ export function refuseWithoutOracle(id, body) {
208
+ try {
209
+ return body();
210
+ }
211
+ catch (e) {
212
+ if (!(e instanceof OracleAbsentError))
213
+ throw e;
214
+ const reason = `${id}: REFUSED TO JUDGE — ${e.message}. An absent oracle is neither a clean bill nor a violation; supply one with --oracle/--vr-oracle or place it under ${ORACLE_DIR}/.`;
215
+ return {
216
+ id,
217
+ anchor: `oracle:ABSENT (${e.which})`,
218
+ state: "could-not-judge",
219
+ verdict: reason,
220
+ reason,
221
+ };
222
+ }
223
+ }
14
224
  /** The eighteen audit columns, in file order. §2 of the standard maps contract
15
225
  * fields onto these column names. */
16
226
  export const ORACLE_COLUMNS = [
@@ -106,16 +316,23 @@ export function toRows(dataRows) {
106
316
  return { n, cells, by };
107
317
  });
108
318
  }
109
- let cache = null;
110
- /** Memoized read of the real pinned oracle fixture. */
111
- export function readOracle() {
112
- if (cache)
113
- return cache;
114
- const text = readFileSync(ORACLE_PATH, "utf8");
319
+ const cache = new Map();
320
+ /** Memoized read of one oracle CSV, keyed by path. */
321
+ export function readOracleFile(path) {
322
+ const hit = cache.get(path);
323
+ if (hit)
324
+ return hit;
325
+ const text = readFileSync(path, "utf8");
115
326
  const raw = parseCsv(text).filter((r) => !(r.length === 1 && r[0] === ""));
116
327
  const [, ...data] = raw; // drop header
117
- cache = toRows(data);
118
- return cache;
328
+ const rows = toRows(data);
329
+ cache.set(path, rows);
330
+ return rows;
331
+ }
332
+ /** The rows of the oracle bound for THIS run (derived from the target).
333
+ * Throws OracleAbsentError when none is bound or none was found. */
334
+ export function readOracle() {
335
+ return readOracleFile(requireOracle().path);
119
336
  }
120
337
  /**
121
338
  * A real inventory row is one tool/table entry. The audit's trailing `TOTAL`
@@ -6,6 +6,13 @@ export interface RegistryEntry {
6
6
  ruleId: string;
7
7
  standardPin: string;
8
8
  }
9
+ /**
10
+ * Merge two detectors that judge halves of the SAME rule into one result: the
11
+ * worst state wins (non-conforming > could-not-judge > absent > present, the
12
+ * tally's own ranking), the anchor is the deciding half's, the title is the LATER
13
+ * (refined) half's, and both halves' verdicts are kept — nothing is dropped.
14
+ */
15
+ export declare function mergeHalves(ruleId: string, a: Detector, b: Detector): Detector;
9
16
  export declare const REGISTRY: RegistryEntry[];
10
17
  /** The rule ids this wave mechanically CHECKS — derived from the registry. */
11
18
  export declare function checkedRuleIds(): string[];
package/dist/registry.js CHANGED
@@ -20,8 +20,38 @@ import { R41, SIBLING_PIN } from "./detectors/standard/r41-frontier.js";
20
20
  import { R46_STANDARD_PIN, STANDARD_SOURCE_DETECTORS, } from "./detectors/standard/r46-returns-validator.js";
21
21
  import { STANDARD_DOC_DETECTORS } from "./detectors/standard/standard-doc.js";
22
22
  import { makeR14, makeR38 } from "./detectors/structural.js";
23
+ import { STATE_RANK } from "./state-rank.js";
23
24
  /** The standard both pins the whole wave against. */
24
25
  export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ 4bcd605656ceff52d4f1593a9176cd6b988471be (elpiarthera/ElPi-Corp)";
26
+ /**
27
+ * Merge two detectors that judge halves of the SAME rule into one result: the
28
+ * worst state wins (non-conforming > could-not-judge > absent > present, the
29
+ * tally's own ranking), the anchor is the deciding half's, the title is the LATER
30
+ * (refined) half's, and both halves' verdicts are kept — nothing is dropped.
31
+ */
32
+ export function mergeHalves(ruleId, a, b) {
33
+ return {
34
+ id: b.id,
35
+ run: (tree) => {
36
+ const ra = a.run(tree);
37
+ const rb = b.run(tree);
38
+ const decider = STATE_RANK[ra.state] > STATE_RANK[rb.state] ? ra : rb;
39
+ const verdict = `${ruleId} (two halves, worst wins): [${ra.id}] ${ra.state} — ${ra.verdict} || [${rb.id}] ${rb.state} — ${rb.verdict}`;
40
+ const reasons = [ra, rb]
41
+ .filter((r) => r.state === "could-not-judge")
42
+ .map((r) => r.reason ?? r.verdict);
43
+ return {
44
+ id: b.id,
45
+ anchor: decider.anchor,
46
+ state: decider.state,
47
+ verdict,
48
+ ...(decider.state === "could-not-judge"
49
+ ? { reason: reasons.join(" || ") }
50
+ : {}),
51
+ };
52
+ },
53
+ };
54
+ }
25
55
  export const REGISTRY = [
26
56
  ...PER_ROW_PREDICATES.map((spec) => ({
27
57
  detector: makeOracleDetector(spec),
@@ -51,7 +81,7 @@ export const REGISTRY = [
51
81
  {
52
82
  detector: makeR40(),
53
83
  ruleId: "R-40",
54
- standardPin: `${STANDARD_PIN} + oracle:vr-by-tool-day159.csv @ a74a042b`,
84
+ standardPin: `${STANDARD_PIN} + second-backend oracle derived from the target (vr-by-tool.csv / --vr-oracle)`,
55
85
  },
56
86
  // The twenty source-tree detectors (built all at once — the wave plan cancelled).
57
87
  // Each reads the REAL convex/ source; R-6/R-16 also carry a live half. Their
@@ -121,11 +151,23 @@ export const REGISTRY = [
121
151
  // VENDORED standard itself, performs its mechanical/static half in full, and
122
152
  // returns could-not-judge naming the sites a human/gate verdict is owed on.
123
153
  ...STANDARD_DOC_DETECTORS,
124
- ].map((e) => ({
125
- detector: e.detector,
126
- ruleId: e.ruleId,
127
- standardPin: STANDARD_PIN,
128
- })),
154
+ ].reduce((acc, e) => {
155
+ // ONE result per rule. Two modules carry an R-31 half (writesafety: the
156
+ // bulk-WRITE self-schedule; readbudget: the refined read+write budget), and
157
+ // both used to print as two R-31 rows under two different titles. They are
158
+ // merged into one row titled by the refined standard text (4bcd605 :266).
159
+ const prev = acc.find((x) => x.ruleId === e.ruleId);
160
+ if (prev) {
161
+ prev.detector = mergeHalves(e.ruleId, prev.detector, e.detector);
162
+ return acc;
163
+ }
164
+ acc.push({
165
+ detector: e.detector,
166
+ ruleId: e.ruleId,
167
+ standardPin: STANDARD_PIN,
168
+ });
169
+ return acc;
170
+ }, []),
129
171
  // R-41 — the ONE fully-mechanical rule of the nine: it reads the backend
130
172
  // standard's §9 frontier AND the four vendored MCP-family siblings, resolving
131
173
  // every cited S-/C-/E-/A- id against its sibling's defined-id set. present when
package/dist/report.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface ReportContext {
17
17
  * bit a reviewer). Empty string means a clean tree.
18
18
  */
19
19
  treeStatus: string;
20
+ /** which oracle files the run used and where each came from (describeOracle). Optional so older callers still compile. */
21
+ oracle?: string;
20
22
  }
21
23
  /** Derive the committed report path from the target basename + the run date. */
22
24
  export declare function reportPath(target: string, dateISO: string, dir: string): string;
@@ -32,4 +34,12 @@ export declare function buildReport(ctx: ReportContext, results: DetectorResult[
32
34
  * <dir> if needed. Returns the absolute path written. The caller (CLI) supplies the
33
35
  * ReportContext (clock + git) and the computed tally/verdict.
34
36
  */
35
- export declare function writeReport(ctx: ReportContext, results: DetectorResult[], t: Tally, v: Verdict, dir: string, severity?: Map<string, Severity>): string;
37
+ export declare function writeReport(ctx: ReportContext, results: DetectorResult[], t: Tally, v: Verdict, dir: string, severity?: Map<string, Severity>,
38
+ /**
39
+ * Doctor-owned sections appended VERBATIM after the rule tables, each in its
40
+ * own fence. CR-1 renders through here: its contract is that every member of
41
+ * every class is NAMED, and the truncation that hid thirteen of fourteen
42
+ * violations is the defect that contract exists not to repeat — so this
43
+ * carries the section's own text rather than re-deriving or summarising it.
44
+ */
45
+ extraSections?: string[]): string;
package/dist/report.js CHANGED
@@ -94,6 +94,7 @@ export function buildReport(ctx, results, t, v, severity = deriveSeverityMap())
94
94
  - **target commit**: \`${ctx.targetCommit}\`
95
95
  - **standard commit** (pinned): \`${STANDARD_SHA}\`
96
96
  - **tool version**: \`${ctx.toolVersion}\`
97
+ - **oracle**: ${ctx.oracle ?? "(not recorded)"}
97
98
  - **run date**: ${ctx.dateISO}
98
99
  - **machine tally**: \`${tallyLine(t)}\`
99
100
  - **verdict**: ${v.summary}
@@ -130,9 +131,21 @@ ${present.length === 0 ? "_none._" : present.join(", ")}
130
131
  * <dir> if needed. Returns the absolute path written. The caller (CLI) supplies the
131
132
  * ReportContext (clock + git) and the computed tally/verdict.
132
133
  */
133
- export function writeReport(ctx, results, t, v, dir, severity = deriveSeverityMap()) {
134
+ export function writeReport(ctx, results, t, v, dir, severity = deriveSeverityMap(),
135
+ /**
136
+ * Doctor-owned sections appended VERBATIM after the rule tables, each in its
137
+ * own fence. CR-1 renders through here: its contract is that every member of
138
+ * every class is NAMED, and the truncation that hid thirteen of fourteen
139
+ * violations is the defect that contract exists not to repeat — so this
140
+ * carries the section's own text rather than re-deriving or summarising it.
141
+ */
142
+ extraSections = []) {
134
143
  mkdirSync(dir, { recursive: true });
135
144
  const path = reportPath(ctx.target, ctx.dateISO, dir);
136
- writeFileSync(path, buildReport(ctx, results, t, v, severity), "utf8");
145
+ const body = [
146
+ buildReport(ctx, results, t, v, severity),
147
+ ...extraSections.map((sec) => `\n\n\`\`\`\n${sec}\n\`\`\`\n`),
148
+ ].join("");
149
+ writeFileSync(path, body, "utf8");
137
150
  return path;
138
151
  }
@@ -0,0 +1,5 @@
1
+ import type { State } from "./engine-types.js";
2
+ /** The four states, worst highest — every reduction (tally, a merged rule) picks
3
+ * the worst. A module of its own so registry.ts can use it without a cycle
4
+ * through tally -> coverage -> registry. */
5
+ export declare const STATE_RANK: Record<State, number>;
@@ -0,0 +1,9 @@
1
+ /** The four states, worst highest — every reduction (tally, a merged rule) picks
2
+ * the worst. A module of its own so registry.ts can use it without a cycle
3
+ * through tally -> coverage -> registry. */
4
+ export const STATE_RANK = {
5
+ "non-conforming": 3,
6
+ "could-not-judge": 2,
7
+ absent: 1,
8
+ present: 0,
9
+ };
package/dist/tally.js CHANGED
@@ -12,13 +12,7 @@
12
12
  import { computeExitCode } from "@vantageos/mcp-doctor";
13
13
  import { deriveRuleIds } from "./coverage.js";
14
14
  import { deriveSeverityMap } from "./severity.js";
15
- /** The four states, in worst-first order — the reduction picks the worst. */
16
- const STATE_RANK = {
17
- "non-conforming": 3,
18
- "could-not-judge": 2,
19
- absent: 1,
20
- present: 0,
21
- };
15
+ import { STATE_RANK } from "./state-rank.js";
22
16
  const ALL_STATES = [
23
17
  "present",
24
18
  "non-conforming",
@@ -0,0 +1,32 @@
1
+ import type { Detector, Tree } from "./engine-types.js";
2
+ export interface TargetScope {
3
+ /** the argument as given, resolved to an absolute path. */
4
+ input: string;
5
+ /** the repository root every `rel` is computed against. */
6
+ repoRoot: string;
7
+ /** which marker made `repoRoot` the root. */
8
+ repoRootBy: "package.json" | ".git" | "no marker (argument used as-is)";
9
+ /** the convex functions dir, or undefined when the repository has none. */
10
+ convexDir: string | undefined;
11
+ /** the directories actually walked, absolute, in order. */
12
+ scanRoots: string[];
13
+ }
14
+ /** Resolve a CLI argument to the backend it names. Pure over the filesystem. */
15
+ export declare function resolveTarget(arg: string): TargetScope;
16
+ /** The ONE tree every detector judges: the scan roots' files, `rel` relative to
17
+ * the repository root, `root` = the repository root. */
18
+ export declare function loadScopedTree(scope: TargetScope): Tree;
19
+ /** Files scanned per top-level directory (two segments under the repo root), so a
20
+ * reader sees WHAT was judged — an empty or surprising surface is visible. */
21
+ export declare function coverageInventory(tree: Tree): Map<string, number>;
22
+ export declare function describeScope(scope: TargetScope, tree: Tree): string;
23
+ /** Wrap detectors so each judges the scoped tree instead of the engine's
24
+ * per-argument tree. runDoctor still owns the loud-fail contract. */
25
+ export declare function onScopedTree(tree: Tree, detectors: Detector[]): Detector[];
26
+ /** Absolute convex dir for a scoped tree (tree.root = repo root) or for a tree
27
+ * rooted AT a convex dir (direct detector tests). `prefix` is what the tree's
28
+ * rels carry in front of a convex-relative path. */
29
+ export declare function convexRootOf(tree: Tree): {
30
+ dir: string;
31
+ prefix: string;
32
+ };