@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.
- package/README.md +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +71 -18
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- 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.
|
|
3
|
-
//
|
|
4
|
-
// (reader R-5, writer R-10)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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).
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
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
|
|
33
|
-
anchor:
|
|
60
|
+
id,
|
|
61
|
+
anchor: `${oracleLabel()} + ${split.sideCar}`,
|
|
34
62
|
state: "present",
|
|
35
|
-
verdict:
|
|
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.
|
|
42
|
-
*
|
|
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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) =>
|
|
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
|
|
67
|
-
anchor:
|
|
98
|
+
return withCount({
|
|
99
|
+
id,
|
|
100
|
+
anchor: csvAnchor(),
|
|
68
101
|
state: "non-conforming",
|
|
69
|
-
verdict:
|
|
70
|
-
`writer authority (R-10) are not two separate fields;
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
77
|
-
anchor:
|
|
111
|
+
id,
|
|
112
|
+
anchor: `${oracleLabel()} + ${split.sideCar}`,
|
|
78
113
|
state: "present",
|
|
79
|
-
verdict: "R-38: all thirteen §2 contract fields present per table
|
|
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
|
}
|
package/dist/not-checkable.d.ts
CHANGED
|
@@ -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
|
package/dist/not-checkable.js
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
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
|
|
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 {
|
|
11
|
+
import { resolveTarget } from "./target.js";
|
|
11
12
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
/**
|
|
13
|
-
|
|
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
|
-
|
|
110
|
-
/** Memoized read of
|
|
111
|
-
export function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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`
|
package/dist/registry.d.ts
CHANGED
|
@@ -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 = "
|
|
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[];
|