@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
package/dist/cli.js CHANGED
@@ -5,17 +5,25 @@
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, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } 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 { coverage, coverageSummary } from "./coverage.js";
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";
15
+ import { coverage, coverageSummary, deriveRuleIds } from "./coverage.js";
13
16
  import { crudCoverageSection, extractReactRouterPaths, scanCrudCoverage, } from "./crud-coverage.js";
17
+ import { CR2_ID, localIdentityResult, localIdentitySection, scanLocalIdentity, } from "./detectors/source/local-identity.js";
14
18
  import { checkStandardReadable } from "./detectors/standard/_doc.js";
15
19
  import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults, } from "./not-checkable.js";
20
+ import { activateOracle, describeOracle, resolveOracle } from "./oracle.js";
16
21
  import { REGISTRY } from "./registry.js";
17
22
  import { writeReport } from "./report.js";
23
+ import { buildEvidence, classifyRules, countMechanical, mechanicalLine, } from "./rule-class.js";
18
24
  import { handlerSurfaceLine, handlerSurfaceStats, tally, tallyLine, verdict, } from "./tally.js";
25
+ import { describeScope, loadScopedTree, onScopedTree, resolveTarget, } from "./target.js";
26
+ import { mechanicalRuleCounts, mechanicalRuleCountsLine, } from "./violation-count.js";
19
27
  const here = dirname(fileURLToPath(import.meta.url));
