sparda-mcp 0.69.1 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,53 +6,97 @@
6
6
 
7
7
  <br/>
8
8
 
9
- > 🇫🇷 **Français** : Pour comprendre SPARDA en 10 minutes (douleur, architecture, vision), lisez le document du fondateur : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
9
+ > 🇫🇷 **Français** _L'IA écrit. SPARDA prouve._ Un gate déterministe et hors-ligne qui détecte quand une modif d'IA retire une garde, expose une route ou casse un invariant — sans clé API, directement dans la boucle d'édition de l'agent. Pour tout comprendre en 10 minutes (douleur, architecture, vision) : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
10
10
 
11
11
  ---
12
12
 
13
- **The trust layer for AI-written code. AI writes. SPARDA proves.**
13
+ <h1 align="center">AI writes. SPARDA proves.</h1>
14
+ <p align="center"><em>L'IA écrit. SPARDA prouve.</em></p>
14
15
 
15
- Under the hood: a compiler for backend behaviorthe LLVM of web applications.
16
+ **The trust layer for AI-written backends.** SPARDA compiles your backend routes, database queries, state mutations, guards, side-effects into one deterministic behavior graph, then **statically proves what can and can't break before you ship**: no unguarded mutation, no broken invariant, no non-atomic aggregate write.
16
17
 
17
- For twenty years software communicated through APIs. Then AI agents arrived, and the industry's answer was to expose more endpoints (MCP). But an agent doesn't *understand* an application from a list of disconnected tools — and neither can a linter, a debugger, or a deploy gate reason about a codebase they only read line by line.
18
+ [![npm](https://img.shields.io/npm/v/sparda-mcp)](https://www.npmjs.com/package/sparda-mcp)
19
+ [![CI](https://github.com/zyx77550/sparda/actions/workflows/ci.yml/badge.svg)](https://github.com/zyx77550/sparda/actions/workflows/ci.yml)
20
+ ![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)
21
+ ![runtime deps](https://img.shields.io/badge/runtime%20deps-4%20pinned-4c1)
22
+ [![license](https://img.shields.io/badge/license-BUSL--1.1-blue)](./LICENSE)
18
23
 
19
- SPARDA compiles your backend routes, database queries, state mutations, permissions, side-effects into one language-agnostic, mathematical graph: the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)). Compile once; then every tool is a simple pass over that graph.
24
+ 100% local · deterministic · zero API key · no cloud account. It fails loudly on a real risk, and when it can only see part of your app it says **PROVEN (PARTIAL)** never a false green. And when it can prove it was not even looking at your whole app, it says **PREMISE NOT VERIFIED** and claims nothing at all.
20
25
 
21
- > [!IMPORTANT]
22
- > **The 3700-Route Proof:** In our latest stress-test (v0.26.0), SPARDA successfully compiled and proved **3700+ routes** from elite open-source monsters (Next.js *Dub*, NestJS *Immich*, *MedusaJS*, and GitHub's OpenAPI) in **< 2.2 seconds** per repo, with zero crashes. It natively resolves deep Dependency Injection, external controllers, and Next.js handlers.
26
+ ## 60-second proof
27
+
28
+ From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
29
+
30
+ ```bash
31
+ npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk, or on an unverified premise
32
+ npx sparda-mcp prove # the whole verdict: proof + coverage + shareable seal
33
+ npx sparda-mcp badge # a README badge: proven · coverage% · routes
34
+ ```
35
+
36
+ Under the hood it compiles your backend into one language-agnostic graph — the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)) — and every command is a pass over that graph.
23
37
 
24
- **What the graph unlocks 100% local, deterministic, zero runtime dependencies, zero API key:**
38
+ ## The wedgecatch an AI edit that removes a guard, in the loop
25
39
 
26
- | Command | What it does |
27
- |---|---|
28
- | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js natively; **any** stack via OpenAPI) |
29
- | **`apocalypse`** | *Prove the deploy* — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
30
- | **`timeless`** | *Time-travel* — record a production request, replay it byte-identically, export the bug as a test |
31
- | **`heal`** | *Self-heal, proven* — bug → fix → the machine proves the fix is correct and breaks nothing |
32
- | **`mirror`** | *Execute the graph* — serve the compiled behavior over HTTP with no framework and no source |
33
- | **`openapi`** | *Emit the standard* — produce an OpenAPI 3.1 spec from the graph |
34
- | **`verify`** | *Prove the compiler's own laws* (determinism, soundness, round-trip) on your app |
35
- | **`init` / `dev`** | Expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution runtime layer) |
40
+ The one thing a text-diff review and a pattern scanner structurally can't do: prove that **this specific edit** dropped a protection the previous version had. `sparda gate` diffs the behavior graph before/after an edit and blocks a regression — deterministic, offline, sub-second, exit 2 (the Claude Code `PostToolUse` contract that stops the agent's edit loop). See it end-to-end in one command, zero setup:
36
41
 
37
42
  ```bash
38
- npx sparda-mcp ubg # compile your backend .sparda/ubg.json
39
- npx sparda-mcp apocalypse # prove the current tree is safe to deploy
43
+ npm run wedge # (from a clone) — or drive it on your own app with `sparda gate --arm` then `sparda gate --hook`
40
44
  ```
41
45
 
42
- No cloud account. No server to host. Exposing raw APIs to AI is the old way — SPARDA compiles the whole system's behavior, then proves, replays, heals, and serves it.
46
+ ```
47
+ 1. baseline armed on the guarded code (POST /admin/delete-user · requireAdmin)
48
+ 2. an AI edit "simplifies" requireAdmin → a pass-through (still compiles, still 200s)
49
+ 3. sparda gate on the edit:
50
+ ✗ [critical] GUARD_REMOVED — POST /admin/delete-user was guarded in the baseline
51
+ and is now reachable without any guard (src/app.js:11)
52
+ ⏱ ~40 ms · deterministic · offline · no API key
53
+ ⛔ exit 2 on --hook — Claude Code PostToolUse blocks the edit
54
+ ```
55
+
56
+ **Wire it into Claude Code in one line** — the [plugin](integrations/claude-code-plugin) registers a `PostToolUse` hook that runs `npx -y sparda-mcp gate --hook` after every `Edit`/`Write`, so a guard-removing edit is caught before it lands.
57
+
58
+ > [!IMPORTANT]
59
+ > **The Route-Compilation Proof — reproduce it yourself.** SPARDA compiles real open-source monsters to their behavior graph with **zero crashes**, each in **≈1–2 seconds**: Next.js _Dub_ (579 routes), NestJS _Immich_ (281), _MedusaJS_ (477). It natively resolves deep Dependency Injection, external controllers, and Next.js handlers. One command clones them and re-measures on your machine:
60
+ >
61
+ > ```bash
62
+ > node bench/repro.mjs # → bench/route-proof.json
63
+ > ```
64
+ >
65
+ > 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.)
66
+
67
+ **What the graph unlocks — 100% local, deterministic, 4 exact-pinned dependencies, zero API key:**
43
68
 
