sparda-mcp 0.66.1 → 0.67.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
@@ -44,7 +44,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
44
44
  >
45
45
  > Honesty first: _compiling_ a route is a parser result (the number above); _proving_ it safe is a separate per-repo verdict — and most real apps come back **NOT_PROVEN**, which is the true state, not a failure. (Our full 25-repo corpus stress compiles **3,565 routes** at ~150 routes/s; that one needs the corpus checked out.)
46
46
 
47
- **What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
47
+ **What the graph unlocks — 100% local, deterministic, 4 exact-pinned dependencies, zero API key:**
48
48
 
49
49
  | Command | What it does |
50
50
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -341,12 +341,17 @@ runtime, so the guidance never goes stale.
341
341
  ## Supported frameworks
342
342
 
343
343
  - **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.
344
- - **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.
345
- - **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).
344
+ - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
345
+ - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, barrel re-exports, and inline handlers. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
346
346
  - **MedusaJS** — Native AST ingestion of complex e-commerce routing.
347
347
  - **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
348
348
  - **FastAPI** (Python >= 3.9) — AST-based router injection.
349
349
 
350
+ ### Effects it resolves (what makes the irreversibility & atomicity proofs bite)
351
+
352
+ - **Databases** — Prisma (incl. named/multiline relations and interactive `$transaction(tx ⇒ …)`), TypeORM, Kysely, Drizzle, Knex, Sequelize, Mongoose, and raw SQL. Foreign keys become aggregate/consistency domains, so a multi-table write outside a transaction is caught.
353
+ - **External side-effects** — recognized by call shape and by import origin, so an irreversible outbound effect next to a DB write is proven compensable-or-not: `fetch`/axios/got, Stripe, Twilio, SendGrid/Resend/nodemailer, AWS SDK v3 (`send(new PutObjectCommand())`), and other payment/mail/cloud/queue clients. A read on such a client stays a non-observable GET — no false alarms.
354
+
350
355
  ## Security posture (honest)
351
356
 
352
357
  - 4 runtime dependencies, exact-pinned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.66.1",
3
+ "version": "0.67.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant — no API key, right in the agent edit loop.",
6
6
  "type": "module",
@@ -61,19 +61,19 @@
61
61
  "author": "Residual Labs (residual-labs.fr)",
62
62
  "license": "BUSL-1.1",
63
63
  "dependencies": {
64
- "@babel/parser": "7.26.5",
65
- "@babel/traverse": "7.26.5",
66
- "@clack/prompts": "0.9.1",
64
+ "@babel/parser": "8.0.4",
65
+ "@babel/traverse": "8.0.4",
66
+ "@clack/prompts": "1.7.0",
67
67
  "@modelcontextprotocol/sdk": "1.29.0"
68
68
  },
69
69
  "devDependencies": {
70
- "@eslint/js": "^9.39.4",
71
- "@vitest/coverage-v8": "^3.2.6",
72
- "eslint": "^9.39.4",
73
- "eslint-config-prettier": "^9.1.2",
74
- "express": "^4.21.0",
75
- "globals": "^15.15.0",
70
+ "@eslint/js": "^10.0.1",
71
+ "@vitest/coverage-v8": "^4.1.10",
72
+ "eslint": "^10.7.0",
73
+ "eslint-config-prettier": "^10.1.8",
74
+ "express": "^5.2.1",
75
+ "globals": "^17.7.0",
76
76
  "prettier": "^3.8.4",
77
- "vitest": "^3.2.6"
77
+ "vitest": "^4.1.10"
78
78
  }
79
79
  }
@@ -82,7 +82,7 @@ export async function runEvolve(opts) {
82
82
  },
83
83
  );
84
84
  }
