sparda-mcp 0.66.2 → 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 +8 -3
- package/package.json +4 -4
- package/src/ubg/apocalypse.js +11 -5
- package/src/ubg/express.js +4 -0
- package/src/ubg/extract.js +417 -1
- package/src/ubg/prisma.js +18 -8
- package/src/ubg/resolve.js +18 -5
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,
|
|
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.
|
|
345
|
-
- **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas,
|
|
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.
|
|
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,12 +61,12 @@
|
|
|
61
61
|
"author": "Residual Labs (residual-labs.fr)",
|
|
62
62
|
"license": "BUSL-1.1",
|
|
63
63
|
"dependencies": {
|
|
64
|
+
"@babel/parser": "8.0.4",
|
|
65
|
+
"@babel/traverse": "8.0.4",
|
|
66
|
+
"@clack/prompts": "1.7.0",
|
|
64
67
|
"@modelcontextprotocol/sdk": "1.29.0"
|
|
65
68
|
},
|
|
66
69
|
"devDependencies": {
|
|
67
|
-
"@babel/parser": "^8.0.4",
|
|
68
|
-
"@babel/traverse": "^8.0.4",
|
|
69
|
-
"@clack/prompts": "^1.7.0",
|
|
70
70
|
"@eslint/js": "^10.0.1",
|
|
71
71
|
"@vitest/coverage-v8": "^4.1.10",
|
|
72
72
|
"eslint": "^10.7.0",
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -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(
|
|
310
|
-
(
|
|
311
|
-
|
|
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) {
|
package/src/ubg/express.js
CHANGED
|
@@ -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') {
|
package/src/ubg/extract.js
CHANGED
|
@@ -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)
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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,
|
package/src/ubg/resolve.js
CHANGED
|
@@ -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(
|
|
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, {
|
|
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
|
-
|
|
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],
|