@vantageos/backend-doctor 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +217 -10
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/crud-coverage.d.ts +81 -0
  12. package/dist/crud-coverage.js +375 -0
  13. package/dist/detectors/oracle-axis1.d.ts +5 -3
  14. package/dist/detectors/oracle-axis1.js +24 -15
  15. package/dist/detectors/oracle-axis2.js +5 -6
  16. package/dist/detectors/predicates.d.ts +3 -0
  17. package/dist/detectors/predicates.js +81 -16
  18. package/dist/detectors/shared.js +5 -5
  19. package/dist/detectors/source/_scan.js +6 -1
  20. package/dist/detectors/source/backfill.js +9 -1
  21. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  22. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  23. package/dist/detectors/source/identity.d.ts +51 -2
  24. package/dist/detectors/source/identity.js +815 -14
  25. package/dist/detectors/source/isolation.d.ts +11 -1
  26. package/dist/detectors/source/isolation.js +106 -25
  27. package/dist/detectors/source/jobs.js +165 -2
  28. package/dist/detectors/source/readbudget.d.ts +1 -0
  29. package/dist/detectors/source/readbudget.js +67 -4
  30. package/dist/detectors/source/security.d.ts +16 -0
  31. package/dist/detectors/source/security.js +225 -7
  32. package/dist/detectors/source/write-isolation.d.ts +8 -1
  33. package/dist/detectors/source/write-isolation.js +64 -30
  34. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  35. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  36. package/dist/detectors/structural.js +14 -10
  37. package/dist/oracle.d.ts +92 -3
  38. package/dist/oracle.js +229 -12
  39. package/dist/registry.d.ts +7 -0
  40. package/dist/registry.js +48 -6
  41. package/dist/report.d.ts +11 -1
  42. package/dist/report.js +15 -2
  43. package/dist/state-rank.d.ts +5 -0
  44. package/dist/state-rank.js +9 -0
  45. package/dist/tally.d.ts +35 -0
  46. package/dist/tally.js +76 -8
  47. package/dist/target.d.ts +32 -0
  48. package/dist/target.js +173 -0
  49. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  50. package/fixtures/cr1-classes/surface.ts +206 -0
  51. package/fixtures/cr1-empty/nothing.ts +1 -0
  52. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  53. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  54. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  55. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  56. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  57. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  58. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -5,16 +5,22 @@
5
5
  // DISTINCTLY. The target is a real directory so the engine's loadTree succeeds;
6
6
  // the first-wave detectors ignore the tree and consult the oracle.
7
7
  import { execFileSync } from "node:child_process";