20
28
  /** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
21
29
  function targetCommit(target) {
@@ -39,6 +47,41 @@ function treeStatus(target) {
39
47
  return "not a git repo";
40
48
  }
41
49
  }
50
+ /** The instrument's own identity for the evidence file: the backend-doctor
51
+ * checkout's HEAD when this module runs from its own git checkout (`+dirty` when
52
+ * src/ or fixtures/ differ from it), else the published package version. Never the
53
+ * HEAD of a repository that merely CONTAINS an installed copy. */
54
+ function cliCommit() {
55
+ const root = resolve(here, "..");
56
+ try {
57
+ const top = execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], {
58
+ encoding: "utf8",
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ }).trim();
61
+ if (resolve(top) !== root)
62
+ throw new Error("not this checkout");
63
+ const head = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
64
+ encoding: "utf8",
65
+ }).trim();
66
+ const dirty = execFileSync("git", ["-C", root, "status", "--porcelain", "--", "src", "fixtures"], { encoding: "utf8" }).trim();
67
+ return dirty ? `${head}+dirty` : head;
68
+ }
69
+ catch {
70
+ return `npm @vantageos/backend-doctor@${toolVersion()}`;
71
+ }
72
+ }
73
+ /** `git -C <dir> <args>` trimmed, or undefined when git refuses. */
74
+ function gitOut(dir, args) {
75
+ try {
76
+ return execFileSync("git", ["-C", dir, ...args], {
77
+ encoding: "utf8",
78
+ stdio: ["ignore", "pipe", "ignore"],
79
+ }).trim();
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
42
85
  function toolVersion() {
43
86
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
44
87
  return pkg.version;
@@ -148,7 +191,49 @@ function probeRoutes(target) {
148
191
  return { candidateDir, routePaths };
149
192
  }
150
193
  function main() {
151
- const target = resolve(process.argv[2] ?? process.cwd());
194
+ // Refuse to judge from a dist/ compiled from other source than this checkout's
195
+ // (src/build-stamp.ts). Runs before anything is read, so no verdict is printed.
196
+ // Flags first: --version/--help answer without reading a target, and an unknown
197
+ // flag is a usage error — never a path handed to the filesystem (an `-v` used to
198
+ // become `<cwd>/-v` and die on an uncaught ENOENT stack).
199
+ const args = parseArgs(process.argv.slice(2));
200
+ if (args.kind === "version") {
201
+ console.log(toolVersion());
202
+ process.exit(0);
203
+ }
204
+ if (args.kind === "help") {
205
+ console.log(USAGE);
206
+ process.exit(0);
207
+ }
208
+ if (args.kind === "usage-error") {
209
+ console.error(`backend-doctor: ${args.message}\n\n${USAGE}`);
210
+ process.exit(2);
211
+ }
212
+ const fresh = checkDistFreshness(here);
213
+ if (!fresh.ok) {
214
+ console.error(`backend-doctor: REFUSING TO RUN — ${fresh.reason}`);
215
+ process.exit(2);
216
+ }
217
+ const target = resolve(args.positional[0] ?? process.cwd());
218
+ if (!existsSync(target) || !statSync(target).isDirectory()) {
219
+ console.error(`backend-doctor: target ${target} is not a readable directory — could not judge.\n\n${USAGE}`);
220
+ process.exit(2);
221
+ }
222
+ // ONE normalisation (src/target.ts): `convex` and `.` resolve to the same repo
223
+ // root, convex dir and file set, so every rule sees the same rels either way.
224
+ const scope = resolveTarget(target);
225
+ const tree = loadScopedTree(scope);
226
+ console.log(describeScope(scope, tree));
227
+ // The oracle is DERIVED from the target (never from this tool's own install
228
+ // directory) and the run says which file it used and where that came from.
229
+ // Absent => the oracle-backed rules refuse to judge (could-not-judge).
230
+ const oracleSel = resolveOracle(target, {
231
+ oracle: args.flags.oracle,
232
+ vrOracle: args.flags["vr-oracle"],
233
+ writerTier: args.flags["writer-tier"],
234
+ });
235
+ activateOracle(oracleSel);
236
+ console.log(describeOracle(oracleSel));
152
237
  // Instrument self-check, BEFORE any judging: a backend-doctor run reads its
153
238
  // pinned standard fixture as material. If that fixture is absent/unreadable the
154
239
  // tool cannot judge — REFUSE with exit 2 (could-not-judge) naming what it could
@@ -163,18 +248,64 @@ function main() {
163
248
  // pending detector) — so the tally's rule total equals the 48 derived rules and
164
249
  // nothing is silently dropped.
165
250
  const results = [
166
- ...runDoctor(target, REGISTRY.map((e) => e.detector)),
251
+ ...runDoctor(scope.repoRoot, onScopedTree(tree, REGISTRY.map((e) => e.detector))),
167
252
  ...notCheckableResults(),
168
253
  ];
169
- console.log(renderTable("backend-doctor (VantagePeers oracle @ day158)", results));
254
+ console.log(renderTable("backend-doctor (oracle derived from target)", results));
170
255
  // CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule
171
256
  // in the pinned 50-rule standard). Rows are EVERY defineTable table derived
172
257
  // from the target schema at run time; the union of rows MUST equal the schema
173
258
  // table set or the run is RED (fail-closed).
174
259
  console.log("");
175
- const crud = scanCrudCoverageForCli(target);
260
+ const crud = scanCrudCoverageForCli(scope.convexDir ?? target);
176
261
  console.log(crudCoverageSection(crud));
177
262
  const crudDefect = crud.status === "unreadable" || !crud.unionEqualsSchema;
263
+ // CALLER RESOLUTION (CR-1) — a DOCTOR-OWNED CRITICAL control, reported in its own
264
+ // section for the same reason CRUD coverage is: the pinned standard numbers
265
+ // R-1..R-51 and does not define it, so putting it in REGISTRY would make
266
+ // coverage.ts report a phantom rule and would forge the standard's pin. It is run
267
+ // through the engine's OWN loader (never a second tree traversal) and its
268
+ // violations drive the exit code — an unjustified NO-IDENTITY or a FAIL-OPEN
269
+ // registration is mechanical, so the gate refuses on it.
270
+ console.log("");
271
+ let crScan;
272
+ runDoctor(scope.repoRoot, onScopedTree(tree, [
273
+ {
274
+ id: CR1_ID,
275
+ run: (t) => {
276
+ crScan = scanCallerResolution(t);
277
+ return callerResolutionResult(crScan);
278
+ },
279
+ },
280
+ ]));
281
+ if (crScan === undefined) {
282
+ console.error("CR-1: the engine did not run the caller-resolution detector — refusing to report a surface that was never scanned.");
283
+ process.exit(2);
284
+ }
285
+ console.log(callerResolutionSection(crScan));
286
+ const crResult = callerResolutionResult(crScan);
287
+ const crDefect = crViolations(crScan).length > 0;
288
+ // LOCAL IDENTITY (CR-2) — DOCTOR-OWNED mechanical control, same footing as
289
+ // CR-1: no application defines its own identity primitive; identity lives
290
+ // only in @vantageos/cloud-identity. Ceiling 0 — any violation exits non-zero.
291
+ console.log("");
292
+ let liScan;
293
+ runDoctor(scope.repoRoot, onScopedTree(tree, [
294
+ {
295
+ id: CR2_ID,
296
+ run: (t) => {
297
+ liScan = scanLocalIdentity(t.files);
298
+ return localIdentityResult(liScan);
299
+ },
300
+ },
301
+ ]));
302
+ if (liScan === undefined) {
303
+ console.error("CR-2: the engine did not run the local-identity detector — refusing to report a surface that was never scanned.");
304
+ process.exit(2);
305
+ }
306
+ console.log(localIdentitySection(liScan));
307
+ const liResult = localIdentityResult(liScan);
308
+ const liDefect = liScan.violate.length > 0;
178
309
  console.log("");
179
310
  const cov = coverage();
180
311
  console.log(coverageSummary(cov));
@@ -199,36 +330,137 @@ function main() {
199
330
  // process rules, so its could-not-judge count is visible on its own and can be
200
331
  // driven to 0 (Laurent: an instrument that cannot go red protects nothing).
201
332
  console.log(handlerSurfaceLine(handlerSurfaceStats(results)));
333
+ 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]}`);
334
+ console.log(`LOCAL-IDENTITY TALLY (CR-2, doctor-owned, outside the pinned rule set): state=${liResult.state} violations=${liScan.violate.length} exempt=${liScan.exempt.length} could-not-judge=${liScan.cnj.length}`);
202
335
  console.log(v.summary);
203
336
  if (v.criticalViolations > 0) {
204
337
  console.log(` critical violations: ${v.violations.critical.join(", ")}`);
205
338
  }
206
339
  if (v.criticalAbstentions > 0) {
207
- console.log(` critical abstentions (human verdict owed): ${v.abstentions.critical.join(", ")}`);
340
+ console.log(` critical abstentions (not decided; each rule names why): ${v.abstentions.critical.join(", ")}`);
341
+ }
342
+ // The process exit code, decided ONCE so the evidence file records the code the
343
+ // process actually exits with. Order matches the NOTE lines printed below.
344
+ let exitCode = v.exitCode;
345
+ if (!cov.ok || crudDefect || crDefect || liDefect)
346
+ exitCode = 1;
347
+ // CR-2 abstaining (an unparsable file) is never a pass.
348
+ if (exitCode === 0 && liResult.state === "could-not-judge")
349
+ exitCode = 2;
350
+ // MECHANICAL VIOLATIONS — the deploy gate's only refusal driver. The class of
351
+ // each rule is DATA (fixtures/rule-classes-<pin>.json, cross-checked against the
352
+ // pinned standard in src/rule-class.ts). Instrument self-defects and an
353
+ // unclassified rule make the count null (could-not-judge), never 0.
354
+ const convexPath = scope.convexDir ?? target;
355
+ const repoOfConvex = scope.convexDir
356
+ ? dirname(scope.convexDir)
357
+ : scope.repoRoot;
358
+ const instrumentDefects = [];
359
+ if (!cov.ok)
360
+ instrumentDefects.push(coverageSummary(cov));
361
+ if (crudDefect)
362
+ instrumentDefects.push("CRUD coverage self-check failed (rows do not cover every schema table, or the schema is unreadable)");
363
+ // CR1_ID is the detector's full label; its rule id is the leading `CR-N` token.
364
+ const cr1Rule = /^CR-\d+/.exec(CR1_ID)?.[0] ?? CR1_ID;
365
+ const cr2Rule = /^CR-\d+/.exec(CR2_ID)?.[0] ?? CR2_ID;
366
+ const classification = classifyRules([...deriveRuleIds(), cr1Rule, cr2Rule]);
367
+ const ruleStates = new Map(t.ruleState);
368
+ ruleStates.set(cr1Rule, crResult.state);
369
+ ruleStates.set(cr2Rule, liResult.state);
370
+ const evidencePath = args.flags["json-evidence"];
371
+ let evidenceSha;
372
+ if (evidencePath !== undefined) {
373
+ evidenceSha = gitOut(repoOfConvex, ["rev-parse", "HEAD"]);
374
+ const dirty = gitOut(convexPath, [
375
+ "status",
376
+ "--porcelain",
377
+ "--untracked-files=all",
378
+ "--",
379
+ ".",
380
+ ]);
381
+ if (evidenceSha !== undefined && dirty === undefined)
382
+ instrumentDefects.push(`git status of ${convexPath} failed — whether the judged tree equals HEAD cannot be shown`);
383
+ else if (dirty)
384
+ instrumentDefects.push(`the judged convex tree differs from HEAD ${evidenceSha?.slice(0, 12)} (${dirty.split("\n").length} path(s) modified or untracked) — the count would describe bytes the sha does not name`);
385
+ }
386
+ const mech = countMechanical(ruleStates, classification, instrumentDefects);
387
+ if (mech.count === null && exitCode === 0)
388
+ exitCode = 2;
389
+ console.log(mechanicalLine(mech));
390
+ // Per red mechanical rule, the count its report line printed — read from the
391
+ // result the detector returned (src/violation-count.ts), never from the text.
392
+ const ruleCounts = mechanicalRuleCounts([...results, crResult, liResult], mech);
393
+ console.log(mechanicalRuleCountsLine(ruleCounts));
394
+ if (classification.stale.length > 0) {
395
+ console.log(` STALE SIDECAR ENTRIES (name no rule of this run): ${classification.stale.join(", ")}`);
208
396
  }
209
397
  // The run WRITES its report to a FILE, ALWAYS — not behind a flag. The clock and
210
398
  // the git facts are gathered HERE (a real process) and handed to the pure writer.
211
399
  const ctx = {
212
400
  target,
213
- targetCommit: targetCommit(target),
401
+ targetCommit: targetCommit(scope.repoRoot),
214
402
  toolVersion: toolVersion(),
215
403
  dateISO: new Date().toISOString().slice(0, 10),
216
- treeStatus: treeStatus(target),
404
+ treeStatus: treeStatus(scope.repoRoot),
405
+ oracle: describeOracle(oracleSel),
217
406
  };
218
407
  const reportDir = join(here, "..", "reports");
219
- const path = writeReport(ctx, results, t, v, reportDir);
408
+ const path = writeReport(ctx, results, t, v, reportDir, undefined, [
409
+ callerResolutionSection(crScan),
410
+ localIdentitySection(liScan),
411
+ mechanicalLine(mech),
412
+ mechanicalRuleCountsLine(ruleCounts),
413
+ ]);
220
414
  console.log(`\nreport written: ${path}`);
415
+ // The deploy-gate evidence, keyed to the sha of the repository holding the
416
+ // judged convex dir. No sha, no file: evidence that names no commit certifies
417
+ // nothing, so the run refuses (exit 2) rather than write one.
418
+ if (evidencePath !== undefined) {
419
+ if (evidenceSha === undefined) {
420
+ console.error(`backend-doctor: --json-evidence REFUSED — \`git -C ${repoOfConvex} rev-parse HEAD\` failed, so the evidence would name no commit. No evidence file written.`);
421
+ process.exit(2);
422
+ }
423
+ const evidence = buildEvidence({
424
+ sha: evidenceSha,
425
+ cli_commit: cliCommit(),
426
+ convex_path: convexPath,
427
+ exit_code: exitCode,
428
+ checked: cov.checked.length,
429
+ total: cov.ruleIds.length,
430
+ mechanical_violations: mech.count,
431
+ mechanical_rule_counts: ruleCounts.counts,
432
+ mechanical_rule_module_counts: ruleCounts.moduleCounts,
433
+ });
434
+ let out = resolve(evidencePath);
435
+ // A trailing slash names a directory even before it exists.
436
+ if (/[/\\]$/.test(evidencePath))
437
+ mkdirSync(out, { recursive: true });
438
+ if (existsSync(out) && statSync(out).isDirectory())
439
+ out = join(out, `backend-doctor-${evidenceSha}.json`);
440
+ else
441
+ mkdirSync(dirname(out), { recursive: true });
442
+ writeFileSync(out, `${JSON.stringify(evidence, null, 2)}\n`);
443
+ console.log(`evidence written: ${out}`);
444
+ }
221
445
  if (!cov.ok) {
222
446
  console.log("\nNOTE: coverage is INCOMPLETE — a rule is in neither bucket. See the summary above.");
223
- process.exit(1);
224
447
  }
225
- // The CRUD coverage self-check is fail-closed: an unreadable schema or a rows
226
- // union that does not cover the schema tables is a DOCTOR DEFECT → RED, never a
227
- // silent pass. (ABSENT cells are honest reported gaps, NOT a defect.)
228
- if (crudDefect) {
448
+ else if (crudDefect) {
449
+ // The CRUD coverage self-check is fail-closed: an unreadable schema or a rows
450
+ // union that does not cover the schema tables is a DOCTOR DEFECT → RED, never a
451
+ // silent pass. (ABSENT cells are honest reported gaps, NOT a defect.)
229
452
  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
- process.exit(1);
231
453
  }
232
- process.exit(v.exitCode);
454
+ else if (crDefect) {
455
+ // CR-1 is CRITICAL: a FAIL-OPEN or unjustified NO-IDENTITY public registration
456
+ // is a mechanical violation, so the gate refuses on it even when every numbered
457
+ // rule is satisfied.
458
+ 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.`);
459
+ }
460
+ if (liDefect) {
461
+ // Reported independently of the NOTE chain above: CR-2 is its own gate.
462
+ console.log(`\nNOTE: CR-2 (local identity) FAILED — ${liScan.violate.length} site(s) define a local identity primitive instead of calling @vantageos/cloud-identity. Ceiling 0. See the LOCAL IDENTITY section above; every site is named there.`);
463
+ }
464
+ process.exit(exitCode);
233
465
  }
234
466
  main();
@@ -1,4 +1,4 @@
1
- import type { OracleColumn } from "./oracle.js";
1
+ import { type OracleColumn, type WriterTierTable } from "./oracle.js";
2
2
  export interface ContractField {
3
3
  /** the §2 contract field name. */
4
4
  field: string;
@@ -9,10 +9,28 @@ export interface ContractField {
9
9
  }
10
10
  /** The thirteen §2 contract fields (R-38 completeness gate operates on this). */
11
11
  export declare const CONTRACT_FIELDS: ContractField[];
12
- /** The two §2 fields that MUST be two distinct columns — reader and writer
13
- * authority. The oracle merges both onto `rbac_qui`: the structural defect. */
12
+ /** Reader authority (R-5) lives in the oracle's own `rbac_qui` column. */
14
13
  export declare const READER_AUTHORITY_COLUMN: OracleColumn;
15
- export declare const WRITER_AUTHORITY_COLUMN: OracleColumn;
16
- /** True when reader and writer authority resolve to the SAME column — the
17
- * merged-authority non-conformance R-14 names by construction. */
18
- export declare function authorityIsMerged(): boolean;
14
+ /** Without a side-car, writer authority (R-10) can only fall back to the SAME
15
+ * column — the merge §12 calls non-conforming by construction. */
16
+ export declare const MERGED_WRITER_AUTHORITY_COLUMN: OracleColumn;
17
+ /** The side-car column that carries writer authority apart from `rbac_qui`. */
18
+ export declare const SPLIT_WRITER_AUTHORITY_COLUMN: "writer_tier";
19
+ export type AuthoritySplit = {
20
+ kind: "split";
21
+ sideCar: string;
22
+ writeTools: number;
23
+ } | {
24
+ kind: "merged";
25
+ reason: string;
26
+ uncovered: string[];
27
+ } | {
28
+ kind: "unjudgeable";
29
+ reason: string;
30
+ };
31
+ /**
32
+ * Resolve where writer authority lives for this oracle.
33
+ * @param writeTools the `outil` of every inventory row whose verb writes.
34
+ * @param sideCar the bound writer-tier side-car.
35
+ */
36
+ export declare function resolveAuthoritySplit(writeTools: readonly string[], sideCar: WriterTierTable): AuthoritySplit;
package/dist/contract.js CHANGED
@@ -1,8 +1,20 @@
1
+ // The §2 per-table contract, transcribed from the pinned standard
2
+ // (backend-standard.md @ a9d7877b, read by command — never from memory).
3
+ //
4
+ // §2 splits authority into TWO distinct contract fields — reader authority (R-5)
5
+ // and writer authority (R-10). The oracle's eighteen columns carry ONE authority
6
+ // column, `rbac_qui`. An oracle alone is therefore merged, and R-14 calls a merged
7
+ // authority column "non-conforming by construction". An oracle declares the split
8
+ // through its writer-tier side-car (see resolveAuthoritySplit below).
9
+ import { basename } from "node:path";
10
+ import { WRITER_TIER_NAME } from "./oracle.js";
1
11
  /** The thirteen §2 contract fields (R-38 completeness gate operates on this). */
2
12
  export const CONTRACT_FIELDS = [
3
13
  { field: "exposure", columns: ["surface"], rule: "R-1" },
4
14
  { field: "operation verb", columns: ["crud"], rule: "R-2" },
5
15
  { field: "reader authority", columns: ["rbac_qui"], rule: "R-5" },
16
+ // §2's table maps writer authority onto `rbac_qui` too; that mapping IS the
17
+ // merge. The split column is the side-car's `writer_tier` (resolveAuthoritySplit).
6
18
  { field: "writer authority", columns: ["rbac_qui"], rule: "R-10" },
7
19
  { field: "retrieval mechanism", columns: ["search_mecanisme"], rule: "R-4" },
8
20
  { field: "pagination", columns: ["search_mecanisme"], rule: "R-3" },
@@ -18,12 +30,45 @@ export const CONTRACT_FIELDS = [
18
30
  rule: "R-13/R-27",
19
31
  },
20
32
  ];
21
- /** The two §2 fields that MUST be two distinct columns — reader and writer
22
- * authority. The oracle merges both onto `rbac_qui`: the structural defect. */
33
+ /** Reader authority (R-5) lives in the oracle's own `rbac_qui` column. */
23
34
  export const READER_AUTHORITY_COLUMN = "rbac_qui";
24
- export const WRITER_AUTHORITY_COLUMN = "rbac_qui";
25
- /** True when reader and writer authority resolve to the SAME column — the
26
- * merged-authority non-conformance R-14 names by construction. */
27
- export function authorityIsMerged() {
28
- return READER_AUTHORITY_COLUMN === WRITER_AUTHORITY_COLUMN;
35
+ /** Without a side-car, writer authority (R-10) can only fall back to the SAME
36
+ * column — the merge §12 calls non-conforming by construction. */
37
+ export const MERGED_WRITER_AUTHORITY_COLUMN = "rbac_qui";
38
+ /** The side-car column that carries writer authority apart from `rbac_qui`. */
39
+ export const SPLIT_WRITER_AUTHORITY_COLUMN = "writer_tier";
40
+ const UNKNOWN_WRITER_TIER = new Set(["", "?", "unknown", "n/a", "tbd"]);
41
+ /**
42
+ * Resolve where writer authority lives for this oracle.
43
+ * @param writeTools the `outil` of every inventory row whose verb writes.
44
+ * @param sideCar the bound writer-tier side-car.
45
+ */
46
+ export function resolveAuthoritySplit(writeTools, sideCar) {
47
+ if (sideCar.status === "absent") {
48
+ return {
49
+ kind: "merged",
50
+ reason: `writer-tier side-car ${WRITER_TIER_NAME} ABSENT (${sideCar.origin}; searched: ${sideCar.searched.join(", ") || "<none>"}), so writer authority has no column of its own`,
51
+ uncovered: [],
52
+ };
53
+ }
54
+ if (sideCar.status === "unreadable") {
55
+ return {
56
+ kind: "unjudgeable",
57
+ reason: `writer-tier side-car ${sideCar.path} unreadable — ${sideCar.reason}; whether \`${SPLIT_WRITER_AUTHORITY_COLUMN}\` declares writer authority cannot be read`,
58
+ };
59
+ }
60
+ const tools = [...new Set(writeTools)];
61
+ const uncovered = tools.filter((t) => {
62
+ const entries = sideCar.byTool.get(t) ?? [];
63
+ return !entries.some((e) => !UNKNOWN_WRITER_TIER.has(e.writer_tier.trim().toLowerCase()));
64
+ });
65
+ const file = basename(sideCar.path);
66
+ if (uncovered.length > 0) {
67
+ return {
68
+ kind: "merged",
69
+ reason: `${file} records no \`${SPLIT_WRITER_AUTHORITY_COLUMN}\` for ${uncovered.length} write tool(s) of ${tools.length} (${uncovered.join(", ")}), so for those tools writer authority is omitted`,
70
+ uncovered,
71
+ };
72
+ }
73
+ return { kind: "split", sideCar: file, writeTools: tools.length };
29
74
  }