44
- **Nomenclature:** **SBIR** is the specification (the format, like "JSON"); **UBG** is the compiled graph itself (the artifact, `ubg.json`). The MCP server is one *output* of the graph, not the product.
69
+ | Command | What it does |
70
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
71
+ | **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + premise check + a shareable seal (`--json` / `--markdown`) |
72
+ | **`apocalypse`** | _Prove the deploy_ — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
73
+ | **`heal`** | _Self-heal, **proven**_ — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
74
+ | **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
75
+ | **`dossier`** | _The public report_ — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
76
+ | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Flask · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
77
+ | **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
78
+ | **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
79
+ | **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
45
80
 
46
- ## Quickstart
81
+ The prover is the product. The MCP server is one _output_ of the graph, not the point — SPARDA compiles the whole system's behavior, then proves, replays, heals, and (optionally) serves it.
82
+
83
+ **Nomenclature:** **SBIR** is the specification (the format, like "JSON"); **UBG** is the compiled graph itself (the artifact, `ubg.json`). The MCP server is one _output_ of the graph, not the product.
84
+
85
+ ## Optional: expose the graph to AI clients (MCP runtime)
86
+
87
+ Beyond proving, SPARDA can turn your running app into a live MCP server — the graph, executable, with write-safety and an immune layer. This is optional and separate from the prover above.
47
88
 
48
89
  1. **Scan + inject** — run once, from your app's directory:
90
+
49
91
  ```bash
50
92
  npx sparda-mcp init
51
93
  ```
94
+
52
95
  SPARDA parses your routes (AST), generates a marked `/mcp` router, injects it into
53
96
  your app (with a backup), and writes `sparda.json`. Every step is reversible.
54
97
 
55
98
  2. **Start your app, then start the bridge:**
99
+
56
100
  ```bash
57
101
  npx sparda-mcp dev
58
102
  ```
@@ -76,25 +120,31 @@ No cloud account. No server to host. Exposing raw APIs to AI is the old way —
76
120
  ## Try the Standalone Demo
77
121
 
78
122
  To see SPARDA in action instantly without modifying your codebase:
123
+
79
124
  ```bash
80
125
  npx sparda-mcp demo
81
126
  ```
127
+
82
128
  This runs the entire MCP lifecycle (detect → parse → generate → inject → remove) on a bundled demo app in a temporary folder, in about 10 seconds. For the compiler itself, run `npx sparda-mcp ubg` then `apocalypse` on any Express/FastAPI app.
83
129
 
84
130
  ## Black Box Report
85
131
 
86
132
  SPARDA is designed as a local organism. To see what it remembers and how much compute it has recycled:
133
+
87
134
  ```bash
88
135
  npx sparda-mcp report
89
136
  ```
137
+
90
138
  This prints a terminal dashboard aggregating your exposed tools, write opt-ins, proof journal decisions, and crystallized composite tools.
91
139
 
92
140
  To write a self-contained, offline HTML dashboard at `.sparda/report.html`, append the `--html` flag:
141
+
93
142
  ```bash
94
143
  npx sparda-mcp report --html
95
144
  ```
96
145
 
97
146
  To output raw JSON for integration:
147
+
98
148
  ```bash
99
149
  npx sparda-mcp report --json
100
150
  ```
@@ -102,28 +152,37 @@ npx sparda-mcp report --json
102
152
  ## Deployment Proof: Apocalypse
103
153
 
104
154
  SPARDA's Behavior Graph is a formal model of your system. Instead of waiting for runtime failures or relying on static analysis vibes, you can statically prove the safety of your backend before any deployment:
155
+
105
156
  ```bash
106
157
  npx sparda-mcp apocalypse
107
158
  ```
159
+
108
160
  This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
109
- * **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
110
- * **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
111
- * **Unvalidated Constrained Write (Medium)**: Flags writes into columns with declared invariants (CHECK, NOT NULL, UNIQUE parsed from your `.sql` DDL **or `schema.prisma`**, Prisma enums included) without prior validation (Zod/Pydantic).
112
- * **Irreversible Observable Effect (High)**: Flags out-of-process actions (like Stripe charges) that happen alongside state writes without a structural compensation path (like a catch-refund).
113
- * **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
161
+
162
+ - **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
163
+ - **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
164
+ - **Unvalidated Constrained Write (Medium)**: Flags writes into columns with declared invariants (CHECK, NOT NULL, UNIQUE parsed from your `.sql` DDL **or `schema.prisma`**, Prisma enums included) without prior validation (Zod/Pydantic).
165
+ - **Irreversible Observable Effect (High)**: Flags out-of-process actions (like Stripe charges) that happen alongside state writes without a structural compensation path (like a catch-refund).
166
+ - **Taint Flow Analysis (High)**: Tracks untrusted input variables through the AST to ensure they do not corrupt critical sinks.
167
+ - **Guard Dominance (Medium)**: Proves that top-level security guards cannot be bypassed by nested or overlapping sibling routes.
168
+ - **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
114
169
 
115
170
  To save your current graph as a safe baseline:
171
+
116
172
  ```bash
117
173
  npx sparda-mcp apocalypse --save-baseline
118
174
  ```
175
+
119
176
  Subsequent runs will diff the candidate graph against this baseline to detect regression vectors:
120
- * Deletion of any security `guard` (Critical).
121
- * Deletion of a database SQL invariant (High).
122
- * API blast radius expansion (Medium).
177
+
178
+ - Deletion of any security `guard` (Critical).
179
+ - Deletion of a database SQL invariant (High).
180
+ - API blast radius expansion (Medium).
123
181
 
124
182
  If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
125
183
 
126
184
  **One step in your workflow — findings land in the GitHub Security tab (SARIF):**
185
+
127
186
  ```yaml
128
187
  - uses: zyx77550/sparda@main
129
188
  with:
@@ -141,11 +200,13 @@ npx sparda-mcp timeless export <id> # the production bug is now a vitest test
141
200
  ```
142
201
 
143
202
  Recording is two lines in your app (ESM), with deterministic sampling and GDPR redaction built in:
203
+
144
204
  ```js
145
205
  import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
146
- const box = getFlightBox(); box.arm();
147
- app.use(box.middleware({ sample: 100 })); // 1 request in 100; passwords/tokens redacted by default
148
- const db = box.wrapClient(pgPool); // your query client, tapped
206
+ const box = getFlightBox();
207
+ box.arm();
208
+ app.use(box.middleware({ sample: 100 })); // 1 request in 100; passwords/tokens redacted by default
209
+ const db = box.wrapClient(pgPool); // your query client, tapped
149
210
  ```
150
211
 
