@vantageos/backend-doctor 0.1.12 → 0.1.14
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/dist/cli.js +128 -4
- package/dist/crud-coverage.d.ts +81 -0
- package/dist/crud-coverage.js +375 -0
- package/dist/detectors/source/_scan.js +6 -1
- package/dist/detectors/source/isolation.js +9 -9
- package/dist/detectors/source/write-isolation.js +10 -10
- package/dist/detectors/source/writesafety.js +16 -15
- package/dist/tally.d.ts +35 -0
- package/dist/tally.js +75 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
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 { readFileSync } from "node:fs";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
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 { coverage, coverageSummary } from "./coverage.js";
|
|
13
|
+
import { crudCoverageSection, extractReactRouterPaths, scanCrudCoverage, } from "./crud-coverage.js";
|
|
13
14
|
import { checkStandardReadable } from "./detectors/standard/_doc.js";
|
|
14
|
-
import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults } from "./not-checkable.js";
|
|
15
|
+
import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults, } from "./not-checkable.js";
|
|
15
16
|
import { REGISTRY } from "./registry.js";
|
|
16
17
|
import { writeReport } from "./report.js";
|
|
17
|
-
import { tally, tallyLine, verdict } from "./tally.js";
|
|
18
|
+
import { handlerSurfaceLine, handlerSurfaceStats, tally, tallyLine, verdict, } from "./tally.js";
|
|
18
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
/** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
|
|
20
21
|
function targetCommit(target) {
|
|
@@ -42,6 +43,110 @@ function toolVersion() {
|
|
|
42
43
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
43
44
|
return pkg.version;
|
|
44
45
|
}
|
|
46
|
+
/** Recursively collect files under `dir` whose basename matches `pred`, as paths
|
|
47
|
+
* relative to `base`. Silently skips node_modules/.git and unreadable dirs. */
|
|
48
|
+
function collectFiles(dir, base, pred, out = []) {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = readdirSync(dir);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
for (const name of entries) {
|
|
57
|
+
if (name === "node_modules" || name === ".git")
|
|
58
|
+
continue;
|
|
59
|
+
const full = join(dir, name);
|
|
60
|
+
let isDir;
|
|
61
|
+
try {
|
|
62
|
+
isDir = statSync(full).isDirectory();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (isDir)
|
|
68
|
+
collectFiles(full, base, pred, out);
|
|
69
|
+
else {
|
|
70
|
+
const rel = relative(base, full);
|
|
71
|
+
if (pred(rel))
|
|
72
|
+
out.push(rel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Read the convex tree + the schema + probe an app routes dir, then render the
|
|
78
|
+
* CRUD coverage section. The target is the convex dir; the app routes dir is a
|
|
79
|
+
* sibling probe (`../src/app`, `../app`, `../src/routes`). */
|
|
80
|
+
function scanCrudCoverageForCli(target) {
|
|
81
|
+
const convexRels = collectFiles(target, target, (rel) => rel.endsWith(".ts") && !rel.endsWith(".test.ts"));
|
|
82
|
+
const files = [];
|
|
83
|
+
let schemaText;
|
|
84
|
+
for (const rel of convexRels) {
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = readFileSync(join(target, rel), "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
files.push({ rel, text });
|
|
93
|
+
if (/(^|\/)schema\.ts$/.test(rel))
|
|
94
|
+
schemaText = text;
|
|
95
|
+
}
|
|
96
|
+
return scanCrudCoverage(schemaText, files, probeRoutes(target));
|
|
97
|
+
}
|
|
98
|
+
/** Probe the app-route surface as a sibling of the convex `target` dir. Reports
|
|
99
|
+
* BOTH whether a candidate app/src routes dir EXISTS and the route paths found in
|
|
100
|
+
* it, so the scan can tell "no routes dir" (n/a) from "dir exists, no router
|
|
101
|
+
* convention recognised" (could-not-judge) — Eta REVISE blocker 3. Recognises
|
|
102
|
+
* Next/SvelteKit convention files AND a react-router `<Route path>` /
|
|
103
|
+
* `createBrowserRouter` router in App.tsx/main.tsx/a routes file. */
|
|
104
|
+
function probeRoutes(target) {
|
|
105
|
+
const ROUTE_FILE = /(^|\/)(page\.tsx|route\.tsx?|\+page\.svelte)$/;
|
|
106
|
+
const appRoot = resolve(target, "..");
|
|
107
|
+
let candidateDir;
|
|
108
|
+
const routePaths = [];
|
|
109
|
+
// (a) Next/SvelteKit convention dirs (a sibling of the convex target).
|
|
110
|
+
for (const candidate of ["src/app", "app", "src/routes"]) {
|
|
111
|
+
const routeRoot = join(appRoot, candidate);
|
|
112
|
+
let isDir = false;
|
|
113
|
+
try {
|
|
114
|
+
isDir = statSync(routeRoot).isDirectory();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
isDir = false;
|
|
118
|
+
}
|
|
119
|
+
if (!isDir)
|
|
120
|
+
continue;
|
|
121
|
+
if (candidateDir === undefined)
|
|
122
|
+
candidateDir = candidate;
|
|
123
|
+
routePaths.push(...collectFiles(routeRoot, routeRoot, (rel) => ROUTE_FILE.test(rel)));
|
|
124
|
+
}
|
|
125
|
+
// (b) react-router (Vite SPA): parse the router source for its route paths.
|
|
126
|
+
for (const rel of [
|
|
127
|
+
"src/App.tsx",
|
|
128
|
+
"src/main.tsx",
|
|
129
|
+
"src/routes.tsx",
|
|
130
|
+
"src/router.tsx",
|
|
131
|
+
"App.tsx",
|
|
132
|
+
"main.tsx",
|
|
133
|
+
]) {
|
|
134
|
+
const full = join(appRoot, rel);
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
text = readFileSync(full, "utf8");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// the src dir hosting the router is a candidate app dir, even if its own
|
|
143
|
+
// route-path extraction turns up empty (→ could-not-judge, never n/a).
|
|
144
|
+
if (candidateDir === undefined)
|
|
145
|
+
candidateDir = dirname(rel) || rel;
|
|
146
|
+
routePaths.push(...extractReactRouterPaths(text));
|
|
147
|
+
}
|
|
148
|
+
return { candidateDir, routePaths };
|
|
149
|
+
}
|
|
45
150
|
function main() {
|
|
46
151
|
const target = resolve(process.argv[2] ?? process.cwd());
|
|
47
152
|
// Instrument self-check, BEFORE any judging: a backend-doctor run reads its
|
|
@@ -62,6 +167,14 @@ function main() {
|
|
|
62
167
|
...notCheckableResults(),
|
|
63
168
|
];
|
|
64
169
|
console.log(renderTable("backend-doctor (VantagePeers oracle @ day158)", results));
|
|
170
|
+
// CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule
|
|
171
|
+
// in the pinned 50-rule standard). Rows are EVERY defineTable table derived
|
|
172
|
+
// from the target schema at run time; the union of rows MUST equal the schema
|
|
173
|
+
// table set or the run is RED (fail-closed).
|
|
174
|
+
console.log("");
|
|
175
|
+
const crud = scanCrudCoverageForCli(target);
|
|
176
|
+
console.log(crudCoverageSection(crud));
|
|
177
|
+
const crudDefect = crud.status === "unreadable" || !crud.unionEqualsSchema;
|
|
65
178
|
console.log("");
|
|
66
179
|
const cov = coverage();
|
|
67
180
|
console.log(coverageSummary(cov));
|
|
@@ -82,6 +195,10 @@ function main() {
|
|
|
82
195
|
const v = verdict(t, results);
|
|
83
196
|
console.log("");
|
|
84
197
|
console.log(tallyLine(t));
|
|
198
|
+
// The handler-surface slice (R-50/R-51) reported APART from the doc/oracle/
|
|
199
|
+
// process rules, so its could-not-judge count is visible on its own and can be
|
|
200
|
+
// driven to 0 (Laurent: an instrument that cannot go red protects nothing).
|
|
201
|
+
console.log(handlerSurfaceLine(handlerSurfaceStats(results)));
|
|
85
202
|
console.log(v.summary);
|
|
86
203
|
if (v.criticalViolations > 0) {
|
|
87
204
|
console.log(` critical violations: ${v.violations.critical.join(", ")}`);
|
|
@@ -105,6 +222,13 @@ function main() {
|
|
|
105
222
|
console.log("\nNOTE: coverage is INCOMPLETE — a rule is in neither bucket. See the summary above.");
|
|
106
223
|
process.exit(1);
|
|
107
224
|
}
|
|
225
|
+
// The CRUD coverage self-check is fail-closed: an unreadable schema or a rows
|
|
226
|
+
// union that does not cover the schema tables is a DOCTOR DEFECT → RED, never a
|
|
227
|
+
// silent pass. (ABSENT cells are honest reported gaps, NOT a defect.)
|
|
228
|
+
if (crudDefect) {
|
|
229
|
+
console.log("\nNOTE: CRUD coverage self-check FAILED — the rows do not cover every schema table (or the schema is unreadable). See the CRUD COVERAGE section above.");
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
108
232
|
process.exit(v.exitCode);
|
|
109
233
|
}
|
|
110
234
|
main();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** A convex source file, reconstructed as text — the unit the scanner reads. */
|
|
2
|
+
export interface SourceFile {
|
|
3
|
+
rel: string;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
/** Each cell is exactly one of these. `n/a: <reason>` (a MEASURED absence — there
|
|
7
|
+
* is genuinely no candidate routes dir) and `could-not-judge: <reason>` (a
|
|
8
|
+
* candidate dir EXISTS but no router convention was recognised, so presence was
|
|
9
|
+
* NOT measured) are DISTINCT — `n/a` must never stand for an unmeasured cell. */
|
|
10
|
+
export type Cell = "present" | "ABSENT" | `n/a: ${string}` | `could-not-judge: ${string}`;
|
|
11
|
+
/**
|
|
12
|
+
* The app-route probe the CLI gathers from the filesystem and hands to the scan.
|
|
13
|
+
* It carries the two facts the cell disposition turns on: whether a candidate
|
|
14
|
+
* app/src routes dir EXISTS at all, and the normalized route paths recognised in
|
|
15
|
+
* it (Next/SvelteKit convention file rels AND react-router `<Route path>` /
|
|
16
|
+
* `createBrowserRouter` paths). The distinction closes Eta REVISE blocker 3:
|
|
17
|
+
* `n/a` asserts absence, but the pre-fix probe only knew its OWN matcher found
|
|
18
|
+
* nothing — a full react-router dir rendered n/a, a false assertion of absence.
|
|
19
|
+
*/
|
|
20
|
+
export interface RouteProbe {
|
|
21
|
+
/** a candidate app/src routes dir that EXISTS on disk, named — undefined iff
|
|
22
|
+
* there is genuinely no candidate routes dir at all (the only `n/a` case). */
|
|
23
|
+
candidateDir?: string;
|
|
24
|
+
/** normalized route path strings — Next/SvelteKit file rels OR react-router
|
|
25
|
+
* paths. Non-empty means a router WAS recognised and cells are judged. */
|
|
26
|
+
routePaths: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Extract react-router route paths from a router source (App.tsx / main.tsx /
|
|
29
|
+
* a routes file): both the `<Route path="…">` element form and the
|
|
30
|
+
* `createBrowserRouter([{ path: "…" }])` object form. Deduped. */
|
|
31
|
+
export declare function extractReactRouterPaths(text: string): string[];
|
|
32
|
+
export interface TableRow {
|
|
33
|
+
table: string;
|
|
34
|
+
create: Cell;
|
|
35
|
+
get: Cell;
|
|
36
|
+
update: Cell;
|
|
37
|
+
remove: Cell;
|
|
38
|
+
list: Cell;
|
|
39
|
+
detail: Cell;
|
|
40
|
+
edit: Cell;
|
|
41
|
+
}
|
|
42
|
+
export interface AbsentCell {
|
|
43
|
+
table: string;
|
|
44
|
+
column: string;
|
|
45
|
+
}
|
|
46
|
+
export interface CrudCoverage {
|
|
47
|
+
status: "ok" | "unreadable";
|
|
48
|
+
/** set when status === "unreadable" — names what could not be read. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
rows: TableRow[];
|
|
51
|
+
rowCount: number;
|
|
52
|
+
schemaTableCount: number;
|
|
53
|
+
unionEqualsSchema: boolean;
|
|
54
|
+
absentCells: AbsentCell[];
|
|
55
|
+
}
|
|
56
|
+
export declare function deriveTableNames(schemaText: string): string[];
|
|
57
|
+
/** One public query/mutation, with the three attribution inputs: the file it
|
|
58
|
+
* lives in, its export name, its `args:` validator text (for `v.id("<table>")`)
|
|
59
|
+
* and its handler body text (for the `ctx.db.*` op it performs). */
|
|
60
|
+
export interface Handler {
|
|
61
|
+
/** module basename, lower-cased, no `.ts` (e.g. `tasks`). */
|
|
62
|
+
fileBase: string;
|
|
63
|
+
/** the export name (e.g. `get`, `update`, `remove`). */
|
|
64
|
+
exportName: string;
|
|
65
|
+
/** the `args:` object literal source text (may be ""). */
|
|
66
|
+
argsText: string;
|
|
67
|
+
/** the `handler:` function source text (may be ""). */
|
|
68
|
+
body: string;
|
|
69
|
+
}
|
|
70
|
+
/** Every PUBLIC query/mutation exported from one module, with its attribution
|
|
71
|
+
* inputs. Non-exported / internal* builders are excluded. */
|
|
72
|
+
export declare function publicHandlers(name: string, text: string): Handler[];
|
|
73
|
+
export declare function scanCrudCoverage(schemaText: string | undefined, files: SourceFile[], routeFiles: string[] | RouteProbe): CrudCoverage;
|
|
74
|
+
export declare function coverageUnionOk(schemaTables: string[], rows: {
|
|
75
|
+
table: string;
|
|
76
|
+
}[]): {
|
|
77
|
+
ok: boolean;
|
|
78
|
+
missing: string[];
|
|
79
|
+
};
|
|
80
|
+
export declare function crudCoverageSection(cov: CrudCoverage): string;
|
|
81
|
+
export declare function crudCoverageLine(cov: CrudCoverage): string;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule in
|
|
2
|
+
// the pinned 50-rule standard). It answers, per SCHEMA TABLE derived at run time:
|
|
3
|
+
// does a public convex create/get/update/remove exist, and (if an app routes dir
|
|
4
|
+
// exists) a list/detail/edit route? Every cell is exactly one of
|
|
5
|
+
// `present` | `ABSENT` | `n/a: <written reason>`.
|
|
6
|
+
//
|
|
7
|
+
// DERIVE-NEVER-TYPE: the rows are EVERY `defineTable` table parsed from the
|
|
8
|
+
// target's schema.ts at run time (never a hand-typed subset). If the schema
|
|
9
|
+
// cannot be parsed / yields no table, the report is `unreadable` (fail-closed) —
|
|
10
|
+
// it must NEVER present a subset as a complete inventory.
|
|
11
|
+
//
|
|
12
|
+
// LOAD-BEARING: the union of the report's rows MUST equal the set of schema
|
|
13
|
+
// tables. A schema table ABSENT from the rows is a DOCTOR DEFECT → the run is RED
|
|
14
|
+
// (fail-closed), never a silent pass. `coverageUnionOk` is that assertion.
|
|
15
|
+
//
|
|
16
|
+
// Parsing reuses the R-46 approach (the TypeScript compiler API) — see
|
|
17
|
+
// src/detectors/standard/r46-returns-validator.ts — so the table set is the same
|
|
18
|
+
// truth R-46 derives, never a second, drifting parser.
|
|
19
|
+
import * as ts from "typescript";
|
|
20
|
+
import { deriveSchemaFields } from "./detectors/standard/r46-returns-validator.js";
|
|
21
|
+
/** Extract react-router route paths from a router source (App.tsx / main.tsx /
|
|
22
|
+
* a routes file): both the `<Route path="…">` element form and the
|
|
23
|
+
* `createBrowserRouter([{ path: "…" }])` object form. Deduped. */
|
|
24
|
+
export function extractReactRouterPaths(text) {
|
|
25
|
+
const paths = [];
|
|
26
|
+
const routeEl = /<Route\b[^>]*\bpath\s*=\s*["'`]([^"'`]*)["'`]/g;
|
|
27
|
+
const routeObj = /\bpath\s*:\s*["'`]([^"'`]*)["'`]/g;
|
|
28
|
+
for (const m of text.matchAll(routeEl))
|
|
29
|
+
paths.push(m[1]);
|
|
30
|
+
for (const m of text.matchAll(routeObj))
|
|
31
|
+
paths.push(m[1]);
|
|
32
|
+
return [...new Set(paths)];
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Schema → table names. Reuse the R-46 derivation so there is ONE parser of
|
|
36
|
+
// truth. Throwing / zero tables is surfaced by the caller as `unreadable`.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
export function deriveTableNames(schemaText) {
|
|
39
|
+
return [...deriveSchemaFields(schemaText).keys()];
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Public-handler bodies. We only judge PUBLIC query/mutation surface — the
|
|
43
|
+
// internal* variants are not a user-facing CRUD path. Each public handler's
|
|
44
|
+
// body text is extracted so the db-op scan never trips on a `v.id("table")` in
|
|
45
|
+
// the args/returns validators (those live outside the handler body).
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// A PUBLIC function-builder identifier: the bare `query`/`mutation`, or a repo
|
|
48
|
+
// custom builder that ends in `Query`/`Mutation` (Convex convention — e.g.
|
|
49
|
+
// `authedQuery`, `writeMutation`, `publicQuery`). `internal*` builders are NOT a
|
|
50
|
+
// user-facing surface and are excluded, so an internalMutation write does not
|
|
51
|
+
// count as a public CRUD path.
|
|
52
|
+
function isPublicBuilder(name) {
|
|
53
|
+
if (/^internal/i.test(name))
|
|
54
|
+
return false;
|
|
55
|
+
return /(^query$|^mutation$|Query$|Mutation$)/.test(name);
|
|
56
|
+
}
|
|
57
|
+
function parse(name, text) {
|
|
58
|
+
return ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
59
|
+
}
|
|
60
|
+
/** Every PUBLIC query/mutation exported from one module, with its attribution
|
|
61
|
+
* inputs. Non-exported / internal* builders are excluded. */
|
|
62
|
+
export function publicHandlers(name, text) {
|
|
63
|
+
let sf;
|
|
64
|
+
try {
|
|
65
|
+
sf = parse(name, text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const fileBase = (name.split("/").pop() ?? name)
|
|
71
|
+
.replace(/\.ts$/, "")
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
const handlers = [];
|
|
74
|
+
function fromConfig(config, exportName) {
|
|
75
|
+
let argsText = "";
|
|
76
|
+
let body = "";
|
|
77
|
+
for (const prop of config.properties) {
|
|
78
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
|
|
79
|
+
continue;
|
|
80
|
+
if (prop.name.text === "args")
|
|
81
|
+
argsText = prop.initializer.getText(sf);
|
|
82
|
+
if (prop.name.text === "handler")
|
|
83
|
+
body = prop.initializer.getText(sf);
|
|
84
|
+
}
|
|
85
|
+
handlers.push({ fileBase, exportName, argsText, body });
|
|
86
|
+
}
|
|
87
|
+
function visit(node) {
|
|
88
|
+
if (ts.isVariableStatement(node) &&
|
|
89
|
+
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
90
|
+
for (const decl of node.declarationList.declarations) {
|
|
91
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer)
|
|
92
|
+
continue;
|
|
93
|
+
const init = decl.initializer;
|
|
94
|
+
if (ts.isCallExpression(init) &&
|
|
95
|
+
ts.isIdentifier(init.expression) &&
|
|
96
|
+
isPublicBuilder(init.expression.text)) {
|
|
97
|
+
const arg = init.arguments[0];
|
|
98
|
+
if (arg && ts.isObjectLiteralExpression(arg))
|
|
99
|
+
fromConfig(arg, decl.name.text);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
ts.forEachChild(node, visit);
|
|
104
|
+
}
|
|
105
|
+
visit(sf);
|
|
106
|
+
return handlers;
|
|
107
|
+
}
|
|
108
|
+
const CREATE_NAMES = new Set(["create", "add", "insert", "new"]);
|
|
109
|
+
const GET_NAMES = new Set(["get", "getbyid", "detail", "show", "byid", "find"]);
|
|
110
|
+
const UPDATE_NAMES = new Set(["update", "patch", "edit", "modify", "set"]);
|
|
111
|
+
const REMOVE_NAMES = new Set(["remove", "delete", "del", "destroy", "archive"]);
|
|
112
|
+
/** the schema table a file basename attributes to, singular/plural tolerant. */
|
|
113
|
+
function schemaTableForBase(base, tables) {
|
|
114
|
+
for (const t of tables) {
|
|
115
|
+
const tl = t.toLowerCase();
|
|
116
|
+
if (base === tl)
|
|
117
|
+
return t;
|
|
118
|
+
if (base === singular(t).toLowerCase())
|
|
119
|
+
return t;
|
|
120
|
+
if (singular(base) === singular(t).toLowerCase())
|
|
121
|
+
return t;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const ID_ARG = /v\.id\(\s*["']([a-zA-Z_][\w]*)["']/g;
|
|
126
|
+
const LITERAL_REF = /["']([a-zA-Z_][\w]*)["']/g;
|
|
127
|
+
function attribute(handlers, tables) {
|
|
128
|
+
const tableSet = new Set(tables);
|
|
129
|
+
const present = new Map();
|
|
130
|
+
const touched = new Set();
|
|
131
|
+
const mark = (t, col) => {
|
|
132
|
+
if (!tableSet.has(t))
|
|
133
|
+
return;
|
|
134
|
+
let s = present.get(t);
|
|
135
|
+
if (!s) {
|
|
136
|
+
s = new Set();
|
|
137
|
+
present.set(t, s);
|
|
138
|
+
}
|
|
139
|
+
s.add(col);
|
|
140
|
+
touched.add(t);
|
|
141
|
+
};
|
|
142
|
+
const markLiterals = (body, op, col) => {
|
|
143
|
+
const re = new RegExp(`\\.db\\.${op}\\(\\s*["']([a-zA-Z_][\\w]*)["']`, "g");
|
|
144
|
+
for (const m of body.matchAll(re))
|
|
145
|
+
mark(m[1], col);
|
|
146
|
+
};
|
|
147
|
+
for (const h of handlers) {
|
|
148
|
+
// (3) explicit table-literal ops
|
|
149
|
+
markLiterals(h.body, "insert", "create");
|
|
150
|
+
markLiterals(h.body, "get", "get");
|
|
151
|
+
markLiterals(h.body, "patch", "update");
|
|
152
|
+
markLiterals(h.body, "replace", "update");
|
|
153
|
+
markLiterals(h.body, "delete", "remove");
|
|
154
|
+
// (1) args `v.id("<t>")` + body op with no table literal
|
|
155
|
+
const argT = new Set();
|
|
156
|
+
for (const m of h.argsText.matchAll(ID_ARG))
|
|
157
|
+
if (tableSet.has(m[1]))
|
|
158
|
+
argT.add(m[1]);
|
|
159
|
+
const bodyGet = /\.db\.get\s*\(/.test(h.body);
|
|
160
|
+
const bodyUpd = /\.db\.(patch|replace)\s*\(/.test(h.body);
|
|
161
|
+
const bodyDel = /\.db\.delete\s*\(/.test(h.body);
|
|
162
|
+
for (const t of argT) {
|
|
163
|
+
touched.add(t);
|
|
164
|
+
if (bodyGet)
|
|
165
|
+
mark(t, "get");
|
|
166
|
+
if (bodyUpd)
|
|
167
|
+
mark(t, "update");
|
|
168
|
+
if (bodyDel)
|
|
169
|
+
mark(t, "remove");
|
|
170
|
+
}
|
|
171
|
+
// (2) file basename + export name
|
|
172
|
+
const ft = schemaTableForBase(h.fileBase, tables);
|
|
173
|
+
if (ft) {
|
|
174
|
+
touched.add(ft);
|
|
175
|
+
const n = h.exportName.toLowerCase();
|
|
176
|
+
if (CREATE_NAMES.has(n))
|
|
177
|
+
mark(ft, "create");
|
|
178
|
+
if (GET_NAMES.has(n))
|
|
179
|
+
mark(ft, "get");
|
|
180
|
+
if (UPDATE_NAMES.has(n))
|
|
181
|
+
mark(ft, "update");
|
|
182
|
+
if (REMOVE_NAMES.has(n))
|
|
183
|
+
mark(ft, "remove");
|
|
184
|
+
}
|
|
185
|
+
// touched (for the n/a distinction): any schema-table name quoted anywhere.
|
|
186
|
+
for (const m of `${h.body}\n${h.argsText}`.matchAll(LITERAL_REF))
|
|
187
|
+
if (tableSet.has(m[1]))
|
|
188
|
+
touched.add(m[1]);
|
|
189
|
+
}
|
|
190
|
+
return { present, touched };
|
|
191
|
+
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// App-route detection. A route dir "exists in this tree" iff at least one route
|
|
194
|
+
// file was probed. When none exists, list/detail/edit are n/a with the written
|
|
195
|
+
// reason. When one exists, a table's route presence is judged by a path segment
|
|
196
|
+
// matching the table name (or its singular), with `[..]`/detail and `edit`.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
const NO_ROUTES = "n/a: no app routes dir in this tree";
|
|
199
|
+
/** a dynamic route segment — Next `[id]` OR react-router `:id`. */
|
|
200
|
+
const DYNAMIC_SEG = /\[[^\]]+\]|:[A-Za-z0-9_]+/;
|
|
201
|
+
const EDIT_SEG = /\/edit(\/|$|\.)/;
|
|
202
|
+
function singular(table) {
|
|
203
|
+
if (table.endsWith("ies"))
|
|
204
|
+
return `${table.slice(0, -3)}y`;
|
|
205
|
+
if (table.endsWith("s"))
|
|
206
|
+
return table.slice(0, -1);
|
|
207
|
+
return table;
|
|
208
|
+
}
|
|
209
|
+
function segMatches(path, table) {
|
|
210
|
+
const segs = path.toLowerCase().split("/");
|
|
211
|
+
const names = new Set([table.toLowerCase(), singular(table).toLowerCase()]);
|
|
212
|
+
return segs.some((s) => names.has(s));
|
|
213
|
+
}
|
|
214
|
+
/** Normalize the third scan argument: a bare `string[]` (legacy / Next-file list)
|
|
215
|
+
* becomes a probe where a non-empty list means a router WAS recognised, and an
|
|
216
|
+
* empty list means no candidate dir at all (`n/a`). A `RouteProbe` passes through. */
|
|
217
|
+
function normalizeProbe(input) {
|
|
218
|
+
if (Array.isArray(input)) {
|
|
219
|
+
return {
|
|
220
|
+
routePaths: input,
|
|
221
|
+
candidateDir: input.length > 0 ? "app routes" : undefined,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return input;
|
|
225
|
+
}
|
|
226
|
+
function routeCells(table, probe) {
|
|
227
|
+
// A router WAS recognised (Next/SvelteKit files or react-router paths) — judge
|
|
228
|
+
// each table's presence against it. An unmatched table is a real ABSENT gap.
|
|
229
|
+
if (probe.routePaths.length > 0) {
|
|
230
|
+
const forTable = probe.routePaths.filter((p) => segMatches(p, table));
|
|
231
|
+
const hasList = forTable.some((p) => !DYNAMIC_SEG.test(p) && !EDIT_SEG.test(p));
|
|
232
|
+
const hasDetail = forTable.some((p) => DYNAMIC_SEG.test(p) && !EDIT_SEG.test(p));
|
|
233
|
+
const hasEdit = forTable.some((p) => EDIT_SEG.test(p));
|
|
234
|
+
return {
|
|
235
|
+
list: hasList ? "present" : "ABSENT",
|
|
236
|
+
detail: hasDetail ? "present" : "ABSENT",
|
|
237
|
+
edit: hasEdit ? "present" : "ABSENT",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// A candidate app/src dir EXISTS but no known router convention was recognised
|
|
241
|
+
// in it — presence was NOT measured. `could-not-judge` NAMING the dir, never
|
|
242
|
+
// `n/a` (which would falsely assert absence of a dir that is right there).
|
|
243
|
+
if (probe.candidateDir !== undefined) {
|
|
244
|
+
const cnj = `could-not-judge: routes dir "${probe.candidateDir}" exists but no known router convention (Next page.tsx / SvelteKit +page.svelte / react-router <Route path>/createBrowserRouter) was recognised — route presence was NOT measured, so this is could-not-judge, not an asserted absence`;
|
|
245
|
+
return { list: cnj, detail: cnj, edit: cnj };
|
|
246
|
+
}
|
|
247
|
+
// Genuinely no candidate routes dir at all — the only honest `n/a`.
|
|
248
|
+
return { list: NO_ROUTES, detail: NO_ROUTES, edit: NO_ROUTES };
|
|
249
|
+
}
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// The scan — one row per schema table, fail-closed on an unreadable schema.
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
export function scanCrudCoverage(schemaText, files, routeFiles) {
|
|
254
|
+
const probe = normalizeProbe(routeFiles);
|
|
255
|
+
if (schemaText === undefined) {
|
|
256
|
+
return unreadable("no schema.ts with a defineTable found in the target tree — the table set cannot be DERIVED (fail-closed, NOT an empty inventory)");
|
|
257
|
+
}
|
|
258
|
+
let tables;
|
|
259
|
+
try {
|
|
260
|
+
tables = deriveTableNames(schemaText);
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
return unreadable(`schema.ts could not be parsed (${e.message}) — the table set cannot be DERIVED (fail-closed)`);
|
|
264
|
+
}
|
|
265
|
+
if (tables.length === 0) {
|
|
266
|
+
return unreadable("schema.ts parsed but yielded no defineTable tables — the inventory cannot be complete (fail-closed)");
|
|
267
|
+
}
|
|
268
|
+
// Collect every public handler across the convex tree once, then attribute
|
|
269
|
+
// its ops to tables via the three signals (v.id arg, filename, literal).
|
|
270
|
+
const handlers = [];
|
|
271
|
+
for (const f of files) {
|
|
272
|
+
if (/\.test\.ts$/.test(f.rel) || f.rel.includes("_generated/"))
|
|
273
|
+
continue;
|
|
274
|
+
if (/(^|\/)schema\.ts$/.test(f.rel))
|
|
275
|
+
continue;
|
|
276
|
+
handlers.push(...publicHandlers(f.rel, f.text));
|
|
277
|
+
}
|
|
278
|
+
const { present, touched } = attribute(handlers, tables);
|
|
279
|
+
const rows = [];
|
|
280
|
+
const absentCells = [];
|
|
281
|
+
for (const table of tables.slice().sort()) {
|
|
282
|
+
const cols = present.get(table) ?? new Set();
|
|
283
|
+
const rc = routeCells(table, probe);
|
|
284
|
+
// A table touched by NO public handler at all (no file, no literal, no v.id
|
|
285
|
+
// arg) is legitimately without a user-facing convex surface in this tree — an
|
|
286
|
+
// n/a with a written reason, not four bare ABSENT cells that read as gaps.
|
|
287
|
+
const naReason = `n/a: no public handler references table "${table}"`;
|
|
288
|
+
const isTouched = cols.size > 0 || touched.has(table);
|
|
289
|
+
const cell = (col) => isTouched ? (cols.has(col) ? "present" : "ABSENT") : naReason;
|
|
290
|
+
const row = {
|
|
291
|
+
table,
|
|
292
|
+
create: cell("create"),
|
|
293
|
+
get: cell("get"),
|
|
294
|
+
update: cell("update"),
|
|
295
|
+
remove: cell("remove"),
|
|
296
|
+
list: rc.list,
|
|
297
|
+
detail: rc.detail,
|
|
298
|
+
edit: rc.edit,
|
|
299
|
+
};
|
|
300
|
+
rows.push(row);
|
|
301
|
+
for (const col of [
|
|
302
|
+
"create",
|
|
303
|
+
"get",
|
|
304
|
+
"update",
|
|
305
|
+
"remove",
|
|
306
|
+
"list",
|
|
307
|
+
"detail",
|
|
308
|
+
"edit",
|
|
309
|
+
]) {
|
|
310
|
+
if (row[col] === "ABSENT")
|
|
311
|
+
absentCells.push({ table, column: col });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const union = coverageUnionOk(tables, rows);
|
|
315
|
+
return {
|
|
316
|
+
status: "ok",
|
|
317
|
+
rows,
|
|
318
|
+
rowCount: rows.length,
|
|
319
|
+
schemaTableCount: tables.length,
|
|
320
|
+
unionEqualsSchema: union.ok,
|
|
321
|
+
absentCells,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function unreadable(reason) {
|
|
325
|
+
return {
|
|
326
|
+
status: "unreadable",
|
|
327
|
+
reason,
|
|
328
|
+
rows: [],
|
|
329
|
+
rowCount: 0,
|
|
330
|
+
schemaTableCount: 0,
|
|
331
|
+
unionEqualsSchema: false,
|
|
332
|
+
absentCells: [],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// The load-bearing coverage assertion: the rows' table union MUST equal the
|
|
337
|
+
// schema table set. A schema table missing from the rows is a DOCTOR DEFECT.
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
export function coverageUnionOk(schemaTables, rows) {
|
|
340
|
+
const have = new Set(rows.map((r) => r.table));
|
|
341
|
+
const missing = schemaTables.filter((t) => !have.has(t)).sort();
|
|
342
|
+
return { ok: missing.length === 0, missing };
|
|
343
|
+
}
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Rendering — the CLI section + the one-line summary.
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
function short(cell) {
|
|
348
|
+
if (cell === "present")
|
|
349
|
+
return "present";
|
|
350
|
+
if (cell === "ABSENT")
|
|
351
|
+
return "ABSENT ";
|
|
352
|
+
return cell; // n/a: ...
|
|
353
|
+
}
|
|
354
|
+
export function crudCoverageSection(cov) {
|
|
355
|
+
const header = "=== CRUD COVERAGE (per schema table, derived at run time) ===";
|
|
356
|
+
if (cov.status === "unreadable") {
|
|
357
|
+
return `${header}\n UNREADABLE (fail-closed): ${cov.reason}\n${crudCoverageLine(cov)}`;
|
|
358
|
+
}
|
|
359
|
+
const lines = cov.rows.map((r) => {
|
|
360
|
+
return (` ${r.table.padEnd(28)} ` +
|
|
361
|
+
`create=${short(r.create)} get=${short(r.get)} ` +
|
|
362
|
+
`update=${short(r.update)} remove=${short(r.remove)} ` +
|
|
363
|
+
`| list=${short(r.list)} detail=${short(r.detail)} edit=${short(r.edit)}`);
|
|
364
|
+
});
|
|
365
|
+
return `${header}\n${lines.join("\n")}\n${crudCoverageLine(cov)}`;
|
|
366
|
+
}
|
|
367
|
+
export function crudCoverageLine(cov) {
|
|
368
|
+
if (cov.status === "unreadable") {
|
|
369
|
+
return `coverage UNREADABLE (fail-closed) — ${cov.reason}`;
|
|
370
|
+
}
|
|
371
|
+
const defect = cov.unionEqualsSchema
|
|
372
|
+
? ""
|
|
373
|
+
: " | DOCTOR DEFECT: rows do NOT cover the schema (RED, fail-closed)";
|
|
374
|
+
return `coverage rows=${cov.rowCount} schema tables=${cov.schemaTableCount} | ABSENT cells=${cov.absentCells.length}${defect}`;
|
|
375
|
+
}
|
|
@@ -40,11 +40,16 @@ export function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
40
40
|
.map((x) => `${x.rel}:${x.n} (${x.note})`)
|
|
41
41
|
.join("; ");
|
|
42
42
|
if (nV > 0) {
|
|
43
|
+
// A non-conforming verdict must NAME its could-not-judge sites, not drop them
|
|
44
|
+
// to a bare integer in the breakdown — the one disposition that owes a human
|
|
45
|
+
// verdict is otherwise the only one printed without an address (Eta REVISE
|
|
46
|
+
// blocker 1: R-51 CRM could-not-judge=1 was counted but never located). The
|
|
47
|
+
// data is already in scan.cnj; list it under its own label whenever non-empty.
|
|
43
48
|
return {
|
|
44
49
|
id,
|
|
45
50
|
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
46
51
|
state: "non-conforming",
|
|
47
|
-
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
|
|
52
|
+
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (human verdict owed): ${fmt(scan.cnj)}.` : ""}`,
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
if (nJ > 0) {
|
|
@@ -342,7 +342,7 @@ export function scanR50(files) {
|
|
|
342
342
|
ungated.push({
|
|
343
343
|
rel: f.rel,
|
|
344
344
|
n: block.lines[0].n,
|
|
345
|
-
note: `${fn}: public query with no org-throw, no fail-closed resolver, and no isolation-contract marker —
|
|
345
|
+
note: `${fn}: public query with no org-throw, no fail-closed resolver, and no isolation-contract marker — an UNMARKED public read. No org-isolation handling and no client contract is visible in convex/; an instrument that abstains here protects nothing (a fully-unmarked read is exactly the surface a subscribing client can hit before an org exists). NON-CONFORMING until the author resolves org via a fail-closed non-throwing path (\`withOrgScope\`), returns a typed empty/unauthorized shape, or records an isolation-contract marker`,
|
|
346
346
|
});
|
|
347
347
|
continue;
|
|
348
348
|
}
|
|
@@ -410,15 +410,15 @@ export function scanR50(files) {
|
|
|
410
410
|
// so the verdict is CNJ-with-sites, not a misleading empty abstain. Gated on "scan
|
|
411
411
|
// otherwise empty" so a tree that already produced subjects (VP: 20 conform) is
|
|
412
412
|
// byte-for-byte unchanged.
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// mixed (bare reads alongside gated subjects).
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
413
|
+
// An UNMARKED bare public read (no throw / resolver / marker) is NON-CONFORMING,
|
|
414
|
+
// never an abstention — whether the scan is otherwise empty (a fully-unmarked
|
|
415
|
+
// backend) OR mixed (bare reads alongside gated subjects). Laurent doctrine: "an
|
|
416
|
+
// instrument that cannot go red protects nothing; CNJ is for UNREADABLE input
|
|
417
|
+
// only." A bare read is fully READABLE — its two absences (no isolation, no
|
|
418
|
+
// contract) are the defect, not a could-not-read. Push to violate; the author's
|
|
419
|
+
// exit is a resolver, a typed-empty return, or a recorded isolation-contract marker.
|
|
420
420
|
for (const u of ungated)
|
|
421
|
-
scan.
|
|
421
|
+
scan.violate.push(u);
|
|
422
422
|
return scan;
|
|
423
423
|
}
|
|
424
424
|
export const R50 = srcDetector("R-50", "R-50 org isolation on a reactively-subscribed read returns a typed empty/unauthorized result, never an uncaught throw", "a public query that resolves org via a fail-closed non-throwing path or documents its client contract — it never throws on the missing-org path a subscribing client cannot catch", scanR50, "R-50: no public query matched in the scanned tree — no reactive read to certify. NOT a clean bill.");
|
|
@@ -252,7 +252,7 @@ export function scanR51(files) {
|
|
|
252
252
|
ungated.push({
|
|
253
253
|
rel: f.rel,
|
|
254
254
|
n: block.lines[0].n,
|
|
255
|
-
note: `${fn}: public mutation with no org-throw, no typed-unauthorized return, and no write-contract marker —
|
|
255
|
+
note: `${fn}: public mutation with no org-throw, no typed-unauthorized return, and no write-contract marker — an UNMARKED public write. No org-isolation handling and no client contract is visible in convex/; an instrument that abstains here protects nothing (a fully-unmarked write is exactly the surface a pre-org client shell can issue). NON-CONFORMING until the author isolates org via a fail-closed path, returns a typed unauthorized result, or records a write-contract marker`,
|
|
256
256
|
});
|
|
257
257
|
continue; // not a gated subject
|
|
258
258
|
}
|
|
@@ -321,16 +321,16 @@ export function scanR51(files) {
|
|
|
321
321
|
// abstain. Gated on "scan otherwise empty" so a tree that already produced
|
|
322
322
|
// subjects (VP: 7 marker CNJ) is byte-for-byte unchanged — the bare-write
|
|
323
323
|
// disposition in a mixed tree is out of this fix's scope.
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// surface) OR mixed (bare writes alongside
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
324
|
+
// An UNMARKED bare public write (no throw / typed-unauth / marker) is
|
|
325
|
+
// NON-CONFORMING, never an abstention — whether the scan is otherwise empty (a
|
|
326
|
+
// fully-unmarked public-write surface) OR mixed (bare writes alongside
|
|
327
|
+
// gated/marker subjects). Laurent doctrine: "an instrument that cannot go red
|
|
328
|
+
// protects nothing; CNJ is for UNREADABLE input only." A bare write is fully
|
|
329
|
+
// READABLE — its two absences (no isolation, no contract) are the defect, not a
|
|
330
|
+
// could-not-read. Push to violate; the author's exit is a fail-closed path, a
|
|
331
|
+
// typed unauthorized return, or a recorded write-contract marker.
|
|
332
332
|
for (const u of ungated)
|
|
333
|
-
scan.
|
|
333
|
+
scan.violate.push(u);
|
|
334
334
|
return scan;
|
|
335
335
|
}
|
|
336
336
|
export const R51 = srcDetector("R-51", "R-51 a public mutation returns a typed unauthorized result on the no-org path, never an uncaught throw a pre-org client shell cannot render", "a public write either returns a typed unauthorized result / documents its client contract, or a human confirms a pre-org client cannot reach it — it never throws NO_ORG on a path an ordinary render can hit", scanR51, "R-51: no public mutation matched in the scanned tree — no write to certify. NOT a clean bill.");
|
|
@@ -179,22 +179,23 @@ const LOOP_NAMED = /\bfor\s*\(|\bfor\s+(?:const|let|var|await)\b|\.forEach\s*\(|
|
|
|
179
179
|
const INDEPENDENCE_STATED = /no\s+invariant\s+spans|invariant\s+spans\s+no|writes?\s+are\s+independent|independently\s+atomic|no\s+cross-write\s+invariant/i;
|
|
180
180
|
// Shape-(1) sub-condition (Eta REVISE #26, 2026-08-31): "a pre-I/O gate mutation
|
|
181
181
|
// precedes a single atomic write" describes itself CHECKABLY — it is EXACTLY TWO
|
|
182
|
-
// ctx.runMutation calls, the FIRST a gate (require*/assert*/
|
|
182
|
+
// ctx.runMutation calls, the FIRST a gate (require*/assert*/bind*). A shape-1
|
|
183
183
|
// marker on anything else (three calls, an unbounded loop, a first call that is not a
|
|
184
184
|
// gate) is INCOMPLETE and stays non-conforming, exactly as an incomplete shape-2 does
|
|
185
185
|
// — otherwise eleven words in a comment remove a real violation from the #1250 prod
|
|
186
186
|
// gate (abstentions do not block; mechanical_violations is the sole driver), and the
|
|
187
187
|
// mechanical count that gate rests on is no longer honest.
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
188
|
+
// The gate stem excludes `ensure*` — the standard prefix for an UPSERT WRITE helper
|
|
189
|
+
// (ensureUser/ensureWorkspace/ensureRow). Both shapes whose FIRST runMutation is the gate
|
|
190
|
+
// (shape 1 pre-io-gate, shape 3 gate-then-idempotent-reingest) checked the callee NAME, so
|
|
191
|
+
// accepting `ensure*` let a first WRITE renamed the usual way (_insertImportedMemory ->
|
|
192
|
+
// ensureImportedMemory) masquerade as the gate and buy the exemption — removing a real
|
|
193
|
+
// violation from the #1250 prod gate. Fixed at the CLASS (Eta REVISE #27 probe B closed
|
|
194
|
+
// shape 3; this closes the same hole in shape 1, task k179fmje8vb7y0tbjdmxbzg78h8dhj3q):
|
|
195
|
+
// the defect was a gate sub-condition decided by a name stem that idiomatically names
|
|
196
|
+
// writes, so the fix is one narrowed stem set for every gate check. `bind` STAYS
|
|
197
|
+
// (bindOrAssertStorageOwnership is kb.ts's real gate); `require`/`assert` are unambiguous.
|
|
198
|
+
const GATE_CALLEE = /\b(require|assert|bind)\w*/i;
|
|
198
199
|
export function scanR29(files) {
|
|
199
200
|
const scan = emptyScan();
|
|
200
201
|
for (const f of files) {
|
|
@@ -218,7 +219,7 @@ export function scanR29(files) {
|
|
|
218
219
|
const shapeGate = count === 2 && GATE_CALLEE.test(firstMutCallee);
|
|
219
220
|
// Shape (3) sub-conditions: a leading gate runMutation, a supersede/dedup on
|
|
220
221
|
// a stable namespace, a re-insert loop, AND an explicit convergence statement.
|
|
221
|
-
const shapeReingest =
|
|
222
|
+
const shapeReingest = GATE_CALLEE.test(firstMutCallee) &&
|
|
222
223
|
REINGEST_SUPERSEDE.test(region) &&
|
|
223
224
|
STABLE_KEY.test(region) &&
|
|
224
225
|
LOOP_NAMED.test(region) &&
|
|
@@ -240,7 +241,7 @@ export function scanR29(files) {
|
|
|
240
241
|
? "the region names its loop and states the per-item writes are independently atomic (no invariant spans two)"
|
|
241
242
|
: shape === SHAPE_GATE_REINGEST
|
|
242
243
|
? "a leading gate runMutation, a supersede/dedup keyed on a stable namespace, and a recovering re-insert loop, with the convergence stated explicitly — a re-ingest of the same stable key converges on one current set"
|
|
243
|
-
: "exactly two runMutations, the first a gate (require*/assert*/
|
|
244
|
+
: "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. R-29 refuses (a reviewer verifies the claim per site) — never a silent green (Eta ruling 2026-08-31)`,
|
|
244
245
|
});
|
|
245
246
|
}
|
|
246
247
|
else if (shape === SHAPE_GATE_REINGEST) {
|
|
@@ -248,7 +249,7 @@ export function scanR29(files) {
|
|
|
248
249
|
// NOT buy could-not-judge (Eta: a shape names a checkable sub-condition, or
|
|
249
250
|
// the carve-out becomes a vocabulary).
|
|
250
251
|
const missing = [
|
|
251
|
-
!
|
|
252
|
+
!GATE_CALLEE.test(firstMutCallee) && "a leading gate runMutation (require*/assert*/bind* — ensure* is an upsert-write stem, excluded here)",
|
|
252
253
|
!REINGEST_SUPERSEDE.test(region) && "a supersede/dedup call",
|
|
253
254
|
!STABLE_KEY.test(region) && "a stable namespace key (namespace/docId/contentHash)",
|
|
254
255
|
!LOOP_NAMED.test(region) && "a re-insert loop (for/for-of/.forEach/.map)",
|
|
@@ -266,7 +267,7 @@ export function scanR29(files) {
|
|
|
266
267
|
scan.violate.push({
|
|
267
268
|
rel: f.rel,
|
|
268
269
|
n: firstMut || openLine,
|
|
269
|
-
note: `action ${inAction} orchestrates ${count} ctx.runMutation calls with an atomicity-exception "${SHAPE_PRE_IO_GATE}" marker that is INCOMPLETE — this shape is EXACTLY TWO runMutations whose FIRST is a gate (require*/assert*/
|
|
270
|
+
note: `action ${inAction} orchestrates ${count} ctx.runMutation calls with an atomicity-exception "${SHAPE_PRE_IO_GATE}" marker that is INCOMPLETE — this shape is EXACTLY TWO runMutations whose FIRST is a gate (require*/assert*/bind*); here ${count !== 2
|
|
270
271
|
? `there are ${count}, not two`
|
|
271
272
|
: "the first runMutation is not a gate call"}. A gate-then-single-write is checkable; a marker cannot remove a >2-call or loop site from the gate`,
|
|
272
273
|
});
|
package/dist/tally.d.ts
CHANGED
|
@@ -53,5 +53,40 @@ export interface Verdict {
|
|
|
53
53
|
* on an exit that means abstention.
|
|
54
54
|
*/
|
|
55
55
|
export declare function verdict(t: Tally, results: DetectorResult[], severity?: Map<string, Severity>): Verdict;
|
|
56
|
+
/**
|
|
57
|
+
* The HANDLER-SURFACE rules — the source/*-isolation detectors that judge the
|
|
58
|
+
* public query/mutation surface (R-50 read-isolation, R-51 write-isolation).
|
|
59
|
+
* Reported APART from the document/oracle/process rules (R-32, R-37, R-40,
|
|
60
|
+
* R-48, …) so the handler-surface could-not-judge count is visible on its own
|
|
61
|
+
* and can be driven to 0 without conflating it with honest human-gate CNJ.
|
|
62
|
+
* Laurent doctrine: an instrument that cannot go red protects nothing — the
|
|
63
|
+
* handler-surface CNJ must reach 0 except genuine parse-fail / marker-borne /
|
|
64
|
+
* ambiguous-domination sites.
|
|
65
|
+
*/
|
|
66
|
+
export declare const HANDLER_SURFACE_RULES: readonly string[];
|
|
67
|
+
export interface HandlerSurfaceStats {
|
|
68
|
+
byState: Record<State, number>;
|
|
69
|
+
/** rule-level state per handler-surface ruleId (worst across its results). */
|
|
70
|
+
ruleState: Map<string, State>;
|
|
71
|
+
/** the handler-surface could-not-judge RULE count — R-50/R-51 as rules abstain
|
|
72
|
+
* or they don't. This is 0 whenever both rules reach a verdict, EVEN when the
|
|
73
|
+
* underlying sites include abstaining ones — so it must never be printed alone. */
|
|
74
|
+
couldNotJudge: number;
|
|
75
|
+
/** the handler-surface could-not-judge SITE count — the sum of every rule's
|
|
76
|
+
* scan.cnj length, parsed from each result's `could-not-judge=N` breakdown. A
|
|
77
|
+
* non-conforming rule can still carry abstaining SITES (Eta REVISE blocker 2:
|
|
78
|
+
* the CRM run had rules-abstaining=0 while 8 sites abstained). Both are printed. */
|
|
79
|
+
sitesAbstaining: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The handler-surface slice of the tally, computed over HANDLER_SURFACE_RULES
|
|
83
|
+
* only. Reduces each rule to its worst result state (same reduction as `tally`)
|
|
84
|
+
* so a rule with any non-conforming result reads non-conforming, and surfaces a
|
|
85
|
+
* distinct handler-surface could-not-judge count the CLI prints separately.
|
|
86
|
+
*/
|
|
87
|
+
export declare function handlerSurfaceStats(results: DetectorResult[], handlerRules?: readonly string[]): HandlerSurfaceStats;
|
|
88
|
+
/** The one-line handler-surface tally string the CLI prints, apart from the
|
|
89
|
+
* document/oracle/process rules. */
|
|
90
|
+
export declare function handlerSurfaceLine(s: HandlerSurfaceStats): string;
|
|
56
91
|
/** The one-line machine tally string the CLI prints. */
|
|
57
92
|
export declare function tallyLine(t: Tally): string;
|
package/dist/tally.js
CHANGED
|
@@ -19,7 +19,12 @@ const STATE_RANK = {
|
|
|
19
19
|
absent: 1,
|
|
20
20
|
present: 0,
|
|
21
21
|
};
|
|
22
|
-
const ALL_STATES = [
|
|
22
|
+
const ALL_STATES = [
|
|
23
|
+
"present",
|
|
24
|
+
"non-conforming",
|
|
25
|
+
"could-not-judge",
|
|
26
|
+
"absent",
|
|
27
|
+
];
|
|
23
28
|
/** Extract the ruleId (`R-N`) a result belongs to, from its leading id token. */
|
|
24
29
|
export function ruleIdOf(result) {
|
|
25
30
|
const m = /^(R-\d+)\b/.exec(result.id);
|
|
@@ -143,6 +148,75 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
|
|
|
143
148
|
summary,
|
|
144
149
|
};
|
|
145
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The HANDLER-SURFACE rules — the source/*-isolation detectors that judge the
|
|
153
|
+
* public query/mutation surface (R-50 read-isolation, R-51 write-isolation).
|
|
154
|
+
* Reported APART from the document/oracle/process rules (R-32, R-37, R-40,
|
|
155
|
+
* R-48, …) so the handler-surface could-not-judge count is visible on its own
|
|
156
|
+
* and can be driven to 0 without conflating it with honest human-gate CNJ.
|
|
157
|
+
* Laurent doctrine: an instrument that cannot go red protects nothing — the
|
|
158
|
+
* handler-surface CNJ must reach 0 except genuine parse-fail / marker-borne /
|
|
159
|
+
* ambiguous-domination sites.
|
|
160
|
+
*/
|
|
161
|
+
export const HANDLER_SURFACE_RULES = ["R-50", "R-51"];
|
|
162
|
+
// The per-rule breakdown token every source detect() emits: `could-not-judge=N`.
|
|
163
|
+
// The site figure is the sum of N across the handler-surface rules' results — the
|
|
164
|
+
// same N that names the sites under decide()'s CNJ label, never a re-derivation.
|
|
165
|
+
const CNJ_SITES_IN_VERDICT = /could-not-judge=(\d+)/;
|
|
166
|
+
/**
|
|
167
|
+
* The handler-surface slice of the tally, computed over HANDLER_SURFACE_RULES
|
|
168
|
+
* only. Reduces each rule to its worst result state (same reduction as `tally`)
|
|
169
|
+
* so a rule with any non-conforming result reads non-conforming, and surfaces a
|
|
170
|
+
* distinct handler-surface could-not-judge count the CLI prints separately.
|
|
171
|
+
*/
|
|
172
|
+
export function handlerSurfaceStats(results, handlerRules = HANDLER_SURFACE_RULES) {
|
|
173
|
+
const set = new Set(handlerRules);
|
|
174
|
+
const ruleState = new Map();
|
|
175
|
+
// Sites abstaining is summed over the RAW results (before the rule reduction),
|
|
176
|
+
// so a non-conforming rule's abstaining sites are still counted — one entry per
|
|
177
|
+
// rule's single reduced result here, but robust to multi-result rules.
|
|
178
|
+
const cnjSitesByRule = new Map();
|
|
179
|
+
for (const r of results) {
|
|
180
|
+
const id = ruleIdOf(r);
|
|
181
|
+
if (!set.has(id))
|
|
182
|
+
continue;
|
|
183
|
+
const prev = ruleState.get(id);
|
|
184
|
+
if (prev === undefined || STATE_RANK[r.state] > STATE_RANK[prev]) {
|
|
185
|
+
ruleState.set(id, r.state);
|
|
186
|
+
}
|
|
187
|
+
const m = CNJ_SITES_IN_VERDICT.exec(r.verdict);
|
|
188
|
+
if (m)
|
|
189
|
+
cnjSitesByRule.set(id, (cnjSitesByRule.get(id) ?? 0) + Number(m[1]));
|
|
190
|
+
}
|
|
191
|
+
const byState = {
|
|
192
|
+
present: 0,
|
|
193
|
+
"non-conforming": 0,
|
|
194
|
+
"could-not-judge": 0,
|
|
195
|
+
absent: 0,
|
|
196
|
+
};
|
|
197
|
+
for (const s of ruleState.values())
|
|
198
|
+
byState[s] += 1;
|
|
199
|
+
let sitesAbstaining = 0;
|
|
200
|
+
for (const n of cnjSitesByRule.values())
|
|
201
|
+
sitesAbstaining += n;
|
|
202
|
+
return {
|
|
203
|
+
byState,
|
|
204
|
+
ruleState,
|
|
205
|
+
couldNotJudge: byState["could-not-judge"],
|
|
206
|
+
sitesAbstaining,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** The one-line handler-surface tally string the CLI prints, apart from the
|
|
210
|
+
* document/oracle/process rules. */
|
|
211
|
+
export function handlerSurfaceLine(s) {
|
|
212
|
+
const ids = [...s.ruleState.entries()]
|
|
213
|
+
.map(([id, st]) => `${id}=${st}`)
|
|
214
|
+
.join(", ");
|
|
215
|
+
return (`HANDLER-SURFACE TALLY (${HANDLER_SURFACE_RULES.join("+")}, reported apart from doc/oracle/process rules): ` +
|
|
216
|
+
`present=${s.byState.present} non-conforming=${s.byState["non-conforming"]} ` +
|
|
217
|
+
`could-not-judge=${s.byState["could-not-judge"]} absent=${s.byState.absent}` +
|
|
218
|
+
`${ids ? ` [${ids}]` : ""} | rules abstaining=${s.couldNotJudge} | sites abstaining=${s.sitesAbstaining}`);
|
|
219
|
+
}
|
|
146
220
|
/** The one-line machine tally string the CLI prints. */
|
|
147
221
|
export function tallyLine(t) {
|
|
148
222
|
return (`TALLY (by rule, ${t.ruleTotal} rules from ${t.resultTotal} results): ` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantageos/backend-doctor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "A conformance instrument that measures the VantagePeers backend against the pinned backend standard. The THIRD doctor built ON the @vantageos/mcp-doctor engine — never a third engine. It consumes runDoctor/renderTable and adds only detectors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|