@vantageos/backend-doctor 0.1.14 → 0.3.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 (93) hide show
  1. package/README.md +66 -5
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +72 -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 +1198 -0
  9. package/dist/cli.js +249 -17
  10. package/dist/contract.d.ts +25 -7
  11. package/dist/contract.js +52 -7
  12. package/dist/coverage-cli.js +8 -0
  13. package/dist/coverage.d.ts +1 -1
  14. package/dist/coverage.js +2 -2
  15. package/dist/detectors/oracle-axis1.d.ts +5 -3
  16. package/dist/detectors/oracle-axis1.js +28 -18
  17. package/dist/detectors/oracle-axis2.d.ts +6 -0
  18. package/dist/detectors/oracle-axis2.js +37 -22
  19. package/dist/detectors/predicates.d.ts +3 -0
  20. package/dist/detectors/predicates.js +93 -19
  21. package/dist/detectors/shared.js +8 -7
  22. package/dist/detectors/source/_ast.d.ts +92 -0
  23. package/dist/detectors/source/_ast.js +353 -0
  24. package/dist/detectors/source/_scan.js +4 -3
  25. package/dist/detectors/source/backfill.js +71 -18
  26. package/dist/detectors/source/contract.js +21 -13
  27. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  28. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  29. package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
  30. package/dist/detectors/source/guard-helper-flow.js +1216 -0
  31. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  32. package/dist/detectors/source/identity-by-id.js +1783 -0
  33. package/dist/detectors/source/identity.d.ts +58 -2
  34. package/dist/detectors/source/identity.js +1158 -28
  35. package/dist/detectors/source/isolation.d.ts +11 -1
  36. package/dist/detectors/source/isolation.js +106 -25
  37. package/dist/detectors/source/jobs.js +178 -4
  38. package/dist/detectors/source/lifecycle.js +14 -2
  39. package/dist/detectors/source/local-identity.d.ts +26 -0
  40. package/dist/detectors/source/local-identity.js +661 -0
  41. package/dist/detectors/source/package-principal.d.ts +9 -0
  42. package/dist/detectors/source/package-principal.js +177 -0
  43. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  44. package/dist/detectors/source/r6-shapes.js +285 -0
  45. package/dist/detectors/source/readbudget.d.ts +1 -0
  46. package/dist/detectors/source/readbudget.js +358 -4
  47. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  48. package/dist/detectors/source/recipient-by-id.js +1108 -0
  49. package/dist/detectors/source/refusal.d.ts +14 -0
  50. package/dist/detectors/source/refusal.js +75 -2
  51. package/dist/detectors/source/security.d.ts +16 -0
  52. package/dist/detectors/source/security.js +680 -33
  53. package/dist/detectors/source/write-isolation.d.ts +8 -1
  54. package/dist/detectors/source/write-isolation.js +64 -30
  55. package/dist/detectors/source/writesafety.js +27 -14
  56. package/dist/detectors/standard/_doc.d.ts +1 -1
  57. package/dist/detectors/standard/_doc.js +5 -4
  58. package/dist/detectors/standard/r41-frontier.js +3 -2
  59. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  60. package/dist/detectors/standard/r46-returns-validator.js +48 -16
  61. package/dist/detectors/structural.d.ts +6 -7
  62. package/dist/detectors/structural.js +84 -48
  63. package/dist/not-checkable.d.ts +1 -1
  64. package/dist/not-checkable.js +11 -0
  65. package/dist/oracle.d.ts +92 -3
  66. package/dist/oracle.js +229 -12
  67. package/dist/registry.d.ts +8 -1
  68. package/dist/registry.js +74 -8
  69. package/dist/report.d.ts +11 -1
  70. package/dist/report.js +15 -2
  71. package/dist/rule-class.d.ts +85 -0
  72. package/dist/rule-class.js +218 -0
  73. package/dist/state-rank.d.ts +5 -0
  74. package/dist/state-rank.js +9 -0
  75. package/dist/tally.d.ts +13 -5
  76. package/dist/tally.js +15 -13
  77. package/dist/target.d.ts +32 -0
  78. package/dist/target.js +173 -0
  79. package/dist/violation-count.d.ts +48 -0
  80. package/dist/violation-count.js +94 -0
  81. package/fixtures/backend-standard-2825122.md +722 -0
  82. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  83. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  84. package/fixtures/cr1-classes/surface.ts +206 -0
  85. package/fixtures/cr1-empty/nothing.ts +1 -0
  86. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  87. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  88. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  89. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  90. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  91. package/fixtures/rule-classes-2825122.json +228 -0
  92. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  93. package/package.json +2 -2
