sparda-mcp 0.15.0 → 0.17.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/demo-app/.sparda/immunity.json +1 -0
- package/package.json +1 -1
- package/src/commands/immunize.js +2 -0
- package/src/commands/speculate.js +74 -0
- package/src/commands/ubg.js +2 -1
- package/src/detect.js +11 -2
- package/src/index.js +6 -0
- package/src/ubg/apocalypse.js +6 -4
- package/src/ubg/compile.js +2 -0
- package/src/ubg/extract.js +31 -4
- package/src/ubg/mirror.js +2 -1
- package/src/ubg/nestjs.js +303 -0
- package/src/ubg/openapi-emit.js +4 -2
- package/src/ubg/speculative.js +56 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"v":"imm1","proven":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}
|
package/package.json
CHANGED
package/src/commands/immunize.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// lookup, no recompile, no LLM, no network. BitNet's move applied to trust.
|
|
6
6
|
// sparda immunize write .sparda/immunity.json + a summary
|
|
7
7
|
// sparda immunize --json print the capsule to stdout
|
|
8
|
+
import fs from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { compileUBG } from '../ubg/compile.js';
|
|
10
11
|
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
@@ -32,6 +33,7 @@ export async function runImmunize(opts) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const outPath = path.join(opts.cwd, '.sparda', 'immunity.json');
|
|
36
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true }); // .sparda/ may not exist yet
|
|
35
37
|
atomicWrite(outPath, JSON.stringify(capsule) + '\n');
|
|
36
38
|
const wire = JSON.stringify(capsule).length;
|
|
37
39
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// commands/speculate.js — speculative verification (ADR-038).
|
|
2
|
+
// Re-verify the working tree against a FROZEN capsule (.sparda/immunity.json) by
|
|
3
|
+
// hash lookup instead of a full re-proof. Routes whose behavioral shape is already
|
|
4
|
+
// in the capsule are settled for free (accepted if safe, rejected if a known-
|
|
5
|
+
// dangerous shape); only NOVEL shapes need the full prover. This is what makes the
|
|
6
|
+
// agent inner loop fast on a monster repo — most edits touch already-proven shapes.
|
|
7
|
+
// sparda speculate triage vs .sparda/immunity.json (run `immunize` first)
|
|
8
|
+
// sparda speculate --json raw { acceptanceRate, accepted, rejected, novel }
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
12
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
|
+
import { speculativeVerify } from '../ubg/speculative.js';
|
|
14
|
+
|
|
15
|
+
export async function runSpeculate(opts) {
|
|
16
|
+
const capsulePath = path.join(opts.cwd, '.sparda', 'immunity.json');
|
|
17
|
+
if (!fs.existsSync(capsulePath)) {
|
|
18
|
+
throw Object.assign(new Error('No frozen capsule to speculate against.'), {
|
|
19
|
+
code: 'USER',
|
|
20
|
+
hint: 'Run `sparda immunize` first to freeze a baseline.',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
let capsule;
|
|
24
|
+
try {
|
|
25
|
+
capsule = JSON.parse(fs.readFileSync(capsulePath, 'utf8'));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw Object.assign(new Error(`Capsule unreadable: ${err.message}`), {
|
|
28
|
+
code: 'USER',
|
|
29
|
+
hint: 'Re-run `sparda immunize` to regenerate .sparda/immunity.json.',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const candidate = canonicalizeGraph(
|
|
34
|
+
compileUBG(opts.cwd, { write: false, openapi: opts.openapi }).graph,
|
|
35
|
+
);
|
|
36
|
+
const result = speculativeVerify(capsule, candidate);
|
|
37
|
+
|
|
38
|
+
if (opts.json) {
|
|
39
|
+
console.log(JSON.stringify(result, null, 2));
|
|
40
|
+
return { result };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pct = (result.acceptanceRate * 100).toFixed(1);
|
|
44
|
+
console.log(
|
|
45
|
+
`SPECULATIVE VERIFY — ${result.settled}/${result.total} route(s) settled by lookup, ` +
|
|
46
|
+
`${result.novel.length} novel (${pct}% verified with zero prover work)`,
|
|
47
|
+
);
|
|
48
|
+
for (const r of result.rejected)
|
|
49
|
+
console.log(
|
|
50
|
+
` ✗ known-exposed shape: ${short(r.entrypoint)} [${r.exposed.join(',')}]`,
|
|
51
|
+
);
|
|
52
|
+
for (const r of result.novel)
|
|
53
|
+
console.log(` ? novel shape (needs full proof): ${short(r.entrypoint)}`);
|
|
54
|
+
if (!result.novel.length && !result.rejected.length)
|
|
55
|
+
console.log(
|
|
56
|
+
' ✓ every route matched a proven-safe shape — re-verification cost nothing.',
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Verdict mirrors apocalypse, computed by lookup: a known-exposed route means the
|
|
60
|
+
// tree is not proven. Novel shapes aren't a failure — they're work the full prover
|
|
61
|
+
// still owes; surface them, don't gate on them.
|
|
62
|
+
console.log(
|
|
63
|
+
result.rejected.length
|
|
64
|
+
? `✗ NOT PROVEN (by lookup) — ${result.rejected.length} known-exposed route(s)` +
|
|
65
|
+
(result.novel.length ? ` · ${result.novel.length} novel need full proof` : '')
|
|
66
|
+
: result.novel.length
|
|
67
|
+
? `· ${result.novel.length} novel shape(s) need the full prover — run \`sparda apocalypse\``
|
|
68
|
+
: `✓ PROVEN (by lookup) — every route settled from the frozen capsule, zero prover work`,
|
|
69
|
+
);
|
|
70
|
+
if (result.rejected.length) process.exitCode = 1;
|
|
71
|
+
return { result };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const short = (id) => id.replace(/^entrypoint:/, '');
|
package/src/commands/ubg.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// there. Artifact: .sparda/ubg.json — regenerable, deterministic, never
|
|
5
5
|
// committed. `--json` prints the raw graph, `--out <file>` redirects it.
|
|
6
6
|
import { compileUBG } from '../ubg/compile.js';
|
|
7
|
+
import { cmp } from '../ubg/schema.js';
|
|
7
8
|
|
|
8
9
|
export async function runUbg(opts) {
|
|
9
10
|
const { report, json, outPath } = compileUBG(opts.cwd, {
|
|
@@ -73,7 +74,7 @@ export async function runUbg(opts) {
|
|
|
73
74
|
|
|
74
75
|
const fmtCounts = (obj) =>
|
|
75
76
|
Object.entries(obj)
|
|
76
|
-
.sort(([a], [b]) => a
|
|
77
|
+
.sort(([a], [b]) => cmp(a, b))
|
|
77
78
|
.map(([k, v]) => `${k} ${v}`)
|
|
78
79
|
.join(' · ');
|
|
79
80
|
|
package/src/detect.js
CHANGED
|
@@ -41,10 +41,19 @@ export function detectStack(cwd) {
|
|
|
41
41
|
expressVersion: deps.express,
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
// NestJS (and Medusa/DI-framework) apps: routes live in @Controller classes,
|
|
45
|
+
// scanned from the source tree rather than followed from a single entry.
|
|
46
|
+
if (deps['@nestjs/core'] || deps['@nestjs/common']) {
|
|
47
|
+
const entryFile = ['src', 'app', '.'].find((d) => {
|
|
48
|
+
const abs = path.join(cwd, d);
|
|
49
|
+
return fs.existsSync(abs) && fs.statSync(abs).isDirectory();
|
|
50
|
+
});
|
|
51
|
+
return { framework: 'nestjs', entryFile: entryFile ?? '.', port: 3000 };
|
|
52
|
+
}
|
|
53
|
+
const known = ['fastify', 'koa'].find((d) => deps[d]);
|
|
45
54
|
if (known)
|
|
46
55
|
throw err(
|
|
47
|
-
`${known} detected — not supported yet. Express & FastAPI
|
|
56
|
+
`${known} detected — not supported yet. Express, NestJS & FastAPI in v0.`,
|
|
48
57
|
'+1 the framework vote: github.com/zyx77550/sparda/issues/1',
|
|
49
58
|
);
|
|
50
59
|
}
|
package/src/index.js
CHANGED
|
@@ -132,6 +132,11 @@ try {
|
|
|
132
132
|
await runImmunize(opts);
|
|
133
133
|
break;
|
|
134
134
|
}
|
|
135
|
+
case 'speculate': {
|
|
136
|
+
const { runSpeculate } = await import('./commands/speculate.js');
|
|
137
|
+
await runSpeculate(opts);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
135
140
|
case 'timeless': {
|
|
136
141
|
const { runTimeless } = await import('./commands/timeless.js');
|
|
137
142
|
await runTimeless(opts, rest);
|
|
@@ -179,6 +184,7 @@ Usage:
|
|
|
179
184
|
npx sparda-mcp fingerprint Portable behavior hash per route — the address for shared diagnoses (--json)
|
|
180
185
|
npx sparda-mcp polarity Ternary safety matrix per route — proof as arithmetic (--json)
|
|
181
186
|
npx sparda-mcp immunize Freeze proven safety into a tiny capsule (1 byte/route) — .sparda/immunity.json
|
|
187
|
+
npx sparda-mcp speculate Re-verify vs the frozen capsule by lookup — full proof only on novel shapes (--json)
|
|
182
188
|
npx sparda-mcp timeless Replay production requests (list | replay <id> | export <id> → vitest)
|
|
183
189
|
npx sparda-mcp mirror Serve the compiled graph over HTTP — no framework, no source (--port)
|
|
184
190
|
npx sparda-mcp openapi Emit an OpenAPI 3.1 spec FROM the graph (--out / --json)
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// structural reachability" — the prover is exactly as strong as what the code
|
|
12
12
|
// and DDL declare. It proves the absence of whole bug classes, not of bugs.
|
|
13
13
|
|
|
14
|
+
import { cmp } from './schema.js';
|
|
15
|
+
|
|
14
16
|
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, info: 3 };
|
|
15
17
|
const CONSTRAINING = new Set(['check', 'not_null', 'unique']);
|
|
16
18
|
|
|
@@ -36,7 +38,7 @@ export function indexGraph(graph) {
|
|
|
36
38
|
}
|
|
37
39
|
const entrypoints = graph.nodes
|
|
38
40
|
.filter((n) => n.kind === 'entrypoint')
|
|
39
|
-
.sort((a, b) => a.id
|
|
41
|
+
.sort((a, b) => cmp(a.id, b.id));
|
|
40
42
|
return { nodes, cfOut, mutOut, gateTargets, compensators, entrypoints };
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -125,7 +127,7 @@ export function checkGraph(graph) {
|
|
|
125
127
|
if (!byDomain.has(domain)) byDomain.set(domain, []);
|
|
126
128
|
byDomain.get(domain).push(w);
|
|
127
129
|
}
|
|
128
|
-
for (const [domain, ws] of [...byDomain].sort((a, b) => a[0]
|
|
130
|
+
for (const [domain, ws] of [...byDomain].sort((a, b) => cmp(a[0], b[0]))) {
|
|
129
131
|
const states = new Set(ws.map((w) => w.stateId));
|
|
130
132
|
if (states.size < 2) continue;
|
|
131
133
|
const txIds = new Set(ws.map((w) => w.effect.meta.transaction?.id ?? null));
|
|
@@ -294,8 +296,8 @@ function sortFindings(findings) {
|
|
|
294
296
|
return findings.sort(
|
|
295
297
|
(a, b) =>
|
|
296
298
|
SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
|
|
297
|
-
a.rule
|
|
298
|
-
a.entrypoint
|
|
299
|
+
cmp(a.rule, b.rule) ||
|
|
300
|
+
cmp(a.entrypoint, b.entrypoint),
|
|
299
301
|
);
|
|
300
302
|
}
|
|
301
303
|
|
package/src/ubg/compile.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { detectStack } from '../detect.js';
|
|
7
7
|
import { clearModuleCache } from './extract.js';
|
|
8
8
|
import { extractExpress } from './express.js';
|
|
9
|
+
import { extractNest } from './nestjs.js';
|
|
9
10
|
import { extractNext } from './nextjs.js';
|
|
10
11
|
import { extractFastAPI } from './fastapi.js';
|
|
11
12
|
import { extractOpenAPI } from './openapi.js';
|
|
@@ -28,6 +29,7 @@ export function compileUBG(
|
|
|
28
29
|
const stack = openapi ? { framework: 'openapi', entryFile: openapi } : detectStack(cwd);
|
|
29
30
|
const extractors = {
|
|
30
31
|
express: () => extractExpress(cwd, stack.entryFile),
|
|
32
|
+
nestjs: () => extractNest(cwd, stack.entryFile),
|
|
31
33
|
nextjs: () => extractNext(cwd, stack.entryFile),
|
|
32
34
|
fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
|
|
33
35
|
openapi: () => extractOpenAPI(cwd, stack.entryFile),
|
package/src/ubg/extract.js
CHANGED
|
@@ -89,7 +89,11 @@ export function parseModule(absFile) {
|
|
|
89
89
|
try {
|
|
90
90
|
facts.ast = parse(src, {
|
|
91
91
|
sourceType: 'unambiguous',
|
|
92
|
-
|
|
92
|
+
// decorators-legacy = TypeScript's `experimentalDecorators`, which is what
|
|
93
|
+
// NestJS/Medusa/TypeORM actually compile with. Unlike the modern `decorators`
|
|
94
|
+
// plugin it allows PARAMETER decorators (`@Body()`, `@Param()`) — without this
|
|
95
|
+
// every Nest controller is a parse error and the app reads as 0 routes.
|
|
96
|
+
plugins: ['typescript', 'jsx', 'decorators-legacy'],
|
|
93
97
|
attachComment: true,
|
|
94
98
|
});
|
|
95
99
|
} catch (err) {
|
|
@@ -424,12 +428,16 @@ function inspectCall(node, out, ctx) {
|
|
|
424
428
|
// raw material StateMachineInference reads (lowercased, same trade-off)
|
|
425
429
|
if (PRISMA_OPS[methodLower] !== undefined) {
|
|
426
430
|
const obj = callee.object;
|
|
431
|
+
// `prisma.user.create(...)` OR the class-based `this.prisma.user.create(...)`
|
|
432
|
+
// that NestJS services / Express controller classes use — `clientBaseName`
|
|
433
|
+
// reads the client name off a bare Identifier or a `this.<field>`.
|
|
434
|
+
const client = obj.type === 'MemberExpression' ? clientBaseName(obj.object) : null;
|
|
427
435
|
if (
|
|
428
436
|
obj.type === 'MemberExpression' &&
|
|
429
437
|
!obj.computed &&
|
|
430
438
|
obj.property.type === 'Identifier' &&
|
|
431
|
-
|
|
432
|
-
/prisma|client|db/i.test(
|
|
439
|
+
client &&
|
|
440
|
+
/prisma|client|db/i.test(client)
|
|
433
441
|
) {
|
|
434
442
|
const op = PRISMA_OPS[methodLower];
|
|
435
443
|
const data = prismaLiteralsOf(node.arguments[0], 'data');
|
|
@@ -636,10 +644,29 @@ function literalArg(arg) {
|
|
|
636
644
|
|
|
637
645
|
function rootIdentifier(memberExpr) {
|
|
638
646
|
let cur = memberExpr;
|
|
639
|
-
while (cur.type === 'MemberExpression')
|
|
647
|
+
while (cur.type === 'MemberExpression') {
|
|
648
|
+
// `this.foo.bar()` — the effective root is the class field `foo`, so
|
|
649
|
+
// class-based access (NestJS services, controller classes) is read like a
|
|
650
|
+
// bare `foo.bar()`. Without this, everything rooted at `this` is invisible.
|
|
651
|
+
if (cur.object.type === 'ThisExpression')
|
|
652
|
+
return cur.property.type === 'Identifier' ? cur.property.name : null;
|
|
653
|
+
cur = cur.object;
|
|
654
|
+
}
|
|
640
655
|
return cur.type === 'Identifier' ? cur.name : null;
|
|
641
656
|
}
|
|
642
657
|
|
|
658
|
+
// the name a member/identifier refers to, unwrapping `this.<field>` → `<field>`.
|
|
659
|
+
function clientBaseName(node) {
|
|
660
|
+
if (node.type === 'Identifier') return node.name;
|
|
661
|
+
if (
|
|
662
|
+
node.type === 'MemberExpression' &&
|
|
663
|
+
node.object.type === 'ThisExpression' &&
|
|
664
|
+
node.property.type === 'Identifier'
|
|
665
|
+
)
|
|
666
|
+
return node.property.name;
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
643
670
|
// knex('users') → users ; supabase.from('users') → users ;
|
|
644
671
|
// supabase.from('users').select() chains: walk member/call chain to a
|
|
645
672
|
// .from('t') or a base call with a string literal argument.
|
package/src/ubg/mirror.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// denials, and state-machine-aware state — never invented business values. Every
|
|
19
19
|
// response carries `x-sparda-mirror: true`.
|
|
20
20
|
import http from 'node:http';
|
|
21
|
+
import { cmp } from './schema.js';
|
|
21
22
|
|
|
22
23
|
export function createMirrorServer(graph) {
|
|
23
24
|
const { routes, machines } = buildRouteTable(graph);
|
|
@@ -176,7 +177,7 @@ function buildRouteTable(graph) {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
const routes = [];
|
|
179
|
-
for (const node of [...nodes.values()].sort((a, b) => a.id
|
|
180
|
+
for (const node of [...nodes.values()].sort((a, b) => cmp(a.id, b.id))) {
|
|
180
181
|
if (node.kind !== 'entrypoint') continue;
|
|
181
182
|
const handlerId = handlerOf.get(node.id);
|
|
182
183
|
const guards = handlerId ? (gateByHandler.get(handlerId) ?? []) : [];
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// ubg/nestjs.js — NestJS (and DI-framework) route extraction. THE wall-breaker.
|
|
2
|
+
//
|
|
3
|
+
// Nest/Medusa/Inversify apps defeated the old detector: routes aren't `app.get()`
|
|
4
|
+
// calls, they're `@Get()` decorators on controller *methods*, and the real effect
|
|
5
|
+
// (the DB write) lives in a *service* wired by dependency injection, not in the
|
|
6
|
+
// controller. The old parser saw 0 routes → NO PROOF → useless.
|
|
7
|
+
//
|
|
8
|
+
// The insight that makes this tractable statically: in TypeScript, DI is expressed
|
|
9
|
+
// as CONSTRUCTOR PARAMETER TYPES — `constructor(private svc: CatsService)` — which
|
|
10
|
+
// are right there in the AST. So we read the decorators for the route table, read
|
|
11
|
+
// the constructor for the DI wiring, and follow `this.svc.method()` to the service
|
|
12
|
+
// method to scan its real effects. No runtime container, no execution. Same UBG out,
|
|
13
|
+
// so everything downstream (apocalypse/polarity/immunize/speculate) just works.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import traverseModule from '@babel/traverse';
|
|
17
|
+
import { parseModule, scanFunction } from './extract.js';
|
|
18
|
+
|
|
19
|
+
const traverse = traverseModule.default ?? traverseModule;
|
|
20
|
+
const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
21
|
+
const EXCLUDE = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.sparda']);
|
|
22
|
+
|
|
23
|
+
// → { routes, globalMiddlewares, helpers, skipped, scannedFiles } (extractExpress shape)
|
|
24
|
+
export function extractNest(cwd, entryDir) {
|
|
25
|
+
const routes = [];
|
|
26
|
+
const helpers = [];
|
|
27
|
+
const skipped = [];
|
|
28
|
+
const scannedFiles = [];
|
|
29
|
+
const root = path.resolve(cwd, entryDir || '.');
|
|
30
|
+
|
|
31
|
+
for (const file of walk(root)) {
|
|
32
|
+
const mod = parseModule(file);
|
|
33
|
+
const rel = relOf(cwd, file);
|
|
34
|
+
if (mod.error) {
|
|
35
|
+
skipped.push({ reason: `${mod.error} in ${rel}`, file: rel });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let sawController = false;
|
|
39
|
+
|
|
40
|
+
traverse(mod.ast, {
|
|
41
|
+
ClassDeclaration(p) {
|
|
42
|
+
const cls = p.node;
|
|
43
|
+
const controller = decoratorArg(cls.decorators, 'Controller');
|
|
44
|
+
if (controller === undefined) return; // not a controller
|
|
45
|
+
sawController = true;
|
|
46
|
+
const prefix = controller.value ?? '';
|
|
47
|
+
const di = constructorDI(cls); // prop -> service type name
|
|
48
|
+
const classGuards = useGuards(cls.decorators);
|
|
49
|
+
|
|
50
|
+
for (const m of cls.body.body) {
|
|
51
|
+
if (m.type !== 'ClassMethod' || !m.key || m.key.type !== 'Identifier') continue;
|
|
52
|
+
const http = httpDecorator(m.decorators);
|
|
53
|
+
if (!http) continue;
|
|
54
|
+
const fullPath = joinPath(prefix, http.path);
|
|
55
|
+
const guards = [...classGuards, ...useGuards(m.decorators)];
|
|
56
|
+
|
|
57
|
+
// the handler's effects = the controller method body + every service
|
|
58
|
+
// method it delegates to through DI (this.<prop>.<call>())
|
|
59
|
+
const scan = resolveHandlerScan(m, di, mod, cwd, scannedFiles, helpers);
|
|
60
|
+
|
|
61
|
+
const chain = [
|
|
62
|
+
...guards.map((name) => ({
|
|
63
|
+
name,
|
|
64
|
+
sourceFile: rel,
|
|
65
|
+
sourceLine: m.loc?.start.line ?? 0,
|
|
66
|
+
fn: null,
|
|
67
|
+
role: 'middleware',
|
|
68
|
+
})),
|
|
69
|
+
{
|
|
70
|
+
name: m.key.name,
|
|
71
|
+
sourceFile: rel,
|
|
72
|
+
sourceLine: m.loc?.start.line ?? 0,
|
|
73
|
+
fn: null,
|
|
74
|
+
scan, // precomputed merged scan — translate uses it as-is
|
|
75
|
+
role: 'handler',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
routes.push({
|
|
80
|
+
method: http.method,
|
|
81
|
+
path: fullPath,
|
|
82
|
+
sourceFile: rel,
|
|
83
|
+
sourceLine: m.loc?.start.line ?? 0,
|
|
84
|
+
params: pathParamsOf(fullPath),
|
|
85
|
+
chain,
|
|
86
|
+
description: '',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (sawController && !scannedFiles.includes(rel)) scannedFiles.push(rel);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
routes.sort((a, b) => cmp(a.path, b.path) || cmp(a.method, b.method));
|
|
96
|
+
return { routes, globalMiddlewares: [], helpers, skipped, scannedFiles };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- decorator readers ------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
// the first string-literal arg of decorator `@Name(...)`, or undefined if the
|
|
102
|
+
// decorator is absent. Returns { value } (value may be '' for a bare `@Name()`).
|
|
103
|
+
function decoratorArg(decorators, name) {
|
|
104
|
+
for (const d of decorators ?? []) {
|
|
105
|
+
const call = d.expression;
|
|
106
|
+
if (call.type === 'CallExpression' && idName(call.callee) === name) {
|
|
107
|
+
const a0 = call.arguments[0];
|
|
108
|
+
return { value: a0?.type === 'StringLiteral' ? a0.value : '' };
|
|
109
|
+
}
|
|
110
|
+
if (call.type === 'Identifier' && call.name === name) return { value: '' };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function httpDecorator(decorators) {
|
|
116
|
+
for (const d of decorators ?? []) {
|
|
117
|
+
const call = d.expression;
|
|
118
|
+
const name = call.type === 'CallExpression' ? idName(call.callee) : idName(call);
|
|
119
|
+
if (!name) continue;
|
|
120
|
+
const verb = name.toLowerCase();
|
|
121
|
+
if (!HTTP.has(verb)) continue;
|
|
122
|
+
const a0 = call.type === 'CallExpression' ? call.arguments[0] : null;
|
|
123
|
+
return { method: verb, path: a0?.type === 'StringLiteral' ? a0.value : '' };
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// every class named in `@UseGuards(A, B)` decorators (class- or method-level)
|
|
129
|
+
function useGuards(decorators) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const d of decorators ?? []) {
|
|
132
|
+
const call = d.expression;
|
|
133
|
+
if (call.type !== 'CallExpression' || idName(call.callee) !== 'UseGuards') continue;
|
|
134
|
+
for (const arg of call.arguments) {
|
|
135
|
+
const n = idName(arg);
|
|
136
|
+
if (n) out.push(n);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// constructor(private catsService: CatsService) → { catsService: 'CatsService' }
|
|
143
|
+
function constructorDI(cls) {
|
|
144
|
+
const di = {};
|
|
145
|
+
const ctor = cls.body.body.find(
|
|
146
|
+
(m) => m.type === 'ClassMethod' && m.kind === 'constructor',
|
|
147
|
+
);
|
|
148
|
+
for (const param of ctor?.params ?? []) {
|
|
149
|
+
// `private x: T` is a TSParameterProperty wrapping an Identifier with a type
|
|
150
|
+
const id = param.type === 'TSParameterProperty' ? param.parameter : param;
|
|
151
|
+
if (id?.type !== 'Identifier') continue;
|
|
152
|
+
const typeName = typeRefName(id.typeAnnotation?.typeAnnotation);
|
|
153
|
+
if (typeName) di[id.name] = typeName;
|
|
154
|
+
}
|
|
155
|
+
return di;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- DI resolution: follow this.<prop>.<method>() into the service ----------
|
|
159
|
+
|
|
160
|
+
function resolveHandlerScan(method, di, ownerMod, cwd, scannedFiles, helpers) {
|
|
161
|
+
const base = scanFunction(method); // the controller body itself
|
|
162
|
+
const merged = { ...base, effects: [...base.effects] };
|
|
163
|
+
|
|
164
|
+
// find `this.<prop>.<m>(...)` calls whose <prop> is a DI'd service
|
|
165
|
+
traverse(
|
|
166
|
+
{ type: 'File', program: { type: 'Program', body: [method], directives: [] } },
|
|
167
|
+
{
|
|
168
|
+
CallExpression(p) {
|
|
169
|
+
const callee = p.node.callee;
|
|
170
|
+
if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
|
|
171
|
+
return;
|
|
172
|
+
const recv = callee.object; // this.<prop>
|
|
173
|
+
if (
|
|
174
|
+
recv.type !== 'MemberExpression' ||
|
|
175
|
+
recv.object.type !== 'ThisExpression' ||
|
|
176
|
+
recv.property.type !== 'Identifier'
|
|
177
|
+
)
|
|
178
|
+
return;
|
|
179
|
+
const serviceType = di[recv.property.name];
|
|
180
|
+
if (!serviceType) return;
|
|
181
|
+
const svcFn = resolveServiceMethod(
|
|
182
|
+
serviceType,
|
|
183
|
+
callee.property.name,
|
|
184
|
+
ownerMod,
|
|
185
|
+
cwd,
|
|
186
|
+
scannedFiles,
|
|
187
|
+
helpers,
|
|
188
|
+
);
|
|
189
|
+
if (svcFn) mergeScan(merged, scanFunction(svcFn));
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
// a minimal scope stand-in; @babel/traverse needs a scope for File roots
|
|
193
|
+
undefined,
|
|
194
|
+
);
|
|
195
|
+
return merged;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// import CatsService from its module, find the class, find the method node
|
|
199
|
+
function resolveServiceMethod(
|
|
200
|
+
typeName,
|
|
201
|
+
methodName,
|
|
202
|
+
ownerMod,
|
|
203
|
+
cwd,
|
|
204
|
+
scannedFiles,
|
|
205
|
+
helpers,
|
|
206
|
+
) {
|
|
207
|
+
const file = ownerMod.imports.get(typeName);
|
|
208
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
209
|
+
const svc = parseModule(file);
|
|
210
|
+
if (svc.error) return null;
|
|
211
|
+
const rel = relOf(cwd, file);
|
|
212
|
+
if (!scannedFiles.includes(rel)) scannedFiles.push(rel);
|
|
213
|
+
let found = null;
|
|
214
|
+
for (const node of svc.ast.program.body) {
|
|
215
|
+
const cls =
|
|
216
|
+
node.type === 'ClassDeclaration'
|
|
217
|
+
? node
|
|
218
|
+
: node.type === 'ExportNamedDeclaration' &&
|
|
219
|
+
node.declaration?.type === 'ClassDeclaration'
|
|
220
|
+
? node.declaration
|
|
221
|
+
: null;
|
|
222
|
+
if (!cls || cls.id?.name !== typeName) continue;
|
|
223
|
+
for (const m of cls.body.body) {
|
|
224
|
+
if (
|
|
225
|
+
m.type === 'ClassMethod' &&
|
|
226
|
+
m.key.type === 'Identifier' &&
|
|
227
|
+
m.key.name === methodName
|
|
228
|
+
) {
|
|
229
|
+
found = m;
|
|
230
|
+
helpers.push({
|
|
231
|
+
name: `${typeName}.${methodName}`,
|
|
232
|
+
sourceFile: rel,
|
|
233
|
+
sourceLine: m.loc?.start.line ?? 0,
|
|
234
|
+
fn: m,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function mergeScan(into, add) {
|
|
243
|
+
into.effects.push(...add.effects);
|
|
244
|
+
into.returnShapes = [...(into.returnShapes ?? []), ...(add.returnShapes ?? [])];
|
|
245
|
+
into.calls = [...(into.calls ?? []), ...(add.calls ?? [])];
|
|
246
|
+
into.validatesInput = into.validatesInput || add.validatesInput;
|
|
247
|
+
into.async = into.async || add.async;
|
|
248
|
+
if (add.guardSignals?.deniesWithStatus) into.guardSignals.deniesWithStatus = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- small AST + path helpers ----------------------------------------------
|
|
252
|
+
|
|
253
|
+
const idName = (node) =>
|
|
254
|
+
node?.type === 'Identifier'
|
|
255
|
+
? node.name
|
|
256
|
+
: node?.type === 'CallExpression'
|
|
257
|
+
? idName(node.callee)
|
|
258
|
+
: null;
|
|
259
|
+
|
|
260
|
+
function typeRefName(t) {
|
|
261
|
+
if (!t) return null;
|
|
262
|
+
if (t.type === 'TSTypeReference' && t.typeName?.type === 'Identifier')
|
|
263
|
+
return t.typeName.name;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
268
|
+
|
|
269
|
+
function joinPath(prefix, p) {
|
|
270
|
+
const norm = (s) => `/${String(s ?? '').replace(/^\/+|\/+$/g, '')}`;
|
|
271
|
+
const a = prefix ? norm(prefix) : '';
|
|
272
|
+
const b = p ? norm(p) : '';
|
|
273
|
+
const joined = `${a}${b}`.replace(/\/{2,}/g, '/');
|
|
274
|
+
return joined === '' ? '/' : joined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pathParamsOf(fullPath) {
|
|
278
|
+
return [...fullPath.matchAll(/:(\w+)/g)].map((m) => ({
|
|
279
|
+
name: m[1],
|
|
280
|
+
in: 'path',
|
|
281
|
+
type: 'string',
|
|
282
|
+
required: true,
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function relOf(cwd, abs) {
|
|
287
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function* walk(dir) {
|
|
291
|
+
let entries;
|
|
292
|
+
try {
|
|
293
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
for (const e of entries.sort((a, b) => cmp(a.name, b.name))) {
|
|
298
|
+
if (EXCLUDE.has(e.name)) continue;
|
|
299
|
+
const abs = path.join(dir, e.name);
|
|
300
|
+
if (e.isDirectory()) yield* walk(abs);
|
|
301
|
+
else if (/\.(m?ts|m?js|cts|cjs)$/.test(e.name) && !/\.d\.ts$/.test(e.name)) yield abs;
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/ubg/openapi-emit.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// tool (Swagger UI, client codegen, Postman) reads SPARDA's understanding of
|
|
7
7
|
// a Go/Java/Rails backend it never had a spec for. Deterministic: sorted
|
|
8
8
|
// paths, sorted keys, no timestamps — same graph, same bytes.
|
|
9
|
+
import { cmp } from './schema.js';
|
|
10
|
+
|
|
9
11
|
const OA_TYPE = {
|
|
10
12
|
string: { type: 'string' },
|
|
11
13
|
integer: { type: 'integer' },
|
|
@@ -49,7 +51,7 @@ export function emitOpenAPI(
|
|
|
49
51
|
|
|
50
52
|
const entrypoints = [...nodes.values()]
|
|
51
53
|
.filter((n) => n.kind === 'entrypoint')
|
|
52
|
-
.sort((a, b) => a.id
|
|
54
|
+
.sort((a, b) => cmp(a.id, b.id));
|
|
53
55
|
|
|
54
56
|
for (const ep of entrypoints) {
|
|
55
57
|
const oaPath = ep.meta.path.replace(/:(\w+)/g, '{$1}'); // :id → {id}
|
|
@@ -69,7 +71,7 @@ export function emitOpenAPI(
|
|
|
69
71
|
required: p.in === 'path' ? true : Boolean(p.required),
|
|
70
72
|
schema: schemaFor(p.type),
|
|
71
73
|
}))
|
|
72
|
-
.sort((a, b) => a.name
|
|
74
|
+
.sort((a, b) => cmp(a.name, b.name));
|
|
73
75
|
if (parameters.length) op.parameters = parameters;
|
|
74
76
|
|
|
75
77
|
if (ep.meta.inputValidated && method !== 'get') {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ubg/speculative.js — speculative verification (ADR-038).
|
|
2
|
+
//
|
|
3
|
+
// Inspiration: speculative decoding — a cheap draft model proposes, the expensive
|
|
4
|
+
// model verifies, and you only pay full cost on the residual the draft got wrong.
|
|
5
|
+
// The analogue for a proof engine: re-proving a whole app after every agent edit is
|
|
6
|
+
// expensive (seconds on a monster like Dub — 559 routes). But most edits touch shapes
|
|
7
|
+
// SPARDA has ALREADY proven. So we speculate: look each candidate route's behaviorHash
|
|
8
|
+
// up in a frozen capsule (O(1), no compile) and accept/reject from that; only the
|
|
9
|
+
// routes whose SHAPE is novel fall through to the full prover.
|
|
10
|
+
//
|
|
11
|
+
// Stronger than speculative decoding: there, the draft can be wrong and the verifier
|
|
12
|
+
// overrides it. Here a capsule hit is EXACT — identical behaviorHash ⇒ identical
|
|
13
|
+
// behavioral shape ⇒ identical obligations ⇒ the same verdict the full prover would
|
|
14
|
+
// give. The shortcut is provably equal to the full answer; we skip the compute, never
|
|
15
|
+
// the correctness. Zero infra: a hash lookup over a few bytes per route.
|
|
16
|
+
import { fingerprintGraph } from './fingerprint.js';
|
|
17
|
+
import { judge } from './immunity.js';
|
|
18
|
+
import { cmp } from './schema.js';
|
|
19
|
+
|
|
20
|
+
// (frozen capsule, candidate graph) → a triage: which routes are settled for free
|
|
21
|
+
// from the capsule, and which are NOVEL and must pay the full prover.
|
|
22
|
+
// accepted — shape known & safe (0 prover work)
|
|
23
|
+
// rejected — shape known & exposed (0 prover work)
|
|
24
|
+
// novel — shape unseen → hand to checkGraph/apocalypse
|
|
25
|
+
export function speculativeVerify(capsule, candidateGraph) {
|
|
26
|
+
const prints = fingerprintGraph(candidateGraph);
|
|
27
|
+
const accepted = [];
|
|
28
|
+
const rejected = [];
|
|
29
|
+
const novel = [];
|
|
30
|
+
|
|
31
|
+
for (const { entrypoint, behaviorHash } of prints) {
|
|
32
|
+
const verdict = judge(capsule, behaviorHash);
|
|
33
|
+
const row = { entrypoint, behaviorHash };
|
|
34
|
+
if (!verdict.known) novel.push(row);
|
|
35
|
+
else if (verdict.safe) accepted.push({ ...row, exposed: [] });
|
|
36
|
+
else rejected.push({ ...row, exposed: verdict.exposed });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const bySort = (a, b) => cmp(a.entrypoint, b.entrypoint);
|
|
40
|
+
accepted.sort(bySort);
|
|
41
|
+
rejected.sort(bySort);
|
|
42
|
+
novel.sort(bySort);
|
|
43
|
+
|
|
44
|
+
const total = prints.length;
|
|
45
|
+
const settled = accepted.length + rejected.length; // verified WITHOUT the prover
|
|
46
|
+
return {
|
|
47
|
+
accepted,
|
|
48
|
+
rejected,
|
|
49
|
+
novel,
|
|
50
|
+
total,
|
|
51
|
+
settled,
|
|
52
|
+
// the speedup metric: fraction of routes decided by lookup alone (0..1).
|
|
53
|
+
// 1.0 means the whole re-verification cost nothing; only `novel` pays.
|
|
54
|
+
acceptanceRate: total ? settled / total : 1,
|
|
55
|
+
};
|
|
56
|
+
}
|