sparda-mcp 0.26.0 → 0.32.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 CHANGED
@@ -16,6 +16,9 @@ For twenty years software communicated through APIs. Then AI agents arrived, and
16
16
 
17
17
  SPARDA compiles your backend — routes, database queries, state mutations, permissions, side-effects — into one language-agnostic, mathematical graph: the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)). Compile once; then every tool is a simple pass over that graph.
18
18
 
19
+ > [!IMPORTANT]
20
+ > **The 3700-Route Proof:** In our latest stress-test (v0.26.0), SPARDA successfully compiled and proved **3700+ routes** from elite open-source monsters (Next.js *Dub*, NestJS *Immich*, *MedusaJS*, and GitHub's OpenAPI) in **< 2.2 seconds** per repo, with zero crashes. It natively resolves deep Dependency Injection, external controllers, and Next.js handlers.
21
+
19
22
  **What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
20
23
 
21
24
  | Command | What it does |
@@ -276,8 +279,11 @@ runtime, so the guidance never goes stale.
276
279
 
277
280
  ## Supported frameworks
278
281
 
279
- - **Next.js App Router (13/14/15)** — file-based injection. Since Next.js uses file-system routing, SPARDA simply creates a catch-all route handler under `app/mcp/[...sparda]/route.js`. **Nothing in your existing codebase's code is touched**; running `remove` simply deletes the generated file.
280
- - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection.
282
+ - **Next.js App Router (13/14/15)** — file-based injection. SPARDA creates a catch-all route handler. It natively resolves wrapped handlers (`export const POST = withAuth(h)`) and deep effect chains.
283
+ - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Supports Prisma, TypeORM, and Kysely.
284
+ - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, and barrel re-exports. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
285
+ - **MedusaJS** — Native AST ingestion of complex e-commerce routing.
286
+ - **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
281
287
  - **FastAPI** (Python >= 3.9) — AST-based router injection.
282
288
 
283
289
  ## Security posture (honest)
package/SKILL.md CHANGED
@@ -139,6 +139,8 @@ Writes are **disabled by default**. The protocol is not optional:
139
139
  manifest validity, the semantic/immune cache, host reachability, and quarantine;
140
140
  it exits non-zero so it can gate CI.
141
141
  - **Formal Deployment Proof** → `sparda apocalypse` reads the compiled graph (`ubg.json`) and proves five correctness obligations: catches unguarded mutations, non-atomic aggregate writes, unvalidated writes to constrained tables, uncompensated observable effects, and aggregate root bypasses. Run `sparda apocalypse --save-baseline` to store the reference graph; subsequent runs diff against the baseline to catch dropped guards, dropped SQL invariants, or grown blast radiuses.
142
+ - **Safety Matrix Report** → `sparda dossier` generates an ultra-premium, self-contained HTML matrix of the app's safety proof, ideal for security engineers and audit compliance (Bloc C / Shadow tier).
143
+ - **Deep Framework Resolution** → SPARDA natively traces multi-hop Dependency Injection in NestJS, external controllers in Express, and wrapped handlers in Next.js, mapping them into the final Behavior Graph.
142
144
  - **OpenAPI Ingestion** → Run `sparda ubg --openapi <openapi_spec.json>` to compile any non-JS/Python backend (Go, Java, Rails, Laravel, .NET) into a Unified Behavior Graph by mapping security schemes into guards and request/response structures. (JSON specs only — convert YAML once with `npx -y js-yaml spec.yaml > spec.json`.)
143
145
  - **Executing the Graph (No code mock)** → Run `sparda mirror` to host a mock HTTP simulation server directly from `ubg.json` without any backend code. Enforces authentication guards, returns typed responses, and acts as a contract sandbox.
144
146
  - **Exporting OpenAPI 3.1 Spec** → Run `sparda openapi` to generate a valid, deterministic OpenAPI 3.1 spec dynamically from the compiled behavior graph.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.26.0",
3
+ "version": "0.32.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
6
6
  "type": "module",
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import { compileUBG } from '../ubg/compile.js';
12
12
  import { canonicalizeGraph } from '../ubg/schema.js';
13
13
  import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
14
+ import { surveyBlindspots } from '../ubg/blindspots.js';
14
15
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
15
16
 
16
17
  const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
@@ -43,6 +44,8 @@ export async function runApocalypse(opts) {
43
44
 
44
45
  const findings = [...staticFindings, ...diffFindings];
45
46
  const verdict = verdictOf(findings, canonical);
47
+ // the honesty companion: where does the proof stop? (see `sparda blindspots`)
48
+ const blind = surveyBlindspots(canonical, report);
46
49
 
47
50
  if (opts.sarif) {
48
51
  const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
@@ -54,7 +57,9 @@ export async function runApocalypse(opts) {
54
57
  }
55
58
 
56
59
  if (opts.json) {
57
- console.log(JSON.stringify({ verdict, obligations, findings }, null, 2));
60
+ console.log(
61
+ JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
62
+ );
58
63
  } else {
59
64
  console.log(
60
65
  `APOCALYPSE — deployment proof over ${canonical.nodes.length} nodes, ${canonical.edges.length} edges` +
@@ -95,10 +100,18 @@ export async function runApocalypse(opts) {
95
100
  `${verdict.safe ? '⚠ RISKY' : '✗ NOT PROVEN'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info`,
96
101
  );
97
102
  }
103
+ // Honesty companion: where the proof stops. A green verdict over a graph riddled
104
+ // with blind spots is not omniscience — say so, on the same screen as the verdict.
105
+ if (blind.surface > 0) {
106
+ const hi = blind.byRisk.critical + blind.byRisk.high;
107
+ console.log(
108
+ ` ◐ blind spots: ${blind.surface} (${hi} high+, ${blind.byRisk.medium} medium, ${blind.byRisk.low} low) · coverage ${(blind.coverage.ratio * 100).toFixed(0)}% — run \`sparda blindspots\` for the map`,
109
+ );
110
+ }
98
111
  }
99
112
 
100
113
  if (!verdict.safe) process.exitCode = 1; // CI gates on this
101
- return { verdict, findings, obligations };
114
+ return { verdict, findings, obligations, blindspots: blind };
102
115
  }
103
116
 
104
117
  // SARIF 2.1.0 — GitHub code scanning eats this directly
@@ -0,0 +1,54 @@
1
+ // commands/blindspots.js — hand the reader the map of SPARDA's own blindness.
2
+ // Compiles the tree, surveys the Unknown Behavior Surface, and prints every place
3
+ // SPARDA could not fully see, ranked by what it could be hiding. This is the honest
4
+ // complement to `apocalypse`: that command tells you what's proven, this one tells
5
+ // you exactly where the proof stops — so a green verdict is never mistaken for
6
+ // omniscience. Exit code 1 if any HIGH-or-worse blind spot sits on the surface
7
+ // (a state-changing route or write whose behavior went unseen is not something CI
8
+ // should wave through silently).
9
+ // sparda blindspots ranked ledger, human-readable
10
+ // sparda blindspots --json the raw survey for tooling
11
+ import { compileUBG } from '../ubg/compile.js';
12
+ import { canonicalizeGraph } from '../ubg/schema.js';
13
+ import { surveyBlindspots } from '../ubg/blindspots.js';
14
+
15
+ const ICON = { critical: '✗', high: '✗', medium: '⚠', low: '·' };
16
+
17
+ export async function runBlindspots(opts) {
18
+ const { graph, report } = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
19
+ const canonical = canonicalizeGraph(graph);
20
+ const survey = surveyBlindspots(canonical, report);
21
+
22
+ if (opts.json) {
23
+ console.log(JSON.stringify({ survey }, null, 2));
24
+ } else {
25
+ const { surface, byRisk, coverage } = survey;
26
+ console.log(
27
+ `BLINDSPOTS — SPARDA's Unknown Behavior Surface over ${report.routes} route(s)`,
28
+ );
29
+ console.log(
30
+ ` ${surface} blind spot(s): ${byRisk.high + byRisk.critical} high+, ${byRisk.medium} medium, ${byRisk.low} low`,
31
+ );
32
+ console.log(
33
+ ` coverage ${(coverage.ratio * 100).toFixed(1)}% — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
34
+ );
35
+ if (surface === 0) {
36
+ console.log(
37
+ ` ✓ nothing hidden — every route, effect target and guard SPARDA saw was fully resolved.`,
38
+ );
39
+ } else {
40
+ const shown = opts.verbose ? survey.spots : survey.spots.slice(0, 20);
41
+ for (const s of shown) {
42
+ console.log(
43
+ ` ${ICON[s.risk]} [${s.risk}] ${s.kind} — ${s.label}${s.location ? ` (${s.location})` : ''}`,
44
+ );
45
+ if (opts.verbose) console.log(` ${s.why}`);
46
+ }
47
+ if (!opts.verbose && survey.spots.length > shown.length)
48
+ console.log(` … ${survey.spots.length - shown.length} more (run --verbose)`);
49
+ }
50
+ }
51
+
52
+ if (survey.byRisk.critical > 0 || survey.byRisk.high > 0) process.exitCode = 1;
53
+ return { survey };
54
+ }
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import { compileUBG } from '../ubg/compile.js';
11
11
  import { canonicalizeGraph } from '../ubg/schema.js';
12
12
  import { checkGraph, verdictOf } from '../ubg/apocalypse.js';
13
+ import { surveyBlindspots } from '../ubg/blindspots.js';
13
14
  import { buildCapsule } from '../ubg/immunity.js';
14
15
  import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
15
16
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
@@ -20,6 +21,7 @@ export async function runDossier(opts) {
20
21
  const { findings, polarity } = checkGraph(canonical);
21
22
  const verdict = verdictOf(findings, canonical);
22
23
  const capsule = buildCapsule(canonical);
24
+ const blindspots = surveyBlindspots(canonical, compiled.report);
23
25
 
24
26
  const data = {
25
27
  app: path.basename(path.resolve(opts.cwd)) || 'app',
@@ -38,6 +40,7 @@ export async function runDossier(opts) {
38
40
  polarity: polarity.map((p) => ({ entrypoint: p.entrypoint, vector: p.vector })),
39
41
  posture: capsule.posture,
40
42
  capsuleBytes: capsule.bytes,
43
+ blindspots,
41
44
  sourceHash: (canonical.meta?.sourceHash ?? '').slice(0, 12),
42
45
  };
43
46
 
@@ -125,6 +128,24 @@ export function renderDossierHTML(d) {
125
128
  const statCard = (n, label) =>
126
129
  `<div class="stat"><b>${esc(n)}</b><span>${esc(label)}</span></div>`;
127
130
 
131
+ // The honesty section: where the proof stops. No other prover shows this.
132
+ const b = d.blindspots ?? { spots: [], surface: 0, coverage: { ratio: 1 } };
133
+ const blindRows = b.spots.length
134
+ ? b.spots
135
+ .slice(0, 40)
136
+ .map(
137
+ (s) => `
138
+ <div class="finding" style="--sev:${SEV_COLOR[s.risk] ?? '#8b8f9a'}">
139
+ <div class="sev">${esc(s.risk)}</div>
140
+ <div class="fbody">
141
+ <div class="frule">${esc(s.kind)}${s.location ? ` · <span class="fep">${esc(s.location)}</span>` : ''}</div>
142
+ <p>${esc(s.label)} — ${esc(s.why)}</p>
143
+ </div>
144
+ </div>`,
145
+ )
146
+ .join('')
147
+ : '<p class="clean">Nothing hidden — every route, effect target and guard SPARDA saw was fully resolved.</p>';
148
+
128
149
  return `<!doctype html>
129
150
  <html lang="en"><head><meta charset="utf-8">
130
151
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -201,6 +222,9 @@ export function renderDossierHTML(d) {
201
222
  <h2>What SPARDA found</h2>
202
223
  ${findingCards}
203
224
 
225
+ <h2>Where the proof stops <small>SPARDA's own blind spots · coverage ${(b.coverage.ratio * 100).toFixed(0)}% · ${b.surface} unseen, ranked by what each could hide</small></h2>
226
+ ${blindRows}
227
+
204
228
  <footer>
205
229
  <span>graph ${esc(d.sourceHash)} · ${esc(d.nodes)} nodes / ${esc(d.edges)} edges</span>
206
230
  <span>generated by SPARDA · residual-labs.fr</span>
package/src/index.js CHANGED
@@ -142,6 +142,11 @@ try {
142
142
  await runDossier(opts);
143
143
  break;
144
144
  }
145
+ case 'blindspots': {
146
+ const { runBlindspots } = await import('./commands/blindspots.js');
147
+ await runBlindspots(opts);
148
+ break;
149
+ }
145
150
  case 'genome': {
146
151
  const { runGenome } = await import('./commands/genome.js');
147
152
  await runGenome(opts);
@@ -196,6 +201,7 @@ Usage:
196
201
  npx sparda-mcp immunize Freeze proven safety into a tiny capsule (1 byte/route) — .sparda/immunity.json
197
202
  npx sparda-mcp speculate Re-verify vs the frozen capsule by lookup — full proof only on novel shapes (--json)
198
203
  npx sparda-mcp dossier Render the whole proof as one self-contained HTML page anyone can read (.sparda/dossier.html)
204
+ npx sparda-mcp blindspots Map SPARDA's own blindness: every unseen route/effect/guard, ranked by what it could hide (--json)
199
205
  npx sparda-mcp genome Sign this app's proofs into the shared world memory — self-verifying antibodies, zero infra (--json)
200
206
  npx sparda-mcp timeless Replay production requests (list | replay <id> | export <id> → vitest)
201
207
  npx sparda-mcp mirror Serve the compiled graph over HTTP — no framework, no source (--port)
@@ -0,0 +1,164 @@
1
+ // ubg/blindspots.js — the Unknown Behavior Surface (UBS) ledger.
2
+ //
3
+ // Every other prover tells you what it PROVED. This one also tells you, precisely
4
+ // and ranked by danger, what it could NOT see — SPARDA's own blindness, made into a
5
+ // first-class, measurable artifact. A static tool that silently drops the 20% it
6
+ // can't resolve reads as green and lies by omission; a tool that hands you the map
7
+ // of its blind spots is the honest one. (Idea seeded by Reyna's UBS, but derived
8
+ // from the REAL graph + skip log here — no hand-authored regions, nothing invented.)
9
+ //
10
+ // A blind spot is one of four things, each computable without guessing:
11
+ // opaque-target — an effect SPARDA saw but whose target it could not name
12
+ // (db op with no table, http/fs with a computed path)
13
+ // skipped-surface — a route/handler/mount the extractor could not bring into the
14
+ // graph at all (a dynamic path, an unresolved handler arg/mount)
15
+ // blind-mutation — a MUTATING entrypoint that resolved to zero behavior: SPARDA
16
+ // saw the door but nothing behind it (an unreadable handler)
17
+ // unverified-guard — a guard trusted only by NAME, never seen to deny (auth on faith)
18
+ //
19
+ // Risk is assigned from what the blind spot could be HIDING, not from its name.
20
+ import { indexGraph, reachOf } from './apocalypse.js';
21
+ import { cmp } from './schema.js';
22
+
23
+ const RISK_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
24
+ const MUTATING_VERB = /\b(post|put|patch|delete)\b/i;
25
+
26
+ // An effect whose target SPARDA could not resolve to a concrete (or symbolic) name.
27
+ // A `symbolic` table (`:collection`, param-derived) is NOT opaque — it is a precise
28
+ // answer expressed as a rule, so it is deliberately excluded.
29
+ function isOpaqueTarget(node) {
30
+ if (node.kind !== 'effect') return false;
31
+ const m = node.meta ?? {};
32
+ if (m.symbolic) return false;
33
+ if (m.effectType === 'db_read' || m.effectType === 'db_write')
34
+ return m.table == null || m.table === '?';
35
+ if (
36
+ m.effectType === 'http_call' ||
37
+ m.effectType === 'fs_write' ||
38
+ m.effectType === 'fs_read'
39
+ )
40
+ return m.target == null || m.target === 'dynamic' || m.target === 'unknown';
41
+ return false;
42
+ }
43
+
44
+ function opaqueRisk(effectType) {
45
+ if (
46
+ effectType === 'db_write' ||
47
+ effectType === 'fs_write' ||
48
+ effectType === 'http_call'
49
+ )
50
+ return 'high';
51
+ return 'medium'; // reads: SPARDA can't name what's read, but nothing changes state
52
+ }
53
+
54
+ // → { surface, byRisk, coverage, spots } — deterministic (sorted), report-optional.
55
+ export function surveyBlindspots(graph, report = {}) {
56
+ const g = indexGraph(graph);
57
+ const spots = [];
58
+
59
+ // 1 — opaque-target effects (SPARDA saw the effect, not its target)
60
+ for (const node of graph.nodes) {
61
+ if (!isOpaqueTarget(node)) continue;
62
+ spots.push({
63
+ kind: 'opaque-target',
64
+ risk: opaqueRisk(node.meta.effectType),
65
+ location: node.loc ? `${node.loc.file}:${node.loc.line}` : null,
66
+ label: node.label,
67
+ why: `${node.meta.effectType} with an unresolved target — SPARDA saw the call but not what it touches`,
68
+ });
69
+ }
70
+
71
+ // 2 — blind mutations: a mutating entrypoint that reached zero observable behavior.
72
+ // Its handler body was unreadable (opaque fn) or delegated somewhere static analysis
73
+ // couldn't follow — the single most dangerous blind spot, because an unguarded write
74
+ // could be hiding right there and the proof would never see it.
75
+ for (const ep of g.entrypoints) {
76
+ if (!ep.meta?.mutating) continue;
77
+ const reached = [...reachOf(ep.id, g.cfOut)].map((id) => g.nodes.get(id));
78
+ const sawBehavior = reached.some((n) => n?.kind === 'effect' || n?.kind === 'state');
79
+ if (sawBehavior) continue;
80
+ // Only a blind spot if a handler body was actually UNREADABLE (opaque) — a
81
+ // mutating route that resolved to a genuinely empty body (SPARDA read it, there
82
+ // was nothing) is not blindness, it's a no-op, and must not be flagged.
83
+ const hasOpaqueBody = reached.some((n) => n?.meta?.opaque);
84
+ if (!hasOpaqueBody) continue;
85
+ spots.push({
86
+ kind: 'blind-mutation',
87
+ risk: 'high',
88
+ entrypoint: ep.id,
89
+ location: ep.loc ? `${ep.loc.file}:${ep.loc.line}` : null,
90
+ label: ep.label,
91
+ why: 'a state-changing route whose behavior did not resolve — an unguarded write could hide here unseen',
92
+ });
93
+ }
94
+
95
+ // 3 — unverified guards: protection asserted by name, never seen to deny.
96
+ for (const node of graph.nodes) {
97
+ if (node.kind !== 'guard' || node.meta?.verified) continue;
98
+ spots.push({
99
+ kind: 'unverified-guard',
100
+ risk: 'low',
101
+ location: node.loc ? `${node.loc.file}:${node.loc.line}` : null,
102
+ label: node.label,
103
+ why: 'trusted as a guard by its name, but SPARDA never saw it deny (opaque body) — auth resting on faith',
104
+ });
105
+ }
106
+
107
+ // 4 — skipped surface: routes/handlers/mounts the extractor could not graph at all.
108
+ for (const s of report.skipped ?? []) {
109
+ const reason = s.reason ?? '';
110
+ spots.push({
111
+ kind: 'skipped-surface',
112
+ risk: MUTATING_VERB.test(reason) ? 'high' : 'medium',
113
+ location: s.file ? `${s.file}${s.line ? `:${s.line}` : ''}` : null,
114
+ label: reason,
115
+ why: 'a surface the static walk could not bring into the graph — its behavior is entirely unseen',
116
+ });
117
+ }
118
+
119
+ spots.sort(
120
+ (a, b) =>
121
+ RISK_RANK[a.risk] - RISK_RANK[b.risk] ||
122
+ cmp(a.kind, b.kind) ||
123
+ cmp(a.location ?? '', b.location ?? '') ||
124
+ cmp(a.label ?? '', b.label ?? ''),
125
+ );
126
+
127
+ const byRisk = { critical: 0, high: 0, medium: 0, low: 0 };
128
+ for (const s of spots) byRisk[s.risk]++;
129
+
130
+ // coverage: of the state-touching behavior SPARDA found SIGNAL for (resolved effects
131
+ // + blind spots that are effect/route-shaped), what fraction did it fully resolve?
132
+ // The honest CBS/UBS ratio — 1.0 means nothing seen was left unresolved.
133
+ const resolved = countResolved(graph);
134
+ const blindBehavior = spots.filter(
135
+ (s) =>
136
+ s.kind === 'opaque-target' ||
137
+ s.kind === 'blind-mutation' ||
138
+ s.kind === 'skipped-surface',
139
+ ).length;
140
+ const denom = resolved + blindBehavior;
141
+ const coverage = {
142
+ resolved,
143
+ blind: blindBehavior,
144
+ ratio: denom === 0 ? 1 : Math.round((resolved / denom) * 1000) / 1000,
145
+ };
146
+
147
+ return { surface: spots.length, byRisk, coverage, spots };
148
+ }
149
+
150
+ // resolved behavior = state nodes + effects whose target IS known (concrete or symbolic).
151
+ const OBSERVABLE = new Set(['db_write', 'db_read', 'http_call', 'fs_write', 'fs_read']);
152
+ function countResolved(graph) {
153
+ let n = 0;
154
+ for (const node of graph.nodes) {
155
+ if (node.kind === 'state') n++;
156
+ else if (
157
+ node.kind === 'effect' &&
158
+ OBSERVABLE.has(node.meta?.effectType) &&
159
+ !isOpaqueTarget(node)
160
+ )
161
+ n++;
162
+ }
163
+ return n;
164
+ }
@@ -6,7 +6,14 @@
6
6
  // Depth stays bounded like the v0 parser: entry file + mounted routers.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { parseModule, resolveRelImport, scanFunction } from './extract.js';
9
+ import {
10
+ parseModule,
11
+ resolveRelImport,
12
+ scanFunction,
13
+ classInModule,
14
+ baseClassOf,
15
+ methodInClassChain,
16
+ } from './extract.js';
10
17
 
11
18
  const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
12
19
  const MAX_HANDLER_DEPTH = 6;
@@ -21,14 +28,14 @@ function mergeScan(into, add) {
21
28
  if (add.guardSignals?.deniesWithStatus) into.guardSignals.deniesWithStatus = true;
22
29
  }
23
30
 
24
- // depth-first walk over an AST subtree, invoking fn on every CallExpression.
25
- function walkCalls(node, fn) {
31
+ // depth-first walk over an AST subtree, invoking fn on every node.
32
+ function walkAst(node, fn) {
26
33
  if (!node || typeof node !== 'object') return;
27
34
  if (Array.isArray(node)) {
28
- for (const n of node) walkCalls(n, fn);
35
+ for (const n of node) walkAst(n, fn);
29
36
  return;
30
37
  }
31
- if (node.type === 'CallExpression') fn(node);
38
+ if (typeof node.type === 'string') fn(node);
32
39
  for (const k of Object.keys(node)) {
33
40
  if (
34
41
  k === 'loc' ||
@@ -38,10 +45,39 @@ function walkCalls(node, fn) {
38
45
  )
39
46
  continue;
40
47
  const v = node[k];
41
- if (v && typeof v === 'object') walkCalls(v, fn);
48
+ if (v && typeof v === 'object') walkAst(v, fn);
42
49
  }
43
50
  }
44
51
 
52
+ function walkCalls(node, fn) {
53
+ walkAst(node, (n) => {
54
+ if (n.type === 'CallExpression') fn(n);
55
+ });
56
+ }
57
+
58
+ // varName → class name for every `const svc = new X(…)` (or `svc = new X(…)`)
59
+ // in the subtree — the raw material of instantiated-service resolution.
60
+ function collectInstances(fnNode) {
61
+ const map = new Map();
62
+ walkAst(fnNode, (node) => {
63
+ if (
64
+ node.type === 'VariableDeclarator' &&
65
+ node.id?.type === 'Identifier' &&
66
+ node.init?.type === 'NewExpression' &&
67
+ node.init.callee.type === 'Identifier'
68
+ )
69
+ map.set(node.id.name, node.init.callee.name);
70
+ else if (
71
+ node.type === 'AssignmentExpression' &&
72
+ node.left.type === 'Identifier' &&
73
+ node.right?.type === 'NewExpression' &&
74
+ node.right.callee.type === 'Identifier'
75
+ )
76
+ map.set(node.left.name, node.right.callee.name);
77
+ });
78
+ return map;
79
+ }
80
+
45
81
  // → { routes, globalMiddlewares, helpers, skipped, scannedFiles }
46
82
  export function extractExpress(cwd, entryFile) {
47
83
  const routes = [];
@@ -51,6 +87,7 @@ export function extractExpress(cwd, entryFile) {
51
87
  const scannedFiles = [];
52
88
  const visited = new Set();
53
89
  const mounts = [];
90
+ const classBundles = new Map(); // memo of resolved class methods — per extract
54
91
 
55
92
  scanFile(path.resolve(cwd, entryFile), '', 0);
56
93
  // growing queue, NOT a snapshot: a mounted router file can itself mount
@@ -87,12 +124,22 @@ export function extractExpress(cwd, entryFile) {
87
124
  helpers.push({ name, sourceFile: relFile, sourceLine: f.line, fn: f.node });
88
125
  }
89
126
 
127
+ // Flatten setup-function bodies into the statement stream. The overwhelmingly
128
+ // common production pattern builds the whole app inside a function —
129
+ // `export default async function createApp() { const app = express(); …;
130
+ // app.use('/x', xRouter); return app; }` (directus, and most real apps) — so the
131
+ // `express()` var and every mount live one level down, invisible to a top-level-only
132
+ // walk. We descend into function declarations, default-exported functions, top-level
133
+ // `const f = () => {…}`, and their control-flow blocks — but NOT into function
134
+ // *arguments* (route handlers), so handler bodies are never mistaken for setup.
135
+ const statements = flattenSetup(mod.ast.program.body);
136
+
90
137
  const appVars = new Set();
91
138
  const routerVars = new Set();
92
- for (const node of mod.ast.program.body) collectAppVars(node, appVars, routerVars);
93
- const routeArrays = collectRouteArrays(mod.ast.program.body);
139
+ for (const node of statements) collectAppVars(node, appVars, routerVars);
140
+ const routeArrays = collectRouteArrays(statements);
94
141
 
95
- walkStatements(mod.ast.program.body);
142
+ walkStatements(statements);
96
143
 
97
144
  function walkStatements(body) {
98
145
  for (const stmt of body) {
@@ -243,13 +290,29 @@ export function extractExpress(cwd, entryFile) {
243
290
  fn: arg,
244
291
  };
245
292
  }
246
- // factory middleware: validate(schema), rateLimit({…}) — the factory
247
- // name is known, the produced closure is out of static reach (blind node)
248
293
  if (arg.type === 'CallExpression') {
249
294
  const name =
250
295
  arg.callee.type === 'Identifier'
251
296
  ? arg.callee.name
252
297
  : (arg.callee.property?.name ?? 'anonymous');
298
+ // wrapped INLINE handler: asyncHandler(async (req, res) => {…}) — the
299
+ // wrapped function IS the behavior (the route-position analogue of the
300
+ // top-level `const h = catchAsync(…)` idiom). Without this, every
301
+ // directus-style route reads as a blind node.
302
+ const fnArg = arg.arguments.find(
303
+ (a) => a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression',
304
+ );
305
+ if (fnArg) {
306
+ return {
307
+ name,
308
+ sourceFile: relFile,
309
+ sourceLine: fnArg.loc?.start.line ?? 0,
310
+ fn: fnArg,
311
+ scan: deepScan(fnArg, mod),
312
+ };
313
+ }
314
+ // factory middleware: validate(schema), rateLimit({…}) — the factory
315
+ // name is known, the produced closure is out of static reach (blind node)
253
316
  return {
254
317
  name,
255
318
  sourceFile: relFile,
@@ -348,6 +411,12 @@ export function extractExpress(cwd, entryFile) {
348
411
  // `User.create()`. This is the CommonJS analogue of the Nest DI hop — the effect is
349
412
  // usually two or three modules below the route, behind `service.method()` calls the
350
413
  // flat scanner can't see. Precomputed so translate uses it as-is (like the Nest scan).
414
+ //
415
+ // The same walk also resolves INSTANTIATED services — `const svc = new XService(…);
416
+ // svc.readMany(…)` (directus and every class-service Express app) — through the
417
+ // class's `extends` chain, including `this.<m>()` / `super.<m>()` hops inside the
418
+ // resolved methods. `this.<m>()` dispatches from the INSTANTIATED class, so an
419
+ // override (ActivityService.readByQuery) wins over the base method it shadows.
351
420
  function deepScan(fnNode, owningMod) {
352
421
  const base = scanFunction(fnNode);
353
422
  const merged = {
@@ -356,43 +425,209 @@ export function extractExpress(cwd, entryFile) {
356
425
  returnShapes: [...(base.returnShapes ?? [])],
357
426
  calls: [...(base.calls ?? [])],
358
427
  };
359
- followMembers(fnNode, owningMod, merged, new Set(), 0);
428
+ followCalls(fnNode, owningMod, merged, new Set(), 0, null, new Set());
360
429
  return merged;
361
430
  }
362
431
 
363
- function followMembers(fnNode, mod, merged, seen, depth) {
432
+ // `seen` dedups sub-scans merged into THIS target; `stack` is the class-method
433
+ // cycle guard and spans the whole recursion; `clsCtx` (top + declaring class) is
434
+ // set while walking a class-method body so `this.` / `super.` calls resolve.
435
+ function followCalls(fnNode, mod, merged, seen, depth, clsCtx, stack) {
364
436
  if (depth >= MAX_HANDLER_DEPTH) return;
437
+ const instances = collectInstances(fnNode);
365
438
  walkCalls(fnNode, (node) => {
366
439
  const callee = node.callee;
367
- if (
368
- callee.type !== 'MemberExpression' ||
369
- callee.property.type !== 'Identifier' ||
370
- callee.object.type !== 'Identifier'
371
- )
440
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
441
+ return;
442
+ const method = callee.property.name;
443
+ const obj = callee.object;
444
+
445
+ // instantiated service: `svc.method()` where `const svc = new X(…)`,
446
+ // or the direct `new X(…).method()` chain
447
+ const className =
448
+ obj.type === 'Identifier' && instances.has(obj.name)
449
+ ? instances.get(obj.name)
450
+ : obj.type === 'NewExpression' && obj.callee.type === 'Identifier'
451
+ ? obj.callee.name
452
+ : null;
453
+ if (className) {
454
+ const bundle = classBundle(className, method, mod, depth, stack);
455
+ if (bundle && !seen.has(bundle.key)) {
456
+ seen.add(bundle.key);
457
+ mergeScan(merged, bundle);
458
+ }
459
+ return;
460
+ }
461
+
462
+ // inside a resolved class method: this.<m>() re-dispatches from the top
463
+ // (instantiated) class; super.<m>() from the declaring class's base
464
+ if (clsCtx && obj.type === 'ThisExpression') {
465
+ const bundle = classMethodBundle(
466
+ clsCtx.topCls,
467
+ clsCtx.topMod,
468
+ method,
469
+ depth,
470
+ stack,
471
+ );
472
+ if (bundle && !seen.has(bundle.key)) {
473
+ seen.add(bundle.key);
474
+ mergeScan(merged, bundle);
475
+ }
372
476
  return;
373
- const targetFile = mod.imports.get(callee.object.name);
477
+ }
478
+ if (clsCtx && obj.type === 'Super') {
479
+ const base = baseClassOf(clsCtx.declCls, clsCtx.declMod);
480
+ const bundle = base
481
+ ? classMethodBundle(base.cls, base.mod, method, depth, stack)
482
+ : null;
483
+ if (bundle && !seen.has(bundle.key)) {
484
+ seen.add(bundle.key);
485
+ mergeScan(merged, bundle);
486
+ }
487
+ return;
488
+ }
489
+
490
+ if (obj.type !== 'Identifier') return;
491
+ const targetFile = mod.imports.get(obj.name);
374
492
  if (!targetFile) return;
375
493
  const targetMod = parseModule(targetFile);
376
494
  if (targetMod.error) return;
377
- const fn = targetMod.functions.get(callee.property.name);
495
+ const fn = targetMod.functions.get(method);
378
496
  if (!fn) return;
379
- const key = `${targetFile}#${callee.property.name}`;
497
+ const key = `${targetFile}#${method}`;
380
498
  if (seen.has(key)) return;
381
499
  seen.add(key);
382
500
  const fromRel = rel(targetFile);
383
501
  if (!scannedFiles.includes(fromRel)) scannedFiles.push(fromRel);
384
502
  helpers.push({
385
- name: `${callee.object.name}.${callee.property.name}`,
503
+ name: `${obj.name}.${method}`,
386
504
  sourceFile: fromRel,
387
505
  sourceLine: fn.line,
388
506
  fn: fn.node,
389
507
  });
390
508
  mergeScan(merged, scanFunction(fn.node));
391
- followMembers(fn.node, targetMod, merged, seen, depth + 1);
509
+ followCalls(fn.node, targetMod, merged, seen, depth + 1, null, stack);
510
+ });
511
+ }
512
+
513
+ // `new X(…)` → class X (imported, or declared in the same module) → the
514
+ // memoized bundle of X.<methodName>.
515
+ function classBundle(className, methodName, mod, depth, stack) {
516
+ const file = mod.imports.get(className);
517
+ const clsMod = file ? parseModule(file) : mod;
518
+ if (clsMod.error) return null;
519
+ const cls = classInModule(clsMod, className);
520
+ if (!cls) return null;
521
+ return classMethodBundle(cls, clsMod, methodName, depth, stack);
522
+ }
523
+
524
+ // The fully-resolved scan of `<topCls>.<methodName>` — its body plus everything
525
+ // reachable below it — memoized per (instantiated class, method) across the whole
526
+ // extract, so a service method reached by N routes is resolved once (same
527
+ // rationale as the Nest bundleCache; twenty took 34s without it). A bundle is
528
+ // computed with its OWN dedup set, so the memo never stores a partial.
529
+ function classMethodBundle(topCls, topMod, methodName, depth, stack) {
530
+ if (depth >= MAX_HANDLER_DEPTH) return null;
531
+ const hit = methodInClassChain(topCls, topMod, methodName);
532
+ if (!hit) return null;
533
+ const key = `${topMod._file ?? ''}#${topCls.id?.name ?? 'anonymous'}.${methodName}`;
534
+ const cached = classBundles.get(key);
535
+ if (cached) return cached;
536
+ if (stack.has(key)) return null; // reference cycle — contribute nothing, don't cache
537
+ stack.add(key);
538
+ const base = scanFunction(hit.fn);
539
+ const bundle = {
540
+ ...base,
541
+ key,
542
+ effects: [...base.effects],
543
+ returnShapes: [...(base.returnShapes ?? [])],
544
+ calls: [...(base.calls ?? [])],
545
+ };
546
+ const declRel = rel(hit.mod._file ?? '');
547
+ if (!scannedFiles.includes(declRel)) scannedFiles.push(declRel);
548
+ helpers.push({
549
+ name: `${topCls.id?.name ?? 'anonymous'}.${methodName}`,
550
+ sourceFile: declRel,
551
+ sourceLine: hit.fn.loc?.start.line ?? 0,
552
+ fn: hit.fn,
392
553
  });
554
+ followCalls(
555
+ hit.fn,
556
+ hit.mod,
557
+ bundle,
558
+ new Set(),
559
+ depth + 1,
560
+ { topCls, topMod, declCls: hit.cls, declMod: hit.mod },
561
+ stack,
562
+ );
563
+ stack.delete(key);
564
+ classBundles.set(key, bundle);
565
+ return bundle;
393
566
  }
394
567
  }
395
568
 
569
+ // Statements the route walk should see = the module top level PLUS the bodies of
570
+ // setup functions and the control-flow blocks inside them, in source order. Bounded
571
+ // (depth + count) and cycle-free by construction. It descends into function *bodies*
572
+ // (declarations, default exports, `const f = () => {}`) and if/for/try/while/block —
573
+ // never into a function passed as a call ARGUMENT, so route handlers stay opaque.
574
+ function flattenSetup(programBody) {
575
+ const out = [];
576
+ const blockOf = (n) => (!n ? [] : n.type === 'BlockStatement' ? n.body : [n]);
577
+ const push = (stmts, depth) => {
578
+ if (!Array.isArray(stmts) || depth > 6 || out.length > 8000) return;
579
+ for (const s of stmts) {
580
+ if (!s || typeof s !== 'object') continue;
581
+ out.push(s);
582
+ descend(s, depth);
583
+ }
584
+ };
585
+ const descend = (s, depth) => {
586
+ switch (s.type) {
587
+ case 'FunctionDeclaration':
588
+ push(s.body?.body, depth + 1);
589
+ break;
590
+ case 'ExportDefaultDeclaration':
591
+ case 'ExportNamedDeclaration':
592
+ if (s.declaration?.body?.body) push(s.declaration.body.body, depth + 1);
593
+ else if (s.declaration) push([s.declaration], depth);
594
+ break;
595
+ case 'VariableDeclaration':
596
+ for (const d of s.declarations) {
597
+ const init = d.init;
598
+ if (
599
+ (init?.type === 'ArrowFunctionExpression' ||
600
+ init?.type === 'FunctionExpression') &&
601
+ init.body?.body
602
+ )
603
+ push(init.body.body, depth + 1);
604
+ }
605
+ break;
606
+ case 'IfStatement':
607
+ push(blockOf(s.consequent), depth);
608
+ push(blockOf(s.alternate), depth);
609
+ break;
610
+ case 'TryStatement':
611
+ push(s.block?.body, depth);
612
+ if (s.handler?.body?.body) push(s.handler.body.body, depth);
613
+ push(s.finalizer?.body, depth);
614
+ break;
615
+ case 'ForStatement':
616
+ case 'ForInStatement':
617
+ case 'ForOfStatement':
618
+ case 'WhileStatement':
619
+ case 'DoWhileStatement':
620
+ push(blockOf(s.body), depth);
621
+ break;
622
+ case 'BlockStatement':
623
+ push(s.body, depth);
624
+ break;
625
+ }
626
+ };
627
+ push(programBody, 0);
628
+ return out;
629
+ }
630
+
396
631
  // const defaultRoutes = [{ path: '/auth', route: authRoute }, …] — collected
397
632
  // per file so a later .forEach mount loop can be unrolled statically
398
633
  function collectRouteArrays(body) {
@@ -404,6 +404,61 @@ function resolveAliasedImport(fromFile, spec) {
404
404
  return null;
405
405
  }
406
406
 
407
+ // ---------------------------------------------------------------------------
408
+ // Class resolution — shared by the Nest DI follower (this.<dep>.<m>()) and the
409
+ // Express instantiated-service follower (new Service().<m>()). A method lookup
410
+ // climbs the `extends` chain because real services inherit their behavior
411
+ // (directus: ActivityService extends ItemsService, where the DB calls live).
412
+ // ---------------------------------------------------------------------------
413
+
414
+ const MAX_CLASS_DEPTH = 6;
415
+
416
+ // find a class declaration named `typeName` at a module's top level (plain,
417
+ // export-named, or export-default with a matching name).
418
+ export function classInModule(mod, typeName) {
419
+ for (const node of mod.ast?.program.body ?? []) {
420
+ const cls =
421
+ node.type === 'ClassDeclaration'
422
+ ? node
423
+ : (node.type === 'ExportNamedDeclaration' ||
424
+ node.type === 'ExportDefaultDeclaration') &&
425
+ node.declaration?.type === 'ClassDeclaration'
426
+ ? node.declaration
427
+ : null;
428
+ if (cls && cls.id?.name === typeName) return cls;
429
+ }
430
+ return null;
431
+ }
432
+
433
+ // resolve `class X extends Base` → the Base class node + its module, via the import.
434
+ export function baseClassOf(cls, mod) {
435
+ if (cls.superClass?.type !== 'Identifier') return null;
436
+ const file = mod.imports.get(cls.superClass.name);
437
+ if (!file || !fs.existsSync(file)) return null;
438
+ const baseMod = parseModule(file);
439
+ if (baseMod.error) return null;
440
+ const baseCls = classInModule(baseMod, cls.superClass.name);
441
+ return baseCls ? { cls: baseCls, mod: baseMod } : null;
442
+ }
443
+
444
+ // locate `methodName` on the class or up its `extends` chain →
445
+ // { fn, mod, cls } where `cls`/`mod` are the DECLARING class and its module
446
+ // (base-class methods live in the base module — the caller needs both for
447
+ // source locations and for resolving `super.<m>()` from the right link).
448
+ export function methodInClassChain(cls, mod, methodName, depth = 0) {
449
+ for (const m of cls.body.body) {
450
+ if (
451
+ m.type === 'ClassMethod' &&
452
+ m.key.type === 'Identifier' &&
453
+ m.key.name === methodName
454
+ )
455
+ return { fn: m, mod, cls };
456
+ }
457
+ if (depth >= MAX_CLASS_DEPTH) return null;
458
+ const base = baseClassOf(cls, mod);
459
+ return base ? methodInClassChain(base.cls, base.mod, methodName, depth + 1) : null;
460
+ }
461
+
407
462
  // ---------------------------------------------------------------------------
408
463
  // scanFunction — what does this function DO?
409
464
  // Plain recursive walk (no scope analysis: deterministic, dependency-free),
@@ -429,10 +484,61 @@ export function scanFunction(fnNode) {
429
484
  isolation: 'default',
430
485
  tryId: null,
431
486
  catchOf: null,
487
+ reqDerived: collectReqDerived(fnNode),
432
488
  });
433
489
  return result;
434
490
  }
435
491
 
492
+ // The request objects a handler names its input through. A table/target sourced from
493
+ // one of these is not a literal, but it is not unknown either: it is precisely "the
494
+ // value the caller supplies here" — a SYMBOLIC target, expressed as a rule.
495
+ const REQ_ROOTS = new Set(['req', 'request', 'ctx', 'context', 'event', 'request_']);
496
+
497
+ // `req.params.collection` / `req.query.table` / `req.body.type` / `req.collection`
498
+ // → the leaf name, prefixed ':' to mark it symbolic. Null if not request-derived.
499
+ function reqParamName(node, reqDerived) {
500
+ if (node?.type === 'Identifier') return reqDerived?.get(node.name) ?? null;
501
+ if (node?.type !== 'MemberExpression' || node.computed) return null;
502
+ if (node.property.type !== 'Identifier') return null;
503
+ const root = rootIdentifier(node);
504
+ if (!root || !REQ_ROOTS.has(root.toLowerCase())) return null;
505
+ return `:${node.property.name}`;
506
+ }
507
+
508
+ // map local vars assigned straight from a request member: `const c = req.params.collection`
509
+ // → { c: ':collection' }. One shallow pass; deterministic; bounded by the function body.
510
+ function collectReqDerived(fnNode) {
511
+ const map = new Map();
512
+ const walk = (n) => {
513
+ if (!n || typeof n !== 'object') return;
514
+ if (Array.isArray(n)) {
515
+ for (const x of n) walk(x);
516
+ return;
517
+ }
518
+ if (
519
+ n.type === 'VariableDeclarator' &&
520
+ n.id?.type === 'Identifier' &&
521
+ n.init?.type === 'MemberExpression'
522
+ ) {
523
+ const name = reqParamName(n.init, null);
524
+ if (name) map.set(n.id.name, name);
525
+ }
526
+ for (const k of Object.keys(n)) {
527
+ if (
528
+ k === 'loc' ||
529
+ k === 'range' ||
530
+ k === 'leadingComments' ||
531
+ k === 'trailingComments'
532
+ )
533
+ continue;
534
+ const v = n[k];
535
+ if (v && typeof v === 'object') walk(v);
536
+ }
537
+ };
538
+ walk(fnNode.body);
539
+ return map;
540
+ }
541
+
436
542
  // SBIR §2.2 — transaction wrappers whose function arguments open a scope
437
543
  const TX_WRAPPERS = new Set(['transaction', '$transaction', 'withTransaction']);
438
544
 
@@ -606,13 +712,16 @@ function inspectCall(node, out, ctx) {
606
712
  // (not a chained .from()), so read it straight off arguments[0].
607
713
  if (KYSELY_OPS[methodLower] !== undefined && callee.type === 'MemberExpression') {
608
714
  const arg0 = node.arguments[0];
609
- const table = arg0?.type === 'StringLiteral' ? arg0.value : null;
715
+ const literal = arg0?.type === 'StringLiteral' ? arg0.value.toLowerCase() : null;
716
+ const symbolic = literal ? null : reqParamName(arg0, ctx.reqDerived);
717
+ const table = literal ?? symbolic;
610
718
  if (table) {
611
719
  const op = KYSELY_OPS[methodLower];
612
720
  pushEffect(out, ctx, {
613
721
  effectType: op === 'select' ? 'db_read' : 'db_write',
614
722
  op,
615
- table: table.toLowerCase(),
723
+ table,
724
+ ...(symbolic ? { symbolic: true } : {}),
616
725
  line,
617
726
  });
618
727
  return;
@@ -621,12 +730,13 @@ function inspectCall(node, out, ctx) {
621
730
 
622
731
  // ---- supabase/knex builder: X.from('t').insert(...) or knex('t').update(...)
623
732
  if (SUPABASE_OPS.has(methodLower)) {
624
- const table = builderTableOf(callee.object);
625
- if (table) {
733
+ const resolved = builderTableOf(callee.object, ctx.reqDerived);
734
+ if (resolved) {
626
735
  pushEffect(out, ctx, {
627
736
  effectType: methodLower === 'select' ? 'db_read' : 'db_write',
628
737
  op: methodLower,
629
- table: table.toLowerCase(),
738
+ table: resolved.symbolic ? resolved.table : resolved.table.toLowerCase(),
739
+ ...(resolved.symbolic ? { symbolic: true } : {}),
630
740
  line,
631
741
  });
632
742
  return;
@@ -964,20 +1074,29 @@ function clientBaseName(node) {
964
1074
  // knex('users') → users ; supabase.from('users') → users ;
965
1075
  // supabase.from('users').select() chains: walk member/call chain to a
966
1076
  // .from('t') or a base call with a string literal argument.
967
- function builderTableOf(node) {
1077
+ // → { table, symbolic } or null. A request-derived arg (`knex(req.params.table)`,
1078
+ // `.from(collection)` where `const collection = req.params.collection`) resolves to a
1079
+ // SYMBOLIC table (`:table`) — a precise rule, not an unknown — so generic CRUD
1080
+ // endpoints stop reading as blind.
1081
+ function builderTableOf(node, reqDerived) {
968
1082
  let cur = node;
1083
+ const lit = (t) => ({ table: t, symbolic: false });
969
1084
  for (let hops = 0; hops < 8 && cur; hops++) {
970
1085
  if (cur.type === 'CallExpression') {
971
1086
  const c = cur.callee;
972
- if (
1087
+ const arg0 = cur.arguments[0];
1088
+ const isFrom =
973
1089
  c.type === 'MemberExpression' &&
974
1090
  c.property.type === 'Identifier' &&
975
- c.property.name === 'from' &&
976
- cur.arguments[0]?.type === 'StringLiteral'
977
- )
978
- return cur.arguments[0].value;
979
- if (c.type === 'Identifier' && cur.arguments[0]?.type === 'StringLiteral')
980
- return cur.arguments[0].value; // knex('users')
1091
+ c.property.name === 'from';
1092
+ const isBaseCall = c.type === 'Identifier'; // knex('users')
1093
+ const isThisCall =
1094
+ c.type === 'MemberExpression' && c.object.type === 'ThisExpression';
1095
+ if (isFrom || isBaseCall || isThisCall) {
1096
+ if (arg0?.type === 'StringLiteral') return lit(arg0.value);
1097
+ const sym = reqParamName(arg0, reqDerived);
1098
+ if (sym) return { table: sym, symbolic: true };
1099
+ }
981
1100
  cur = c.type === 'MemberExpression' ? c.object : null;
982
1101
  } else if (cur.type === 'MemberExpression') {
983
1102
  cur = cur.object;
package/src/ubg/nestjs.js CHANGED
@@ -14,7 +14,13 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import traverseModule from '@babel/traverse';
17
- import { parseModule, scanFunction } from './extract.js';
17
+ import {
18
+ parseModule,
19
+ scanFunction,
20
+ classInModule,
21
+ baseClassOf,
22
+ methodInClassChain,
23
+ } from './extract.js';
18
24
 
19
25
  const traverse = traverseModule.default ?? traverseModule;
20
26
  const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
@@ -289,11 +295,11 @@ function resolveMethod(typeName, methodName, ownerMod, cwd, scannedFiles, helper
289
295
  const rel = relOf(cwd, file);
290
296
  if (!scannedFiles.includes(rel)) scannedFiles.push(rel);
291
297
 
292
- const m = methodInClassChain(cls, mod, methodName, cwd, 0);
298
+ const m = methodInClassChain(cls, mod, methodName);
293
299
  if (!m) return null;
294
300
  helpers.push({
295
301
  name: `${typeName}.${methodName}`,
296
- sourceFile: m.rel ?? rel,
302
+ sourceFile: relOf(cwd, moduleFileOf(m.mod)) || rel,
297
303
  sourceLine: m.fn.loc?.start.line ?? 0,
298
304
  fn: m.fn,
299
305
  });
@@ -325,47 +331,8 @@ function diMapWithMod(cls, clsMod) {
325
331
  return out;
326
332
  }
327
333
 
328
- // find a class declaration named `typeName` at a module's top level.
329
- function classInModule(mod, typeName) {
330
- for (const node of mod.ast.program.body) {
331
- const cls =
332
- node.type === 'ClassDeclaration'
333
- ? node
334
- : node.type === 'ExportNamedDeclaration' &&
335
- node.declaration?.type === 'ClassDeclaration'
336
- ? node.declaration
337
- : null;
338
- if (cls && cls.id?.name === typeName) return cls;
339
- }
340
- return null;
341
- }
342
-
343
- // locate `methodName` on the class or, failing that, up the `extends` chain →
344
- // { fn, mod, rel }. Base-class methods live in the base module, so carry it along.
345
- function methodInClassChain(cls, mod, methodName, cwd, depth) {
346
- for (const m of cls.body.body) {
347
- if (
348
- m.type === 'ClassMethod' &&
349
- m.key.type === 'Identifier' &&
350
- m.key.name === methodName
351
- )
352
- return { fn: m, mod, rel: relOf(cwd, moduleFileOf(mod)) };
353
- }
354
- if (depth >= MAX_DI_DEPTH) return null;
355
- const base = baseClassOf(cls, mod);
356
- return base ? methodInClassChain(base.cls, base.mod, methodName, cwd, depth + 1) : null;
357
- }
358
-
359
- // resolve `class X extends Base` → the Base class node + its module, via the import.
360
- function baseClassOf(cls, mod) {
361
- if (cls.superClass?.type !== 'Identifier') return null;
362
- const file = mod.imports.get(cls.superClass.name);
363
- if (!file || !fs.existsSync(file)) return null;
364
- const baseMod = parseModule(file);
365
- if (baseMod.error) return null;
366
- const baseCls = classInModule(baseMod, cls.superClass.name);
367
- return baseCls ? { cls: baseCls, mod: baseMod } : null;
368
- }
334
+ // classInModule / methodInClassChain / baseClassOf live in extract.js shared
335
+ // with the Express instantiated-service follower (`new Service().method()`).
369
336
 
370
337
  const moduleFileOf = (mod) => mod.ast?.loc?.filename ?? mod._file ?? '';
371
338
 
@@ -218,6 +218,10 @@ function ensureChainNode(graph, step, scanCache) {
218
218
  existing.meta.returnShapes = cachedScan.returnShapes;
219
219
  return { id, scan: cachedScan };
220
220
  }
221
+ // opaque = SPARDA holds NO body for this step (fn:null and no precomputed scan) —
222
+ // a handler/middleware it registered by name but could not read. The blindspot
223
+ // ledger uses this to tell "read and empty" apart from "couldn't read".
224
+ const opaque = !step.fn && !step.scan;
221
225
  addNode(
222
226
  graph,
223
227
  makeNode(
@@ -228,6 +232,7 @@ function ensureChainNode(graph, step, scanCache) {
228
232
  {
229
233
  role: step.role,
230
234
  async: scan.async,
235
+ ...(opaque ? { opaque: true } : {}),
231
236
  ...(kind === 'guard'
232
237
  ? { guardType: guardTypeOf(step.name, scan), verified: gf.verified }
233
238
  : {}),
@@ -263,6 +268,9 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
263
268
  effectType: eff.effectType,
264
269
  ...(eff.op ? { op: eff.op } : {}),
265
270
  ...(eff.table ? { table: eff.table } : {}),
271
+ // symbolic table (`:collection`): a request-derived target, resolved as a
272
+ // rule, not a literal — carried so the blindspot ledger doesn't flag it opaque
273
+ ...(eff.symbolic ? { symbolic: true } : {}),
266
274
  ...(eff.target ? { target: eff.target } : {}),
267
275
  ...(eff.driver ? { driver: eff.driver } : {}),
268
276
  ...(eff.httpMethod ? { httpMethod: eff.httpMethod } : {}),
@@ -1 +0,0 @@
1
- {"v":"imm1","proven":false,"surfaceOnly":true,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}