@@ -1,51 +1,81 @@
1
1
  // The two STRUCTURAL detectors — R-14 and R-38. They judge the inventory SCHEMA
2
- // against the §2 contract, not a single row. Run against THIS oracle they MUST
3
- // return `non-conforming`, because §2 splits authority into two distinct fields
4
- // (reader R-5, writer R-10) and the oracle carries a SINGLE merged `rbac_qui`
5
- // column. Agreeing with the oracle here would be certifying the defect the
6
- // instrument exists to find — so these are NOT tuned to pass.
7
- import { CONTRACT_FIELDS, READER_AUTHORITY_COLUMN, WRITER_AUTHORITY_COLUMN, authorityIsMerged } from "../contract.js";
8
- import { ORACLE_COLUMNS, readOracle } from "../oracle.js";
9
- const CSV_ANCHOR = "oracle:vp-by-tool-day158.csv col#9 rbac_qui";
2
+ // against the §2 contract, not a single row.
3
+ //
4
+ // §2 splits authority into two contract fields (reader R-5, writer R-10). The
5
+ // oracle carries ONE authority column, `rbac_qui`; writer authority has a column
6
+ // of its own only through the writer-tier side-car (`writer_tier`, joined on
7
+ // `outil` — see resolveAuthoritySplit in src/contract.ts). So:
8
+ // side-car absent, or missing a write tool -> non-conforming (merged/omitted)
9
+ // side-car unreadable -> could-not-judge
10
+ // side-car complete over every write tool -> the split is declared
11
+ // An oracle alone is never green here: agreeing with a merged column would be
12
+ // certifying the defect the instrument exists to find.
13
+ import { CONTRACT_FIELDS, READER_AUTHORITY_COLUMN, resolveAuthoritySplit, SPLIT_WRITER_AUTHORITY_COLUMN, } from "../contract.js";
14
+ import { isInventoryRow, ORACLE_COLUMNS, oracleLabel, readOracle, readWriterTier, refuseWithoutOracle, requireOracle, } from "../oracle.js";
15
+ import { itemCount, withCount } from "../violation-count.js";
16
+ import { WRITE_VERBS } from "./shared.js";
17
+ const csvAnchor = () => `${oracleLabel()} col#9 rbac_qui`;
18
+ function splitFor(rows, sideCar) {
19
+ const writeTools = rows
20
+ .filter(isInventoryRow)
21
+ .filter((r) => WRITE_VERBS.has(r.by.crud))
22
+ .map((r) => r.by.outil);
23
+ return resolveAuthoritySplit(writeTools, sideCar());
24
+ }
25
+ function unjudgeable(id, reason) {
26
+ const verdict = `${id.split(" ")[0]}: could not judge — ${reason}.`;
27
+ return { id, anchor: csvAnchor(), state: "could-not-judge", verdict, reason: verdict };
28
+ }
10
29
  /**
11
30
  * R-14 — inventory-schema parity, contract → inventory, one direction.
12
31
  * A merged reader/writer authority column is "non-conforming by construction"
13
- * (§12). The oracle's column 9 `rbac_qui` is that single merged column.
32
+ * (§12). Writer authority is a separate column only when the side-car declares it.
14
33
  */