@@ -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("");
@@ -4,7 +4,7 @@
4
4
  * canonical 48-rule standard (51ec7af1) to 2bb402f7, which closes R-7/R-8/R-37/R-40
5
5
  * as a written-justification-token control (§23) — the paired standard PR the four
6
6
  * decisive detectors below track. Still 48 rules; the pin moves forward with them. */
7
- export declare const STANDARD_SHA = "4bcd605656ceff52d4f1593a9176cd6b988471be";
7
+ export declare const STANDARD_SHA = "2825122a3ab1e4ddb4b955cfb136b7e8ab04f8f3";
8
8
  export declare const VENDORED_STANDARD_PATH: string;
9
9
  /**
10
10
  * Derive the full rule list R-1..R-42 from the VENDORED standard fixture —
package/dist/coverage.js CHANGED
@@ -18,8 +18,8 @@ const here = dirname(fileURLToPath(import.meta.url));
18
18
  * canonical 48-rule standard (51ec7af1) to 2bb402f7, which closes R-7/R-8/R-37/R-40
19
19
  * as a written-justification-token control (§23) — the paired standard PR the four
20
20
  * decisive detectors below track. Still 48 rules; the pin moves forward with them. */
21
- export const STANDARD_SHA = "4bcd605656ceff52d4f1593a9176cd6b988471be";
22
- export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-4bcd605.md");
21
+ export const STANDARD_SHA = "2825122a3ab1e4ddb4b955cfb136b7e8ab04f8f3";
22
+ export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-2825122.md");
23
23
  /**
24
24
  * Derive the full rule list R-1..R-42 from the VENDORED standard fixture —
25
25
  * never typed, never a git show against a local repo. Reads the `**R-NN`
@@ -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,18 +18,20 @@
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 { activeOracle, ORACLE_COLUMNS, oracleLabel, parseCsv, readOracle, refuseWithoutOracle, requireVrOracle, } from "../oracle.js";
24
+ import { itemCount, withCount } from "../violation-count.js";
24
25
  const here = dirname(fileURLToPath(import.meta.url));
25
- /** Absolute path to the pinned SECOND-backend (VantageRegistry) oracle fixture.
26
+ /** The vendored day159 SECOND-backend (VantageRegistry) snapshot — a FIXTURE for
27
+ * this tool's tests, never a default (see oracle.ts resolveOracle).
26
28
  * Vendored at pin a74a042b, blob b53e2626954a887303d11d944b6132a11dbd34f6. */