8
- import { readFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
8
+ import { existsSync, 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
+ import { parseArgs, USAGE } from "./args.js";
13
+ import { checkDistFreshness } from "./build-stamp.js";
14
+ import { CR1_ID, callerResolutionResult, callerResolutionSection, violations as crViolations, scanCallerResolution, } from "./caller-resolution.js";
12
15
  import { coverage, coverageSummary } from "./coverage.js";
16
+ import { crudCoverageSection, extractReactRouterPaths, scanCrudCoverage, } from "./crud-coverage.js";
13
17
  import { checkStandardReadable } from "./detectors/standard/_doc.js";
14
- import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults } from "./not-checkable.js";
18
+ import { NOT_MECHANICALLY_CHECKABLE, notCheckableResults, } from "./not-checkable.js";
19
+ import { activateOracle, describeOracle, resolveOracle } from "./oracle.js";
15
20
  import { REGISTRY } from "./registry.js";
16
21
  import { writeReport } from "./report.js";
17
- import { tally, tallyLine, verdict } from "./tally.js";
22
+ import { handlerSurfaceLine, handlerSurfaceStats, tally, tallyLine, verdict, } from "./tally.js";
23
+ import { describeScope, loadScopedTree, onScopedTree, resolveTarget, } from "./target.js";
18
24
  const here = dirname(fileURLToPath(import.meta.url));
19
25
  /** `git -C <target> rev-parse HEAD`, or "not a git repo" when it is not one. */
20
26
  function targetCommit(target) {
@@ -42,8 +48,154 @@ function toolVersion() {
42
48
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
43
49
  return pkg.version;
44
50
  }
51
+ /** Recursively collect files under `dir` whose basename matches `pred`, as paths
52
+ * relative to `base`. Silently skips node_modules/.git and unreadable dirs. */
53
+ function collectFiles(dir, base, pred, out = []) {
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir);
57
+ }
58
+ catch {
59
+ return out;
60
+ }
61
+ for (const name of entries) {
62
+ if (name === "node_modules" || name === ".git")
63
+ continue;
64
+ const full = join(dir, name);
65
+ let isDir;
66
+ try {
67
+ isDir = statSync(full).isDirectory();
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (isDir)
73
+ collectFiles(full, base, pred, out);
74
+ else {
75
+ const rel = relative(base, full);
76
+ if (pred(rel))
77
+ out.push(rel);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+ /** Read the convex tree + the schema + probe an app routes dir, then render the
83
+ * CRUD coverage section. The target is the convex dir; the app routes dir is a
84
+ * sibling probe (`../src/app`, `../app`, `../src/routes`). */
85
+ function scanCrudCoverageForCli(target) {
86
+ const convexRels = collectFiles(target, target, (rel) => rel.endsWith(".ts") && !rel.endsWith(".test.ts"));
87
+ const files = [];
88
+ let schemaText;
89
+ for (const rel of convexRels) {
90
+ let text;
91
+ try {
92
+ text = readFileSync(join(target, rel), "utf8");
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ files.push({ rel, text });
98
+ if (/(^|\/)schema\.ts$/.test(rel))
99
+ schemaText = text;
100
+ }
101
+ return scanCrudCoverage(schemaText, files, probeRoutes(target));
102
+ }
103
+ /** Probe the app-route surface as a sibling of the convex `target` dir. Reports
104
+ * BOTH whether a candidate app/src routes dir EXISTS and the route paths found in
105
+ * it, so the scan can tell "no routes dir" (n/a) from "dir exists, no router
106
+ * convention recognised" (could-not-judge) — Eta REVISE blocker 3. Recognises
107
+ * Next/SvelteKit convention files AND a react-router `<Route path>` /
108
+ * `createBrowserRouter` router in App.tsx/main.tsx/a routes file. */
109
+ function probeRoutes(target) {
110
+ const ROUTE_FILE = /(^|\/)(page\.tsx|route\.tsx?|\+page\.svelte)$/;
111
+ const appRoot = resolve(target, "..");
112
+ let candidateDir;
113
+ const routePaths = [];
114
+ // (a) Next/SvelteKit convention dirs (a sibling of the convex target).
115
+ for (const candidate of ["src/app", "app", "src/routes"]) {
116
+ const routeRoot = join(appRoot, candidate);
117
+ let isDir = false;
118
+ try {
119
+ isDir = statSync(routeRoot).isDirectory();
120
+ }
121
+ catch {
122
+ isDir = false;
123
+ }
124
+ if (!isDir)
125
+ continue;
126
+ if (candidateDir === undefined)
127
+ candidateDir = candidate;
128
+ routePaths.push(...collectFiles(routeRoot, routeRoot, (rel) => ROUTE_FILE.test(rel)));
129
+ }
130
+ // (b) react-router (Vite SPA): parse the router source for its route paths.
131
+ for (const rel of [
132
+ "src/App.tsx",
133
+ "src/main.tsx",
134
+ "src/routes.tsx",
135
+ "src/router.tsx",
136
+ "App.tsx",
137
+ "main.tsx",
138
+ ]) {
139
+ const full = join(appRoot, rel);
140
+ let text;
141
+ try {
142
+ text = readFileSync(full, "utf8");
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ // the src dir hosting the router is a candidate app dir, even if its own
148
+ // route-path extraction turns up empty (→ could-not-judge, never n/a).
149
+ if (candidateDir === undefined)
150
+ candidateDir = dirname(rel) || rel;
151
+ routePaths.push(...extractReactRouterPaths(text));
152
+ }
153
+ return { candidateDir, routePaths };
154
+ }
45
155
  function main() {
46
- const target = resolve(process.argv[2] ?? process.cwd());
156
+ // Refuse to judge from a dist/ compiled from other source than this checkout's
157
+ // (src/build-stamp.ts). Runs before anything is read, so no verdict is printed.
158
+ // Flags first: --version/--help answer without reading a target, and an unknown
159
+ // flag is a usage error — never a path handed to the filesystem (an `-v` used to
160
+ // become `<cwd>/-v` and die on an uncaught ENOENT stack).
161
+ const args = parseArgs(process.argv.slice(2));
162
+ if (args.kind === "version") {
163
+ console.log(toolVersion());
164
+ process.exit(0);
165
+ }
166
+ if (args.kind === "help") {
167
+ console.log(USAGE);
168
+ process.exit(0);
169
+ }
170
+ if (args.kind === "usage-error") {
171
+ console.error(`backend-doctor: ${args.message}\n\n${USAGE}`);
172
+ process.exit(2);
173
+ }
174
+ const fresh = checkDistFreshness(here);
175
+ if (!fresh.ok) {
176
+ console.error(`backend-doctor: REFUSING TO RUN — ${fresh.reason}`);
177
+ process.exit(2);
178
+ }
179
+ const target = resolve(args.positional[0] ?? process.cwd());
180
+ if (!existsSync(target) || !statSync(target).isDirectory()) {
181
+ console.error(`backend-doctor: target ${target} is not a readable directory — could not judge.\n\n${USAGE}`);
182
+ process.exit(2);
183
+ }
184
+ // ONE normalisation (src/target.ts): `convex` and `.` resolve to the same repo
185
+ // root, convex dir and file set, so every rule sees the same rels either way.
186
+ const scope = resolveTarget(target);
187
+ const tree = loadScopedTree(scope);
188
+ console.log(describeScope(scope, tree));
189
+ // The oracle is DERIVED from the target (never from this tool's own install
190
+ // directory) and the run says which file it used and where that came from.
191
+ // Absent => the oracle-backed rules refuse to judge (could-not-judge).
192
+ const oracleSel = resolveOracle(target, {
193
+ oracle: args.flags.oracle,
194
+ vrOracle: args.flags["vr-oracle"],
195
+ writerTier: args.flags["writer-tier"],
196
+ });
197
+ activateOracle(oracleSel);
198
+ console.log(describeOracle(oracleSel));
47
199
  // Instrument self-check, BEFORE any judging: a backend-doctor run reads its
48
200
  // pinned standard fixture as material. If that fixture is absent/unreadable the
49
201
  // tool cannot judge — REFUSE with exit 2 (could-not-judge) naming what it could
@@ -58,10 +210,43 @@ function main() {
58
210
  // pending detector) — so the tally's rule total equals the 48 derived rules and
59
211
  // nothing is silently dropped.
60
212
  const results = [
61
- ...runDoctor(target, REGISTRY.map((e) => e.detector)),
213
+ ...runDoctor(scope.repoRoot, onScopedTree(tree, REGISTRY.map((e) => e.detector))),
62
214
  ...notCheckableResults(),
63
215
  ];
64
- console.log(renderTable("backend-doctor (VantagePeers oracle @ day158)", results));
216
+ console.log(renderTable("backend-doctor (oracle derived from target)", results));
217
+ // CRUD COVERAGE — an inventory/coverage report DIMENSION (not a numbered rule
218
+ // in the pinned 50-rule standard). Rows are EVERY defineTable table derived
219
+ // from the target schema at run time; the union of rows MUST equal the schema
220
+ // table set or the run is RED (fail-closed).
221
+ console.log("");
222
+ const crud = scanCrudCoverageForCli(scope.convexDir ?? target);
223
+ console.log(crudCoverageSection(crud));
224
+ const crudDefect = crud.status === "unreadable" || !crud.unionEqualsSchema;
225
+ // CALLER RESOLUTION (CR-1) — a DOCTOR-OWNED CRITICAL control, reported in its own
226
+ // section for the same reason CRUD coverage is: the pinned standard numbers
227
+ // R-1..R-51 and does not define it, so putting it in REGISTRY would make
228
+ // coverage.ts report a phantom rule and would forge the standard's pin. It is run
229
+ // through the engine's OWN loader (never a second tree traversal) and its
230
+ // violations drive the exit code — an unjustified NO-IDENTITY or a FAIL-OPEN
231
+ // registration is mechanical, so the gate refuses on it.
232
+ console.log("");
233
+ let crScan;
234
+ runDoctor(scope.repoRoot, onScopedTree(tree, [
235
+ {
236
+ id: CR1_ID,
237
+ run: (t) => {
238
+ crScan = scanCallerResolution(t);
239
+ return callerResolutionResult(crScan);
240
+ },
241
+ },
242
+ ]));
243
+ if (crScan === undefined) {
244
+ console.error("CR-1: the engine did not run the caller-resolution detector — refusing to report a surface that was never scanned.");
245
+ process.exit(2);
246
+ }
247
+ console.log(callerResolutionSection(crScan));
248
+ const crResult = callerResolutionResult(crScan);
249
+ const crDefect = crViolations(crScan).length > 0;
65
250
  console.log("");
66
251
  const cov = coverage();
67
252
  console.log(coverageSummary(cov));
@@ -82,6 +267,11 @@ function main() {
82
267
  const v = verdict(t, results);
83
268
  console.log("");
84
269
  console.log(tallyLine(t));
270
+ // The handler-surface slice (R-50/R-51) reported APART from the doc/oracle/
271
+ // process rules, so its could-not-judge count is visible on its own and can be
272
+ // driven to 0 (Laurent: an instrument that cannot go red protects nothing).
273
+ console.log(handlerSurfaceLine(handlerSurfaceStats(results)));
274
+ console.log(`CALLER-RESOLUTION TALLY (CR-1, doctor-owned, outside the pinned rule set): state=${crResult.state} violations=${crViolations(crScan).length} | ${crResult.verdict.split("\n")[0]}`);
85
275
  console.log(v.summary);
86
276
  if (v.criticalViolations > 0) {
87
277
  console.log(` critical violations: ${v.violations.critical.join(", ")}`);
@@ -93,18 +283,35 @@ function main() {
93
283
  // the git facts are gathered HERE (a real process) and handed to the pure writer.
94
284
  const ctx = {
95
285
  target,
96
- targetCommit: targetCommit(target),
286
+ targetCommit: targetCommit(scope.repoRoot),
97
287
  toolVersion: toolVersion(),
98
288
  dateISO: new Date().toISOString().slice(0, 10),
99
- treeStatus: treeStatus(target),
289
+ treeStatus: treeStatus(scope.repoRoot),
290
+ oracle: describeOracle(oracleSel),
100
291
  };
101
292
  const reportDir = join(here, "..", "reports");
102
- const path = writeReport(ctx, results, t, v, reportDir);
293
+ const path = writeReport(ctx, results, t, v, reportDir, undefined, [
294
+ callerResolutionSection(crScan),
295
+ ]);
103
296
  console.log(`\nreport written: ${path}`);
104
297
  if (!cov.ok) {
105
298
  console.log("\nNOTE: coverage is INCOMPLETE — a rule is in neither bucket. See the summary above.");
106
299
  process.exit(1);
107
300
  }
301
+ // The CRUD coverage self-check is fail-closed: an unreadable schema or a rows
302
+ // union that does not cover the schema tables is a DOCTOR DEFECT → RED, never a
303
+ // silent pass. (ABSENT cells are honest reported gaps, NOT a defect.)
304
+ if (crudDefect) {
305
+ console.log("\nNOTE: CRUD coverage self-check FAILED — the rows do not cover every schema table (or the schema is unreadable). See the CRUD COVERAGE section above.");
306
+ process.exit(1);
307
+ }
308
+ // CR-1 is CRITICAL: a FAIL-OPEN or unjustified NO-IDENTITY public registration is
309
+ // a mechanical violation, so the gate refuses on it even when every numbered rule
310
+ // is satisfied. Reported BEFORE the numbered-rule exit so the reason is legible.
311
+ if (crDefect) {
312
+ console.log(`\nNOTE: CR-1 (caller resolution) FAILED — ${crViolations(crScan).length} public registration(s) serve or write data without resolving their caller. CRITICAL, no bypass. See the CALLER RESOLUTION section above; every member is named there.`);
313
+ process.exit(1);
314
+ }
108
315
  process.exit(v.exitCode);
109
316
  }
110
317
  main();
@@ -2,8 +2,16 @@
2
2
  // Prints the coverage proof over the TWO buckets — CHECKED and
3
3
  // NOT_MECHANICALLY_CHECKABLE. Exits non-zero only if the union does not equal
4
4
  // the derived rule ids (a rule in no bucket, in two, or a stale bucket entry).
5
+ import { dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { checkDistFreshness } from "./build-stamp.js";
5
8
  import { coverage, coverageSummary } from "./coverage.js";
6
9
  import { NOT_MECHANICALLY_CHECKABLE } from "./not-checkable.js";
10
+ const fresh = checkDistFreshness(dirname(fileURLToPath(import.meta.url)));
11
+ if (!fresh.ok) {
12
+ console.error(`backend-doctor --coverage: REFUSING TO RUN — ${fresh.reason}`);
13
+ process.exit(2);
14
+ }
7
15
  const r = coverage();
8
16
  console.log(coverageSummary(r));
9
17
  console.log("");
@@ -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;