@vantageos/backend-doctor 0.2.0 → 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 (63) hide show
  1. package/README.md +48 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  22. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  23. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  24. package/dist/detectors/source/guard-helper-flow.js +49 -6
  25. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  26. package/dist/detectors/source/identity-by-id.js +1783 -0
  27. package/dist/detectors/source/identity.d.ts +8 -1
  28. package/dist/detectors/source/identity.js +364 -35
  29. package/dist/detectors/source/jobs.js +13 -2
  30. package/dist/detectors/source/lifecycle.js +14 -2
  31. package/dist/detectors/source/local-identity.d.ts +26 -0
  32. package/dist/detectors/source/local-identity.js +661 -0
  33. package/dist/detectors/source/package-principal.d.ts +9 -0
  34. package/dist/detectors/source/package-principal.js +177 -0
  35. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  36. package/dist/detectors/source/r6-shapes.js +285 -0
  37. package/dist/detectors/source/readbudget.js +291 -0
  38. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  39. package/dist/detectors/source/recipient-by-id.js +1108 -0
  40. package/dist/detectors/source/refusal.d.ts +14 -0
  41. package/dist/detectors/source/refusal.js +75 -2
  42. package/dist/detectors/source/security.js +455 -26
  43. package/dist/detectors/source/writesafety.js +27 -14
  44. package/dist/detectors/standard/_doc.d.ts +1 -1
  45. package/dist/detectors/standard/_doc.js +5 -4
  46. package/dist/detectors/standard/r41-frontier.js +3 -2
  47. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  48. package/dist/detectors/structural.d.ts +6 -7
  49. package/dist/detectors/structural.js +75 -43
  50. package/dist/not-checkable.d.ts +1 -1
  51. package/dist/not-checkable.js +11 -0
  52. package/dist/registry.d.ts +1 -1
  53. package/dist/registry.js +27 -3
  54. package/dist/rule-class.d.ts +85 -0
  55. package/dist/rule-class.js +218 -0
  56. package/dist/tally.d.ts +13 -5
  57. package/dist/tally.js +14 -6
  58. package/dist/violation-count.d.ts +48 -0
  59. package/dist/violation-count.js +94 -0
  60. package/fixtures/backend-standard-2825122.md +722 -0
  61. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  62. package/fixtures/rule-classes-2825122.json +228 -0
  63. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A conformance instrument. It scores a Convex backend against the **VantagePeers backend standard** — a *pinned, frozen fixture*, not a document authored here — and prints, per rule, the sites that fail it or the sites a human verdict is owed on. It **consumes** the `@vantageos/mcp-doctor` engine (`runDoctor` + `renderTable`) — it never re-implements it and touches no engine file; the contract types are derived from the engine's own signatures.
4
4
 