15
- export function makeR14(columns = ORACLE_COLUMNS) {
34
+ export function makeR14(columns = ORACLE_COLUMNS, rowsProvider = readOracle, sideCar = readWriterTier) {
35
+ const id = "R-14 inventory-schema parity";
16
36
  return {
17
- id: "R-14 inventory-schema parity",
18
- run: () => {
19
- const authorityColumns = columns.filter((c) => c === "rbac_qui");
20
- if (authorityIsMerged() || authorityColumns.length < 2) {
21
- return {
22
- id: "R-14 inventory-schema parity",
23
- anchor: CSV_ANCHOR,
37
+ id,
38
+ // The verdict is about the oracle's schema, so with no oracle bound there is
39
+ // no subject: refuse (could-not-judge), never a verdict on the tool's own constant.
40
+ run: () => refuseWithoutOracle(id, () => {
41
+ requireOracle();
42
+ const missing = CONTRACT_FIELDS.flatMap((f) => f.columns.filter((c) => !columns.includes(c)).map((c) => `${f.field}→${c}`));
43
+ const split = splitFor(rowsProvider(), sideCar);
44
+ if (split.kind === "unjudgeable")
45
+ return unjudgeable(id, split.reason);
46
+ if (split.kind === "merged" || missing.length > 0) {
47
+ return withCount({
48
+ id,
49
+ anchor: csvAnchor(),
24
50
  state: "non-conforming",
25
- verdict: "R-14: the inventory holds a SINGLE merged authority column `rbac_qui` (col#9), " +
26
- "but §2 splits authority into two distinct contract fields — reader authority (R-5) " +
27
- "and writer authority (R-10). A merged authority column is non-conforming by construction: " +
28
- "the reader/writer divergence R-10 and R-7 exist to record cannot be written in it.",
29
- };
51
+ verdict: "R-14: §2 splits authority into two distinct contract fields — reader authority (R-5) " +
52
+ `and writer authority (R-10) — but the inventory's only authority column is \`${READER_AUTHORITY_COLUMN}\` (col#9)` +
53
+ (split.kind === "merged" ? `: ${split.reason}. ` : ". ") +
54
+ "A merged authority column is non-conforming by construction: the reader/writer divergence " +
55
+ "R-10 and R-7 exist to record cannot be written in it." +
56
+ (missing.length ? ` Also missing: ${missing.join(", ")}.` : ""),
57
+ }, itemCount(1));
30
58
  }
31
59
  return {
32
- id: "R-14 inventory-schema parity",
33
- anchor: "oracle:vp-by-tool-day158.csv",
60
+ id,
61
+ anchor: `${oracleLabel()} + ${split.sideCar}`,
34
62
  state: "present",
35
- verdict: "R-14: every §2 contract field present; reader and writer authority are two separate columns.",
63
+ verdict: `R-14: every §2 contract field present; reader authority is \`${READER_AUTHORITY_COLUMN}\`, writer authority is ` +
64
+ `\`${SPLIT_WRITER_AUTHORITY_COLUMN}\` in ${split.sideCar}, recorded for all ${split.writeTools} write tools.`,
36
65
  };
37
- },
66
+ }),
38
67
  };
39
68
  }
40
69
  /**
41
- * R-38 — the completeness gate over the thirteen §2 fields. The oracle cannot
42
- * satisfy the contract because reader and writer authority are not two separate
43
- * fields (both map to `rbac_qui`).
70
+ * R-38 — the completeness gate over the thirteen §2 fields. Writer authority is
71
+ * complete only when the side-car records a tier for every write tool.
44
72
  */
45
- export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
73
+ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle, sideCar = readWriterTier) {
74
+ const id = "R-38 thirteen-field completeness";
46
75
  return {
47
- id: "R-38 thirteen-field completeness",
48
- run: () => {
76
+ id,
77
+ run: () => refuseWithoutOracle(id, () => {
78
+ requireOracle();
49
79
  const colSet = new Set(columns);
50
80
  const missing = [];
51
81
  for (const f of CONTRACT_FIELDS) {
@@ -54,30 +84,36 @@ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
54
84
  missing.push(`${f.field}→${col}`);
55
85
  }
56
86
  }
57
- const authorityMerged = READER_AUTHORITY_COLUMN === WRITER_AUTHORITY_COLUMN && colSet.has(READER_AUTHORITY_COLUMN);
58
- if (authorityMerged || missing.length > 0) {
59
- const rows = rowsProvider();
87
+ const rows = rowsProvider();
88
+ const split = splitFor(rows, sideCar);
89
+ if (split.kind === "unjudgeable")
90
+ return unjudgeable(id, split.reason);
91
+ if (split.kind === "merged" || missing.length > 0) {
92
+ const uncovered = split.kind === "merged" ? new Set(split.uncovered) : new Set();
60
93
  const sample = rows
61
- .filter((r) => !r.malformed && r.by.rbac_qui && r.by.table !== "TOTAL")
94
+ .filter((r) => isInventoryRow(r) && r.by.rbac_qui && (uncovered.size === 0 || uncovered.has(r.by.outil)))
62
95
  .slice(0, 3)
63
96
  .map((r) => `row#${r.n} "${r.by.outil || r.by.table}" rbac_qui="${r.by.rbac_qui}"`)
64
97
  .join("; ");
65
- return {
66
- id: "R-38 thirteen-field completeness",
67
- anchor: CSV_ANCHOR,
98
+ return withCount({
99
+ id,
100
+ anchor: csvAnchor(),
68
101
  state: "non-conforming",
69
- verdict: `R-38: the oracle cannot satisfy the thirteen-field §2 contract — reader authority (R-5) and ` +
70
- `writer authority (R-10) are not two separate fields; both map to the single column ` +
71
- `\`rbac_qui\` (col#9).${missing.length ? ` Also missing: ${missing.join(", ")}.` : ""} ` +
72
- `Evidence: ${sample}.`,
73
- };
102
+ verdict: "R-38: the oracle cannot satisfy the thirteen-field §2 contract — reader authority (R-5) and " +
103
+ `writer authority (R-10) are not two separate fields for every tool; writer authority falls back to ` +
104
+ `\`${READER_AUTHORITY_COLUMN}\` (col#9)` +
105
+ (split.kind === "merged" ? `: ${split.reason}.` : ".") +
106
+ (missing.length ? ` Also missing: ${missing.join(", ")}.` : "") +
107
+ ` Evidence: ${sample}.`,
108
+ }, itemCount(1));
74
109
  }
75
110
  return {
76
- id: "R-38 thirteen-field completeness",
77
- anchor: "oracle:vp-by-tool-day158.csv",
111
+ id,
112
+ anchor: `${oracleLabel()} + ${split.sideCar}`,
78
113
  state: "present",
79
- verdict: "R-38: all thirteen §2 contract fields present per table, each within its vocabulary.",
114
+ verdict: "R-38: all thirteen §2 contract fields present per table; writer authority is " +
115
+ `\`${SPLIT_WRITER_AUTHORITY_COLUMN}\` in ${split.sideCar} for all ${split.writeTools} write tools.`,
80
116
  };
81
- },
117
+ }),
82
118
  };
83
119
  }
@@ -1,3 +1,4 @@
1
+ import type { DetectorResult } from "./engine-types.js";
1
2
  /**
2
3
  * The deferral bucket. R-1..R-47 each carry an executable control (registry.ts),
3
4
  * so none is deferred to "a human decides". R-48 (authority-totality, the #130
@@ -9,7 +10,6 @@
9
10
  * moves to registry.ts and this entry is removed. The export is consumed by
10
11
  * coverage.ts / coverage-cli.ts / cli.ts.
11
12
  */
12
- import type { DetectorResult } from "./engine-types.js";
13
13
  export declare const NOT_MECHANICALLY_CHECKABLE: Record<string, string>;
14
14
  /**
15
15
  * One `could-not-judge` DetectorResult per NOT_MECHANICALLY_CHECKABLE rule, so
@@ -15,6 +15,17 @@
15
15
  // input is vendored. They were built, not relabelled. The bucket was not renamed to a
16
16
  // gentler word — it was emptied.
17
17
  import { STANDARD_FILE } from "./detectors/standard/_doc.js";
18
+ /**
19
+ * The deferral bucket. R-1..R-47 each carry an executable control (registry.ts),
20
+ * so none is deferred to "a human decides". R-48 (authority-totality, the #130
21
+ * class) was added to the standard at the 51ec7af1 re-pin AFTER its detector; its
22
+ * text is now pinned so coverage enumerates 48, but no detector exists yet — so it
23
+ * is reported here as NOT_MECHANICALLY_CHECKABLE with a written reason rather than
24
+ * silently omitted, which would let coverage pretend 47 is the whole document. The
25
+ * detector is task k17chpr4kzp399j3s7r7edpm4s8cscbz (after D5); when it lands, R-48
26
+ * moves to registry.ts and this entry is removed. The export is consumed by
27
+ * coverage.ts / coverage-cli.ts / cli.ts.
28
+ */
18
29
  export const NOT_MECHANICALLY_CHECKABLE = {
19
30
  "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 4bcd605 so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
20
31
  };
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`
@@ -1,11 +1,18 @@
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 @ 4bcd605656ceff52d4f1593a9176cd6b988471be (elpiarthera/ElPi-Corp)";
3
+ export declare const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ 2825122a3ab1e4ddb4b955cfb136b7e8ab04f8f3 (elpiarthera/ElPi-Corp, merge of PR #228 on origin/main, after #226 at 9f7d789)";
4
4
  export interface RegistryEntry {
5
5
  detector: Detector;
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[];