@vantageos/backend-doctor 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +19 -1
- package/dist/build-stamp.json +2 -2
- package/dist/caller-resolution.js +11 -8
- package/dist/cli.js +164 -15
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +5 -4
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +32 -16
- package/dist/detectors/predicates.js +12 -3
- package/dist/detectors/shared.js +3 -2
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +62 -17
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/credential-hygiene.d.ts +11 -0
- package/dist/detectors/source/credential-hygiene.js +619 -0
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
- package/dist/detectors/source/guard-helper-flow.js +49 -6
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +8 -1
- package/dist/detectors/source/identity.js +364 -35
- package/dist/detectors/source/jobs.js +13 -2
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +666 -0
- package/dist/detectors/source/narrow-then-bound.d.ts +11 -0
- package/dist/detectors/source/narrow-then-bound.js +571 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.js +291 -0
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.js +455 -26
- package/dist/detectors/source/service-account-claim.d.ts +15 -0
- package/dist/detectors/source/service-account-claim.js +491 -0
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.js +3 -2
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +75 -43
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +12 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +55 -3
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +14 -6
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/backend-standard-bacc54a.md +785 -0
- package/fixtures/rule-classes-bacc54a.json +244 -0
- 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 `
|
|
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 `bacc54a`, 56 rules — on ElPi-Corp `main`; it adds R-54..R-57 and the MCP rule S-28, merged as ElPi-Corp #239 = `7d7df89` before the 2026-10-10 history rewrite, same file blob `85d8530e`; 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-56 |
|
|
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,66 @@ 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
|
|
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-bacc54a.json`). Any violation is counted in `mechanical_rule_counts` / `mechanical_rule_module_counts` and exits 1.
|
|
106
|
+
|
|
107
|
+
**CR-2 is the standard's R-57, reported under its doctor-owned key.** It was authored before the standard numbered it; the standard at `bacc54a` defines the same rule as R-57. The count stays under the key `CR-2` because the vantage-peers deploy-gate baseline (`.claude/config/backend-doctor-baseline.json`) reads `CR-2`; reporting it under both ids would count one defect as two rules, so R-57 itself is listed under NOT_MECHANICALLY_CHECKABLE with that reason. Renaming the key to `R-57` is a follow-up that must move that baseline in the same delivery. 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-54, R-55, R-56 — the door audit's causes 1, 4 and 5
|
|
114
|
+
|
|
115
|
+
Standard `bacc54a` §4. Each is mechanical, has no justification-token exit, and counts in `mechanical_violations`. Their material is the door audit of vantage-peers `16f0907` (`docs/audit/2026-10-09-door-audit.md`, branch `argus/door-audit-16f0907`); the real files are vendored byte-exact in `tests/fixtures/vp-16f0907/` (`.pin`).
|
|
116
|
+
|
|
117
|
+
- **R-54** (`src/detectors/source/service-account-claim.ts`): a public door that resolves its caller through the organisation-scope resolver (`withOrgScope`, or any in-tree function that calls it) must declare a transport-verified claim argument (`verifiedActor` / `verifiedOrg` / `verifiedPerson`) AND refuse the call that is the service account and carries no claim. Red (A): no claim argument. Red (B): a claim argument but no such refusal, so its absence leaves the call master. A refusal is a `throw` (or a refusing `return`) whose path condition holds the service-account state and the claim's absence (a local derived from the claim counts; a refusal of the OTHER population does not). Exits, each listed and none silent: (a) service-account-only (`requireServiceAccount`, or `requireResolvedCaller` with `masterOnly` and `mcpBoundOnly`); (b) callers resolve only through a verified session (`requireOrgAdmin`, a bare identity read) and the door never reaches the scope resolver; (c) `fleet-wide: <reason of 8+ characters>`. Could-not-judge: an action whose scope arrives through a run call, a resolver-shaped callee defined outside the tree, a claim argument spread from a validator the scan cannot find, a door registered through a wrapper. A door that resolves no caller at all is outside the rule (CR-1 / R-6) and not counted. Clause 2 of the rule (the claim confines every row) is a dynamic property and is NOT decided here.
|
|
118
|
+
- **R-55** (`src/detectors/source/narrow-then-bound.ts`): in a public door and the helpers it reaches, a read bounded by `.take`/`.paginate` (or re-bounded by `.slice(0, n)`) whose rows are then narrowed by a caller-dependent predicate (a filter reading the door's arguments or the resolved scope, a `filterBy…` helper, an in-tree helper that narrows its parameter, a loop that skips on such a test) is red. Listed as covered, not silent: a fill loop that holds both the bound and the narrowing, and a refusal on a full window (a throw naming the cap, overflow or truncation) between the latest bound and the narrowing. An unbounded `.collect()` is R-11 / R-47's.
|
|
119
|
+
- **R-56** (`src/detectors/source/credential-hygiene.ts`), four clauses, one site tag each: (1) a credential-named column (`code`, `token`, `secret`, `apiKey`, …) stored without a digest, or a digest of a chosen secret (a door argument named like a secret, an environment secret) that reaches a stored row; (2) a table named like a refresh-token store, read by a hash index, with no function that reads it by hash AND marks the token consumed/revoked or deletes it; (3a) a table whose inserted rows carry `tokenHash` and `expiresAt` with no function that revokes or deletes a row of it, (3b) a mint into such a table (no code or refresh-token linkage) that neither revokes the principal's earlier bearers nor caps them; (4) a request-supplied commit/hash/token identifier matched to stored text by `includes`/`startsWith`/`indexOf` with no 40- or 64-digit check, and (4e) a verification response that echoes stored content. The entropy of an externally generated secret and the binding of a minted bearer to the session's claims are NOT decided here.
|
|
120
|
+
- **R-57** is measured by the doctor-owned CR-2 (`src/detectors/source/local-identity.ts`) and reported under the key `CR-2` (see the CR-2 section). R-57 itself is listed under NOT_MECHANICALLY_CHECKABLE with that reason and is never reported present.
|
|
121
|
+
|
|
122
|
+
### R-52 and R-53 — the write reaches the right row, and the actor is who it is by ID
|
|
123
|
+
|
|
124
|
+
- **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.
|
|
125
|
+
- **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.
|
|
126
|
+
|
|
127
|
+
Neither rule has a justification-token exit. Both are mechanical and count in `mechanical_violations`.
|
|
128
|
+
|
|
129
|
+
### The deploy-gate evidence — `--json-evidence` and `mechanical_violations`
|
|
130
|
+
|
|
131
|
+
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:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npx tsx src/cli.ts <repo>/convex --json-evidence <repo>/qa/ # a directory -> backend-doctor-<sha>.json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
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):
|
|
138
|
+
|
|
139
|
+
- `sha` = `git -C <convex_path>/.. rev-parse HEAD`; no sha, no file (exit 2).
|
|
140
|
+
- `cli_commit` = this checkout's HEAD (`+dirty` when `src/` or `fixtures/` differ from it), or `npm @vantageos/backend-doctor@<version>` for an installed copy.
|
|
141
|
+
- `exit_code` = the code the process actually exits with; `checked`/`total` = the coverage line.
|
|
142
|
+
- `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.
|
|
143
|
+
- `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.
|
|
144
|
+
- `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`.
|
|
145
|
+
|
|
146
|
+
**The class is data, not code.** `fixtures/rule-classes-bacc54a.json` gives every numbered rule plus CR-1 and CR-2 exactly one class with a one-phrase basis (42 mechanical = the standard's 40 + 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 `bacc54a` lists all 56 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 to R-57**; R-16..R-20 and the other §15 judgement rules print but are not counted, exactly as the standard classifies them.
|
|
147
|
+
|
|
148
|
+
**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
149
|
|
|
97
150
|
## Pins
|
|
98
151
|
|
|
99
152
|
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
153
|
|
|
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-
|
|
154
|
+
**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-bacc54a.md` (56 rules — adds R-54, a door the service account can reach acts inside a transport-verified organisation and refuses a claimless call; R-55, a read narrows before it bounds; R-56, credentials as digests, single-use refresh tokens, revocable bearers, full-length verification tokens; and R-57, no local identity primitive; canonical source: ElPi-Corp `vantagepeers/backend-standard/backend-standard.md`). `bacc54a` is on ElPi-Corp `main` (`gh api repos/elpiarthera/ElPi-Corp/compare/bacc54a53d5f6f9f20e5254908e2b46807902faa...main` -> ahead, behind 0); the PR #239 merge `7d7df89` carries the same blob but left `main` in the 2026-10-10 history rewrite (`PINS.txt`). The previous pin `backend-standard-2825122.md` (52 rules) and the older ones stay in the tree as provenance. 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
155
|
|
|
103
156
|
## Discipline (how a detector is proven)
|
|
104
157
|
|
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([
|
|
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
|
|
package/dist/build-stamp.json
CHANGED
|
@@ -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-
|
|
55
|
-
// this obligation, so CR-1 is deliberately NOT in
|
|
56
|
-
// vendored standard does not define would make
|
|
57
|
-
// and would forge a pin. It is rendered in its
|
|
58
|
-
// line, and it drives the exit code. Numbering it
|
|
59
|
-
//
|
|
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 (
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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();
|
package/dist/contract.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
/**
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|
package/dist/coverage.d.ts
CHANGED
|
@@ -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 = "
|
|
7
|
+
export declare const STANDARD_SHA = "bacc54a53d5f6f9f20e5254908e2b46807902faa";
|
|
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 = "
|
|
22
|
-
export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-
|
|
21
|
+
export const STANDARD_SHA = "bacc54a53d5f6f9f20e5254908e2b46807902faa";
|
|
22
|
+
export const VENDORED_STANDARD_PATH = join(here, "..", "fixtures", "backend-standard-bacc54a.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`
|