@vantageos/backend-doctor 0.1.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 (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,221 @@
1
+ // AXIS-1 between-backend divergence — R-40. §18: "two backends' shared field
2
+ // compared (mechanical); a divergence's written justification sound (human)."
3
+ // Prose (~line 570): two backends governed by this standard fill the same
4
+ // contract field from the same closed vocabulary; a divergence between two
5
+ // backends on a shared field publishes only with a WRITTEN JUSTIFICATION.
6
+ // Axis-2 (divergence WITHIN one backend) is R-37; Axis-1 (BETWEEN two backends)
7
+ // is R-40 — it fires the moment a second backend's inventory exists.
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.
15
+ //
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
+ import { readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { ORACLE_COLUMNS, parseCsv, readOracle, } from "../oracle.js";
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ /** Absolute path to the pinned SECOND-backend (VantageRegistry) oracle fixture.
25
+ * Vendored at pin a74a042b, blob b53e2626954a887303d11d944b6132a11dbd34f6. */
26
+ export const VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
27
+ /** The vendored standard (the R-5 law) — the SAME pin coverage.ts parses. The
28
+ * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
29
+ * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
30
+ * import cycle. */
31
+ export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-51ec7af1.md");
32
+ /** Load any backend inventory CSV generically from its OWN header — the shared
33
+ * fields are DERIVED from these real headers, never a prose illustration. */
34
+ export function loadInventory(path) {
35
+ const raw = parseCsv(readFileSync(path, "utf8")).filter((r) => !(r.length === 1 && r[0] === ""));
36
+ const header = (raw[0] ?? []).map((h) => h.trim());
37
+ const rows = raw.slice(1).map((cells) => {
38
+ const by = {};
39
+ header.forEach((h, i) => {
40
+ by[h] = (cells[i] ?? "").trim();
41
+ });
42
+ return by;
43
+ });
44
+ return { header, rows };
45
+ }
46
+ let vrCache = null;
47
+ /** Memoized read of the pinned VR inventory. */
48
+ export function readVrInventory() {
49
+ if (vrCache)
50
+ return vrCache;
51
+ vrCache = loadInventory(VR_ORACLE_PATH);
52
+ return vrCache;
53
+ }
54
+ /** The VP inventory, adapted from the memoized VP oracle reader — the SAME rows
55
+ * the axis-2 detectors read, exposed with VP's real header. */
56
+ export function vpInventory(rowsProvider = readOracle) {
57
+ return {
58
+ header: [...ORACLE_COLUMNS],
59
+ rows: rowsProvider()
60
+ .filter((r) => !r.malformed)
61
+ .map((r) => ({ ...r.by })),
62
+ };
63
+ }
64
+ /** A real inventory row — the audit's trailing `TOTAL` aggregate is not a tool. */
65
+ function isInventoryEntry(by) {
66
+ if (by.table === "TOTAL")
67
+ return false;
68
+ if ((by.outil ?? "") === "" &&
69
+ (by.crud ?? "") === "" &&
70
+ (by.surface ?? "") === "")
71
+ return false;
72
+ return true;
73
+ }
74
+ /** a value that actually FILLS the field — a blank or `n/a` is an omission, not a
75
+ * populated vocabulary member, so it never sources a between-backend divergence. */
76
+ function isFilled(v) {
77
+ const t = (v ?? "").trim().toLowerCase();
78
+ return t !== "" && t !== "n/a";
79
+ }
80
+ /** The SHARED FIELDS: the columns present in BOTH backends' real headers —
81
+ * computed as the intersection, never hardcoded. Used for the STRUCTURAL
82
+ * (schema) compare; the VALUE compare is narrowed to the §2 [shared] contract
83
+ * columns (see `sharedContractColumns`). */
84
+ export function sharedFields(vp, vr) {
85
+ const vrSet = new Set(vr);
86
+ return vp.filter((c) => vrSet.has(c));
87
+ }
88
+ /** The §2 CONTRACT columns the standard marks **[shared]** — DERIVED by parsing
89
+ * the vendored standard's §2 table (the R-5 law), never hardcoded. A [shared]
90
+ * row binds a contract field to its `audit column`, wrapped in backticks in the
91
+ * table's second cell. The five [shared] fields (operation verb, reader
92
+ * authority, writer authority, retrieval mechanism, pagination) resolve to the
93
+ * three audit columns `crud`, `rbac_qui`, `search_mecanisme` — reader/writer
94
+ * authority both live in the single merged `rbac_qui` (itself the R-14 defect),
95
+ * and retrieval mechanism + pagination both live in `search_mecanisme`.
96
+ *
97
+ * The R-40 VALUE compare runs ONLY over these closed-vocabulary columns. Free
98
+ * identifiers — `table`, `outil` (row identity) and `T2_verdict`,
99
+ * `T2_composant_cible` (audit bookkeeping) — are NOT vocabularies and are NEVER
100
+ * sited: comparing them counts row-identity noise as divergence and buries the
101
+ * one finding R-40 exists to catch. */
102
+ export function sharedContractColumns(standardPath = VENDORED_STANDARD_PATH) {
103
+ const text = readFileSync(standardPath, "utf8");
104
+ const cols = [];
105
+ for (const line of text.split("\n")) {
106
+ if (!line.trim().startsWith("|"))
107
+ continue;
108
+ const cells = line.split("|").map((c) => c.trim());
109
+ const contractCell = cells[1] ?? "";
110
+ if (!contractCell.includes("[shared]"))
111
+ continue;
112
+ const m = /`([^`]+)`/.exec(cells[2] ?? "");
113
+ if (m)
114
+ cols.push(m[1]);
115
+ }
116
+ return [...new Set(cols)];
117
+ }
118
+ export function schemaDivergence(vp, vr) {
119
+ const vpSet = new Set(vp);
120
+ const vrSet = new Set(vr);
121
+ return {
122
+ vpOnlyColumns: vp.filter((c) => !vrSet.has(c)),
123
+ vrOnlyColumns: vr.filter((c) => !vpSet.has(c)),
124
+ };
125
+ }
126
+ /** A field is a divergence SITE iff one backend fills it with a vocabulary
127
+ * member the other never uses. */
128
+ export function isSite(d) {
129
+ return d.vpOnly.length > 0 || d.vrOnly.length > 0;
130
+ }
131
+ /** Compare two inventories on each §2 [shared] contract field present in BOTH
132
+ * real headers. The scope is the CLOSED-VOCABULARY columns the standard marks
133
+ * [shared] (`crud`, `rbac_qui`, `search_mecanisme`) intersected with the two
134
+ * real headers — never all shared columns (that counts `table`/`outil`/`T2_*`
135
+ * free identifiers as vocabularies). Returns the per-field vocabulary comparison
136
+ * for every in-scope field (sites and agreeing fields alike) so a caller can
137
+ * prove BOTH poles: an agreeing value is not a site, a diverging value is. */
138
+ export function compareFields(vp, vr) {
139
+ const contract = new Set(sharedContractColumns());
140
+ const fields = sharedFields(vp.header, vr.header).filter((c) => contract.has(c));
141
+ const vpRows = vp.rows.filter(isInventoryEntry);
142
+ const vrRows = vr.rows.filter(isInventoryEntry);
143
+ return fields.map((field) => {
144
+ const vpVals = new Set(vpRows.map((r) => r[field]).filter((v) => v !== undefined && isFilled(v)));
145
+ const vrVals = new Set(vrRows.map((r) => r[field]).filter((v) => v !== undefined && isFilled(v)));
146
+ const shared = [...vpVals].filter((v) => vrVals.has(v)).sort();
147
+ const vpOnly = [...vpVals].filter((v) => !vrVals.has(v)).sort();
148
+ const vrOnly = [...vrVals].filter((v) => !vpVals.has(v)).sort();
149
+ return { field, shared, vpOnly, vrOnly };
150
+ });
151
+ }
152
+ export function compareBackends(vpProvider = readOracle, vrProvider = readVrInventory) {
153
+ const vp = vpInventory(vpProvider);
154
+ const vr = vrProvider();
155
+ const fields = compareFields(vp, vr);
156
+ return {
157
+ schema: schemaDivergence(vp.header, vr.header),
158
+ fields,
159
+ sites: fields.filter(isSite),
160
+ };
161
+ }
162
+ const R40_ID = "R-40 between-backend divergence";
163
+ const R40_ANCHOR = "oracle:vp-by-tool-day158.csv vs oracle:vr-by-tool-day159.csv";
164
+ /** Wrap the Axis-1 compare into a Detector. Providers are injectable for tests;
165
+ * production reads the two memoized real oracles. */
166
+ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
167
+ return {
168
+ id: R40_ID,
169
+ run: () => {
170
+ const cmp = compareBackends(vpProvider, vrProvider);
171
+ const { schema, sites } = cmp;
172
+ // the schema (column) divergence is itself a site — surfaced, never dropped.
173
+ const schemaBits = [];
174
+ if (schema.vpOnlyColumns.length)
175
+ schemaBits.push(`VP-only column(s): ${schema.vpOnlyColumns.join(", ")}`);
176
+ if (schema.vrOnlyColumns.length)
177
+ schemaBits.push(`VR-only column(s): ${schema.vrOnlyColumns.join(", ")}`);
178
+ 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
+ : "No between-schema column divergence.";
181
+ const totalValueDivergences = sites.reduce((n, s) => n + s.vpOnly.length + s.vrOnly.length, 0);
182
+ const breakdown = `[shared-field-sites=${sites.length} value-divergences=${totalValueDivergences} schema-col-divergences=${schema.vpOnlyColumns.length + schema.vrOnlyColumns.length}]`;
183
+ 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}`;
189
+ return {
190
+ id: R40_ID,
191
+ anchor: R40_ANCHOR,
192
+ state: "could-not-judge",
193
+ verdict: reason,
194
+ reason,
195
+ };
196
+ }
197
+ const examples = sites
198
+ .slice(0, 5)
199
+ .map((s) => {
200
+ const vp = s.vpOnly
201
+ .slice(0, 2)
202
+ .map((v) => `"${v.slice(0, 28)}"`)
203
+ .join(", ");
204
+ const vr = s.vrOnly
205
+ .slice(0, 2)
206
+ .map((v) => `"${v.slice(0, 28)}"`)
207
+ .join(", ");
208
+ return `${s.field}: VP-only {${vp || "—"}} vs VR-only {${vr || "—"}}`;
209
+ })
210
+ .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}.`;
212
+ return {
213
+ id: R40_ID,
214
+ anchor: `${R40_ANCHOR} field=${sites[0]?.field ?? "schema"}`,
215
+ state: "could-not-judge",
216
+ verdict: reason,
217
+ reason,
218
+ };
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,22 @@
1
+ import type { Detector } from "../engine-types.js";
2
+ import { type OracleRow } from "../oracle.js";
3
+ import type { RulePredicate } from "./shared.js";
4
+ export declare const r7: RulePredicate;
5
+ export declare const r8: RulePredicate;
6
+ export interface DivergenceSite {
7
+ table: string;
8
+ field: string;
9
+ toolA: string;
10
+ valA: string;
11
+ toolB: string;
12
+ valB: string;
13
+ }
14
+ /**
15
+ * Group the inventory rows by table and, per shared field, name ONE diverging
16
+ * filled pair (the two tools whose filled values differ). The `n/a` pseudo-table
17
+ * groups unrelated tools and has no per-table contract, so it is excluded.
18
+ */
19
+ export declare function findDivergences(rows: OracleRow[]): DivergenceSite[];
20
+ /** Wrap the filled-neighbour compare into a Detector. `rowsProvider` is
21
+ * injectable for tests; production reads the memoized real oracle. */
22
+ export declare function makeR37(rowsProvider?: () => OracleRow[]): Detector;
@@ -0,0 +1,149 @@
1
+ import { isInventoryRow, readOracle, } from "../oracle.js";
2
+ // ---- R-7 read/write coherence divergence (INCOHERENT* table-aggregate flag) -
3
+ // §18: "`INCOHERENT*` flag (mechanical); divergence justified (human)". The
4
+ // oracle's `rbac_coherence_table` carries the audit's table-aggregate coherence
5
+ // verdict; 105 rows are flagged `INCOHERENT...` (Finding 6). MECHANICAL: find
6
+ // every flagged row. HUMAN: whether each incoherence carries a written
7
+ // justification. So a flagged row is `cnj` (a human verdict is owed on it),
8
+ // never `violate` (which would usurp the human disposition) and never a pass.
9
+ export const r7 = {
10
+ ruleId: "R-7",
11
+ 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",
13
+ classify: (r) => {
14
+ const v = r.by.rbac_coherence_table;
15
+ if (/^INCOHERENT/i.test(v))
16
+ 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`,
19
+ };
20
+ if (/^COHERENT/i.test(v))
21
+ return { kind: "conform" };
22
+ return { kind: "na" }; // no coherence verdict recorded on this row (e.g. n/a)
23
+ },
24
+ };
25
+ // ---- R-8 zero scope check on a data op (empty / `AUCUN`) --------------------
26
+ // §18: "empty/`AUCUN` + a data op (mechanical); justification sound (human)".
27
+ // MATERIAL: 5 rows carry `scope_enforcement=AUCUN(public_no_scope_check)` while
28
+ // performing a data op. MECHANICAL: find every such row. HUMAN: whether the
29
+ // stateless (no-scope) justification is sound. A matched row is `cnj`.
30
+ export const r8 = {
31
+ ruleId: "R-8",
32
+ 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",
34
+ classify: (r) => {
35
+ const scope = r.by.scope_enforcement.trim();
36
+ const crud = r.by.crud.trim();
37
+ const hasDataOp = crud !== "" && crud.toLowerCase() !== "n/a";
38
+ if (!hasDataOp)
39
+ return { kind: "na" }; // no data op — R-8 does not apply
40
+ const noScope = scope === "" || /AUCUN/i.test(scope);
41
+ if (noScope)
42
+ 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`,
45
+ };
46
+ return { kind: "conform" }; // a genuine scope check is recorded
47
+ },
48
+ };
49
+ // ---- R-37 per-table filled-neighbour divergence ----------------------------
50
+ // §17.5 detector spec: "For each table, group its tool rows and, for every
51
+ // shared contract field (§2: exposure, verb effect-class, reader/writer
52
+ // authority, retrieval mechanism, isolation locus, boundary key), flag any two
53
+ // tools whose FILLED values differ." MECHANICAL: the filled-neighbour compare,
54
+ // naming WHICH TWO TOOLS diverge (the attribution R-7's lossy union label
55
+ // cannot carry). HUMAN: whether each divergence's per-tool justification is
56
+ // sound. §2 field → oracle column, using the ACTUAL column names.
57
+ const SHARED_FIELDS = [
58
+ { field: "exposure", col: "surface" },
59
+ { field: "verb effect-class", col: "crud" },
60
+ { field: "reader/writer authority", col: "rbac_qui" },
61
+ { field: "retrieval mechanism", col: "search_mecanisme" },
62
+ { field: "isolation locus", col: "scope_enforcement" },
63
+ { field: "boundary key", col: "namespace_ou_org" },
64
+ ];
65
+ /** a value that actually FILLS the field — a blank or `n/a` is an omission
66
+ * (R-38's concern), not a filled neighbour, so it never sources a divergence. */
67
+ function isFilled(v) {
68
+ const t = v.trim().toLowerCase();
69
+ return t !== "" && t !== "n/a";
70
+ }
71
+ /**
72
+ * Group the inventory rows by table and, per shared field, name ONE diverging
73
+ * filled pair (the two tools whose filled values differ). The `n/a` pseudo-table
74
+ * groups unrelated tools and has no per-table contract, so it is excluded.
75
+ */
76
+ export function findDivergences(rows) {
77
+ const inv = rows.filter(isInventoryRow);
78
+ const byTable = new Map();
79
+ for (const r of inv) {
80
+ const t = r.by.table;
81
+ if (t === "" || t === "n/a")
82
+ continue; // not a real per-table contract
83
+ const g = byTable.get(t);
84
+ if (g)
85
+ g.push(r);
86
+ else
87
+ byTable.set(t, [r]);
88
+ }
89
+ const sites = [];
90
+ for (const [table, group] of byTable) {
91
+ if (group.length < 2)
92
+ continue; // a single tool has no neighbour
93
+ for (const { field, col } of SHARED_FIELDS) {
94
+ const filled = group.filter((r) => isFilled(r.by[col]));
95
+ if (filled.length < 2)
96
+ continue;
97
+ const first = filled[0];
98
+ const neighbour = filled.find((r) => r.by[col] !== first.by[col]);
99
+ if (neighbour)
100
+ sites.push({
101
+ table,
102
+ field,
103
+ toolA: first.by.outil,
104
+ valA: first.by[col],
105
+ toolB: neighbour.by.outil,
106
+ valB: neighbour.by[col],
107
+ });
108
+ }
109
+ }
110
+ return sites;
111
+ }
112
+ const R37_ID = "R-37 filled-neighbour divergence";
113
+ const R37_ANCHOR = "oracle:vp-by-tool-day158.csv";
114
+ /** Wrap the filled-neighbour compare into a Detector. `rowsProvider` is
115
+ * injectable for tests; production reads the memoized real oracle. */
116
+ export function makeR37(rowsProvider = readOracle) {
117
+ return {
118
+ id: R37_ID,
119
+ run: () => {
120
+ const sites = findDivergences(rowsProvider());
121
+ const tables = new Set(sites.map((s) => s.table));
122
+ const breakdown = `[filled-neighbour-divergences=${sites.length} across tables=${tables.size}]`;
123
+ 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}`;
127
+ return {
128
+ id: R37_ID,
129
+ anchor: R37_ANCHOR,
130
+ state: "could-not-judge",
131
+ verdict: reason,
132
+ reason,
133
+ };
134
+ }
135
+ const examples = sites
136
+ .slice(0, 5)
137
+ .map((s) => `${s.table}.${s.field}: ${s.toolA} "${s.valA.slice(0, 32)}" vs ${s.toolB} "${s.valB.slice(0, 32)}"`)
138
+ .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}.`;
140
+ return {
141
+ id: R37_ID,
142
+ anchor: `oracle:vp-by-tool-day158.csv table=${sites[0].table}`,
143
+ state: "could-not-judge",
144
+ verdict: reason,
145
+ reason,
146
+ };
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,19 @@
1
+ import { type RulePredicate } from "./shared.js";
2
+ /** Collapse a raw `surface` to one of the three canonical exposures, or null. */
3
+ export declare function collapseSurface(raw: string): string | null;
4
+ export declare const r1: RulePredicate;
5
+ export declare const r2: RulePredicate;
6
+ export declare const r3: RulePredicate;
7
+ export declare const r4: RulePredicate;
8
+ /** Collapse a raw `rbac_qui` value to one of the five canonical tiers, or null
9
+ * if it maps to none. `fleet_interne` → fleet-internal; every `toutes_orgs(...)`
10
+ * variant → an org tier (the documented collapse, standard line 61). */
11
+ export declare function collapseTier(raw: string): string | null;
12
+ export declare const r5: RulePredicate;
13
+ export declare const r9: RulePredicate;
14
+ export declare const r10: RulePredicate;
15
+ export declare const r12: RulePredicate;
16
+ export declare const r13: RulePredicate;
17
+ export declare const r27: RulePredicate;
18
+ export declare const r28: RulePredicate;
19
+ export declare const PER_ROW_PREDICATES: RulePredicate[];