151
212
  The closed loop nobody else has: **production bug → recorded flight → failing test → AI writes the fix → `apocalypse` proves the fix breaks no guard, invariant or transaction → deploy.** Replay is per-request (concurrent-race capture is out of scope for v1 — stated, not hidden).
@@ -162,7 +223,7 @@ npx sparda-mcp heal <flightId> --check --expect '{"status":404}'
162
223
 
163
224
  The brief is built from the graph itself — it hands the fixer the handler's `file:line`, the capabilities the fix must not grow, and the guards it must not remove. Then the **gate** — the actual product — proves the fix on three axes at once:
164
225
 
165
- 1. **Behavior** — lenient replay of the recorded flight (same deterministic inputs) now produces the *expected* response, not the recorded bug. The fix may reformulate a query (the tap is relabeled, allowed); it may **not** change the effect order or kinds.
226
+ 1. **Behavior** — lenient replay of the recorded flight (same deterministic inputs) now produces the _expected_ response, not the recorded bug. The fix may reformulate a query (the tap is relabeled, allowed); it may **not** change the effect order or kinds.
166
227
  2. **Compiler laws** — `verify` still passes: the graph is still sound and deterministic.
167
228
  3. **No regression** — `apocalypse` diff against the frozen pre-fix graph: zero new critical/high findings, no guard removed, no blast radius grown.
168
229
 
@@ -170,11 +231,11 @@ The brief is built from the graph itself — it hands the fixer the handler's `f
170
231
  ✓ HEALED & PROVEN — same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.
171
232
  ```
172
233
 
173
- The gate is honest in both directions: an unfixed bug, or a "fix" that silently drops a guard, keeps it **closed** (exit 1). This is the difference between an AI that writes plausible code and a system that *proves* the code is correct — the trust layer the agent era is missing.
234
+ The gate is honest in both directions: an unfixed bug, or a "fix" that silently drops a guard, keeps it **closed** (exit 1). This is the difference between an AI that writes plausible code and a system that _proves_ the code is correct — the trust layer the agent era is missing.
174
235
 
175
236
  ## Any Backend On Earth: OpenAPI Lowering
176
237
 
177
- SPARDA parses Express, FastAPI and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
238
+ SPARDA parses Express, FastAPI, Flask and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
178
239
 
179
240
  ```bash
180
241
  npx sparda-mcp ubg --openapi openapi.json
@@ -215,7 +276,7 @@ To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte
215
276
  5. **Nothing leaves your machine.** No telemetry to us, no cloud, local key auth, 4 exact-pinned dependencies.
216
277
  6. **What it learns is never lost.** Diagnoses, descriptions, settings — versioned with your git, surviving every re-init.
217
278
 
218
- What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
279
+ What we _don't_ promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
219
280
 
220
281
  ## How it works
221
282
 
@@ -232,34 +293,52 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
232
293
  ## What SPARDA gives your AI
233
294
 
234
295
  ### Operate, not just read
296
+
235
297
  Every route becomes a tool that runs against your live process — real auth, real data,
236
298
  warm connections. One call to **`sparda_get_context`** hands the AI the whole living
237
299
  picture: enabled tools, suggested workflows, runtime telemetry, quarantine state, and
238
300
  immune memory — so every session resumes where the last one stopped.
239
301
 
302
+ ### Prove the edit before you commit — the one check an LLM can't do to itself
303
+
304
+ The AI just edited a route. Did it quietly drop a guard? It calls **`sparda_prove`** and
305
+ finds out **now**, not in a CI run later. The tool recompiles the app to its behavior graph,
306
+ discharges the same static obligations as `sparda apocalypse`, and returns a deterministic
307
+ verdict — the exact word the CLI and badge emit, so it can never over-claim (a low-coverage
308
+ clean app reads `SURFACE`, never a bare `PROVEN`). Save a baseline once
309
+ (`sparda apocalypse --save-baseline`) and every later `sparda_prove` flags any finding with
310
+ `regression: true` — the guard your edit removed, the route it dropped, the blast radius it
311
+ grew. That's _"AI writes. SPARDA proves."_ inside the edit loop. Clients that list MCP prompts
312
+ also get the **`prove-my-edit`** workflow.
313
+
240
314
  ### Write-safety: the AI can't write until you say so
315
+
241
316
  - Writes (POST/PUT/DELETE) ship **disabled**. Enable them per tool in `sparda.json`; your choice survives every re-init.
242
317
  - An enabled write is **never executed on the first call**. SPARDA returns an `awaiting_confirmation` envelope — a single-use token plus a preview of the action — and commits only after an explicit confirm step.
243
318
  - When your client supports MCP elicitation, that confirmation prompt appears **in the AI's own UI**.
244
319
  - **Proof-after-write**: every successful write is followed by a read-back of the same resource, so the AI — and you — see the real effect, not a hopeful guess.
245
320
 
246
321
  ### Your app defends itself — zero LLM on the hot path
322
+
247
323
  - **Quarantine.** A tool that returns 3 consecutive 5xx is quarantined: further calls get a `503` with a reason and a retry delay instead of hammering your broken route. After a cooldown it half-opens for a single probe.
248
324
  - **Latency & anomaly flags.** The router learns each route's baseline and flags deviations locally, in a few lines of math.
249
325
  - **Adaptive diagnosis, only on surprise.** A genuinely new failure wakes your AI client's own model to diagnose it once; the diagnosis is cached as an "antibody" in `sparda.json`, so the same failure later costs zero tokens. Cloning your code doesn't clone its immune memory.
250
326
 
251
327
  ### A free intelligence layer, zero API key
328
+
252
329
  On first connection your AI client's own model (via MCP sampling) rewrites raw routes
253
330
  into business-language tool descriptions and proposes multi-step workflows — cached in
254
331
  `sparda.json` and exposed as MCP prompts. Nothing to configure, nothing to pay.
255
332
 
256
333
  ### It gets cheaper the more you use it
334
+
257
335
  - **Response recycling.** When a read keeps returning the same answer, SPARDA serves the next identical call straight from memory — without touching your host app. Reads only; writes always hit the host.
258
336
  - **A recycling gauge.** `GET /mcp/stats` counts how many calls were answered from SPARDA's own knowledge vs. how many paid the host route. It reads 0% on day one and fills with usage — a measure, never a promise.
259
337
 
260
338
  ### Tools nobody wrote — Labs, opt-in, default OFF
339
+
261
340
  Turn it on with `"labs": { "recordSequences": true }` in `sparda.json`. SPARDA then
262
- notices when one tool's output feeds the next tool's input and records the *circuit*
341
+ notices when one tool's output feeds the next tool's input and records the _circuit_
263
342
  structure only (tool names, argument names, counts), never your data. A read-only
264
343
  circuit seen enough times **crystallizes into a composite tool**, announced
265
344
  mid-session: one call runs the whole chain, auto-feeding each step from the previous