85
- let exemplars = {};
85
+ let exemplars;
86
86
  try {
87
87
  exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? {};
88
88
  } catch {
@@ -100,7 +100,7 @@ async function gate(opts, id, flight, healDir) {
100
100
  // 3. apocalypse: no new critical/high, nothing protected got removed
101
101
  const fixedGraph = canonicalizeGraph(compileUBG(opts.cwd, { write: false }).graph);
102
102
  const staticFindings = checkGraph(fixedGraph).findings;
103
- let regressions = [];
103
+ let regressions;
104
104
  const baselinePath = path.join(healDir, 'baseline.json');
105
105
  if (fs.existsSync(baselinePath)) {
106
106
  const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
@@ -277,7 +277,7 @@ export async function runSeed(opts, args) {
277
277
  if (opts.germinate) {
278
278
  const { buildGrammar } = await import('./grammar.js');
279
279
  const { twinFilePath } = await import('./twin.js');
280
- let exemplars = null;
280
+ let exemplars;
281
281
  try {
282
282
  exemplars =
283
283
  JSON.parse(fs.readFileSync(twinFilePath(opts.cwd), 'utf8')).exemplars ?? null;
@@ -27,7 +27,7 @@ export function evaluateHealing(flight, replay, expectation = null) {
27
27
  reasons.push('response is byte-identical to the recorded bug — nothing changed');
28
28
  }
29
29
 
30
- let expectationMet = null;
30
+ let expectationMet;
31
31
  if (expectation) {
32
32
  expectationMet = true;
33
33
  if (expectation.status !== undefined && replay.actual.status !== expectation.status) {
@@ -80,7 +80,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
80
80
  const isESM = moduleType === 'esm';
81
81
  const isTS = ext === '.ts';
82
82
 
83
- let importLine = '';
83
+ let importLine;
84
84
  let reqType = '';
85
85
  let resType = '';
86
86
  let nextType = '';
@@ -42,7 +42,7 @@ export function ensureSpardaKey(cwd, prevManifest = null) {
42
42
  // state (opt-in flags + observed circuits), and the `sparding` security
43
43
  // memory survive as long as the tool's method+path are unchanged.
44
44
  export function carryOverManifest(cwd, tools) {
45
- let prev = null;
45
+ let prev;
46
46
  try {
47
47
  prev = JSON.parse(fs.readFileSync(path.join(cwd, 'sparda.json'), 'utf8'));
48
48
  } catch {
@@ -30,6 +30,7 @@ export function parseFastAPIProject(cwd, entryFile, pythonCmd = 'python') {
30
30
  } catch (err) {
31
31
  throw new Error(
32
32
  `Failed to parse Python extractor output: ${err.message}. Raw output: ${res.stdout}`,
33
+ { cause: err },
33
34
  );
34
35
  }
35
36
  }
@@ -304,12 +304,18 @@ export function checkGraph(graph) {
304
304
  });
305
305
  }
306
306
 
307
- // O2 — writes into constrained tables need validated input
307
+ // O2 — writes into constrained tables need validated input. A DELETE removes a row, so it
308
+ // can never violate a value constraint (CHECK / NOT NULL / UNIQUE) — those constrain the
309
+ // values a row CARRIES, not its removal. Flagging a delete here was noise (medium finding on
310
+ // `DELETE /users/:id` because `users.email` is UNIQUE). Only value-writing ops qualify; an
311
+ // unknown op (raw SQL we couldn't classify) is kept, conservatively.
308
312
  obligations++;
309
- const constrained = writes.filter((w) =>
310
- (g.nodes.get(w.stateId)?.meta.invariants ?? []).some((i) =>
311
- CONSTRAINING.has(i.type),
312
- ),
313
+ const constrained = writes.filter(
314
+ (w) =>
315
+ w.effect.meta.op !== 'delete' &&
316
+ (g.nodes.get(w.stateId)?.meta.invariants ?? []).some((i) =>
317
+ CONSTRAINING.has(i.type),
318
+ ),
313
319
  );
314
320
  if (constrained.length) vec.validation = ep.meta.inputValidated ? 1 : -1;
315
321
  if (!ep.meta.inputValidated && constrained.length) {
@@ -224,6 +224,10 @@ export function extractExpress(cwd, entryFile) {
224
224
  sourceFile: relFile,
225
225
  sourceLine: arg.loc?.start.line ?? 0,
226
226
  fn: arg,
227
+ // deep-scan the inline handler like every other callable branch: follows its
228
+ // service/model calls AND carries the module's effect-client provenance (import
229
+ // labels), so an SDK call in an inline handler is recognized by origin.
230
+ scan: resolver.deepScan(arg, mod),
227
231
  };
228
232
  }
229
233
  if (arg.type === 'CallExpression') {
@@ -109,6 +109,23 @@ const PRISMA_OPS = {
109
109
  delete: 'delete',
110
110
  deletemany: 'delete',
111
111
  };
112
+ // TypeORM write verbs on a repository / entity-manager. Only fire when the RECEIVER is provably
113
+ // a repository (in ctx.repoTables) — a generic `.save()`/`.update()` on an unknown object never
114
+ // fires, so false positives stay near zero. Reads (find/findOne/count/…) are not here.
115
+ const TYPEORM_WRITE = {
116
+ save: 'insert',
117
+ insert: 'insert',
118
+ upsert: 'insert',
119
+ update: 'update',
120
+ increment: 'update',
121
+ decrement: 'update',
122
+ delete: 'delete',
123
+ remove: 'delete',
124
+ softdelete: 'delete',
125
+ softremove: 'delete',
126
+ restore: 'update',
127
+ };
128
+
112
129
  const FS_WRITE = new Set([
113
130
  'writefile',
114
131
  'writefilesync',
@@ -133,6 +150,310 @@ const FS_READ = new Set([
133
150
  'existssync',
134
151
  ]);
135
152
  const HTTP_CLIENTS = new Set(['axios', 'got', 'ky', 'superagent', 'undici']);
153
+
154
+ // Innate immunity (PAMP table): vendor-SDK call shapes that ARE an irreversible external
155
+ // effect but wear no `fetch`/http-client skin (the SDK hides the network call). Extraction
156
+ // blind spot #1 in the audit: `stripe.charges.create()` charges a card, yet resolved to
157
+ // nothing, so O4 (irreversibility) never fired on real payment code. We can't enumerate every
158
+ // SDK — so, like the olfactory system, we recognize a small set of CONSERVED, highly-specific
159
+ // call SHAPES directly (combinatorial coding, not one receptor per molecule). Matched on the
160
+ // property path BELOW the (user-named) root, so the variable name is irrelevant; DB handlers
161
+ // above have already returned, so no double-count. Additive + write-only: it can only ever
162
+ // RAISE a finding (an observable http_call), never fabricate a false PROVEN — a stale catalog
163
+ // under-detects, it never lies. This is the deterministic, zero-LLM, zero-network innate layer;
164
+ // the adaptive layer (LLM-on-surprise → antibody) is a later brick that fills the long tail.
165
+ const EFFECT_SDK_PATHS = new Set([
166
+ // Stripe — money movement, irreversible
167
+ 'charges.create',
168
+ 'paymentintents.create',
169
+ 'paymentintents.confirm',
170
+ 'paymentintents.capture',
171
+ 'refunds.create',
172
+ 'payouts.create',
173
+ 'transfers.create',
174
+ 'subscriptions.create',
175
+ 'subscriptions.cancel',
176
+ 'invoices.pay',
177
+ 'checkout.sessions.create',
178
+ 'paymentmethods.attach',
179
+ // Twilio — SMS / voice, irreversible once sent
180
+ 'messages.create',
181
+ 'calls.create',
182
+ // Resend / modern mail SDKs — the email leaves once sent
183
+ 'emails.send',
184
+ 'emails.create',
185
+ ]);
186
+ // AWS SDK v3 & friends speak `client.send(new XCommand(...))` — the verb is a generic `.send`,
187
+ // but the COMMAND class in the argument names the effect unmistakably. Match the command name
188
+ // (lowercased) so the modern AWS style is not a blind spot the way v2 `s3.putObject()` isn't.
189
+ const EFFECT_SDK_COMMANDS = new Set([
190
+ 'putobjectcommand', // S3 write
191
+ 'deleteobjectcommand',
192
+ 'copyobjectcommand',
193
+ 'sendemailcommand', // SES
194
+ 'sendrawemailcommand',
195
+ 'sendtemplatedemailcommand',
196
+ 'sendmessagecommand', // SQS
197
+ 'sendmessagebatchcommand',
198
+ 'publishcommand', // SNS
199
+ 'publishbatchcommand',
200
+ 'invokecommand', // Lambda
201
+ 'putitemcommand', // DynamoDB writes
202
+ 'updateitemcommand',
203
+ 'deleteitemcommand',
204
+ ]);
205
+ // Strong single-token methods: specific enough to fire without a multi-segment path
206
+ // (an email leaves, an object is written to a bucket, a message enters a queue — all
207
+ // observable and irreversible). Deliberately narrow to keep false positives near zero.
208
+ const EFFECT_SDK_METHODS = new Set([
209
+ 'sendmail', // nodemailer transporter.sendMail
210
+ 'sendemail', // AWS SES sendEmail
211
+ 'sendemailcommand',
212
+ 'putobject', // AWS S3 putObject
213
+ 'deleteobject',
214
+ 'copyobject',
215
+ 'sendmessage', // AWS SQS sendMessage
216
+ 'publishcommand',
217
+ ]);
218
+
219
+ // The property path below the root of a call (`stripe.charges.create` → "charges.create"),
220
+ // lowercased, non-computed segments only. Null if not a plain member chain.
221
+ function memberPathBelowRoot(memberExpr) {
222
+ const segs = [];
223
+ let cur = memberExpr;
224
+ while (cur.type === 'MemberExpression') {
225
+ if (cur.computed || cur.property.type !== 'Identifier') return null;
226
+ segs.unshift(cur.property.name.toLowerCase());
227
+ if (cur.object.type === 'ThisExpression') break;
228
+ cur = cur.object;
229
+ }
230
+ segs.pop(); // drop the method itself — we want the path BELOW root, incl. method re-added below
231
+ return segs;
232
+ }
233
+
234
+ // Innate-immunity match: does this call's shape name a known irreversible external effect?
235
+ // Returns { httpMethod, target } to emit as an observable http_call, or null.
236
+ function knownExternalEffect(callee, methodLower, node) {
237
+ if (callee.type !== 'MemberExpression') return null;
238
+ // AWS SDK v3 shape: `client.send(new PutObjectCommand(...))` — the command class in the
239
+ // argument names the effect, the `.send` verb alone does not.
240
+ if (methodLower === 'send' && node) {
241
+ const arg0 = node.arguments?.[0];
242
+ const cmd =
243
+ arg0?.type === 'NewExpression' && arg0.callee?.type === 'Identifier'
244
+ ? arg0.callee.name.toLowerCase()
245
+ : null;
246
+ if (cmd && EFFECT_SDK_COMMANDS.has(cmd))
247
+ return { httpMethod: 'POST', target: `sdk:${cmd}` };
248
+ }
249
+ const below = memberPathBelowRoot(callee); // e.g. ["charges"] for stripe.charges.create
250
+ const path = below === null ? methodLower : [...below, methodLower].join('.');
251
+ const hit =
252
+ EFFECT_SDK_METHODS.has(methodLower) ||
253
+ EFFECT_SDK_PATHS.has(path) ||
254
+ [...EFFECT_SDK_PATHS].some((sig) => path.endsWith('.' + sig));
255
+ if (!hit) return null;
256
+ // Vendor writes are non-idempotent from the outside → POST makes the effect algebra mark
257
+ // it observable + non-compensable, which is the whole point (O4 must see the irreversibility).
258
+ return { httpMethod: 'POST', target: `sdk:${path}` };
259
+ }
260
+ // Import provenance for effect clients (the ant cuticular-hydrocarbon / "colony odor" model):
261
+ // a binding IMPORTED from a package whose whole job is an external side effect (payment, mail,
262
+ // SMS, cloud storage, queue, push) carries an effect LABEL acquired at its source — and that
263
+ // label propagates by contact: `const stripe = require('stripe')(key)`, `const s3 = new
264
+ // S3Client()`, `const x = s3`. Thereafter ANY call on a labeled binding is recognized as an
265
+ // external effect by ORIGIN, not by guessing the method name — which is what the bare `.send()`
266
+ // tail (SendGrid `sgMail.send`, Kafka `producer.send`) needs. Read-shaped methods stay GET (not
267
+ // observable), so a `.get`/`.list`/`.retrieve` never becomes a false irreversibility finding.
268
+ const EFFECT_PACKAGES = new Set([
269
+ // payment
270
+ 'stripe',
271
+ 'braintree',
272
+ 'razorpay',
273
+ 'square',
274
+ // mail
275
+ '@sendgrid/mail',
276
+ 'nodemailer',
277
+ 'resend',
278
+ 'postmark',
279
+ 'mailgun.js',
280
+ '@mailchimp/mailchimp_transactional',
281
+ // sms / comms / push
282
+ 'twilio',
283
+ '@slack/web-api',
284
+ 'firebase-admin',
285
+ // cloud (v3 clients) + legacy aws-sdk v2
286
+ 'aws-sdk',
287
+ '@aws-sdk/client-s3',
288
+ '@aws-sdk/client-ses',
289
+ '@aws-sdk/client-sesv2',
290
+ '@aws-sdk/client-sns',
291
+ '@aws-sdk/client-sqs',
292
+ '@aws-sdk/client-lambda',
293
+ '@aws-sdk/client-dynamodb',
294
+ '@google-cloud/storage',
295
+ '@google-cloud/pubsub',
296
+ // queues / streams
297
+ 'kafkajs',
298
+ 'amqplib',
299
+ ]);
300
+ // A read-shaped method on an effect client is a GET (idempotent, NOT observable) — so tagging a
301
+ // client can never turn a `.retrieve`/`.list` into a false irreversible effect. Everything else
302
+ // on a labeled client is treated as an outbound POST (observable). Config/wiring methods emit no
303
+ // effect at all.
304
+ const SDK_READ_METHOD =
305
+ /^(get|list|describe|retrieve|fetch|read|find|query|scan|head|count|exists|search)/;
306
+ const SDK_IGNORE_METHOD = new Set([
307
+ 'setapikey',
308
+ 'config',
309
+ 'configure',
310
+ 'use',
311
+ 'on',
312
+ 'once',
313
+ 'off',
314
+ 'addeventlistener',
315
+ 'connect',
316
+ 'disconnect',
317
+ 'end',
318
+ 'destroy',
319
+ 'constructor',
320
+ 'middleware',
321
+ 'setclient',
322
+ ]);
323
+
324
+ // Collect the effect-labeled bindings of a module, in source order so a later `new X()` sees the
325
+ // earlier `import X`. Pure over the top-level body; returns a Set of local names.
326
+ function collectEffectClients(body) {
327
+ const labeled = new Set();
328
+ const isLabeled = (n) => n?.type === 'Identifier' && labeled.has(n.name);
329
+ for (const node of body) {
330
+ if (node.type === 'ImportDeclaration') {
331
+ if (EFFECT_PACKAGES.has(node.source.value))
332
+ for (const s of node.specifiers) labeled.add(s.local.name);
333
+ continue;
334
+ }
335
+ if (node.type !== 'VariableDeclaration') continue;
336
+ for (const d of node.declarations) {
337
+ const init = d.init;
338
+ if (!init || d.id.type !== 'Identifier') continue;
339
+ const requirePkg =
340
+ init.type === 'CallExpression' &&
341
+ init.callee.type === 'Identifier' &&
342
+ init.callee.name === 'require' &&
343
+ init.arguments[0]?.type === 'StringLiteral'
344
+ ? init.arguments[0].value
345
+ : null;
346
+ if (
347
+ // const x = require('stripe') OR const x = require('stripe')(key) (factory)
348
+ (requirePkg && EFFECT_PACKAGES.has(requirePkg)) ||
349
+ (init.type === 'CallExpression' &&
350
+ init.callee.type === 'CallExpression' &&
351
+ init.callee.callee?.type === 'Identifier' &&
352
+ init.callee.callee.name === 'require' &&
353
+ EFFECT_PACKAGES.has(init.callee.arguments?.[0]?.value)) ||
354
+ // const y = new X(...) / const y = X(...) where X is already labeled
355
+ (init.type === 'NewExpression' && isLabeled(init.callee)) ||
356
+ (init.type === 'CallExpression' && isLabeled(init.callee)) ||
357
+ // const y = x (alias)
358
+ isLabeled(init)
359
+ )
360
+ labeled.add(d.id.name);
361
+ }
362
+ }
363
+ return labeled;
364
+ }
365
+
366
+ // TypeORM repository provenance — like the effect-client label, but for ORM repositories, so a
367
+ // `repo.save()` / `this.userRepo.save()` resolves to a db_write with its entity table even though
368
+ // the table is nowhere in the call. Two sources: (1) a class constructor param that is an injected
369
+ // or typed repository (`@InjectRepository(User) repo` / `repo: Repository<User>`), keyed by field
370
+ // name; (2) a local `const r = getRepository(User)` (or `ds.getRepository(User)`), keyed by var.
371
+ // The entity name is lowercased to a table, matching how Prisma models are keyed.
372
+ export function collectRepoFields(cls) {
373
+ const map = new Map();
374
+ if (!cls?.body?.body) return map;
375
+ const ctor = cls.body.body.find(
376
+ (m) => m.type === 'ClassMethod' && m.kind === 'constructor',
377
+ );
378
+ for (const param of ctor?.params ?? []) {
379
+ // NestJS constructor params are `TSParameterProperty` wrapping the real Identifier.
380
+ const inner = param.type === 'TSParameterProperty' ? param.parameter : param;
381
+ const field = inner?.type === 'Identifier' ? inner.name : null;
382
+ if (!field) continue;
383
+ // entity from `@InjectRepository(User)` (authoritative) …
384
+ let entity = null;
385
+ for (const dec of param.decorators ?? []) {
386
+ const call = dec.expression;
387
+ if (
388
+ call?.type === 'CallExpression' &&
389
+ call.callee.type === 'Identifier' &&
390
+ call.callee.name === 'InjectRepository' &&
391
+ call.arguments[0]?.type === 'Identifier'
392
+ )
393
+ entity = call.arguments[0].name;
394
+ }
395
+ // … or from the `Repository<User>` / `TreeRepository<User>` type annotation.
396
+ if (!entity) {
397
+ const ta = inner.typeAnnotation?.typeAnnotation;
398
+ if (
399
+ ta?.type === 'TSTypeReference' &&
400
+ /repository$/i.test(ta.typeName?.name ?? '') &&
401
+ ta.typeParameters?.params?.[0]?.type === 'TSTypeReference'
402
+ )
403
+ entity = ta.typeParameters.params[0].typeName?.name ?? null;
404
+ }
405
+ if (entity) map.set(field, entity.toLowerCase());
406
+ }
407
+ return map;
408
+ }
409
+
410
+ // Merge the class-level repo fields with the body-local repo vars into one lookup (or null when
411
+ // both are empty, so the common non-TypeORM path allocates nothing).
412
+ function mergeRepoTables(fields, vars) {
413
+ if ((!fields || !fields.size) && (!vars || !vars.size)) return null;
414
+ const m = new Map(fields ?? []);
415
+ for (const [k, v] of vars ?? []) m.set(k, v);
416
+ return m;
417
+ }
418
+
419
+ function collectRepoVars(fnNode) {
420
+ const map = new Map();
421
+ walkAst(fnNode, (node) => {
422
+ if (
423
+ node.type !== 'VariableDeclarator' ||
424
+ node.id?.type !== 'Identifier' ||
425
+ node.init?.type !== 'CallExpression'
426
+ )
427
+ return;
428
+ const callee = node.init.callee;
429
+ const isGetRepo =
430
+ (callee.type === 'Identifier' && callee.name === 'getRepository') ||
431
+ (callee.type === 'MemberExpression' &&
432
+ callee.property?.type === 'Identifier' &&
433
+ callee.property.name === 'getRepository');
434
+ const entity = node.init.arguments?.[0];
435
+ if (isGetRepo && entity?.type === 'Identifier')
436
+ map.set(node.id.name, entity.name.toLowerCase());
437
+ });
438
+ return map;
439
+ }
440
+
441
+ // walkAst lives in resolve.js; a local minimal copy for the collectors above (extract.js must not
442
+ // import resolve.js — resolve.js imports extract.js).
443
+ function walkAst(node, fn) {
444
+ if (!node || typeof node !== 'object') return;
445
+ if (Array.isArray(node)) {
446
+ for (const n of node) walkAst(n, fn);
447
+ return;
448
+ }
449
+ if (typeof node.type === 'string') fn(node);
450
+ for (const k of Object.keys(node)) {
451
+ if (k === 'loc' || k === 'leadingComments' || k === 'trailingComments') continue;
452
+ const v = node[k];
453
+ if (v && typeof v === 'object') walkAst(v, fn);
454
+ }
455
+ }
456
+
136
457
  const GUARD_NAME = /auth|guard|acl|permission|verif|session|admin|protect|role|token/i;
137
458
 
138
459
  const moduleCache = new Map(); // absFile -> module facts (parse once per compile run)
@@ -181,6 +502,7 @@ export function parseModule(absFile) {
181
502
  }
182
503
 
183
504
  for (const node of facts.ast.program.body) collectTopLevel(node, facts, absFile, false);
505
+ facts.effectClients = collectEffectClients(facts.ast.program.body);
184
506
  return facts;
185
507
  }
186
508
 
@@ -346,6 +668,29 @@ function collectTopLevel(node, facts, absFile, exported) {
346
668
  const resolved = resolveRelImport(absFile, rhs.arguments[0].value);
347
669
  if (resolved) facts.reexports.set(left.property.name, resolved);
348
670
  }
671
+ // Direct function export: `module.exports.createOrder = async (b) => …` / `exports.f =
672
+ // function () {}` / a wrapped `exports.create = catchAsync(async () => …)`. A common
673
+ // CommonJS style the const-arrow path never saw, so the exported function was invisible
674
+ // and every effect below it (often a sibling workspace package away) dead-ended. Register
675
+ // it under the exported name so a `service.createOrder()` call resolves to this body.
676
+ else if (isExports) {
677
+ const bodyFn =
678
+ rhs.type === 'ArrowFunctionExpression' || rhs.type === 'FunctionExpression'
679
+ ? rhs
680
+ : rhs.type === 'CallExpression'
681
+ ? rhs.arguments.find(
682
+ (a) =>
683
+ a?.type === 'ArrowFunctionExpression' ||
684
+ a?.type === 'FunctionExpression',
685
+ )
686
+ : null;
687
+ if (bodyFn)
688
+ facts.functions.set(left.property.name, {
689
+ node: bodyFn,
690
+ line: node.loc?.start.line ?? 0,
691
+ exported: true,
692
+ });
693
+ }
349
694
  }
350
695
  }
351
696
 
@@ -854,6 +1199,12 @@ export function scanFunction(fnNode, env = {}) {
854
1199
  // CLASS METHOD reached through `new X(req.params.collection)` — lets a
855
1200
  // `this.knex(this.collection)` inside a service resolve to `:collection`.
856
1201
  thisSymbols: env.thisSymbols ?? null,
1202
+ // effect-labeled bindings of the owning module (import-provenance), so a call on an
1203
+ // SDK client is recognized by origin. Absent → the path/command catalog still applies.
1204
+ effectClients: env.effectClients ?? null,
1205
+ // TypeORM repository provenance: class-injected repo fields (env.repoFields, from the owning
1206
+ // class) merged with local `getRepository(Entity)` vars found in THIS body → receiver → table.
1207
+ repoTables: mergeRepoTables(env.repoFields, collectRepoVars(fnNode)),
857
1208
  });
858
1209
  // guard-dominance: a mutation on an unguarded path is a real bypass ONLY when THIS body also holds
859
1210
  // a guard (so a guard is genuinely being circumvented). A body with no guard of its own is guarded
@@ -1256,10 +1607,26 @@ function visit(node, out, ctx) {
1256
1607
  callee.property?.type === 'Identifier' &&
1257
1608
  TX_WRAPPERS.has(callee.property.name)
1258
1609
  ) {
1610
+ // Client-provenance (the "prion" bind): an interactive transaction hands its callback a
1611
+ // transactional client — `prisma.$transaction(async (tx) => { tx.order.create(...) })`. The
1612
+ // param `tx` IS a db client, but wears a name the /prisma|client|db/ heuristic can't see, so
1613
+ // every write inside used to VANISH (audit blind spot #3: the dominant Prisma idiom compiled
1614
+ // to SURFACE). We propagate the receiver's db-identity onto the callback param name for the
1615
+ // scope of the transaction — templated by contact, not by name. Scoped to txCtx, so the alias
1616
+ // never leaks past the transaction body.
1617
+ const cbParams = node.arguments
1618
+ .filter(
1619
+ (a) =>
1620
+ a?.type === 'ArrowFunctionExpression' || a?.type === 'FunctionExpression',
1621
+ )
1622
+ .flatMap((fn) => fn.params ?? [])
1623
+ .filter((p) => p?.type === 'Identifier')
1624
+ .map((p) => p.name);
1259
1625
  const txCtx = {
1260
1626
  ...ctx,
1261
1627
  tx: node.loc?.start.line ?? 0,
1262
1628
  isolation: isolationLiteralOf(node) ?? 'default',
1629
+ dbAliases: new Set([...(ctx.dbAliases ?? []), ...cbParams]),
1263
1630
  };
1264
1631
  for (const arg of node.arguments) visit(arg, out, txCtx);
1265
1632
  return;
@@ -1475,6 +1842,29 @@ function inspectCall(node, out, ctx) {
1475
1842
  }
1476
1843
  }
1477
1844
 
1845
+ // ---- TypeORM: `repo.save(dto)` / `this.userRepo.update(...)` / `manager.remove(...)`. Fires
1846
+ // ONLY when the receiver is a known repository (ctx.repoTables — injected/typed repo field or a
1847
+ // local getRepository(Entity) var), so a generic `.save()`/`.update()` never trips it. The table
1848
+ // is the entity the repo is typed to; a repo with no resolvable entity still yields a db_write
1849
+ // with an unknown table (enough for O1 guard checks and to lift the handler off SURFACE).
1850
+ if (TYPEORM_WRITE[methodLower] !== undefined && ctx.repoTables) {
1851
+ const recv =
1852
+ callee.object.type === 'Identifier' || callee.object.type === 'MemberExpression'
1853
+ ? clientBaseName(callee.object)
1854
+ : null;
1855
+ if (recv && ctx.repoTables.has(recv)) {
1856
+ const table = ctx.repoTables.get(recv);
1857
+ pushEffect(out, ctx, {
1858
+ effectType: 'db_write',
1859
+ op: TYPEORM_WRITE[methodLower],
1860
+ table: table || null,
1861
+ ...(valueTainted(node.arguments[0], ctx) ? { tainted: true } : {}),
1862
+ line,
1863
+ });
1864
+ return;
1865
+ }
1866
+ }
1867
+
1478
1868
  // ---- prisma: prisma.user.findMany() → table "user". Literal values in
1479
1869
  // `data:`/`where:` are harvested like SQL SET/WHERE literals — they are the
1480
1870
  // raw material StateMachineInference reads (lowercased, same trade-off)
@@ -1489,7 +1879,7 @@ function inspectCall(node, out, ctx) {
1489
1879
  !obj.computed &&
1490
1880
  obj.property.type === 'Identifier' &&
1491
1881
  client &&
1492
- /prisma|client|db/i.test(client)
1882
+ (/prisma|client|db/i.test(client) || ctx.dbAliases?.has(client))
1493
1883
  ) {
1494
1884
  const op = PRISMA_OPS[methodLower];
1495
1885
  const data = prismaLiteralsOf(node.arguments[0], 'data');
@@ -1564,6 +1954,32 @@ function inspectCall(node, out, ctx) {
1564
1954
  return;
1565
1955
  }
1566
1956
 
1957
+ // ---- innate immunity: known vendor-SDK effects (stripe.charges.create, transporter.sendMail…)
1958
+ // that carry no http-client skin. Checked AFTER every DB handler has returned, so an ORM call
1959
+ // is never misread as an SDK effect. See EFFECT_SDK_PATHS.
1960
+ const sdkEffect = knownExternalEffect(callee, methodLower, node);
1961
+ if (sdkEffect) {
1962
+ pushEffect(out, ctx, { effectType: 'http_call', ...sdkEffect, line });
1963
+ return;
1964
+ }
1965
+ // Import-provenance: a call whose ROOT binding was labeled an effect client (imported from a
1966
+ // payment/mail/cloud/queue package, or built from one). Recognized by origin, not method name —
1967
+ // this is what catches the bare `.send()` tail (sgMail.send, producer.send). A read-shaped
1968
+ // method stays GET (not observable); config/wiring methods emit nothing.
1969
+ if (
1970
+ rootName &&
1971
+ ctx.effectClients?.has(rootName) &&
1972
+ !SDK_IGNORE_METHOD.has(methodLower)
1973
+ ) {
1974
+ pushEffect(out, ctx, {
1975
+ effectType: 'http_call',
1976
+ target: `sdk:${rootName}.${methodLower}`,
1977
+ httpMethod: SDK_READ_METHOD.test(methodLower) ? 'GET' : 'POST',
1978
+ line,
1979
+ });
1980
+ return;
1981
+ }
1982
+
1567
1983
  // ---- HTTP clients: axios.get(...), got.post(...) — the member IS the method
1568
1984
  if (rootName && HTTP_CLIENTS.has(rootName.toLowerCase())) {
1569
1985
  const httpMethod = /^(get|post|put|patch|delete|head)$/.test(methodLower)
@@ -38,6 +38,7 @@ export function extractFastAPI(cwd, entryFile, pythonCmd = 'python') {
38
38
  } catch (err) {
39
39
  throw new Error(
40
40
  `FastAPI UBG extractor returned invalid JSON: ${err.message}. Raw: ${res.stdout.slice(0, 200)}`,
41
+ { cause: err },
41
42
  );
42
43
  }
43
44
  if (payload.error) throw new Error(payload.error);
package/src/ubg/genome.js CHANGED
@@ -134,7 +134,7 @@ export function verifyAntibody(ab) {
134
134
  const id = `${ANTIBODY_VERSION}_${sha256(body).slice(0, 32)}`;
135
135
  if (id !== ab.id) return { ok: false, reason: 'content-address' };
136
136
  // signature: Ed25519 over the same claim bytes the id commits to.
137
- let sigOk = false;
137
+ let sigOk;
138
138
  try {
139
139
  sigOk = crypto.verify(
140
140
  null,
package/src/ubg/prisma.js CHANGED
@@ -192,14 +192,9 @@ function parseModel(modelName, body, line, sourceFile, enums, modelNames, skippe
192
192
  const name = fieldName.toLowerCase();
193
193
  const typeLower = rawType.toLowerCase();
194
194
 
195
- // relation field (points at another model) — FK lives in @relation
196
- if (modelNames.has(rawType)) {
197
- const rel = attrs.match(
198
- /@relation\(\s*fields:\s*\[([^\]]*)\]\s*,\s*references:\s*\[([^\]]*)\]/,
199
- );
200
- if (rel) fk(fieldList(rel[1]), rawType, fieldList(rel[2]));
201
- continue; // relations are edges, not columns
202
- }
195
+ // relation field (points at another model) — an edge, never a column. The FK itself is
196
+ // harvested from the WHOLE body below (multiline- and order-robust), not from this line.
197
+ if (modelNames.has(rawType)) continue;
203
198
  if (isList) continue; // scalar lists & back-relations carry no column
204
199
 
205
200
  const isPk = /@id\b/.test(attrs);
@@ -237,6 +232,21 @@ function parseModel(modelName, body, line, sourceFile, enums, modelNames, skippe
237
232
  }
238
233
  }
239
234
 
235
+ // FK harvest over the WHOLE model body — real schemas write `@relation("Name", fields: [...],
236
+ // references: [...], onDelete: Cascade)` with a leading relation name, extra attributes, or the
237
+ // call split across several lines. The old single-line `@relation(\s*fields:` regex silently
238
+ // dropped all of those, so consistency domains collapsed to one-table islands on serious apps
239
+ // (ghostfolio: 0 FK edges) and O3/O5 never fired. Scanning the body with `[\s\S]` and pulling
240
+ // `fields:`/`references:` INDEPENDENTLY makes the harvest order- and newline-robust. The field's
241
+ // type token (a known model) is captured right before `@relation(`; back-relation list fields
242
+ // carry no `fields:`/`references:` and are naturally skipped.
243
+ for (const rm of body.matchAll(/(\w+)(?:\[\])?\??\s+@relation\(([\s\S]*?)\)/g)) {
244
+ if (!modelNames.has(rm[1])) continue; // the token before @relation must be a model (the target)
245
+ const fMatch = rm[2].match(/fields:\s*\[([^\]]*)\]/);
246
+ const rMatch = rm[2].match(/references:\s*\[([^\]]*)\]/);
247
+ if (fMatch && rMatch) fk(fieldList(fMatch[1]), rm[1], fieldList(rMatch[1]));
248
+ }
249
+
240
250
  const name = modelName.toLowerCase();
241
251
  return {
242
252
  name,
@@ -26,6 +26,7 @@ import {
26
26
  methodInClassChain,
27
27
  computeThisSymbols,
28
28
  resolveExportedFunction,
29
+ collectRepoFields,
29
30
  } from './extract.js';
30
31
 
31
32
  export const MAX_RESOLVE_DEPTH = 6;
@@ -146,7 +147,7 @@ export function createResolver({ cwd, scannedFiles, helpers }) {
146
147
  // resolved methods. `this.<m>()` dispatches from the INSTANTIATED class, so an
147
148
  // override (ActivityService.readByQuery) wins over the base method it shadows.
148
149
  function deepScan(fnNode, owningMod) {
149
- const base = scanFunction(fnNode);
150
+ const base = scanFunction(fnNode, { effectClients: owningMod?.effectClients });
150
151
  const merged = {
151
152
  ...base,
152
153
  effects: [...base.effects],
@@ -202,7 +203,7 @@ export function createResolver({ cwd, scannedFiles, helpers }) {
202
203
  // `assertIntegrationEnvironmentScope` gating a public register → a false PROVEN).
203
204
  // A route's real gate is a chain step or a DIRECTLY-resolved verifier, not any
204
205
  // denying function somewhere in its transitive closure. Effects merge; guards do not.
205
- const bare = scanFunction(fn);
206
+ const bare = scanFunction(fn, { effectClients: fnMod?.effectClients });
206
207
  bare.guardSignals = { deniesWithStatus: false };
207
208
  mergeScan(merged, bare);
208
209
  followCalls(fn, fnMod, merged, seen, depth + 1, null, stack);
@@ -318,7 +319,10 @@ export function createResolver({ cwd, scannedFiles, helpers }) {
318
319
  sourceLine: fn.line,
319
320
  fn: fn.node,
320
321
  });
321
- mergeScan(merged, scanFunction(fn.node));
322
+ mergeScan(
323
+ merged,
324
+ scanFunction(fn.node, { effectClients: targetMod?.effectClients }),
325
+ );
322
326
  followCalls(fn.node, targetMod, merged, seen, depth + 1, null, stack);
323
327
  });
324
328
  }
@@ -369,7 +373,12 @@ export function createResolver({ cwd, scannedFiles, helpers }) {
369
373
  if (cached) return cached;
370
374
  if (stack.has(key)) return null; // reference cycle — contribute nothing, don't cache
371
375
  stack.add(key);
372
- const base = scanFunction(hit.fn, { thisSymbols });
376
+ const base = scanFunction(hit.fn, {
377
+ thisSymbols,
378
+ effectClients: hit.mod?.effectClients,
379
+ // the method's own class supplies the injected/typed repository fields (TypeORM)
380
+ repoFields: collectRepoFields(hit.cls ?? topCls),
381
+ });
373
382
  const bundle = {
374
383
  ...base,
375
384
  key,
@@ -417,7 +426,11 @@ export function createResolver({ cwd, scannedFiles, helpers }) {
417
426
  // service → repository → kysely, wiring often inherited), which is why the walk
418
427
  // is recursive, bounded, and memoized (E-027: twenty took 34s without the memo).
419
428
  function handlerScan(method, di, mod, cls) {
420
- const base = scanFunction(method); // the handler body itself
429
+ // the handler body itself
430
+ const base = scanFunction(method, {
431
+ effectClients: mod?.effectClients,
432
+ repoFields: collectRepoFields(cls),
433
+ });
421
434
  const merged = {
422
435
  ...base,
423
436
  effects: [...base.effects],