@vantageos/backend-doctor 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +91 -8
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/detectors/oracle-axis1.d.ts +5 -3
  12. package/dist/detectors/oracle-axis1.js +24 -15
  13. package/dist/detectors/oracle-axis2.js +5 -6
  14. package/dist/detectors/predicates.d.ts +3 -0
  15. package/dist/detectors/predicates.js +81 -16
  16. package/dist/detectors/shared.js +5 -5
  17. package/dist/detectors/source/backfill.js +9 -1
  18. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  19. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  20. package/dist/detectors/source/identity.d.ts +51 -2
  21. package/dist/detectors/source/identity.js +815 -14
  22. package/dist/detectors/source/isolation.d.ts +11 -1
  23. package/dist/detectors/source/isolation.js +106 -25
  24. package/dist/detectors/source/jobs.js +165 -2
  25. package/dist/detectors/source/readbudget.d.ts +1 -0
  26. package/dist/detectors/source/readbudget.js +67 -4
  27. package/dist/detectors/source/security.d.ts +16 -0
  28. package/dist/detectors/source/security.js +225 -7
  29. package/dist/detectors/source/write-isolation.d.ts +8 -1
  30. package/dist/detectors/source/write-isolation.js +64 -30
  31. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  32. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  33. package/dist/detectors/structural.js +14 -10
  34. package/dist/oracle.d.ts +92 -3
  35. package/dist/oracle.js +229 -12
  36. package/dist/registry.d.ts +7 -0
  37. package/dist/registry.js +48 -6
  38. package/dist/report.d.ts +11 -1
  39. package/dist/report.js +15 -2
  40. package/dist/state-rank.d.ts +5 -0
  41. package/dist/state-rank.js +9 -0
  42. package/dist/tally.js +1 -7
  43. package/dist/target.d.ts +32 -0
  44. package/dist/target.js +173 -0
  45. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  46. package/fixtures/cr1-classes/surface.ts +206 -0
  47. package/fixtures/cr1-empty/nothing.ts +1 -0
  48. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  49. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  50. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  51. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  52. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  53. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  54. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -5,17 +5,22 @@
5
5
  // DISTINCTLY. The target is a real directory so the engine's loadTree succeeds;
6
6
  // the first-wave detectors ignore the tree and consult the oracle.
7
7
  import { execFileSync } from "node:child_process";