@@ -267,11 +346,13 @@ step's real response. Write routes are never absorbed — their per-call confirm
267
346
  always stands.
268
347
 
269
348
  ### Living context & telemetry
349
+
270
350
  `GET /mcp/stats` (per-tool calls/errors, tool "purity", quarantine state) and
271
351
  `GET /mcp/events` (errors, latency anomalies, cached diagnoses) expose exactly what
272
352
  your app is doing — surfaced to the AI as live notifications.
273
353
 
274
354
  ## Built for AI clients: the bundled Skill
355
+
275
356
  SPARDA ships with an Agent Skill ([`SKILL.md`](./SKILL.md)) that teaches any compatible
276
357
  AI client how to drive a SPARDA server to its **full potential** — call
277
358
  `sparda_get_context` first, exploit response recycling, honor quarantine, prefer
@@ -282,13 +363,20 @@ runtime, so the guidance never goes stale.
282
363
  ## Supported frameworks
283
364
 
284
365
  - **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.
285
- - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Supports Prisma, TypeORM, and Kysely.
286
- - **Express 4/5** (JS/TS, ESM/CJS) AST-based router injection. Deeply resolves external controllers, Mongoose schemas, and barrel re-exports. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
366
+ - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Fully supports composite decorators (`applyDecorators`). Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
367
+ - **Strapi** Native AST ingestion of Strapi content-types, core controllers, and custom routes.
368
+ - **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).
287
369
  - **MedusaJS** — Native AST ingestion of complex e-commerce routing.
288
370
  - **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
289
371
  - **FastAPI** (Python >= 3.9) — AST-based router injection.
290
372
 
373
+ ### Effects it resolves (what makes the irreversibility & atomicity proofs bite)
374
+
375
+ - **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.
376
+ - **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.
377
+
291
378
  ## Security posture (honest)
379
+
292
380
  - 4 runtime dependencies, exact-pinned.
293
381
  - **Dynamic Local Key Resolution.** The generated router contains no baked secrets. It resolves authorization keys at runtime from the `SPARDA_LOCAL_KEY` environment variable or the local gitignored `.sparda/key` file, and fails closed (503) when neither is found. For custom production or staging setups, you can override this behavior by exposing `SPARDA_LOCAL_KEY` in your environment.
294
382
  - Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
@@ -298,18 +386,21 @@ runtime, so the guidance never goes stale.
298
386
  Full threat model and known gaps: [docs/SECURITY.md](./docs/SECURITY.md).
299
387
 
300
388
  ## Documentation