5
- The standard is vendored under `fixtures/backend-standard-<pin>.md`, byte-identical to its upstream git blob; the canonical source lives in ElPi-Corp at `analysis/vantagepeers/backend-standard/backend-standard.md` (pin `51ec7af1`, 48 rules). The doctor never authors the standard — it scores against the frozen copy.
5
+ The standard is vendored under `fixtures/backend-standard-<pin>.md`, byte-identical to its upstream git blob; the canonical source lives in ElPi-Corp at `vantagepeers/backend-standard/backend-standard.md` (pin `2825122`, 52 rules — the merge of ElPi-Corp PR #228 on `origin/main`, on top of #226's merge `9f7d789`; see `PINS.txt`). The doctor never authors the standard — it scores against the frozen copy.
6
6
 
7
7
  ## What it checks — every rule, one bucket, derived not typed
8
8
 
@@ -21,7 +21,7 @@ The detectors read four kinds of material, each a **comparison target, never an
21
21
  | oracle-CSV (Axis-2) | the per-tool inventory the target's own `.backend-doctor/vp-by-tool.csv` (or `--oracle <file>`); absent => could-not-judge | R-1..R-13, R-7, R-8, R-37 |
22
22
  | writer-tier side-car | `vp-writer-tier.csv` beside the VP oracle (or `--writer-tier <file>`), joined on `outil`; absent => R-10 could-not-judge, file named | R-10 |
23
23
  | oracle-CSV (Axis-1) | VP oracle **vs** the VR inventory `.backend-doctor/vr-by-tool.csv` (or `--vr-oracle <file>`) | R-40 |
24
- | source-tree | the real `convex/` source (live path, or `fixtures/vp-convex/` in CI) | R-6, R-11, R-16..R-36 |
24
+ | source-tree | the real `convex/` source and `mcp-server/src` (live path, or `fixtures/vp-convex/` in CI) | R-6, R-11, R-16..R-36, R-50..R-53 |
25
25
  | standard-document | the vendored standard + the four MCP-family docs | R-14, R-15, R-32, R-38, R-39, R-41, R-42 |
26
26
 
27
27
  ### The acceptance property — surfacing, not deferring
@@ -92,13 +92,57 @@ Alongside the numbered rules the CLI renders a **CALLER RESOLUTION (CR-1)** sect
92
92
  - **It asks whether the caller is RESOLVED, never whether a refusal is shaped correctly.** That second question is R-50/R-51's; a handler with no guard has nothing to throw, so it cannot fail a rule about throwing.
93
93
  - On a tree that exposes nothing it reports **no applicable site**, never a count.
94
94
 
95
- **CR-1 is NOT one of the numbered rules** in the pinned standard, so it is not in `src/registry.ts` and the 50-of-50 coverage line is unchanged: registering an id the vendored standard does not define would make `coverage.ts` report a phantom rule. Numbering it upstream (proposed **R-52**) is a separate change to the standard document in its own repository. Source and rationale: `src/caller-resolution.ts`.
95
+ **CR-1 is NOT one of the numbered rules** in the pinned standard, so it is not in `src/registry.ts` and does not enter the coverage line: registering an id the vendored standard does not define would make `coverage.ts` report a phantom rule. Its upstream number is **pending**: the earlier proposal "R-52" is withdrawn because the standard at `2825122` gives R-52 to the cross-tenant-write rule and R-53 to identity-by-ID (§16); CR-1 takes the next free number when it is upstreamed, in the standard's own repository. Source and rationale: `src/caller-resolution.ts`.
96
+
97
+ ### CR-2 — no local identity primitive (doctor-owned, mechanical, ceiling 0)
98
+
99
+ Operator order 2026-10-09: *"No application, no MCP server, and no starter defines its own primitive for: resolving a caller to a tenant, asserting membership of an organisation, filtering rows by a caller's scope, or validating a master credential."* Identity lives only in `@vantageos/cloud-identity`. The CLI renders a **LOCAL IDENTITY (CR-2)** section; every site is named by `file:line`.
100
+
101
+ - **One site per function that defines a primitive** (a helper, an MCP tool handler, or a Convex registration whose own handler does it) in `convex/` and `mcp-server/src`. Each site names every kind it holds, with its line: **tenant** (resolves a caller to a tenant or agent), **membership** (a gate on a role / master field, a membership-table read), **scope** (an allow-list field read, a membership test over a caller's tenant / name / scope list), **credential** (`timingSafeEqual`, token hashing, `jose` / `jsonwebtoken` verification), **name** (an agent identified by name: an index keyed on a name field, a comparison through a name normaliser).
102
+ - **R-53 is reused, not re-derived**: its (d) local identity layers, its doors whose principal is resolved by the handler itself, its clause-1 recipient-by-name sites and its (c) master-on-absence branches become CR-2 sites of the matching kind. What R-53 already sanctions (a `getUserIdentity()` value that reaches only a package resolver, the org-ID adapter read) is not a site.
103
+ - **EXEMPT, and reported as exempt**: a file of `@vantageos/cloud-identity` itself, and a site carrying `allow-local-identity: <reason of 8+ characters>` in its leading comment or body. Each exempt site is listed with its reason.
104
+ - **COULD-NOT-JUDGE**: a file with a syntax error is named and judged nowhere (exit 2 when nothing else is red); an empty population is never a clean bill.
105
+ - **Ceiling 0**: CR-2 is mechanical (`fixtures/rule-classes-2825122.json`). Any violation is counted in `mechanical_rule_counts` / `mechanical_rule_module_counts` and exits 1.
106
+
107
+ **CR-2 is NOT one of the numbered rules** for the same reason as CR-1: the pinned standard numbers R-1..R-53, and an `R-54` it does not define would be reported by `coverage.ts` as an unknown rule and left unclassified by `rule-class.ts` (which would null the mechanical count). Source and rationale: `src/detectors/source/local-identity.ts`.
108
+
109
+ ### R-31 — the read budget of a bulk mutation
110
+
111
+ - **R-31** (`src/detectors/source/readbudget.ts`): a `.collect()` inside a mutation is non-conforming unless it reads an index-equality range carrying `// read-bound: <reason>`; a bounded read (`.take(n)`/`.paginate`) conforms when the mutation self-reschedules with `ctx.scheduler.runAfter`, or when it is a **caller-driven drain** — the `.paginate` result flows to one return statement of the handler itself (a nested callback's return does not count) carrying both the continuation cursor and the `isDone` flag (any field names), so the caller re-invokes it page by page, and no `ctx.runMutation`/`ctx.runAction` in the same transaction carries that cursor or names the mutation; every other bounded read is could-not-judge.
112
+
113
+ ### R-52 and R-53 — the write reaches the right row, and the actor is who it is by ID
114
+
115
+ - **R-52** (`src/detectors/source/cross-tenant-write.ts`): a write that reaches a row with no tenant value (no tenant column, or an optional one) through a value the caller chose — an argument, or a stored field a public write set from its arguments, such as `task.project` — must be dominated by a guard reading a server-stamped tenant: the target's own tenant when it has a column, a fleet-only decision when it has none. Tenant columns are the standard's `orgId`/`tenantId`/`workspaceId`/`orgSlug`, or a `namespace` checked against the caller's allowed prefixes (ElPi-Corp #228). The namespace condition is decided per table from its public doors (every query/mutation/action that reads or writes it): every door runs a prefix test (a function taking a namespace parameter and testing it with `.startsWith`, closed over calls — vantage-peers `isNamespaceAllowedForScope` — called on a namespace value: the argument at its namespace parameter derives from `args.namespace`, a row's `.namespace` or a binding of one, never a constant), refuses every non-master, or reads the table only on the true side of an `isMaster` test -> tenant column; no door does -> the table is untenanted; mixed, or no public door -> every R-52 site targeting it is could-not-judge, never green. On vantage-peers `2faa399`, `memories` is mixed (nine doors checked, `oauth.ts:provisionOrganization` reads it through `findSeatNameCollision` unchecked), so its six sites are could-not-judge.
116
+ - **R-53** (`src/detectors/source/identity-by-id.ts`): every public write door resolves its principal through `@vantageos/cloud-identity` (never a `client_org_mapping` lookup in product code, save the one read the ruling of task k1742q0vm05x2f5pqawnz1eah18fp01p admits: keyed by the Clerk org ID — `.withIndex("by_clerk_org_id", (q) => q.eq("clerkOrgId", K))` or `.filter((q) => q.eq(q.field("clerkOrgId"), K))`, then `.unique()`/`.first()` — where `K` is the org ID the package hands the `orgKindOf` adapter (its first parameter, or a helper parameter every call site fills with it), inside an `orgKindOf` or `organisationById` member of a lookups object passed to `resolveActingPrincipal`/`assertTargetBelongsTo` — inline, through a local, or as the return of a named builder that only assembles (no branch, no throw, no expression statement) — deciding nothing but its own return, an `organisationById` answering only `{ id, active }` read from the row or null; a slug or name key, `withOrgScope`/`lookupOrgMapping`, and a read whose result reaches anything else stay red; a `getUserIdentity()` read is admitted only when its value, followed through locals, destructuring and helpers, ends solely as the first argument of `resolveActingPrincipal`/`resolvePersonPrincipal`, since the package verifies nothing about the credential itself), admits by comparing stored IDs (inline, or through the package's own `assertTargetBelongsTo(principal, { orgId: row.orgId[, ownerId: row.ownerId] }, …)` — synchronous at cloud-identity `a6e5780` or awaited with a lookups argument at PR #21 — whose `!ok` branch throws or returns before the write; never a name field against a typed or credential-carried name), and refuses an unresolved principal; any helper or MCP tool handler that compares names to admit, or resolves identity itself, is a local identity layer.
117
+
118
+ Neither rule has a justification-token exit. Both are mechanical and count in `mechanical_violations`.
119
+
120
+ ### The deploy-gate evidence — `--json-evidence` and `mechanical_violations`
121
+
122
+ The vantage-peers deploy hook (`enforce-backend-doctor-before-deploy.py`) refuses a Convex deploy unless `qa/backend-doctor-<sha>.json` pins the deployed commit and carries an integer `mechanical_violations == 0`. It refuses on **mechanical** non-conformance only, never on a judgement/process rule. The CLI writes that file:
123
+
124
+ ```bash
125
+ npx tsx src/cli.ts <repo>/convex --json-evidence <repo>/qa/ # a directory -> backend-doctor-<sha>.json
126
+ ```
127
+
128
+ It writes exactly `{"sha","cli_commit","convex_path","exit_code","checked","total","mechanical_violations","mechanical_rule_counts","mechanical_rule_module_counts"}` (the last two are additive; the first seven are unchanged):
129
+
130
+ - `sha` = `git -C <convex_path>/.. rev-parse HEAD`; no sha, no file (exit 2).
131
+ - `cli_commit` = this checkout's HEAD (`+dirty` when `src/` or `fixtures/` differ from it), or `npm @vantageos/backend-doctor@<version>` for an installed copy.
132
+ - `exit_code` = the code the process actually exits with; `checked`/`total` = the coverage line.
133
+ - `mechanical_violations` = the number of **rules** whose reduced state is `non-conforming` and whose class is **mechanical** (CR-1 and CR-2 each count as one rule). The same count is printed as `MECHANICAL VIOLATIONS: N` right under the VERDICT line, with the per-rule breakdown.
134
+ - `mechanical_rule_counts` = an object mapping each red mechanical rule id to its **non-conforming count**, the number its report line prints: sites for a source rule (`R-53: 113 site(s) VIOLATE` -> `"R-53": 113`), rows for an oracle rule (`R-2: 6 of 106 applicable oracle rows VIOLATE` -> `"R-2": 6`), registrations for CR-1, defining functions for CR-2. Its key count equals `mechanical_violations`; `{}` on a clean run. The count is attached by the reduction that formats the printed line, from the same variable (`src/violation-count.ts`), never parsed back from the text. A rule reduced from two red halves (R-31) sums its red halves; a single structural finding (R-14, R-38) counts 1. Printed as `MECHANICAL RULE COUNTS: R-2 6, …` under the MECHANICAL line. **`null`** when `mechanical_violations` is null, or when a red mechanical rule's producer attached no count (printed as `MECHANICAL RULE COUNTS: COULD-NOT-JUDGE — no count attached for …`): a partial map would read as complete.
135
+ - `mechanical_rule_module_counts` = for each **site-based** red mechanical rule (source scans, R-46, CR-1, CR-2), `{ module: count }` summing to that rule's `mechanical_rule_counts` entry. A `convex/` file's module is its basename without extension (`convex/tasks.ts` -> `tasks`, `convex/lib/auth.ts` -> `auth`); a file outside `convex/` keeps its path without extension (R-53 also reads `mcp-server/src`: `mcp-server/src/tools`). Row- and finding-based rules (R-2, R-8, R-13, R-28 and every other oracle rule, R-14, R-37, R-38, R-40, R-41, the standard-document rules) are absent. `null` with `mechanical_rule_counts`.
136
+
137
+ **The class is data, not code.** `fixtures/rule-classes-2825122.json` gives every numbered rule plus CR-1 and CR-2 exactly one class with a one-phrase basis (38 mechanical = the standard's 36 + CR-1 + CR-2; 16 judgement = 13 judgement + 3 process). Each R- entry is cross-checked at run time against the pinned standard's own build kind (§15 table, which at `2825122` lists all 52 rules; a Detector spec's `*kind:*` token is the fallback for a rule the table omits), `process` mapping to judgement. Consequence: **R-6, R-11, R-29, R-33, R-34, R-35 now COUNT** (ElPi-Corp #225 settled them mechanical), as do **R-52 and R-53**; R-16..R-20 and the other §15 judgement rules print but are not counted, exactly as the standard classifies them.
138
+
139
+ **Never a silent 0.** Abstentions (`could-not-judge`) are not violations: they are listed apart on the MECHANICAL line and keep the exit code at 2 when no violation exists. The count becomes `null` (the hook reads it as INCOMPLETE and refuses) when it cannot be certified: a rule with no class, a sidecar class that contradicts the standard, a sidecar pinned to another standard, incomplete coverage, a failed CRUD self-check, or a judged `convex/` tree that differs from the commit `sha` names. Each reason is printed as `CANNOT CERTIFY: …`.
96
140
 
97
141
  ## Pins
98
142
 
99
143
  Every input is pinned and, where it is a vendored document, proven byte-identical to its upstream git blob (an un-hashable vendored doc is an authority granted to oneself).
100
144
 
101
- **The single authority for which standard fixture is active is the loader constant** `STANDARD_FILE` in [`src/detectors/standard/_doc.ts`](./src/detectors/standard/_doc.ts) — currently `backend-standard-4bcd605.md` (50 rules — R-50/R-51, org isolation on a subscribed read / a pre-org write is a typed empty/unauthorised result, never an uncaught throw; at this pin a gated `customQuery`/`customMutation` wrapper is JUDGED red/green from its readable `customCtx`, not could-not-judge; canonical source: ElPi-Corp `analysis/vantagepeers/backend-standard/backend-standard.md`). Everything else is provenance, NOT the active pin: `PINS.txt` and any other `fixtures/backend-standard-<sha>.md` still in the tree (e.g. the superseded `backend-standard-a9d7877b.md`, 47 rules — it lacks R-48) are kept only to show what was scored before. If any of them disagrees with `STANDARD_FILE`, `STANDARD_FILE` wins. Read the active pin from `STANDARD_FILE`, never from `PINS.txt` or a number typed elsewhere — following a stale `PINS.txt` would silently score the superseded 47-rule document and never surface R-48. See [`PINS.txt`](./PINS.txt) for the engine, backend snapshot, and MCP-family doc + VR inventory pins.
145
+ **The single authority for which standard fixture is active is the loader constant** `STANDARD_FILE` in [`src/detectors/standard/_doc.ts`](./src/detectors/standard/_doc.ts) — currently `backend-standard-2825122.md` (52 rules — adds R-52, a write reaching a row with no tenant value through a caller-choosable key checks the target's tenant first, and R-53, actor and target identified by ID through `@vantageos/cloud-identity`, a name never selects or authorises; canonical source: ElPi-Corp `vantagepeers/backend-standard/backend-standard.md`). `2825122` is the merge of ElPi-Corp PR #228 on `origin/main` (after #226 at `9f7d789`); it resolves. The previous pins `backend-standard-4dcd3e0.md` (the provisional #226 head) and `backend-standard-4bcd605.md` (50 rules) stay in the tree as provenance. **Declared divergence at this pin:** the R-53 text at `2825122` rules `resolveVerifiedPerson` red under clause 1 because it selects the organisation by `clerkOrgSlug`, a slug, rather than by ID; the R-53 detector reports the slug-keyed lookup itself (vantage-peers `2faa399` `convex/lib/auth.ts:465` `lookupOrgMapping`, clause (d), `query("client_org_mapping").withIndex(<clerk claim>)`) but does not report `resolveVerifiedPerson` (`convex/lib/personPrincipal.ts:73`), which receives that lookup injected, as a site of its own under clause 1 — a gap carried to a follow-up, not a green. Everything else is provenance, NOT the active pin: `PINS.txt` and any other `fixtures/backend-standard-<sha>.md` still in the tree (e.g. the superseded `backend-standard-a9d7877b.md`, 47 rules — it lacks R-48) are kept only to show what was scored before. If any of them disagrees with `STANDARD_FILE`, `STANDARD_FILE` wins. Read the active pin from `STANDARD_FILE`, never from `PINS.txt` or a number typed elsewhere — following a stale `PINS.txt` would silently score the superseded 47-rule document and never surface R-48. See [`PINS.txt`](./PINS.txt) for the engine, backend snapshot, and MCP-family doc + VR inventory pins.
102
146
 
103
147
  ## Discipline (how a detector is proven)
104
148
 
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const USAGE = "usage: backend-doctor [<target>] [--oracle <csv>] [--vr-oracle <csv>] [--writer-tier <csv>]\n backend-doctor --version | --help\n\n <target> the repository root or its convex/ dir (default: the current dir).\n Both name the same backend: paths are normalised to the repo root\n (nearest dir holding package.json or .git), so the counts are identical.\n --oracle, --vr-oracle, --writer-tier\n explicit oracle CSVs; by default they are read from\n <repo>/convex/.backend-doctor/ or <repo>/.backend-doctor/ \u2014 never from\n above the repo root and never from this tool's bundled fixtures.\n --version print the tool version and exit 0.\n --help print this text and exit 0.\n\nexit: 0 approve, 1 a violation / revise, 2 the run could not judge (bad usage,\nunreadable target, stale build, unreadable standard).";
1
+ export declare const USAGE = "usage: backend-doctor [<target>] [--oracle <csv>] [--vr-oracle <csv>] [--writer-tier <csv>]\n [--json-evidence <file-or-dir>]\n backend-doctor --version | --help\n\n <target> the repository root or its convex/ dir (default: the current dir).\n Both name the same backend: paths are normalised to the repo root\n (nearest dir holding package.json or .git), so the counts are identical.\n --oracle, --vr-oracle, --writer-tier\n explicit oracle CSVs; by default they are read from\n <repo>/convex/.backend-doctor/ or <repo>/.backend-doctor/ \u2014 never from\n above the repo root and never from this tool's bundled fixtures.\n --json-evidence <file-or-dir>\n also write the deploy-gate evidence JSON, exactly\n {\"sha\",\"cli_commit\",\"convex_path\",\"exit_code\",\"checked\",\"total\",\n \"mechanical_violations\",\"mechanical_rule_counts\",\n \"mechanical_rule_module_counts\"}; a directory receives\n backend-doctor-<sha>.json. mechanical_violations is null (never 0)\n when it cannot be certified (an unclassified rule, incomplete\n coverage, a CRUD self-check failure, or a convex/ tree that differs\n from HEAD). mechanical_rule_counts maps each red mechanical rule to\n the count its report line prints; module counts split site-based\n rules by convex module. Both are null when the count is null or a\n red rule carries no count.\n --version print the tool version and exit 0.\n --help print this text and exit 0.\n\nexit: 0 approve, 1 a violation / revise, 2 the run could not judge (bad usage,\nunreadable target, stale build, unreadable standard).";
2
2
  export type ParsedArgs = {
3
3
  kind: "run";
4
4
  positional: string[];
package/dist/args.js CHANGED
@@ -1,7 +1,13 @@
1
1
  // CLI argument parsing, kept apart from cli.ts (which runs main() on import) so the
2
2
  // flag contract is testable without spawning a process.
3
- const VALUE_FLAGS = new Set(["oracle", "vr-oracle", "writer-tier"]);
3
+ const VALUE_FLAGS = new Set([
4
+ "oracle",
5
+ "vr-oracle",
6
+ "writer-tier",
7
+ "json-evidence",
8
+ ]);
4
9
  export const USAGE = `usage: backend-doctor [<target>] [--oracle <csv>] [--vr-oracle <csv>] [--writer-tier <csv>]
10
+ [--json-evidence <file-or-dir>]
5
11
  backend-doctor --version | --help
6
12
 
7
13
  <target> the repository root or its convex/ dir (default: the current dir).
@@ -11,6 +17,18 @@ export const USAGE = `usage: backend-doctor [<target>] [--oracle <csv>] [--vr-or
11
17
  explicit oracle CSVs; by default they are read from
12
18
  <repo>/convex/.backend-doctor/ or <repo>/.backend-doctor/ — never from
13
19
  above the repo root and never from this tool's bundled fixtures.
20
+ --json-evidence <file-or-dir>
21
+ also write the deploy-gate evidence JSON, exactly
22
+ {"sha","cli_commit","convex_path","exit_code","checked","total",
23
+ "mechanical_violations","mechanical_rule_counts",
24
+ "mechanical_rule_module_counts"}; a directory receives
25
+ backend-doctor-<sha>.json. mechanical_violations is null (never 0)
26
+ when it cannot be certified (an unclassified rule, incomplete
27
+ coverage, a CRUD self-check failure, or a convex/ tree that differs
28
+ from HEAD). mechanical_rule_counts maps each red mechanical rule to
29
+ the count its report line prints; module counts split site-based
30
+ rules by convex module. Both are null when the count is null or a
31
+ red rule carries no count.
14
32
  --version print the tool version and exit 0.
15
33
  --help print this text and exit 0.
16
34
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "sourceDigest": "f9c7040c0ed49bdf7a77370eeed51651d4556c1ec8a8f674c2ca8ffd55000592",
3
- "builtAt": "2026-10-03T20:51:43.107Z"
2
+ "sourceDigest": "b6bfdbdcea24a6c2a672ed0ebc0b19dcaa680893b280820c23cbaf960130423d",
3
+ "builtAt": "2026-10-10T15:07:02.464Z"
4
4
  }
@@ -51,15 +51,18 @@
51
51
  // MECHANICAL violations, so the deploy gate refuses on them.
52
52
  //
53
53
  // STANDING GAP, NAMED: CR-1 is DOCTOR-OWNED. The pinned backend standard
54
- // (fixtures/backend-standard-4bcd605.md) numbers R-1..R-51 and does not yet carry
55
- // this obligation, so CR-1 is deliberately NOT in `registry.ts` — adding an id the
56
- // vendored standard does not define would make `coverage.ts` report a phantom rule
57
- // and would forge a pin. It is rendered in its own section, tallied on its own
58
- // line, and it drives the exit code. Numbering it upstream (proposed R-52) is a
59
- // separate change to the standard document in its own repository.
54
+ // (fixtures/backend-standard-2825122.md) numbers R-1..R-53 (R-49 held, not
55
+ // defined) and does not carry this obligation, so CR-1 is deliberately NOT in
56
+ // `registry.ts` — adding an id the vendored standard does not define would make
57
+ // `coverage.ts` report a phantom rule and would forge a pin. It is rendered in its
58
+ // own section, tallied on its own line, and it drives the exit code. Numbering it
59
+ // upstream (CR-1, numbering pending — R-52 is now the cross-tenant-write rule and
60
+ // R-53 identity-by-ID, standard §16; CR-1 takes the next free number when it is
61
+ // upstreamed) is a separate change to the standard document in its own repository.
60
62
  import { readFileSync } from "node:fs";
61
63
  import { join } from "node:path";
62
64
  import { convexRootOf } from "./target.js";
65
+ import { siteCount, withCount } from "./violation-count.js";
63
66
  // ---------------------------------------------------------------------------
64
67
  // population — the exposed surface, from the TARGET tree's own api.d.ts
65
68
  // ---------------------------------------------------------------------------
@@ -1124,14 +1127,14 @@ export function callerResolutionResult(scan) {
1124
1127
  const named = v
1125
1128
  .map((f) => `${f.api} @ ${f.rel}:${f.n} [${f.cls}] — ${f.why}`)
1126
1129
  .join("\n ");
1127
- return {
1130
+ return withCount({
1128
1131
  id: CR1_ID,
1129
1132
  anchor: `${v[0].rel}:${v[0].n}`,
1130
1133
  state: "non-conforming",
1131
1134
  verdict: `CR-1: ${v.length} public registration(s) do NOT resolve their caller before serving or writing data. ${counts}\n ${named}${scan.selfDefects.length
1132
1135
  ? `\n CR-1 SELF-DEFECTS (the instrument's own, never a tree finding): ${scan.selfDefects.join(" | ")}`
1133
1136
  : ""}`,
1134
- };
1137
+ }, siteCount(v));
1135
1138
  }
1136
1139
  if (a.length > 0 || scan.selfDefects.length > 0) {
1137
1140
  const reason = `CR-1: 0 violations, but ${a.length} registration(s) could not be judged and ${scan.selfDefects.length} instrument self-defect(s) stand. ${counts} ${[
package/dist/cli.js CHANGED
@@ -5,22 +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 { existsSync, 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
12
  import { parseArgs, USAGE } from "./args.js";
13
13
  import { checkDistFreshness } from "./build-stamp.js";
14
14
  import { CR1_ID, callerResolutionResult, callerResolutionSection, violations as crViolations, scanCallerResolution, } from "./caller-resolution.js";
15
- import { coverage, coverageSummary } from "./coverage.js";
15
+ import { coverage, coverageSummary, deriveRuleIds } from "./coverage.js";
16
16
  import { crudCoverageSection, extractReactRouterPaths, scanCrudCoverage, } from "./crud-coverage.js";
17
+ import { CR2_ID, localIdentityResult, localIdentitySection, scanLocalIdentity, } from "./detectors/source/local-identity.js";
17
18
  import { checkStandardReadable } from "./detectors/standard/_doc.js";
18
19
  import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults, } from "./not-checkable.js";
19
20
  import { activateOracle, describeOracle, resolveOracle } from "./oracle.js";
20
21
  import { REGISTRY } from "./registry.js";
21
22
  import { writeReport } from "./report.js";
23
+ import { buildEvidence, classifyRules, countMechanical, mechanicalLine, } from "./rule-class.js";
22
24
  import { handlerSurfaceLine, handlerSurfaceStats, tally, tallyLine, verdict, } from "./tally.js";
23
25
  import { describeScope, loadScopedTree, onScopedTree, resolveTarget, } from "./target.js";
26
+ import { mechanicalRuleCounts, mechanicalRuleCountsLine, } from "./violation-count.js";
24
27
  const here = dirname(fileURLToPath(import.meta.url));
25
28
  /** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
26
29
  function targetCommit(target) {
@@ -44,6 +47,41 @@ function treeStatus(target) {
44
47
  return "not a git repo";
45
48
  }
46
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
+ }
47
85
  function toolVersion() {
48
86
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
49
87
  return pkg.version;
@@ -247,6 +285,27 @@ function main() {
247
285
  console.log(callerResolutionSection(crScan));
248
286
  const crResult = callerResolutionResult(crScan);
249
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;
250
309
  console.log("");
251
310
  const cov = coverage();
252
311
  console.log(coverageSummary(cov));
@@ -272,12 +331,68 @@ function main() {
272
331
  // driven to 0 (Laurent: an instrument that cannot go red protects nothing).
273
332
  console.log(handlerSurfaceLine(handlerSurfaceStats(results)));
274
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}`);
275
335
  console.log(v.summary);
276
336
  if (v.criticalViolations > 0) {
277
337
  console.log(` critical violations: ${v.violations.critical.join(", ")}`);
278
338
  }
279
339
  if (v.criticalAbstentions > 0) {
280
- 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(", ")}`);
281
396
  }
282
397
  // The run WRITES its report to a FILE, ALWAYS — not behind a flag. The clock and
283
398
  // the git facts are gathered HERE (a real process) and handed to the pure writer.
@@ -292,26 +407,60 @@ function main() {
292
407
  const reportDir = join(here, "..", "reports");
293
408
  const path = writeReport(ctx, results, t, v, reportDir, undefined, [
294
409
  callerResolutionSection(crScan),
410
+ localIdentitySection(liScan),
411
+ mechanicalLine(mech),
412
+ mechanicalRuleCountsLine(ruleCounts),
295
413
  ]);
296
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
+ }
297
445
  if (!cov.ok) {
298
446
  console.log("\nNOTE: coverage is INCOMPLETE — a rule is in neither bucket. See the summary above.");
299
- process.exit(1);
300
447
  }
301
- // The CRUD coverage self-check is fail-closed: an unreadable schema or a rows
302
- // union that does not cover the schema tables is a DOCTOR DEFECT → RED, never a
303
- // silent pass. (ABSENT cells are honest reported gaps, NOT a defect.)
304
- 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.)
305
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.");
306
- process.exit(1);
307
453
  }
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) {
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.
312
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.`);
313
- process.exit(1);
314
459
  }
315
- process.exit(v.exitCode);
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);
316
465
  }
317
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
  }
@@ -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`
@@ -20,7 +20,8 @@
20
20
  import { readFileSync } from "node:fs";
21
21
  import { basename, dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
- import { ORACLE_COLUMNS, parseCsv, activeOracle, oracleLabel, readOracle, refuseWithoutOracle, requireVrOracle, } 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
26
  /** The vendored day159 SECOND-backend (VantageRegistry) snapshot — a FIXTURE for
26
27
  * this tool's tests, never a default (see oracle.ts resolveOracle).
@@ -30,7 +31,7 @@ export const FIXTURE_VR_ORACLE_PATH = join(here, "..", "..", "fixtures", "vr-by-
30
31
  * §2 [shared] contract columns are DERIVED from this file, never hardcoded.
31
32
  * Defined locally (not imported from coverage.ts) to avoid a registry ↔ coverage
32
33
  * import cycle. */
33
- 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");
34
35
  /** Load any backend inventory CSV generically from its OWN header — the shared
35
36
  * fields are DERIVED from these real headers, never a prose illustration. */
36
37
  export function loadInventory(path) {
@@ -217,12 +218,12 @@ export function makeR40(vpProvider = readOracle, vrProvider = readVrInventory) {
217
218
  })
218
219
  .join("; ");
219
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}.`;
220
- return {
221
+ return withCount({
221
222
  id: R40_ID,
222
223
  anchor: `${r40Anchor()} field=${sites[0]?.field ?? "schema"}`,
223
224
  state: "non-conforming",
224
225
  verdict: reason,
225
- };
226
+ }, itemCount(sites.length));
226
227
  }),
227
228
  };
228
229
  }