8
- import { readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
9
9
  import { dirname, join, relative, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { renderTable, runDoctor } from "@vantageos/mcp-doctor";
12
+ import { parseArgs, USAGE } from "./args.js";
13
+ import { checkDistFreshness } from "./build-stamp.js";
14
+ import { CR1_ID, callerResolutionResult, callerResolutionSection, violations as crViolations, scanCallerResolution, } from "./caller-resolution.js";
12
15
  import { coverage, coverageSummary } from "./coverage.js";
13
16
  import { crudCoverageSection, extractReactRouterPaths, scanCrudCoverage, } from "./crud-coverage.js";
14
17
  import { checkStandardReadable } from "./detectors/standard/_doc.js";
15
18
  import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults, } from "./not-checkable.js";
19
+ import { activateOracle, describeOracle, resolveOracle } from "./oracle.js";
16
20
  import { REGISTRY } from "./registry.js";
17
21
  import { writeReport } from "./report.js";
18
22
  import { handlerSurfaceLine, handlerSurfaceStats, tally, tallyLine, verdict, } from "./tally.js";
23
+ import { describeScope, loadScopedTree, onScopedTree, resolveTarget, } from "./target.js";
19
24
  const here = dirname(fileURLToPath(import.meta.url));
20
25
  /** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
21
26
  function targetCommit(target) {
@@ -148,7 +153,49 @@ function probeRoutes(target) {
148
153
  return { candidateDir, routePaths };
149
154
  }
150
155
  function main() {
151
- const target = resolve(process.argv[2] ?? process.cwd());
156
+ // Refuse to judge from a dist/ compiled from other source than this checkout's
157
+ // (src/build-stamp.ts). Runs before anything is read, so no verdict is printed.
158
+ // Flags first: --version/--help answer without reading a target, and an unknown
159
+ // flag is a usage error — never a path handed to the filesystem (an `-v` used to
160
+ // become `<cwd>/-v` and die on an uncaught ENOENT stack).
161
+ const args = parseArgs(process.argv.slice(2));
162
+ if (args.kind === "version") {
163
+ console.log(toolVersion());
164
+ process.exit(0);
165
+ }
166
+ if (args.kind === "help") {
167
+ console.log(USAGE);
168
+ process.exit(0);
169
+ }
170
+ if (args.kind === "usage-error") {
171
+ console.error(`backend-doctor: ${args.message}\n\n${USAGE}`);
172
+ process.exit(2);
173
+ }
174
+ const fresh = checkDistFreshness(here);
175
+ if (!fresh.ok) {
176
+ console.error(`backend-doctor: REFUSING TO RUN — ${fresh.reason}`);
177
+ process.exit(2);
178
+ }
179
+ const target = resolve(args.positional[0] ?? process.cwd());
180
+ if (!existsSync(target) || !statSync(target).isDirectory()) {
181
+ console.error(`backend-doctor: target ${target} is not a readable directory — could not judge.\n\n${USAGE}`);
182
+ process.exit(2);
183
+ }
184
+ // ONE normalisation (src/target.ts): `convex` and `.` resolve to the same repo
185
+ // root, convex dir and file set, so every rule sees the same rels either way.
186
+ const scope = resolveTarget(target);
187
+ const tree = loadScopedTree(scope);
188
+ console.log(describeScope(scope, tree));
189
+ // The oracle is DERIVED from the target (never from this tool's own install
190
+ // directory) and the run says which file it used and where that came from.
191
+ // Absent => the oracle-backed rules refuse to judge (could-not-judge).
192
+ const oracleSel = resolveOracle(target, {
193
+ oracle: args.flags.oracle,
194
+ vrOracle: args.flags["vr-oracle"],
195
+ writerTier: args.flags["writer-tier"],
196
+ });
197
+ activateOracle(oracleSel);
198
+ console.log(describeOracle(oracleSel));
152
199
  // Instrument self-check, BEFORE any judging: a backend-doctor run reads its
153
200
  // pinned standard fixture as material. If that fixture is absent/unreadable the
154
201
  // tool cannot judge — REFUSE with exit 2 (could-not-judge) naming what it could
@@ -163,18 +210,43 @@ function main() {
163
210
  // pending detector) — so the tally's rule total equals the 48 derived rules and
164
211
  // nothing is silently dropped.
165
212
  const results = [
166
- ...runDoctor(target, REGISTRY.map((e) => e.detector)),
213
+ ...runDoctor(scope.repoRoot, onScopedTree(tree, REGISTRY.map((e) => e.detector))),
167
214
  ...notCheckableResults(),
168
215
  ];
169
- console.log(renderTable("backend-doctor (VantagePeers oracle @ day158)", results));
216
+ console.log(renderTable("backend-doctor (oracle derived from target)", results));
170
217
  // CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule
171
218
  // in the pinned 50-rule standard). Rows are EVERY defineTable table derived
172
219
  // from the target schema at run time; the union of rows MUST equal the schema
173
220
  // table set or the run is RED (fail-closed).
174
221
  console.log("");
175
- const crud = scanCrudCoverageForCli(target);
222
+ const crud = scanCrudCoverageForCli(scope.convexDir ?? target);
176
223
  console.log(crudCoverageSection(crud));
177
224
  const crudDefect = crud.status === "unreadable" || !crud.unionEqualsSchema;
225
+ // CALLER RESOLUTION (CR-1) — a DOCTOR-OWNED CRITICAL control, reported in its own
226
+ // section for the same reason CRUD coverage is: the pinned standard numbers
227
+ // R-1..R-51 and does not define it, so putting it in REGISTRY would make
228
+ // coverage.ts report a phantom rule and would forge the standard's pin. It is run
229
+ // through the engine's OWN loader (never a second tree traversal) and its
230
+ // violations drive the exit code — an unjustified NO-IDENTITY or a FAIL-OPEN
231
+ // registration is mechanical, so the gate refuses on it.
232
+ console.log("");
233
+ let crScan;
234
+ runDoctor(scope.repoRoot, onScopedTree(tree, [
235
+ {
236
+ id: CR1_ID,
237
+ run: (t) => {
238
+ crScan = scanCallerResolution(t);
239
+ return callerResolutionResult(crScan);
240
+ },
241
+ },
242
+ ]));
243
+ if (crScan === undefined) {
244
+ console.error("CR-1: the engine did not run the caller-resolution detector — refusing to report a surface that was never scanned.");
245
+ process.exit(2);
246
+ }
247
+ console.log(callerResolutionSection(crScan));
248
+ const crResult = callerResolutionResult(crScan);
249
+ const crDefect = crViolations(crScan).length > 0;
178
250
  console.log("");
179
251
  const cov = coverage();
180
252
  console.log(coverageSummary(cov));
@@ -199,6 +271,7 @@ function main() {
199
271
  // process rules, so its could-not-judge count is visible on its own and can be
200
272
  // driven to 0 (Laurent: an instrument that cannot go red protects nothing).
201
273
  console.log(handlerSurfaceLine(handlerSurfaceStats(results)));
274
+ console.log(`CALLER-RESOLUTION TALLY (CR-1, doctor-owned, outside the pinned rule set): state=${crResult.state} violations=${crViolations(crScan).length} | ${crResult.verdict.split("\n")[0]}`);
202
275
  console.log(v.summary);
203
276
  if (v.criticalViolations > 0) {
204
277
  console.log(` critical violations: ${v.violations.critical.join(", ")}`);
@@ -210,13 +283,16 @@ function main() {
210
283
  // the git facts are gathered HERE (a real process) and handed to the pure writer.
211
284
  const ctx = {
212
285
  target,
213
- targetCommit: targetCommit(target),
286
+ targetCommit: targetCommit(scope.repoRoot),
214
287
  toolVersion: toolVersion(),
215
288
  dateISO: new Date().toISOString().slice(0, 10),
216
- treeStatus: treeStatus(target),
289
+ treeStatus: treeStatus(scope.repoRoot),
290
+ oracle: describeOracle(oracleSel),
217
291
  };
218
292
  const reportDir = join(here, "..", "reports");
219
- const path = writeReport(ctx, results, t, v, reportDir);
293
+ const path = writeReport(ctx, results, t, v, reportDir, undefined, [
294
+ callerResolutionSection(crScan),
295
+ ]);
220
296
  console.log(`\nreport written: ${path}`);
221
297
  if (!cov.ok) {
222
298
  console.log("\nNOTE: coverage is INCOMPLETE — a rule is in neither bucket. See the summary above.");
@@ -229,6 +305,13 @@ function main() {
229
305
  console.log("\nNOTE: CRUD coverage self-check FAILED — the rows do not cover every schema table (or the schema is unreadable). See the CRUD COVERAGE section above.");
230
306
  process.exit(1);
231
307
  }
308
+ // CR-1 is CRITICAL: a FAIL-OPEN or unjustified NO-IDENTITY public registration is
309
+ // a mechanical violation, so the gate refuses on it even when every numbered rule
310
+ // is satisfied. Reported BEFORE the numbered-rule exit so the reason is legible.
311
+ if (crDefect) {
312
+ console.log(`\nNOTE: CR-1 (caller resolution) FAILED — ${crViolations(crScan).length} public registration(s) serve or write data without resolving their caller. CRITICAL, no bypass. See the CALLER RESOLUTION section above; every member is named there.`);
313
+ process.exit(1);
314
+ }
232
315
  process.exit(v.exitCode);
233
316
  }
234
317
  main();
@@ -2,8 +2,16 @@
2
2
  // Prints the coverage proof over the TWO buckets — CHECKED and
3
3
  // NOT_MECHANICALLY_CHECKABLE. Exits non-zero only if the union does not equal
4
4
  // the derived rule ids (a rule in no bucket, in two, or a stale bucket entry).
5
+ import { dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { checkDistFreshness } from "./build-stamp.js";
5
8
  import { coverage, coverageSummary } from "./coverage.js";
6
9
  import { NOT_MECHANICALLY_CHECKABLE } from "./not-checkable.js";
10
+ const fresh = checkDistFreshness(dirname(fileURLToPath(import.meta.url)));
11
+ if (!fresh.ok) {
12
+ console.error(`backend-doctor --coverage: REFUSING TO RUN — ${fresh.reason}`);
13
+ process.exit(2);
14
+ }
7
15
  const r = coverage();
8
16
  console.log(coverageSummary(r));
9
17
  console.log("");
@@ -1,8 +1,9 @@
1
1
  import type { Detector } from "../engine-types.js";
2
2
  import { type OracleRow } from "../oracle.js";
3
- /** Absolute path to the pinned SECOND-backend (VantageRegistry) oracle fixture.
3
+ /** The vendored day159 SECOND-backend (VantageRegistry) snapshot — a FIXTURE for
4
+ * this tool's tests, never a default (see oracle.ts resolveOracle).
4
5
  * Vendored at pin a74a042b, blob b53e2626954a887303d11d944b6132a11dbd34f6. */
5
- export declare const VR_ORACLE_PATH: string;
6
+ export declare const FIXTURE_VR_ORACLE_PATH: string;
6
7
  /** The vendored standard (the R-5 law) — the SAME pin coverage.ts parses. The
7
8
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
8
9
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
@@ -19,7 +20,8 @@ export interface Inventory {
19
20
  /** Load any backend inventory CSV generically from its OWN header — the shared
20
21
  * fields are DERIVED from these real headers, never a prose illustration. */
21
22
  export declare function loadInventory(path: string): Inventory;
22
- /** Memoized read of the pinned VR inventory. */
23
+ /** Memoized read of the VR inventory bound for THIS run (derived from the target).
24
+ * Throws OracleAbsentError when none was found. */
23
25
  export declare function readVrInventory(): Inventory;
24
26
  /** The VP inventory, adapted from the memoized VP oracle reader — the SAME rows
25
27
  * the axis-2 detectors read, exposed with VP's real header. */
@@ -18,13 +18,14 @@
18
18
  //
19
19
  // Both inputs are IN HAND (VP oracle + VR oracle, both vendored + hash-checked).
20
20
  import { readFileSync } from "node:fs";
21
- import { dirname, join } from "node:path";
21
+ import { basename, dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
- import { ORACLE_COLUMNS, parseCsv, readOracle, } from "../oracle.js";
23
+ import { ORACLE_COLUMNS, parseCsv, activeOracle, oracleLabel, readOracle, refuseWithoutOracle, requireVrOracle, } from "../oracle.js";
24
24
  const here = dirname(fileURLToPath(import.meta.url));
25
- /** Absolute path to the pinned SECOND-backend (VantageRegistry) oracle fixture.
25
+ /** The vendored day159 SECOND-backend (VantageRegistry) snapshot — a FIXTURE for
26
+ * this tool's tests, never a default (see oracle.ts resolveOracle).
26
27
  * Vendored at pin a74a042b, blob b53e2626954a887303d11d944b6132a11dbd34f6. */
27
- export const VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
28
+ export const FIXTURE_VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
28
29
  /** The vendored standard (the R-5 law) — the SAME pin coverage.ts parses. The
29
30
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
30
31
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
@@ -44,13 +45,17 @@ export function loadInventory(path) {
44
45
  });
45
46
  return { header, rows };
46
47
  }
47
- let vrCache = null;
48
- /** Memoized read of the pinned VR inventory. */
48
+ const vrCache = new Map();
49
+ /** Memoized read of the VR inventory bound for THIS run (derived from the target).
50
+ * Throws OracleAbsentError when none was found. */
49
51
  export function readVrInventory() {
50
- if (vrCache)
51
- return vrCache;
52
- vrCache = loadInventory(VR_ORACLE_PATH);
53
- return vrCache;
52
+ const path = requireVrOracle().path;
53
+ const hit = vrCache.get(path);
54
+ if (hit)
55
+ return hit;
56
+ const inv = loadInventory(path);
57
+ vrCache.set(path, inv);
58
+ return inv;
54
59
  }
55
60
  /** The VP inventory, adapted from the memoized VP oracle reader — the SAME rows
56
61
  * the axis-2 detectors read, exposed with VP's real header. */
@@ -161,13 +166,17 @@ export function compareBackends(vpProvider = readOracle, vrProvider = readVrInve
161
166
  };
162
167
  }
163
168
  const R40_ID = "R-40 between-backend divergence";
164
- const R40_ANCHOR = "oracle:vp-by-tool-day158.csv vs oracle:vr-by-tool-day159.csv";
169
+ function r40Anchor() {
170
+ const vr = activeOracle()?.vr;
171
+ const vrName = vr && vr.status === "found" ? basename(vr.path) : "ABSENT";
172
+ return `${oracleLabel()} vs oracle:${vrName}`;
173
+ }
165
174
  /** Wrap the Axis-1 compare into a Detector. Providers are injectable for tests;
166
175
  * production reads the two memoized real oracles. */
167
176
  export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
168
177
  return {
169
178
  id: R40_ID,
170
- run: () => {
179
+ run: () => refuseWithoutOracle(R40_ID, () => {
171
180
  const cmp = compareBackends(vpProvider, vrProvider);
172
181
  const { schema, sites } = cmp;
173
182
  // the schema (column) divergence is itself a site — surfaced, never dropped.
@@ -188,7 +197,7 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
188
197
  const reason = `R-40: the VP and VR inventories agree on every shared field's closed vocabulary and share every column; the mechanical between-backend compare is exhausted with no divergence. Present — decided by the compare, not a human verdict. ${breakdown}`;
189
198
  return {
190
199
  id: R40_ID,
191
- anchor: R40_ANCHOR,
200
+ anchor: r40Anchor(),
192
201
  state: "present",
193
202
  verdict: reason,
194
203
  };
@@ -210,10 +219,10 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
210
219
  const reason = `R-40: ${sites.length} shared field(s) diverge BETWEEN VP and VR on their closed vocabulary — the mechanical compare names WHICH FIELD and WHICH value on VP vs VR, and none carries a written between-backend justification; non-conforming until each divergence is reconciled or carries a recorded justification. ${schemaFinding} ${breakdown} Field sites: ${examples}.`;
211
220
  return {
212
221
  id: R40_ID,
213
- anchor: `${R40_ANCHOR} field=${sites[0]?.field ?? "schema"}`,
222
+ anchor: `${r40Anchor()} field=${sites[0]?.field ?? "schema"}`,
214
223
  state: "non-conforming",
215
224
  verdict: reason,
216
225
  };
217
- },
226
+ }),
218
227
  };
219
228
  }