27
- export const VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
29
+ export const FIXTURE_VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-tool-day159.csv");
28
30
  /** The vendored standard (the R-5 law) — the SAME pin coverage.ts parses. The
29
31
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
30
32
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
31
33
  * import cycle. */
32
- export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-4bcd605.md");
34
+ export const VENDORED_STANDARD_PATH = join(here, "..", "..", "fixtures", "backend-standard-2825122.md");
33
35
  /** Load any backend inventory CSV generically from its OWN header — the shared
34
36
  * fields are DERIVED from these real headers, never a prose illustration. */
35
37
  export function loadInventory(path) {
@@ -44,13 +46,17 @@ export function loadInventory(path) {
44
46
  });
45
47
  return { header, rows };
46
48
  }
47
- let vrCache = null;
48
- /** Memoized read of the pinned VR inventory. */
49
+ const vrCache = new Map();
50
+ /** Memoized read of the VR inventory bound for THIS run (derived from the target).
51
+ * Throws OracleAbsentError when none was found. */
49
52
  export function readVrInventory() {
50
- if (vrCache)
51
- return vrCache;
52
- vrCache = loadInventory(VR_ORACLE_PATH);
53
- return vrCache;
53
+ const path = requireVrOracle().path;
54
+ const hit = vrCache.get(path);
55
+ if (hit)
56
+ return hit;
57
+ const inv = loadInventory(path);
58
+ vrCache.set(path, inv);
59
+ return inv;
54
60
  }