389
+
301
390
  - [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — how `init`, the injected router, and the bridge fit together, plus the `sparda.json` schema.
302
391
  - [docs/SECURITY.md](./docs/SECURITY.md) — threat model, defenses, and honest known gaps.
303
392
  - [docs/TESTING.md](./docs/TESTING.md) — how the promises above are kept honest in CI.
304
393
  - [docs/ERRORS.md](./docs/ERRORS.md) — the error knowledge base.
305
394
 
306
395
  ## Beyond the open core
396
+
307
397
  SPARDA is free, including in production (see License). Team-scale capabilities —
308
398
  fine-grained per-person access policies and a signed, tamper-evident audit log — are
309
399
  planned for a future paid tier. The open core stands on its own; nothing here is
310
400
  crippled to upsell you.
311
401
 
312
402
  ## License
403
+
313
404
  [Business Source License 1.1](./LICENSE) — free to use, including in production.
314
405
  You may not resell SPARDA or offer it as a competing commercial service.
315
406
  Each version converts to Apache 2.0 four years after its release.
package/SKILL.md CHANGED
@@ -13,11 +13,11 @@ description: >-
13
13
 
14
14
  A SPARDA server is driven by a compiled **Unified Behavior Graph (UBG)** — the graph SPARDA's compiler produces from the host application's states, transitions, permissions, and side-effects, serialized under the **SBIR** specification. Instead of exposing raw, disconnected endpoints, SPARDA compiles the app into a deterministic behavioral model. The local **SPARDA Runtime** dynamically executes this graph inside the live host process, powering the MCP interface, the Twin simulation clone, and the Immune system offline.
15
15
 
16
- > This skill covers the **runtime** (driving a live MCP server) — the live half of SPARDA's trust layer: *"AI writes. SPARDA proves."* The same graph also powers dev-time proof commands you run in the app's repo — `sparda review` (the behavior diff of a PR), `apocalypse` (prove the deploy), `timeless` (record/replay a request), `heal` (prove a fix), `mirror` (serve the graph), `ubg` (compile), `verify` (prove the compiler's laws). Those are CLI, not MCP tools; see the project README.
16
+ > This skill covers the **runtime** (driving a live MCP server) — the live half of SPARDA's trust layer: _"AI writes. SPARDA proves."_ The same graph also powers dev-time proof commands you run in the app's repo — `sparda review` (the behavior diff of a PR), `apocalypse` (prove the deploy), `timeless` (record/replay a request), `heal` (prove a fix), `mirror` (serve the graph), `ubg` (compile), `verify` (prove the compiler's laws). Those are CLI, not MCP tools; see the project README.
17
17
 
18
18
  ## Rule 0 — call `sparda_get_context` first, every session
19
19
 
20
- Before anything else, call **`sparda_get_context`** (no params). It returns the *live* state of the SPARDA Behavior Graph:
20
+ Before anything else, call **`sparda_get_context`** (no params). It returns the _live_ state of the SPARDA Behavior Graph:
21
21
 
22
22
  - the active routes/tools, workflows, and type-propagated schemas;
23
23
  - `runtime` — current stats (calls, errors, quarantine states, Twin mode active);
@@ -35,19 +35,45 @@ Read it to orient yourself inside the graph. `sparda_info` gives a lighter summa
35
35
  parameter schema was only partially inferred — pass arguments carefully.
36
36
  - **Meta-tools** — `sparda_get_context`, `sparda_info`,
37
37
  `sparda_list_disabled_tools`, `sparda_confirm`.
38
+ - **The proof tool** — `sparda_prove`. Call it **after you edit a route, before you
39
+ commit** — see _Prove your own edit_ below. It's the one check you can't do to
40
+ yourself by re-reading your code.
38
41
  - **Composite tools** — labelled `[Labs circuit ×N]`, `readOnly`. One call runs a
39
- whole proven multi-step chain (see *Crystallized circuits* below).
42
+ whole proven multi-step chain (see _Crystallized circuits_ below).
40
43
 
41
44
  Only **enabled** tools appear. Write tools are hidden until the user opts in, so a
42
- missing write is a config state, not an error — see *Writing safely*.
45
+ missing write is a config state, not an error — see _Writing safely_.
46
+
47
+ ## Prove your own edit — call `sparda_prove` before you commit
48
+
49
+ This is the tool an LLM needs most and can least fake. After you edit a route,
50
+ **call `sparda_prove`** — it recompiles the app to its behavior graph and discharges
51
+ the same static obligations as `sparda apocalypse` (unguarded mutation, non-atomic
52
+ aggregate write, unvalidated constrained write), then returns a deterministic verdict.
53
+
54
+ - **Focus it.** Pass `route` with the method+path you just touched (e.g.
55
+ `{ "route": "DELETE /orders" }`) to narrow the finding list. The **verdict still
56
+ reflects the whole app** — a filter never buys you a greener light.
57
+ - **Read the verdict honestly.** `PROVEN` / `PARTIAL` are safe to commit. `SURFACE`
58
+ and `NO_PROOF` mean SPARDA _couldn't resolve enough to prove it_ — that is
59
+ "unknown", **never** a pass. The word is the exact one the CLI and badge emit; it
60
+ physically cannot over-claim.
61
+ - **The regression check is the point.** If a baseline was saved
62
+ (`sparda apocalypse --save-baseline` on a known-good state), any finding with
63
+ `regression: true` means _your edit_ removed a guard, dropped a route, or grew the
64
+ blast radius vs the last proven state. Fix those before committing — this is the
65
+ check you cannot perform by re-reading your own diff.
66
+ - Clients that list MCP prompts also see **`prove-my-edit`**, the built-in workflow
67
+ that walks these steps.
43
68
 
44
69
  ## Exploit the intelligence layer (this is the "full potential")
45
70
 
46
71
  **1. Response-recycling flywheel — make repeated reads free.**
47
- When the *same* read tool returns a byte-identical result for the *same* arguments
72
+ When the _same_ read tool returns a byte-identical result for the _same_ arguments
48
73
  **3 times within 30 seconds**, SPARDA serves the next identical call straight from
49
74
  RAM (`servedByFlywheel: true`) **without touching the host app**. So:
50
- - Don't fear repeating stable GETs — repetition is what *activates* the cache.
75
+
76
+ - Don't fear repeating stable GETs — repetition is what _activates_ the cache.
51
77
  - Don't bolt your own client-side cache on top; you'd hide the signal that lets
52
78
  SPARDA recycle, and you'd lose freshness control.
53
79
  - Watch `recycling.flywheel.servedFromMemory` climb in context — that's free work.
@@ -55,7 +81,7 @@ RAM (`servedByFlywheel: true`) **without touching the host app**. So:
55
81
 
56
82
  **2. Circuit-breaker / quarantine — stop hammering a sick backend.**
57
83
  After **3 consecutive 5xx** on a tool, SPARDA quarantines it: subsequent calls
58
- return **HTTP 503** with `reason` and `retryInMs` *instead of* hitting the failing
84
+ return **HTTP 503** with `reason` and `retryInMs` _instead of_ hitting the failing
59
85
  host. Honor `retryInMs` — do not retry-loop. Check `runtime.quarantine` in context
60
86
  before depending on a tool. After a cooldown (~60s) the tool half-opens for one
61
87
  probe; one more 5xx re-quarantines it.
@@ -68,7 +94,7 @@ single **composite tool** for that chain and announces it mid-session via
68
94
  call and it's marked read-only. (Writes are never absorbed into a circuit.)
69
95
 
70
96
  **4. Adaptive immunity — read the diagnosis before retrying.**
71
- Repeated, unfamiliar failures trigger a *one-shot* LLM diagnosis that SPARDA caches
97
+ Repeated, unfamiliar failures trigger a _one-shot_ LLM diagnosis that SPARDA caches
72
98
  as an "antibody" (keyed by `source|tool|status`). Recurrences reuse the cached
73
99
  diagnosis at zero cost. When an error event carries a diagnosis, **read it** and
74
100
  adapt — don't blindly retry the same call.
@@ -78,11 +104,13 @@ an `immune` event in `/mcp/events`. Treat it as a hint to back off or warn the u
78
104
 
79
105
  **6. Twin Simulation Mode — practice safely on a clone.**
80
106
  When `/mcp/stats` or `sparda_get_context.runtime` contains `"twin": true`, you are connected to a safe, in-memory mock clone of the application.
107
+
81
108
  - All GET reads return learned exemplars (observed response shapes and mock values).
82
109
  - All write tools return simulated `202` echoes but do not write to database or external APIs.
83
110
  - Use this twin mode to practice multi-step workflows, debug tool sequences, and test your plans without touching the live production backend.
84
111
 
85
112
  **7. Grammar & Evolution — discover optimal workflows.**
113
+
86
114
  - You can query or contribute to the app's grammar (`.sparda/grammar.json`). The grammar maps valid sequences of tool calls (edges).
87
115
  - Running `sparda evolve` mutates and runs candidate chains against the twin. The successful evolved sequences are suggested as mid-session workflows.
88
116
 
@@ -151,7 +179,8 @@ Writes are **disabled by default**. The protocol is not optional:
151
179
  - **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
152
180
 
153
181
  ---
154
- *This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
182
+
183
+ _This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
155
184
  surface each release, so it tracks new tools and behaviors. The **live, per-project**
156
185
  tool list, stats, and workflows always come from `sparda_get_context` at runtime —
157
- trust it over any static list.*
186
+ trust it over any static list._
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.69.1",
3
+ "version": "0.70.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
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.",
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 \u2014 no API key, right in the agent edit loop.",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "scripts": {
19
19
  "test": "vitest run",
20
- "prepublishOnly": "vitest run",
20
+ "prepublishOnly": "node scripts/release-gate.mjs",
21
21
  "corpus": "node scripts/corpus-oracle.mjs",
22
22
  "corpus:update": "node scripts/corpus-oracle.mjs --update",
23
23
  "test:router": "node tests/router-selftest.cjs",
@@ -29,7 +29,9 @@
29
29
  "format:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
30
30
  "bench:check": "node bench/check-readme.mjs",
31
31
  "mutation": "node tests/mutation/run.mjs",
32
- "wedge": "node bench/wedge.mjs"
32
+ "wedge": "node bench/wedge.mjs",
33
+ "release:check": "node scripts/release-gate.mjs",
34
+ "publish:vscode": "cd extensions/vscode && vsce publish"
33
35
  },
34
36
  "files": [
35
37
  "src",
@@ -799,6 +799,36 @@ export function resolveExportedFunction(mod, name, seen = new Set()) {
799
799
  return null;
800
800
  }
801
801
 
802
+ // The CLASS twin of `resolveExportedFunction`, and it exists for the same reason one
803
+ // level up. A monorepo package (`@novu/dal`, `@novu/application-generic`) resolves to its
804
+ // entry file, and that entry file is a BARREL — sixty `export * from './repositories/…'`
805
+ // lines and not one class declaration. `classInModule` looks for a class DECLARED in the
806
+ // module it was handed, so every DI hop into a workspace package died on the barrel:
807
+ // measured on novu, 1479 of 2039 constructor-DI hops resolved to nothing, `PinoLogger`
808
+ // and the repository classes at the top of the list. The route's real behavior — every
809
+ // db_write the repository performs — was simply absent from the graph.
810
+ //
811
+ // Bounded by `seen` (a barrel cycle terminates) and memoized by the caller, because a
812
+ // wide barrel is walked once per hop otherwise.
813
+ export function resolveExportedClass(mod, name, seen = new Set()) {
814
+ if (!mod || mod.error) return null;
815
+ const direct = classInModule(mod, name);
816
+ if (direct) return { cls: direct, mod };
817
+ const named = mod.reexports.get(name); // export { X } from './x'
818
+ if (named && !seen.has(named)) {
819
+ seen.add(named);
820
+ const hit = resolveExportedClass(parseModule(named), name, seen);
821
+ if (hit) return hit;
822
+ }
823
+ for (const file of mod.starReexports ?? []) {
824
+ if (seen.has(file)) continue;
825
+ seen.add(file);
826
+ const hit = resolveExportedClass(parseModule(file), name, seen);
827
+ if (hit) return hit;
828
+ }
829
+ return null;
830
+ }
831
+
802
832
  function collectTopLevel(node, facts, absFile, exported) {
803
833
  if (node.type === 'ExportNamedDeclaration' && node.declaration) {
804
834
  collectTopLevel(node.declaration, facts, absFile, true);
package/src/ubg/nestjs.js CHANGED
@@ -96,6 +96,12 @@ export function extractNest(cwd, entryDir) {
96
96
  // verified (immich: 253 guards, 0 verified). Prove it ONCE here — resolve its canActivate
97
97
  // through DI to a real deny — and every auth-named guard on the app earns `verified`.
98
98
  const globalGuardDenies = detectGlobalDenyGuard(root, engine);
99
+ // Middleware bound in a module's `configure()` via `consumer.apply(X).forRoutes(...)`
100
+ // (ADR-089) — the auth a per-controller decorator scan cannot see. Detected once; matched
101
+ // per concrete route below. Unreadable bindings are declared, never guessed.
102
+ const { bindings: consumerBindings, blindspots: consumerBlindspots } =
103
+ detectConsumerMiddleware(root, engine);
104
+ for (const b of consumerBlindspots) skipped.push(b);
99
105
  // one resolution per decorator DEFINITION, not per use — novu applies the same four
100
106
  // composites across 451 routes
101
107
  const compositeCache = new Map();
@@ -245,7 +251,7 @@ export function extractNest(cwd, entryDir) {
245
251
  // method it delegates to through DI (this.<prop>.<call>())
246
252
  const scan = engine.handlerScan(m, di, mod, cls);
247
253
 
248
- const chain = [
254
+ const baseChain = [
249
255
  ...guards.map(({ name, mod: srcMod }) => {
250
256
  const scan = guardStepScan(name, srcMod, false);
251
257
  return {
@@ -306,13 +312,24 @@ export function extractNest(cwd, entryDir) {
306
312
  : [fullPath];
307
313
  for (const verb of verbs)
308
314
  for (const routePath of paths) {
315
+ // Middleware this concrete (verb, path) earns from module forRoutes bindings,
316
+ // computed per route because a binding matches on method + path, which vary
317
+ // across the @All / multi-path expansion. Prepended: middleware runs first.
318
+ const mwSteps = consumerGuardSteps(
319
+ consumerBindings,
320
+ verb,
321
+ routePath,
322
+ cls.id?.name ?? null,
323
+ rel,
324
+ m.loc?.start.line ?? 0,
325
+ ).filter((s) => !baseChain.some((c) => c.name === s.name));
309
326
  routes.push({
310
327
  method: verb,
311
328
  path: routePath,
312
329
  sourceFile: rel,
313
330
  sourceLine: m.loc?.start.line ?? 0,
314
331
  params: pathParamsOf(routePath),
315
- chain,
332
+ chain: mwSteps.length ? [...mwSteps, ...baseChain] : baseChain,
316
333
  description: '',
317
334
  authOptOut: http.optOut === true, // temp — consumed by the posture pass
318
335
  });
@@ -666,6 +683,266 @@ function returnsFalse(fnNode) {
666
683
  return found;
667
684
  }
668
685
 
686
+ // --- MiddlewareConsumer.forRoutes() (ADR-089) ------------------------------------------
687
+ //
688
+ // The auth that decorator scans miss. A Nest module can gate routes NOT with `@UseGuards`
689
+ // on the controller, but by registering middleware in its own `configure()` method:
690
+ //
691
+ // export class ArticleModule implements NestModule {
692
+ // configure(consumer: MiddlewareConsumer) {
693
+ // consumer.apply(AuthMiddleware).forRoutes(
694
+ // { path: 'articles/:slug', method: RequestMethod.DELETE }, …);
695
+ // }
696
+ // }
697
+ //
698
+ // The controller carries no guard decorator, so a per-method scan reads the mutation as
699
+ // UNGUARDED — a false CRITICAL on a route the framework actually authenticates (measured
700
+ // on nestjs-realworld: 7 hard findings, 4 of them false because AuthMiddleware guards the
701
+ // route from the module, not the controller). This is the middleware twin of the global
702
+ // APP_GUARD detection above: read the binding once, in the module, and attach the guard to
703
+ // every route it PROVABLY targets.
704
+ //
705
+ // SOUNDNESS. Attaching a guard DOWNGRADES a finding, so a binding matched to a route it does
706
+ // not cover would HIDE a hole (Direction 2). Two disciplines keep it honest:
707
+ // 1. the matcher never OVER-covers — a target `:param` covers a route segment (literal or
708
+ // param), but a target literal never covers a route `:param` (the route serves URLs the
709
+ // literal target does not), and a shorter target never covers a deeper route. Anything
710
+ // unreadable (computed path, spread, an `exclude()` we cannot parse) matches NOTHING and
711
+ // is DECLARED, never guessed — an under-match is a surfaceable false positive, the safe
712
+ // direction.
713
+ // 2. only an AUTH-NAMED middleware attaches (here as an ASSERTED guard, ADR-063 — trusted
714
+ // by name, never `verified`); a `LoggerMiddleware` registered via forRoutes gates
715
+ // nothing and must not soften a critical. Proving the middleware's `use()` actually
716
+ // denies (→ verified) is the separate, stricter upgrade in `middlewareUseScan`.
717
+ const ASSERTED_MIDDLEWARE_SCAN = {
718
+ effects: [],
719
+ returnShapes: [],
720
+ calls: [],
721
+ async: false,
722
+ validatesInput: false,
723
+ guardSignals: {},
724
+ assertedGuard: true,
725
+ };
726
+
727
+ // The deny-proven twin (ADR-089): when a forRoutes middleware's `use()` resolves through DI
728
+ // to a real deny (a 4xx throw / `res.status(401)`), its guard reads VERIFIED — the mutation
729
+ // reaches PROVEN, exactly like a proven APP_GUARD. Behaviour over names: a middleware PROVEN
730
+ // to deny is a guard even if its name reads nothing like one; a `LoggerMiddleware` that only
731
+ // calls next() proves no deny and stays out of the chain.
732
+ const VERIFIED_MIDDLEWARE_SCAN = {
733
+ effects: [],
734
+ returnShapes: [],
735
+ calls: [],
736
+ async: true,
737
+ validatesInput: false,
738
+ guardSignals: { deniesWithStatus: true },
739
+ };
740
+
741
+ // A verb from a `RequestMethod.X` member expression: `RequestMethod.DELETE` → 'delete',
742
+ // `RequestMethod.ALL` → 'all'. undefined = a method we cannot read (a variable / computed).
743
+ function requestMethodOf(node) {
744
+ if (node?.type !== 'MemberExpression' || node.property?.type !== 'Identifier')
745
+ return undefined;
746
+ const v = node.property.name.toLowerCase();
747
+ return v === 'all' || NEST_ALL_EXPANSION.includes(v) || v === 'options' || v === 'head'
748
+ ? v
749
+ : undefined;
750
+ }
751
+
752
+ // segments of a path, leading/trailing slashes stripped: '/articles/:slug/' → ['articles',':slug']
753
+ const segsOf = (p) =>
754
+ String(p ?? '')
755
+ .replace(/^\/+|\/+$/g, '')
756
+ .split('/')
757
+ .filter(Boolean);
758
+
759
+ // Does a forRoutes target PATH cover a route PATH, in Nest's path-to-regexp sense, WITHOUT
760
+ // ever over-covering? A target param covers any route segment; a target literal covers only
761
+ // the identical literal (a route param at that position serves URLs the literal misses); a
762
+ // trailing `*` covers the remainder; unmatched depth on either side is no cover.
763
+ function pathCovers(targetPath, routePath) {
764
+ const t = segsOf(targetPath);
765
+ const r = segsOf(routePath);
766
+ for (let i = 0; i < t.length; i++) {
767
+ const ts = t[i];
768
+ if (ts === '*' || ts === '(.*)' || ts === '**') return true; // wildcard → covers the rest
769
+ const rs = r[i];
770
+ if (rs === undefined) return false; // target reaches deeper than the route
771
+ if (ts.startsWith(':')) continue; // param target covers any segment (literal or param)
772
+ if (rs.startsWith(':')) return false; // literal target cannot cover a route param
773
+ if (ts !== rs) return false; // literal vs literal
774
+ }
775
+ return t.length === r.length; // no leftover route segments the target fails to cover
776
+ }
777
+
778
+ // One forRoutes / exclude argument → a normalized target. `unread` is DECLARED, never treated
779
+ // as covering anything (soundness: an unreadable binding is a blind spot, not a wildcard).
780
+ function parseConsumerTarget(arg) {
781
+ if (arg.type === 'StringLiteral')
782
+ return { kind: 'path', path: arg.value, method: 'all' };
783
+ if (arg.type === 'Identifier') return { kind: 'controller', name: arg.name };
784
+ if (arg.type === 'ObjectExpression') {
785
+ let p = null;
786
+ let method = 'all'; // Nest default when `method` is omitted is RequestMethod.ALL
787
+ let unread = false;
788
+ for (const pr of arg.properties) {
789
+ if (pr.type !== 'ObjectProperty' || pr.key?.type !== 'Identifier') continue;
790
+ if (pr.key.name === 'path') {
791
+ if (pr.value.type === 'StringLiteral') p = pr.value.value;
792
+ else unread = true;
793
+ } else if (pr.key.name === 'method') {
794
+ const m = requestMethodOf(pr.value);
795
+ if (m === undefined) unread = true;
796
+ else method = m;
797
+ }
798
+ }
799
+ if (unread || p === null) return { kind: 'unread', src: 'route-object' };
800
+ return { kind: 'path', path: p, method };
801
+ }
802
+ return { kind: 'unread', src: arg.type };
803
+ }
804
+
805
+ // The `.apply(...).forRoutes(...)` (optionally `.exclude(...)`) chain rooted at a forRoutes
806
+ // call: the applied middleware NAMES, the forRoutes TARGETS, and the exclude targets.
807
+ function consumerChainOf(forRoutesCall) {
808
+ const targets = forRoutesCall.arguments.map(parseConsumerTarget);
809
+ const applied = [];
810
+ const excludes = [];
811
+ let node =
812
+ forRoutesCall.callee.type === 'MemberExpression' ? forRoutesCall.callee.object : null;
813
+ while (
814
+ node &&
815
+ node.type === 'CallExpression' &&
816
+ node.callee?.type === 'MemberExpression'
817
+ ) {
818
+ const prop = node.callee.property?.name;
819
+ if (prop === 'apply')
820
+ for (const a of node.arguments) {
821
+ const n = idName(a);
822
+ if (n) applied.push(n);
823
+ else excludes.push({ kind: 'unread', src: 'apply-arg' }); // unreadable middleware ref
824
+ }
825
+ else if (prop === 'exclude')
826
+ excludes.push(...node.arguments.map(parseConsumerTarget));
827
+ node = node.callee.object;
828
+ }
829
+ return { applied, targets, excludes };
830
+ }
831
+
832
+ // → { bindings, blindspots }. Each binding: { middlewares:[name], mod, targets, excludes,
833
+ // hasUnread }. Scans module files (textual pre-filter) for every forRoutes chain. The mod is
834
+ // carried so a later deny-proof can resolve the middleware class through its imports.
835
+ function detectConsumerMiddleware(root, engine) {
836
+ const bindings = [];
837
+ const blindspots = [];
838
+ for (const file of walk(root)) {
839
+ let head;
840
+ try {
841
+ head = fs.readFileSync(file, 'utf8');
842
+ } catch {
843
+ continue;
844
+ }
845
+ if (!/forRoutes/.test(head)) continue;
846
+ const mod = parseModule(file);
847
+ if (mod.error) continue;
848
+ const rel = relOf(root, file);
849
+ walkAst(mod.ast.program, (n) => {
850
+ if (
851
+ n.type !== 'CallExpression' ||
852
+ n.callee?.type !== 'MemberExpression' ||
853
+ n.callee.property?.name !== 'forRoutes'
854
+ )
855
+ return;
856
+ const { applied, targets, excludes } = consumerChainOf(n);
857
+ if (!applied.length) return; // a forRoutes with no readable middleware — nothing to attach
858
+ const hasUnread =
859
+ targets.some((t) => t.kind === 'unread') ||
860
+ excludes.some((t) => t.kind === 'unread');
861
+ if (hasUnread)
862
+ blindspots.push({
863
+ reason: `a MiddlewareConsumer binding in ${rel} applies ${applied.join(', ')} through a route/exclude target SPARDA could not read — routes it may cover are left flagged rather than silently credited`,
864
+ file: rel,
865
+ risk: 'high',
866
+ });
867
+ // Prove the deny ONCE per binding (ADR-089): a middleware whose `use()` resolves to a
868
+ // real deny reads VERIFIED wherever it is bound; one whose deny we cannot prove stays
869
+ // asserted-by-name. Resolution follows the middleware's own imports from this module.
870
+ const denies = new Set();
871
+ for (const name of applied)
872
+ if (middlewareDenies(name, mod, engine)) denies.add(name);
873
+ bindings.push({ middlewares: applied, denies, mod, targets, excludes });
874
+ });
875
+ }
876
+ return { bindings, blindspots };
877
+ }
878
+
879
+ // Resolve a forRoutes middleware class → its `use()` → prove it can deny (a 4xx throw /
880
+ // `res.status(401)` / `next(err)`), following DI so a delegated deny is seen. The middleware
881
+ // twin of `guardClassDenies`. A functional middleware (a bare function, not a class) has no
882
+ // class to resolve → false, and the caller falls back to the asserted-by-name reading.
883
+ function middlewareDenies(name, mod, engine) {
884
+ const file = mod.imports.get(name);
885
+ const gmod = file ? parseModule(file) : mod;
886
+ if (gmod.error) return false;
887
+ const cls = classInModule(gmod, name);
888
+ if (!cls) return false;
889
+ const hit = methodInClassChain(cls, gmod, 'use');
890
+ if (!hit) return false;
891
+ const di = diMapWithMod(cls, hit.mod);
892
+ const scan = engine.handlerScan(hit.fn, di, hit.mod, cls);
893
+ return Boolean(scan.guardSignals?.deniesWithStatus);
894
+ }
895
+
896
+ // The middleware guard steps a route earns from the app's forRoutes bindings. A binding
897
+ // applies to the route iff some target covers it AND no exclude does; an unreadable exclude
898
+ // makes the binding abstain on that route (never attach — the route might be the excluded
899
+ // one). A middleware PROVEN to deny attaches VERIFIED (→ PROVEN); one only auth-NAMED attaches
900
+ // asserted (name-trusted, unverified); anything else — a LoggerMiddleware that proves no deny
901
+ // and reads nothing like a guard — attaches nothing and never softens the critical.
902
+ function consumerGuardSteps(bindings, verb, routePath, controllerName, sourceFile, line) {
903
+ const steps = [];
904
+ const seen = new Set();
905
+ for (const b of bindings) {
906
+ const excluded = b.excludes.some((t) =>
907
+ targetCovers(t, verb, routePath, controllerName),
908
+ );
909
+ const excludeUnreadable = b.excludes.some((t) => t.kind === 'unread');
910
+ if (excluded || excludeUnreadable) continue;
911
+ if (!b.targets.some((t) => targetCovers(t, verb, routePath, controllerName)))
912
+ continue;
913
+ for (const name of b.middlewares) {
914
+ if (seen.has(name)) continue;
915
+ const proven = b.denies?.has(name);
916
+ // proof beats name (behaviour over names): a proven denier is a guard whatever its
917
+ // name; an unproven middleware must be auth-named to attach even asserted.
918
+ if (!proven && !(GUARD_DECORATOR.test(name) || nameLooksLikePrincipal(name)))
919
+ continue;
920
+ seen.add(name);
921
+ steps.push({
922
+ name,
923
+ sourceFile,
924
+ sourceLine: line ?? 0,
925
+ fn: null,
926
+ scan: proven ? VERIFIED_MIDDLEWARE_SCAN : ASSERTED_MIDDLEWARE_SCAN,
927
+ role: 'middleware',
928
+ });
929
+ }
930
+ }
931
+ return steps;
932
+ }
933
+
934
+ // Does a normalized target cover (verb, routePath, controllerName)? Unreadable targets cover
935
+ // nothing — the whole point of declaring them.
936
+ function targetCovers(target, verb, routePath, controllerName) {
937
+ if (target.kind === 'controller') return target.name === controllerName;
938
+ if (target.kind === 'path')
939
+ return (
940
+ (target.method === 'all' || target.method === verb) &&
941
+ pathCovers(target.path, routePath)
942
+ );
943
+ return false;
944
+ }
945
+
669
946
  // --- composite decorators: `applyDecorators(UseGuards(X), …)` (ADR-084) ---------------
670
947
  //
671
948
  // NestJS's OFFICIAL composition API. A house decorator is declared once and used
@@ -26,6 +26,7 @@ import {
26
26
  methodInClassChain,
27
27
  computeThisSymbols,
28
28
  resolveExportedFunction,
29
+ resolveExportedClass,
29
30
  collectRepoFields,
30
31
  collectReqDerived,
31
32
  reqParamName,
@@ -156,6 +157,7 @@ function seedTaint(fn, args, callerReq) {
156
157
  // live here, scoped to the run.
157
158
  export function createResolver({ cwd, scannedFiles, helpers }) {
158
159
  const classBundles = new Map(); // memo: per (class file, class.method[, symbols])
160
+ const classLookups = new Map(); // memo: per (module, class name) — barrel walks
159
161
  const rel = (abs) => relOf(cwd, abs);
160
162
 
161
163
  // ---- member-call following (imports, instantiated services, this/super) ----
@@ -411,9 +413,22 @@ export function createResolver({ cwd, scannedFiles, helpers }) {
411
413
  const file = mod.imports.get(className);
412
414
  const clsMod = file ? parseModule(file) : mod;
413
415
  if (clsMod.error) return null;
414
- const cls = classInModule(clsMod, className);
415
- if (!cls) return null;
416
- return classMethodBundle(cls, clsMod, methodName, depth, stack, thisSymbols);
416
+ const hit = classThroughBarrels(clsMod, className);
417
+ if (!hit) return null;
418
+ return classMethodBundle(hit.cls, hit.mod, methodName, depth, stack, thisSymbols);
419
+ }
420
+
421
+ // A class is looked up THROUGH barrels: declared here, or re-exported from a module
422
+ // this one re-exports. A monorepo package entry point is a barrel and nothing else, so
423
+ // without this every DI hop into a workspace package resolves to nothing at all —
424
+ // silently, since an unresolved hop leaves no trace of its own. Memoized per (module,
425
+ // class): a sixty-line barrel is otherwise re-walked once per hop.
426
+ function classThroughBarrels(clsMod, className) {
427
+ const key = `cls:${clsMod._file ?? ''}#${className}`;
428
+ if (classLookups.has(key)) return classLookups.get(key);
429
+ const hit = resolveExportedClass(clsMod, className);
430
+ classLookups.set(key, hit);
431
+ return hit;
417
432
  }
418
433
 
419
434
  // The fully-resolved scan of `<topCls>.<methodName>` — its body plus everything