@@ -1,4 +1,4 @@
1
- import { isInventoryRow, readOracle, } from "../oracle.js";
1
+ import { isInventoryRow, oracleLabel, readOracle, refuseWithoutOracle, } from "../oracle.js";
2
2
  // A written justification token turns a flagged divergence from a DEFECT the
3
3
  // author owes into a recorded, accepted divergence. It is the mechanical pole
4
4
  // that replaced "a human decides whether the divergence is sound": the control
@@ -152,13 +152,12 @@ export function findDivergences(rows) {
152
152
  return sites;
153
153
  }
154
154
  const R37_ID = "R-37 filled-neighbour divergence";
155
- const R37_ANCHOR = "oracle:vp-by-tool-day158.csv";
156
155
  /** Wrap the filled-neighbour compare into a Detector. `rowsProvider` is
157
156
  * injectable for tests; production reads the memoized real oracle. */
158
157
  export function makeR37(rowsProvider = readOracle) {
159
158
  return {
160
159
  id: R37_ID,
161
- run: () => {
160
+ run: () => refuseWithoutOracle(R37_ID, () => {
162
161
  const all = findDivergences(rowsProvider());
163
162
  // MECHANICAL disposition: an unjustified divergence is the defect; a
164
163
  // divergence carrying a written per-tool justification token is accepted.
@@ -173,7 +172,7 @@ export function makeR37(rowsProvider = readOracle) {
173
172
  const reason = `R-37: no UNJUSTIFIED filled-neighbour divergence across the oracle's per-table groups (${all.length} divergence(s), all carrying a written per-tool justification token, or none present). The mechanical compare is exhaustive and the disposition is decided by the token's presence, not a human verdict. ${breakdown}`;
174
173
  return {
175
174
  id: R37_ID,
176
- anchor: R37_ANCHOR,
175
+ anchor: oracleLabel(),
177
176
  state: "present",
178
177
  verdict: reason,
179
178
  };
@@ -185,10 +184,10 @@ export function makeR37(rowsProvider = readOracle) {
185
184
  const reason = `R-37: ${sites.length} UNJUSTIFIED filled-neighbour divergence(s) across ${tables.size} tables — the mechanical compare names WHICH TWO TOOLS diverge on a shared contract field, and none carries a written per-tool justification token (JUSTIFIÉ:/JUSTIFIED: in rbac_adjustment_needed); each owes a fix or a recorded justification. This IS a violation, not an abstention. ${breakdown} Examples: ${examples}.`;
186
185
  return {
187
186
  id: R37_ID,
188
- anchor: `oracle:vp-by-tool-day158.csv table=${sites[0].table}`,
187
+ anchor: `${oracleLabel()} table=${sites[0].table}`,
189
188
  state: "non-conforming",
190
189
  verdict: reason,
191
190
  };
192
- },
191
+ }),
193
192
  };
194
193
  }
@@ -1,3 +1,4 @@
1
+ import { type WriterTierTable } from "../oracle.js";
1
2
  import { type RulePredicate } from "./shared.js";
2
3
  /** Collapse a raw `surface` to one of the three canonical exposures, or null. */
3
4
  export declare function collapseSurface(raw: string): string | null;
@@ -11,6 +12,8 @@ export declare const r4: RulePredicate;
11
12
  export declare function collapseTier(raw: string): string | null;
12
13
  export declare const r5: RulePredicate;
13
14
  export declare const r9: RulePredicate;
15
+ export declare const WRITER_TIERS: ReadonlySet<string>;
16
+ export declare function makeR10(sideCar: () => WriterTierTable): RulePredicate;
14
17
  export declare const r10: RulePredicate;
15
18
  export declare const r12: RulePredicate;
16
19
  export declare const r13: RulePredicate;
@@ -1,3 +1,9 @@
1
+ // The eleven per-row first-wave predicates. Each transcribes its §15 "The
2
+ // check" row and its §2 field mapping from the pinned standard, then applies
3
+ // that predicate to the oracle rows. Structural R-14 and R-38 live in
4
+ // structural.ts (they judge the inventory SCHEMA, not a row).
5
+ import { basename } from "node:path";
6
+ import { WRITER_TIER_NAME, readWriterTier } from "../oracle.js";
1
7
  import { LIST_VERBS, WRITE_VERBS, isMcpPublic } from "./shared.js";
2
8
  // ---- R-1 exposure declared ------------------------------------------------
3
9
  // §2 line 32: exposure -> `surface` = `public` (MCP surface) / `internal` /
@@ -200,22 +206,81 @@ export const r9 = {
200
206
  },
201
207
  };
202
208
  // ---- R-10 writer authority, a distinct resolved tier ----------------------
203
- // §2 writer authority [shared] -> `rbac_qui`. §15: writer tier present, DISTINCT,
204
- // one of five. The oracle has a SINGLE `rbac_qui` column, so no write can carry
205
- // a writer tier distinct from its reader tier — the headline merge (R-14).
206
- export const r10 = {
207
- ruleId: "R-10",
208
- id: "R-10 writer authority, distinct tier",
209
- check: "writer tier present, distinct from reader, one of five",
210
- classify: (r) => {
211
- if (!WRITE_VERBS.has(r.by.crud))
212
- return { kind: "na" };
213
- return {
214
- kind: "violate",
215
- note: "writer authority is not recorded as a field distinct from reader authority — both merged into the single `rbac_qui` column",
216
- };
217
- },
218
- };
209
+ // §2 line 36: writer authority is "a **distinct** tier from the same set, resolved
210
+ // from a verified identity"; §15 line 413: "writer tier present, distinct, one of
211
+ // five"; line 63: "Reader and writer are two fields". DISTINCT is a property of
212
+ // the FIELD, not of the value: the standard requires the writer tier to be
213
+ // RECORDED apart from the reader tier so a `read=public write=admin` divergence
214
+ // can be written — it nowhere forbids a tool whose writer tier happens to equal
215
+ // its reader tier (an org member reading and writing its own org's rows). So an
216
+ // equal VALUE conforms; a MERGED field (no separate record, or one cell naming
217
+ // several tiers) violates.
218
+ //
219
+ // Material: the writer-tier side-car (src/oracle.ts WRITER_TIER_NAME), joined on
220
+ // `outil`. Branches, per write row:
221
+ // side-car absent / unreadable -> cnj, naming the file(s) looked at
222
+ // no side-car row for the tool -> violate (tier absent)
223
+ // two rows with different tiers -> violate (merged)
224
+ // tier empty / `?` -> violate (unknown)
225
+ // tier naming several tiers (a/b, a+b) -> violate (merged)
226
+ // tier outside the five -> violate
227
+ // writer_gate empty or `mcp-scope:<t>` -> cnj: the tier was not derived from
228
+ // a gate on a write door, so "resolved from a verified identity" is not
229
+ // evidenced by the side-car (never a silent pass)
230
+ // otherwise -> conform
231
+ export const WRITER_TIERS = new Set([
232
+ "public",
233
+ "org-member",
234
+ "org-admin",
235
+ "fleet-internal",
236
+ "master",
237
+ ]);
238
+ const UNKNOWN_TIER = new Set(["", "?", "unknown", "n/a", "tbd"]);
239
+ const MERGED_TIER = /[/+,|&]|\s(and|or|et|ou)\s/i;
240
+ export function makeR10(sideCar) {
241
+ return {
242
+ ruleId: "R-10",
243
+ id: "R-10 writer authority, distinct tier",
244
+ check: "writer tier recorded in its own field (side-car joined on `outil`), one of five, gated on a write door; equal-to-reader values conform (the field must be distinct, §2 line 36)",
245
+ classify: (r) => {
246
+ if (!WRITE_VERBS.has(r.by.crud))
247
+ return { kind: "na" };
248
+ const t = sideCar();
249
+ if (t.status === "absent")
250
+ return {
251
+ kind: "cnj",
252
+ note: `writer-tier side-car ${WRITER_TIER_NAME} ABSENT (${t.origin}; searched: ${t.searched.join(", ") || "<none>"}) — this oracle has write rows, so writer authority cannot be judged`,
253
+ };
254
+ if (t.status === "unreadable")
255
+ return { kind: "cnj", note: `writer-tier side-car ${t.path} unreadable — ${t.reason}` };
256
+ const file = basename(t.path);
257
+ const entries = t.byTool.get(r.by.outil) ?? [];
258
+ if (entries.length === 0)
259
+ return { kind: "violate", note: `no writer tier recorded for "${r.by.outil}" in ${file} — writer authority absent` };
260
+ const tiers = [...new Set(entries.map((e) => e.writer_tier))];
261
+ if (tiers.length > 1)
262
+ return {
263
+ kind: "violate",
264
+ note: `${file} records ${tiers.length} writer tiers for "${r.by.outil}" (${tiers.join(", ")}) — merged, not one tier`,
265
+ };
266
+ const tier = tiers[0];
267
+ if (UNKNOWN_TIER.has(tier.toLowerCase()))
268
+ return { kind: "violate", note: `writer_tier="${tier || "<empty>"}" for "${r.by.outil}" — unknown, not a tier` };
269
+ if (MERGED_TIER.test(tier))
270
+ return { kind: "violate", note: `writer_tier="${tier}" names several tiers — merged, not one tier` };
271
+ if (!WRITER_TIERS.has(tier))
272
+ return { kind: "violate", note: `writer_tier="${tier}" is none of the five tiers {${[...WRITER_TIERS].join(", ")}}` };
273
+ const gate = entries[0].writer_gate;
274
+ if (gate === "" || gate.startsWith("mcp-scope:"))
275
+ return {
276
+ kind: "cnj",
277
+ note: `writer_tier="${tier}" for "${r.by.outil}" rests on ${gate === "" ? "no writer_gate" : `the MCP-scope fallback "${gate}"`} — no write-door gate evidences resolution from a verified identity`,
278
+ };
279
+ return { kind: "conform" };
280
+ },
281
+ };
282
+ }
283
+ export const r10 = makeR10(readWriterTier);
219
284
  // ---- R-12 vector search declares namespace + topK -------------------------
220
285
  // §2 vector boundary -> `search_mecanisme`. §15: real namespace + numeric topK.
221
286
  export const r12 = {
@@ -1,4 +1,4 @@
1
- import { isInventoryRow, readOracle } from "../oracle.js";
1
+ import { isInventoryRow, oracleLabel, readOracle, refuseWithoutOracle, } from "../oracle.js";
2
2
  /** Run a predicate over a row set, tallying per-kind. */
3
3
  export function tally(spec, rows) {
4
4
  const t = { conform: 0, violate: 0, cnj: 0, na: 0, violations: [], unjudged: [] };
@@ -52,7 +52,7 @@ export function evaluate(spec, rows) {
52
52
  .join("; ")}. This is NOT a clean bill. ${breakdown}`;
53
53
  return {
54
54
  id: spec.id,
55
- anchor: anchorFor(t.unjudged, "oracle:vp-by-tool-day158.csv"),
55
+ anchor: anchorFor(t.unjudged, oracleLabel()),
56
56
  state: "could-not-judge",
57
57
  verdict: reason,
58
58
  reason,
@@ -65,14 +65,14 @@ export function evaluate(spec, rows) {
65
65
  .join("; ");
66
66
  return {
67
67
  id: spec.id,
68
- anchor: anchorFor(t.violations, "oracle:vp-by-tool-day158.csv"),
68
+ anchor: anchorFor(t.violations, oracleLabel()),
69
69
  state: "non-conforming",
70
70
  verdict: `${spec.ruleId}: ${t.violate} of ${applicable} applicable oracle rows VIOLATE (${spec.check}). ${breakdown} Examples: ${examples}.`,
71
71
  };
72
72
  }
73
73
  return {
74
74
  id: spec.id,
75
- anchor: "oracle:vp-by-tool-day158.csv",
75
+ anchor: oracleLabel(),
76
76
  state: "present",
77
77
  verdict: `${spec.ruleId}: ${t.conform} applicable oracle rows conform (${spec.check}); 0 violations. ${breakdown}`,
78
78
  };
@@ -85,7 +85,7 @@ export function evaluate(spec, rows) {
85
85
  export function makeOracleDetector(spec, rowsProvider = readOracle) {
86
86
  return {
87
87
  id: spec.id,
88
- run: () => evaluate(spec, rowsProvider()),
88
+ run: () => refuseWithoutOracle(spec.id, () => evaluate(spec, rowsProvider())),
89
89
  };
90
90
  }
91
91
  // ---- small shared classifiers over oracle vocabulary --------------------
@@ -73,7 +73,15 @@ export function derivedStructures(files) {
73
73
  if (FIELD_LINE.test(l.raw))
74
74
  fields += 1;
75
75
  const id = ID_REF.exec(l.raw);
76
- if (id && !fkTarget)
76
+ // A SYSTEM table (`_storage`, `_scheduled_functions` — Convex reserves the
77
+ // leading underscore) is not a source a migration can backfill FROM: its
78
+ // rows are platform objects, not domain rows, and there is no
79
+ // `ctx.db.query("_storage")` that enumerates what the write path would
80
+ // have bound. A table whose only foreign key points at a system table
81
+ // (a TOFU ownership binding of uploaded blobs, kbUploads) is therefore
82
+ // not a derived structure in R-44's sense. The first NON-system FK is
83
+ // the source; a table with none is skipped.
84
+ if (id && !fkTarget && !id[1].startsWith("_"))
77
85
  fkTarget = id[1];
78
86
  if (HAS_INDEX.test(l.raw))
79
87
  hasIndex = true;
@@ -0,0 +1,112 @@
1
+ import * as ts from "typescript";
2
+ export type GuardHelperKind = "binds-arg" | "master-only";
3
+ export type HelperVerdict = {
4
+ verdict: "honoured";
5
+ } | {
6
+ verdict: "refused";
7
+ why: string;
8
+ } | {
9
+ verdict: "could-not-judge";
10
+ why: string;
11
+ };
12
+ /** A callee name, as seen from the function being judged, resolved to its
13
+ * definition in the tree (and to a resolver for names seen from THAT file). */
14
+ export type CallResolver = (name: string) => {
15
+ fn: ts.FunctionLikeDeclaration;
16
+ key: string;
17
+ resolve: CallResolver;
18
+ } | undefined;
19
+ /** Judge one helper function on its control flow. */
20
+ export declare function judgeHelperFunction(fn: ts.FunctionLikeDeclaration, kind: GuardHelperKind): HelperVerdict;
21
+ export interface FoundHelper {
22
+ name: string;
23
+ line: number;
24
+ exported: boolean;
25
+ judgement: HelperVerdict;
26
+ }
27
+ /** Every definition, in `source`, of a helper named in `kinds`. */
28
+ export declare function findHelperDefinitions(fileName: string, source: string, kinds: Readonly<Record<string, GuardHelperKind>>): FoundHelper[];
29
+ /** The arguments of each `await <helper>(…)` that is a DIRECT statement of the
30
+ * handler `name` (`export const name = mutation({ handler: … })`) and starts
31
+ * before `beforeLine`. A helper call nested in a branch, a try, or a closure of
32
+ * the handler is not a statement every path runs, so it is not returned.
33
+ * `null` = the handler's body could not be located in the file. */
34
+ export declare function topLevelHelperCalls(fileName: string, source: string, handlerName: string,
35
+ /** `null` = any identifier callee (judged by the caller on its body). */
36
+ helpers: ReadonlySet<string> | null, beforeLine: number): {
37
+ name: string;
38
+ args: string;
39
+ argNodes: readonly ts.Expression[];
40
+ line: number;
41
+ }[] | null;
42
+ export interface TopLevelFunction {
43
+ name: string;
44
+ line: number;
45
+ exported: boolean;
46
+ fn: ts.FunctionLikeDeclaration;
47
+ }
48
+ /** Every top-level function declaration and `const f = (…) => {…}` /
49
+ * `function (…) {…}` of a file. */
50
+ export declare function topLevelFunctions(fileName: string, source: string): TopLevelFunction[];
51
+ /** Judge a function as an IDENTITY RESOLVER (shape 1 above). */
52
+ export declare function judgeResolverFunction(fn: ts.FunctionLikeDeclaration): HelperVerdict;
53
+ export type ValidatorSummary = {
54
+ ok: false;
55
+ why: string;
56
+ } | {
57
+ ok: true;
58
+ /** every normal exit binds the target parameter (or is the master's) */
59
+ exitsBound: boolean;
60
+ /** every normal exit is the fleet master's */
61
+ exitsMaster: boolean;
62
+ /** no normal exit at all: it refuses every caller */
63
+ never: boolean;
64
+ };
65
+ export declare function summarizeValidator(fn: ts.FunctionLikeDeclaration, o: {
66
+ /** the parameter whose binding is asked; undefined = master-only asked */
67
+ boundIdx: number | undefined;
68
+ /** the parameter receiving the caller's verified scope, if any */
69
+ identityIdx: number | undefined;
70
+ /** the call's arguments (object literals become constants) */
71
+ callArgs: readonly ts.Expression[];
72
+ resolve: CallResolver;
73
+ key: string;
74
+ depth: number;
75
+ }): ValidatorSummary;
76
+ /** Judge a function as a REFUSING PREDICATE (shape 2 above): parameter
77
+ * `identityIdx` receives the caller's verified scope, `boundIdx` the value the
78
+ * caller then writes. */
79
+ export declare function judgePredicateFunction(fn: ts.FunctionLikeDeclaration, identityIdx: number, boundIdx: number): HelperVerdict;
80
+ /** What a mutation handler's own body declares and refuses before `beforeLine`. */
81
+ export interface HandlerGuards {
82
+ /** Top-level `const` locals: whether bound straight to an identity read, or
83
+ * to `await <callee>(…)` (the callee is then judged by the caller). */
84
+ consts: Map<string, {
85
+ identityRead: boolean;
86
+ awaitedCallee?: string;
87
+ }>;
88
+ /** `if (!P(a, b, …)) { …throw }` statements: the predicate and its args. */
89
+ refusals: {
90
+ predicate: string;
91
+ args: {
92
+ text: string;
93
+ ident?: string;
94
+ memberBase?: string;
95
+ }[];
96
+ line: number;
97
+ }[];
98
+ }
99
+ /** Read the direct statements of handler `name` before `beforeLine`. `null`
100
+ * when the handler's body cannot be located. */
101
+ export declare function readHandlerGuards(fileName: string, source: string, handlerName: string, beforeLine: number): HandlerGuards | null;
102
+ /** A CONDITIONAL boundary value at a write site (`orgId: c ? a : b`): which arm
103
+ * reads the caller's `args`, and whether the condition is a positive
104
+ * `<local>.isMaster` test (the fleet carve-out, which may write what the
105
+ * master names). Read with the TypeScript parser on the write-site text. */
106
+ export interface ConditionalBoundary {
107
+ field: string;
108
+ masterBase?: string;
109
+ trueReadsArgs: boolean;
110
+ falseReadsArgs: boolean;
111
+ }
112
+ export declare function conditionalBoundaries(text: string, fields: ReadonlySet<string>): ConditionalBoundary[];