sparda-mcp 0.15.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
6
6
  "type": "module",
@@ -32,9 +32,8 @@ export async function runImmunize(opts) {
32
32
  return { capsule };
33
33
  }
34
34
 
35
- const dotSparda = path.join(opts.cwd, '.sparda');
36
- fs.mkdirSync(dotSparda, { recursive: true });
37
- const outPath = path.join(dotSparda, 'immunity.json');
35
+ const outPath = path.join(opts.cwd, '.sparda', 'immunity.json');
36
+ fs.mkdirSync(path.dirname(outPath), { recursive: true }); // .sparda/ may not exist yet
38
37
  atomicWrite(outPath, JSON.stringify(capsule) + '\n');
39
38
  const wire = JSON.stringify(capsule).length;
40
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:/, '');
@@ -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.localeCompare(b))
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
- const known = ['@nestjs/core', 'fastify', 'koa'].find((d) => deps[d]);
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 only in v0.`,
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)
@@ -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.localeCompare(b.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].localeCompare(b[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.localeCompare(b.rule) ||
298
- a.entrypoint.localeCompare(b.entrypoint),
299
+ cmp(a.rule, b.rule) ||
300
+ cmp(a.entrypoint, b.entrypoint),
299
301
  );
300
302
  }
301
303
 
@@ -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),
@@ -89,7 +89,11 @@ export function parseModule(absFile) {
89
89
  try {
90
90
  facts.ast = parse(src, {
91
91
  sourceType: 'unambiguous',
92
- plugins: ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: true }]],
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
- obj.object.type === 'Identifier' &&
432
- /prisma|client|db/i.test(obj.object.name)
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') cur = cur.object;
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.localeCompare(b.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
+ }
@@ -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.localeCompare(b.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.localeCompare(b.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
+ }