55
61
  /** The VP inventory, adapted from the memoized VP oracle reader — the SAME rows
56
62
  * the axis-2 detectors read, exposed with VP's real header. */
@@ -161,13 +167,17 @@ export function compareBackends(vpProvider = readOracle, vrProvider = readVrInve
161
167
  };
162
168
  }
163
169
  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";
170
+ function r40Anchor() {
171
+ const vr = activeOracle()?.vr;
172
+ const vrName = vr && vr.status === "found" ? basename(vr.path) : "ABSENT";
173
+ return `${oracleLabel()} vs oracle:${vrName}`;
174
+ }
165
175
  /** Wrap the Axis-1 compare into a Detector. Providers are injectable for tests;
166
176
  * production reads the two memoized real oracles. */
167
177
  export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
168
178
  return {
169
179
  id: R40_ID,
170
- run: () => {
180
+ run: () => refuseWithoutOracle(R40_ID, () => {
171
181
  const cmp = compareBackends(vpProvider, vrProvider);
172
182
  const { schema, sites } = cmp;
173
183
  // the schema (column) divergence is itself a site — surfaced, never dropped.
@@ -188,7 +198,7 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
188
198
  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
199
  return {
190
200
  id: R40_ID,
191
- anchor: R40_ANCHOR,
201
+ anchor: r40Anchor(),
192
202
  state: "present",
193
203
  verdict: reason,
194
204
  };
@@ -208,12 +218,12 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
208
218
  })
209
219
  .join("; ");
210
220
  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
- return {
221
+ return withCount({
212
222
  id: R40_ID,
213
- anchor: `${R40_ANCHOR} field=${sites[0]?.field ?? "schema"}`,
223
+ anchor: `${r40Anchor()} field=${sites[0]?.field ?? "schema"}`,
214
224
  state: "non-conforming",
215
225
  verdict: reason,
216
- };
217
- },
226
+ }, itemCount(sites.length));
227
+ }),
218
228
  };
219
229
  }
@@ -1,7 +1,13 @@
1
1
  import type { Detector } from "../engine-types.js";
2
2
  import { type OracleRow } from "../oracle.js";
3
3
  import type { RulePredicate } from "./shared.js";
4
+ export declare const JUSTIFICATION_TOKEN_RE: RegExp;
5
+ /** The oracle-row form: reads the row's remediation cell (`rbac_adjustment_needed`). */
4
6
  export declare function hasJustificationToken(r: OracleRow): boolean;
7
+ /** The raw-text form — for a SOURCE-scan site with no oracle row: a nearby code
8
+ * comment window is that site's "remediation cell". Same regex, same word-
9
+ * boundary/negation guards; never a second matcher. */
10
+ export declare function hasJustificationTokenInText(text: string): boolean;
5
11
  export declare const r7: RulePredicate;
6
12
  export declare const r8: RulePredicate;
7
13
  export interface DivergenceSite {