sparda-mcp 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -132
- package/SKILL.md +10 -39
- package/package.json +5 -4
- package/src/ubg/apocalypse.js +30 -7
- package/src/ubg/extract.js +30 -0
- package/src/ubg/nestjs.js +68 -33
- package/src/ubg/resolve.js +18 -3
package/README.md
CHANGED
|
@@ -6,97 +6,53 @@
|
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
9
|
-
> 🇫🇷 **Français**
|
|
9
|
+
> 🇫🇷 **Français** : Pour comprendre SPARDA en 10 minutes (douleur, architecture, vision), lisez le document du fondateur : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
<p align="center"><em>L'IA écrit. SPARDA prouve.</em></p>
|
|
13
|
+
**The trust layer for AI-written code. AI writes. SPARDA proves.**
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
Under the hood: a compiler for backend behavior — the LLVM of web applications.
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
[](https://github.com/zyx77550/sparda/actions/workflows/ci.yml)
|
|
20
|
-

|
|
21
|
-

|
|
22
|
-
[](./LICENSE)
|
|
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.
|
|
23
18
|
|
|
24
|
-
|
|
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.
|
|
25
20
|
|
|
26
|
-
|
|
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.
|
|
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.
|
|
37
23
|
|
|
38
|
-
|
|
24
|
+
**What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
|
|
39
25
|
|
|
40
|
-
|
|
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) |
|
|
41
36
|
|
|
42
37
|
```bash
|
|
43
|
-
|
|
38
|
+
npx sparda-mcp ubg # compile your backend → .sparda/ubg.json
|
|
39
|
+
npx sparda-mcp apocalypse # prove the current tree is safe to deploy
|
|
44
40
|
```
|
|
45
41
|
|
|
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:**
|
|
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.
|
|
68
43
|
|
|
69
|
-
|
|
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) |
|
|
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.
|
|
80
45
|
|
|
81
|
-
|
|
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.
|
|
46
|
+
## Quickstart
|
|
88
47
|
|
|
89
48
|
1. **Scan + inject** — run once, from your app's directory:
|
|
90
|
-
|
|
91
49
|
```bash
|
|
92
50
|
npx sparda-mcp init
|
|
93
51
|
```
|
|
94
|
-
|
|
95
52
|
SPARDA parses your routes (AST), generates a marked `/mcp` router, injects it into
|
|
96
53
|
your app (with a backup), and writes `sparda.json`. Every step is reversible.
|
|
97
54
|
|
|
98
55
|
2. **Start your app, then start the bridge:**
|
|
99
|
-
|
|
100
56
|
```bash
|
|
101
57
|
npx sparda-mcp dev
|
|
102
58
|
```
|
|
@@ -120,31 +76,25 @@ Beyond proving, SPARDA can turn your running app into a live MCP server — the
|
|
|
120
76
|
## Try the Standalone Demo
|
|
121
77
|
|
|
122
78
|
To see SPARDA in action instantly without modifying your codebase:
|
|
123
|
-
|
|
124
79
|
```bash
|
|
125
80
|
npx sparda-mcp demo
|
|
126
81
|
```
|
|
127
|
-
|
|
128
82
|
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.
|
|
129
83
|
|
|
130
84
|
## Black Box Report
|
|
131
85
|
|
|
132
86
|
SPARDA is designed as a local organism. To see what it remembers and how much compute it has recycled:
|
|
133
|
-
|
|
134
87
|
```bash
|
|
135
88
|
npx sparda-mcp report
|
|
136
89
|
```
|
|
137
|
-
|
|
138
90
|
This prints a terminal dashboard aggregating your exposed tools, write opt-ins, proof journal decisions, and crystallized composite tools.
|
|
139
91
|
|
|
140
92
|
To write a self-contained, offline HTML dashboard at `.sparda/report.html`, append the `--html` flag:
|
|
141
|
-
|
|
142
93
|
```bash
|
|
143
94
|
npx sparda-mcp report --html
|
|
144
95
|
```
|
|
145
96
|
|
|
146
97
|
To output raw JSON for integration:
|
|
147
|
-
|
|
148
98
|
```bash
|
|
149
99
|
npx sparda-mcp report --json
|
|
150
100
|
```
|
|
@@ -152,37 +102,28 @@ npx sparda-mcp report --json
|
|
|
152
102
|
## Deployment Proof: Apocalypse
|
|
153
103
|
|
|
154
104
|
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
|
-
|
|
156
105
|
```bash
|
|
157
106
|
npx sparda-mcp apocalypse
|
|
158
107
|
```
|
|
159
|
-
|
|
160
108
|
This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
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.
|
|
169
114
|
|
|
170
115
|
To save your current graph as a safe baseline:
|
|
171
|
-
|
|
172
116
|
```bash
|
|
173
117
|
npx sparda-mcp apocalypse --save-baseline
|
|
174
118
|
```
|
|
175
|
-
|
|
176
119
|
Subsequent runs will diff the candidate graph against this baseline to detect regression vectors:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
- API blast radius expansion (Medium).
|
|
120
|
+
* Deletion of any security `guard` (Critical).
|
|
121
|
+
* Deletion of a database SQL invariant (High).
|
|
122
|
+
* API blast radius expansion (Medium).
|
|
181
123
|
|
|
182
124
|
If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
|
|
183
125
|
|
|
184
126
|
**One step in your workflow — findings land in the GitHub Security tab (SARIF):**
|
|
185
|
-
|
|
186
127
|
```yaml
|
|
187
128
|
- uses: zyx77550/sparda@main
|
|
188
129
|
with:
|
|
@@ -200,13 +141,11 @@ npx sparda-mcp timeless export <id> # the production bug is now a vitest test
|
|
|
200
141
|
```
|
|
201
142
|
|
|
202
143
|
Recording is two lines in your app (ESM), with deterministic sampling and GDPR redaction built in:
|
|
203
|
-
|
|
204
144
|
```js
|
|
205
145
|
import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
|
|
206
|
-
const box = getFlightBox();
|
|
207
|
-
box.
|
|
208
|
-
|
|
209
|
-
const db = box.wrapClient(pgPool); // your query client, tapped
|
|
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
|
|
210
149
|
```
|
|
211
150
|
|
|
212
151
|
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).
|
|
@@ -223,7 +162,7 @@ npx sparda-mcp heal <flightId> --check --expect '{"status":404}'
|
|
|
223
162
|
|
|
224
163
|
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:
|
|
225
164
|
|
|
226
|
-
1. **Behavior** — lenient replay of the recorded flight (same deterministic inputs) now produces the
|
|
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.
|
|
227
166
|
2. **Compiler laws** — `verify` still passes: the graph is still sound and deterministic.
|
|
228
167
|
3. **No regression** — `apocalypse` diff against the frozen pre-fix graph: zero new critical/high findings, no guard removed, no blast radius grown.
|
|
229
168
|
|
|
@@ -231,11 +170,11 @@ The brief is built from the graph itself — it hands the fixer the handler's `f
|
|
|
231
170
|
✓ HEALED & PROVEN — same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.
|
|
232
171
|
```
|
|
233
172
|
|
|
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
|
|
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.
|
|
235
174
|
|
|
236
175
|
## Any Backend On Earth: OpenAPI Lowering
|
|
237
176
|
|
|
238
|
-
SPARDA parses Express, FastAPI
|
|
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.
|
|
239
178
|
|
|
240
179
|
```bash
|
|
241
180
|
npx sparda-mcp ubg --openapi openapi.json
|
|
@@ -276,7 +215,7 @@ To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte
|
|
|
276
215
|
5. **Nothing leaves your machine.** No telemetry to us, no cloud, local key auth, 4 exact-pinned dependencies.
|
|
277
216
|
6. **What it learns is never lost.** Diagnoses, descriptions, settings — versioned with your git, surviving every re-init.
|
|
278
217
|
|
|
279
|
-
What we
|
|
218
|
+
What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
|
|
280
219
|
|
|
281
220
|
## How it works
|
|
282
221
|
|
|
@@ -293,52 +232,34 @@ What we _don't_ promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
|
|
|
293
232
|
## What SPARDA gives your AI
|
|
294
233
|
|
|
295
234
|
### Operate, not just read
|
|
296
|
-
|
|
297
235
|
Every route becomes a tool that runs against your live process — real auth, real data,
|
|
298
236
|
warm connections. One call to **`sparda_get_context`** hands the AI the whole living
|
|
299
237
|
picture: enabled tools, suggested workflows, runtime telemetry, quarantine state, and
|
|
300
238
|
immune memory — so every session resumes where the last one stopped.
|
|
301
239
|
|
|
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
|
-
|
|
314
240
|
### Write-safety: the AI can't write until you say so
|
|
315
|
-
|
|
316
241
|
- Writes (POST/PUT/DELETE) ship **disabled**. Enable them per tool in `sparda.json`; your choice survives every re-init.
|
|
317
242
|
- 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.
|
|
318
243
|
- When your client supports MCP elicitation, that confirmation prompt appears **in the AI's own UI**.
|
|
319
244
|
- **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.
|
|
320
245
|
|
|
321
246
|
### Your app defends itself — zero LLM on the hot path
|
|
322
|
-
|
|
323
247
|
- **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.
|
|
324
248
|
- **Latency & anomaly flags.** The router learns each route's baseline and flags deviations locally, in a few lines of math.
|
|
325
249
|
- **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.
|
|
326
250
|
|
|
327
251
|
### A free intelligence layer, zero API key
|
|
328
|
-
|
|
329
252
|
On first connection your AI client's own model (via MCP sampling) rewrites raw routes
|
|
330
253
|
into business-language tool descriptions and proposes multi-step workflows — cached in
|
|
331
254
|
`sparda.json` and exposed as MCP prompts. Nothing to configure, nothing to pay.
|
|
332
255
|
|
|
333
256
|
### It gets cheaper the more you use it
|
|
334
|
-
|
|
335
257
|
- **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.
|
|
336
258
|
- **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.
|
|
337
259
|
|
|
338
260
|
### Tools nobody wrote — Labs, opt-in, default OFF
|
|
339
|
-
|
|
340
261
|
Turn it on with `"labs": { "recordSequences": true }` in `sparda.json`. SPARDA then
|
|
341
|
-
notices when one tool's output feeds the next tool's input and records the
|
|
262
|
+
notices when one tool's output feeds the next tool's input and records the *circuit* —
|
|
342
263
|
structure only (tool names, argument names, counts), never your data. A read-only
|
|
343
264
|
circuit seen enough times **crystallizes into a composite tool**, announced
|
|
344
265
|
mid-session: one call runs the whole chain, auto-feeding each step from the previous
|
|
@@ -346,13 +267,11 @@ step's real response. Write routes are never absorbed — their per-call confirm
|
|
|
346
267
|
always stands.
|
|
347
268
|
|
|
348
269
|
### Living context & telemetry
|
|
349
|
-
|
|
350
270
|
`GET /mcp/stats` (per-tool calls/errors, tool "purity", quarantine state) and
|
|
351
271
|
`GET /mcp/events` (errors, latency anomalies, cached diagnoses) expose exactly what
|
|
352
272
|
your app is doing — surfaced to the AI as live notifications.
|
|
353
273
|
|
|
354
274
|
## Built for AI clients: the bundled Skill
|
|
355
|
-
|
|
356
275
|
SPARDA ships with an Agent Skill ([`SKILL.md`](./SKILL.md)) that teaches any compatible
|
|
357
276
|
AI client how to drive a SPARDA server to its **full potential** — call
|
|
358
277
|
`sparda_get_context` first, exploit response recycling, honor quarantine, prefer
|
|
@@ -363,20 +282,13 @@ runtime, so the guidance never goes stale.
|
|
|
363
282
|
## Supported frameworks
|
|
364
283
|
|
|
365
284
|
- **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.
|
|
366
|
-
- **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports.
|
|
367
|
-
- **
|
|
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).
|
|
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).
|
|
369
287
|
- **MedusaJS** — Native AST ingestion of complex e-commerce routing.
|
|
370
288
|
- **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
|
|
371
289
|
- **FastAPI** (Python >= 3.9) — AST-based router injection.
|
|
372
290
|
|
|
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
|
-
|
|
378
291
|
## Security posture (honest)
|
|
379
|
-
|
|
380
292
|
- 4 runtime dependencies, exact-pinned.
|
|
381
293
|
- **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.
|
|
382
294
|
- Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
|
|
@@ -386,21 +298,18 @@ runtime, so the guidance never goes stale.
|
|
|
386
298
|
Full threat model and known gaps: [docs/SECURITY.md](./docs/SECURITY.md).
|
|
387
299
|
|
|
388
300
|
## Documentation
|
|
389
|
-
|
|
390
301
|
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — how `init`, the injected router, and the bridge fit together, plus the `sparda.json` schema.
|
|
391
302
|
- [docs/SECURITY.md](./docs/SECURITY.md) — threat model, defenses, and honest known gaps.
|
|
392
303
|
- [docs/TESTING.md](./docs/TESTING.md) — how the promises above are kept honest in CI.
|
|
393
304
|
- [docs/ERRORS.md](./docs/ERRORS.md) — the error knowledge base.
|
|
394
305
|
|
|
395
306
|
## Beyond the open core
|
|
396
|
-
|
|
397
307
|
SPARDA is free, including in production (see License). Team-scale capabilities —
|
|
398
308
|
fine-grained per-person access policies and a signed, tamper-evident audit log — are
|
|
399
309
|
planned for a future paid tier. The open core stands on its own; nothing here is
|
|
400
310
|
crippled to upsell you.
|
|
401
311
|
|
|
402
312
|
## License
|
|
403
|
-
|
|
404
313
|
[Business Source License 1.1](./LICENSE) — free to use, including in production.
|
|
405
314
|
You may not resell SPARDA or offer it as a competing commercial service.
|
|
406
315
|
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:
|
|
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
|
|
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,45 +35,19 @@ 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.
|
|
41
38
|
- **Composite tools** — labelled `[Labs circuit ×N]`, `readOnly`. One call runs a
|
|
42
|
-
whole proven multi-step chain (see
|
|
39
|
+
whole proven multi-step chain (see *Crystallized circuits* below).
|
|
43
40
|
|
|
44
41
|
Only **enabled** tools appear. Write tools are hidden until the user opts in, so a
|
|
45
|
-
missing write is a config state, not an error — see
|
|
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.
|
|
42
|
+
missing write is a config state, not an error — see *Writing safely*.
|
|
68
43
|
|
|
69
44
|
## Exploit the intelligence layer (this is the "full potential")
|
|
70
45
|
|
|
71
46
|
**1. Response-recycling flywheel — make repeated reads free.**
|
|
72
|
-
When the
|
|
47
|
+
When the *same* read tool returns a byte-identical result for the *same* arguments
|
|
73
48
|
**3 times within 30 seconds**, SPARDA serves the next identical call straight from
|
|
74
49
|
RAM (`servedByFlywheel: true`) **without touching the host app**. So:
|
|
75
|
-
|
|
76
|
-
- Don't fear repeating stable GETs — repetition is what _activates_ the cache.
|
|
50
|
+
- Don't fear repeating stable GETs — repetition is what *activates* the cache.
|
|
77
51
|
- Don't bolt your own client-side cache on top; you'd hide the signal that lets
|
|
78
52
|
SPARDA recycle, and you'd lose freshness control.
|
|
79
53
|
- Watch `recycling.flywheel.servedFromMemory` climb in context — that's free work.
|
|
@@ -81,7 +55,7 @@ RAM (`servedByFlywheel: true`) **without touching the host app**. So:
|
|
|
81
55
|
|
|
82
56
|
**2. Circuit-breaker / quarantine — stop hammering a sick backend.**
|
|
83
57
|
After **3 consecutive 5xx** on a tool, SPARDA quarantines it: subsequent calls
|
|
84
|
-
return **HTTP 503** with `reason` and `retryInMs`
|
|
58
|
+
return **HTTP 503** with `reason` and `retryInMs` *instead of* hitting the failing
|
|
85
59
|
host. Honor `retryInMs` — do not retry-loop. Check `runtime.quarantine` in context
|
|
86
60
|
before depending on a tool. After a cooldown (~60s) the tool half-opens for one
|
|
87
61
|
probe; one more 5xx re-quarantines it.
|
|
@@ -94,7 +68,7 @@ single **composite tool** for that chain and announces it mid-session via
|
|
|
94
68
|
call and it's marked read-only. (Writes are never absorbed into a circuit.)
|
|
95
69
|
|
|
96
70
|
**4. Adaptive immunity — read the diagnosis before retrying.**
|
|
97
|
-
Repeated, unfamiliar failures trigger a
|
|
71
|
+
Repeated, unfamiliar failures trigger a *one-shot* LLM diagnosis that SPARDA caches
|
|
98
72
|
as an "antibody" (keyed by `source|tool|status`). Recurrences reuse the cached
|
|
99
73
|
diagnosis at zero cost. When an error event carries a diagnosis, **read it** and
|
|
100
74
|
adapt — don't blindly retry the same call.
|
|
@@ -104,13 +78,11 @@ an `immune` event in `/mcp/events`. Treat it as a hint to back off or warn the u
|
|
|
104
78
|
|
|
105
79
|
**6. Twin Simulation Mode — practice safely on a clone.**
|
|
106
80
|
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
|
-
|
|
108
81
|
- All GET reads return learned exemplars (observed response shapes and mock values).
|
|
109
82
|
- All write tools return simulated `202` echoes but do not write to database or external APIs.
|
|
110
83
|
- Use this twin mode to practice multi-step workflows, debug tool sequences, and test your plans without touching the live production backend.
|
|
111
84
|
|
|
112
85
|
**7. Grammar & Evolution — discover optimal workflows.**
|
|
113
|
-
|
|
114
86
|
- You can query or contribute to the app's grammar (`.sparda/grammar.json`). The grammar maps valid sequences of tool calls (edges).
|
|
115
87
|
- Running `sparda evolve` mutates and runs candidate chains against the twin. The successful evolved sequences are suggested as mid-session workflows.
|
|
116
88
|
|
|
@@ -179,8 +151,7 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
179
151
|
- **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
|
|
180
152
|
|
|
181
153
|
---
|
|
182
|
-
|
|
183
|
-
_This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
|
|
154
|
+
*This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
|
|
184
155
|
surface each release, so it tracks new tools and behaviors. The **live, per-project**
|
|
185
156
|
tool list, stats, and workflows always come from `sparda_get_context` at runtime —
|
|
186
|
-
trust it over any static list
|
|
157
|
+
trust it over any static list.*
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
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
|
|
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": "
|
|
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,8 @@
|
|
|
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"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"src",
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -410,6 +410,17 @@ export function checkGraph(graph) {
|
|
|
410
410
|
obligations++;
|
|
411
411
|
if (observables.length && writes.length) {
|
|
412
412
|
let bad = false;
|
|
413
|
+
// ONE finding per route, not one per effect NODE. A single logical send resolved
|
|
414
|
+
// through a provider-strategy DI graph becomes many nodes — twenty's
|
|
415
|
+
// `POST /graphql/sendEmail` fans out to Gmail / Microsoft / IMAP-SMTP / email-group
|
|
416
|
+
// senders and reported the SAME missing compensation path 12 times, 12 of the app's
|
|
417
|
+
// 28 high findings. Nothing is dropped: every call is named in the message and kept
|
|
418
|
+
// in `evidence`, the severity is the strongest of them, and the gate reads exactly
|
|
419
|
+
// as before. This is `collapseFloods`'s "loss of contrast" argument (ADR-071) at the
|
|
420
|
+
// ROUTE level — the remediation is per route (add a compensation path), never per
|
|
421
|
+
// call, so per call was never the honest unit (E-094).
|
|
422
|
+
const dangerous = [];
|
|
423
|
+
const generic = [];
|
|
413
424
|
for (const obs of observables) {
|
|
414
425
|
if (obs.meta.compensable) continue;
|
|
415
426
|
if (g.compensators.has(obs.id)) continue; // the undo itself is not a risk
|
|
@@ -424,17 +435,29 @@ export function checkGraph(graph) {
|
|
|
424
435
|
// WITHOUT hiding a hole: the unknown call stays surfaced (advisory), at the honest
|
|
425
436
|
// confidence level (we cannot prove a generic fetch is irreversible), never silenced.
|
|
426
437
|
const knownDangerous = String(obs.meta.target ?? '').startsWith('sdk:');
|
|
438
|
+
(knownDangerous ? dangerous : generic).push(obs);
|
|
439
|
+
if (knownDangerous) bad = true;
|
|
440
|
+
}
|
|
441
|
+
// A route with BOTH kinds reports once, at the hard severity: the generic calls are
|
|
442
|
+
// already the weaker claim, and splitting them out would put the same route on two
|
|
443
|
+
// lines saying the same thing twice.
|
|
444
|
+
const flagged = dangerous.length ? dangerous : generic;
|
|
445
|
+
if (flagged.length) {
|
|
446
|
+
const hard = dangerous.length > 0;
|
|
447
|
+
const names = [...new Set(flagged.map((o) => o.meta.target ?? o.label))].sort();
|
|
448
|
+
const list =
|
|
449
|
+
names.slice(0, 4).join(', ') +
|
|
450
|
+
(names.length > 4 ? `, +${names.length - 4} more` : '');
|
|
427
451
|
findings.push({
|
|
428
452
|
rule: 'IRREVERSIBLE_OBSERVABLE',
|
|
429
|
-
severity:
|
|
430
|
-
...(
|
|
453
|
+
severity: hard ? 'high' : 'info',
|
|
454
|
+
...(hard ? {} : { advisory: true }),
|
|
431
455
|
entrypoint: ep.id,
|
|
432
|
-
message:
|
|
433
|
-
? `${ep.label} makes an irreversible external call
|
|
434
|
-
: `${ep.label} makes an external call
|
|
435
|
-
evidence:
|
|
456
|
+
message: hard
|
|
457
|
+
? `${ep.label} makes ${names.length === 1 ? 'an irreversible external call' : `${names.length} irreversible external calls`} (${list}) while also mutating state — no compensation path exists if the write fails`
|
|
458
|
+
: `${ep.label} makes ${names.length === 1 ? 'an external call' : `${names.length} external calls`} (${list}) while also mutating state — if any is irreversible (payment/mail/…), add a compensation path; a generic fetch/read is fine`,
|
|
459
|
+
evidence: flagged.map((o) => `${o.id} (${locOf(o)})`).sort(),
|
|
436
460
|
});
|
|
437
|
-
if (knownDangerous) bad = true;
|
|
438
461
|
}
|
|
439
462
|
vec.reversibility = bad ? -1 : 1;
|
|
440
463
|
}
|
package/src/ubg/extract.js
CHANGED
|
@@ -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
|
@@ -65,8 +65,17 @@ const VERB_DECORATOR =
|
|
|
65
65
|
/^(?:http)?(get|post|put|patch|delete|options|head|all)(?:mapping)?$/i;
|
|
66
66
|
// cheap file pre-filter: a route decorator or a controller/resolver brand must appear
|
|
67
67
|
// textually before we pay for a full parse (keeps the twenty-scale monorepo fast).
|
|
68
|
+
// A house brand is the norm, not the exception: twenty registers 54 GraphQL resolver
|
|
69
|
+
// classes as `@MetadataResolver` / `@CoreResolver` / `@AdminResolver` and exactly ONE as
|
|
70
|
+
// `@Resolver`. A fixed vocabulary matched 33 of its 6090 files; matching the SUFFIX
|
|
71
|
+
// matches 128 — the other 95 were route-bearing files SPARDA never opened, so their
|
|
72
|
+
// routes produced no route, no skip and no unknown handler (E-092).
|
|
73
|
+
// Deliberately NOT widened to `Mutation|Query|Subscription`: those are also PARAMETER
|
|
74
|
+
// decorators (`@Query('id') id: string`) in ordinary REST controllers, and they buy one
|
|
75
|
+
// extra file out of 6090 — a class that registers a GraphQL operation carries a
|
|
76
|
+
// Resolver-suffixed brand, which is what the suffix already catches.
|
|
68
77
|
const CANDIDATE_RE =
|
|
69
|
-
/@(?:Controller|
|
|
78
|
+
/@(?:[A-Za-z]*Controller|[A-Za-z]*Resolver|(?:Http)?(?:Get|Post|Put|Patch|Delete|Options|Head|All)(?:Mapping)?)\b/;
|
|
70
79
|
|
|
71
80
|
// → { routes, globalMiddlewares, helpers, skipped, unknownHandlers, scannedFiles }
|
|
72
81
|
export function extractNest(cwd, entryDir) {
|
|
@@ -179,7 +188,7 @@ export function extractNest(cwd, entryDir) {
|
|
|
179
188
|
traverse(mod.ast, {
|
|
180
189
|
ClassDeclaration(p) {
|
|
181
190
|
const cls = p.node;
|
|
182
|
-
const resolver =
|
|
191
|
+
const resolver = resolverBrandOf(cls);
|
|
183
192
|
// Structural admission (ADR-055): a class is a route source if it is a REST
|
|
184
193
|
// controller by decorator (any brand), a GraphQL @Resolver, OR simply carries a
|
|
185
194
|
// method with an HTTP-verb decorator — the last case catches a framework whose
|
|
@@ -290,18 +299,24 @@ export function extractNest(cwd, entryDir) {
|
|
|
290
299
|
});
|
|
291
300
|
}
|
|
292
301
|
const verbs = http.method === 'all' ? NEST_ALL_EXPANSION : [http.method];
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
302
|
+
// a decorator may register several paths (`@Post(['a','b'])`) — one route each,
|
|
303
|
+
// exactly as `@All` expands into one route per verb
|
|
304
|
+
const paths = http.paths
|
|
305
|
+
? http.paths.map((pp) => joinPath(prefix, pp))
|
|
306
|
+
: [fullPath];
|
|
307
|
+
for (const verb of verbs)
|
|
308
|
+
for (const routePath of paths) {
|
|
309
|
+
routes.push({
|
|
310
|
+
method: verb,
|
|
311
|
+
path: routePath,
|
|
312
|
+
sourceFile: rel,
|
|
313
|
+
sourceLine: m.loc?.start.line ?? 0,
|
|
314
|
+
params: pathParamsOf(routePath),
|
|
315
|
+
chain,
|
|
316
|
+
description: '',
|
|
317
|
+
authOptOut: http.optOut === true, // temp — consumed by the posture pass
|
|
318
|
+
});
|
|
319
|
+
}
|
|
305
320
|
}
|
|
306
321
|
},
|
|
307
322
|
});
|
|
@@ -353,20 +368,6 @@ function applyAuthPosture(routes) {
|
|
|
353
368
|
|
|
354
369
|
// --- decorator readers ------------------------------------------------------
|
|
355
370
|
|
|
356
|
-
// the first string-literal arg of decorator `@Name(...)`, or undefined if the
|
|
357
|
-
// decorator is absent. Returns { value } (value may be '' for a bare `@Name()`).
|
|
358
|
-
function decoratorArg(decorators, name) {
|
|
359
|
-
for (const d of decorators ?? []) {
|
|
360
|
-
const call = d.expression;
|
|
361
|
-
if (call.type === 'CallExpression' && idName(call.callee) === name) {
|
|
362
|
-
const a0 = call.arguments[0];
|
|
363
|
-
return { value: a0?.type === 'StringLiteral' ? a0.value : '' };
|
|
364
|
-
}
|
|
365
|
-
if (call.type === 'Identifier' && call.name === name) return { value: '' };
|
|
366
|
-
}
|
|
367
|
-
return undefined;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
371
|
// An auth-opt-OUT flag in a route decorator's options object: `@Get('/x', { skipAuth:
|
|
371
372
|
// true })` (n8n), `{ authenticate: false }`, `{ public: true }`. Its very EXISTENCE in
|
|
372
373
|
// an app is the signal that the app is guarded-BY-DEFAULT (why carry an opt-out unless
|
|
@@ -396,13 +397,31 @@ function httpDecorator(decorators) {
|
|
|
396
397
|
}
|
|
397
398
|
}
|
|
398
399
|
const pathArg = args[0];
|
|
399
|
-
// a
|
|
400
|
-
//
|
|
401
|
-
|
|
400
|
+
// `@Post(['a', 'b'])` is ONE decorator registering TWO routes — Nest serves every
|
|
401
|
+
// element. Reading only `args[0]` saw an ArrayExpression, judged it "not a literal",
|
|
402
|
+
// and fell back to the controller prefix: twenty's four array-pathed webhook
|
|
403
|
+
// controllers all collapsed onto a single phantom `POST /`, and the two findings that
|
|
404
|
+
// hold its verdict were reported against a route the app does not serve (E-093).
|
|
405
|
+
// Taking only the FIRST element would be worse than the collapse — it loses a live
|
|
406
|
+
// endpoint in silence, which the registration invariant forbids (ADR-079).
|
|
407
|
+
const elements =
|
|
408
|
+
pathArg?.type === 'ArrayExpression' ? pathArg.elements.filter(Boolean) : null;
|
|
409
|
+
const literals = (elements ?? (pathArg ? [pathArg] : [])).filter(
|
|
410
|
+
(e) => e.type === 'StringLiteral',
|
|
411
|
+
);
|
|
412
|
+
// a path argument that exists but is not a literal is a path we cannot read —
|
|
413
|
+
// distinct from no argument at all, which legitimately means "the prefix". In an
|
|
414
|
+
// array, an unreadable ELEMENT is its own lost route, so the doubt survives even
|
|
415
|
+
// when its siblings read cleanly.
|
|
416
|
+
const unreadable = (elements ?? (pathArg ? [pathArg] : [])).filter(
|
|
417
|
+
(e) => e.type !== 'StringLiteral',
|
|
418
|
+
);
|
|
402
419
|
return {
|
|
403
420
|
method: m[1].toLowerCase(),
|
|
404
|
-
path:
|
|
405
|
-
|
|
421
|
+
path: literals[0]?.value ?? '',
|
|
422
|
+
// every path this decorator registers, in source order — one route each
|
|
423
|
+
paths: literals.length > 1 ? literals.map((e) => e.value) : undefined,
|
|
424
|
+
...(unreadable.length ? { pathDynamic: true } : {}),
|
|
406
425
|
optOut,
|
|
407
426
|
};
|
|
408
427
|
}
|
|
@@ -437,6 +456,22 @@ function controllerPrefixOf(cls) {
|
|
|
437
456
|
return null;
|
|
438
457
|
}
|
|
439
458
|
|
|
459
|
+
// The class's GraphQL-resolver brand, read by SUFFIX for the same reason
|
|
460
|
+
// `controllerPrefixOf` reads controllers by suffix (ADR-055): the protocol is universal,
|
|
461
|
+
// the decorator name is the house's. `@MetadataResolver(() => WorkspaceEntity)` is a
|
|
462
|
+
// resolver; an exact-name check for `Resolver` saw none of twenty's 54.
|
|
463
|
+
// Returns `{ value }` (the shape the caller already consumes) or undefined.
|
|
464
|
+
function resolverBrandOf(cls) {
|
|
465
|
+
for (const d of cls.decorators ?? []) {
|
|
466
|
+
const call = d.expression;
|
|
467
|
+
const name = call.type === 'CallExpression' ? idName(call.callee) : idName(call);
|
|
468
|
+
if (!name || !/resolver$/i.test(name)) continue;
|
|
469
|
+
const a0 = call.type === 'CallExpression' ? call.arguments[0] : null;
|
|
470
|
+
return { value: a0?.type === 'StringLiteral' ? a0.value : '' };
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
440
475
|
// A GraphQL operation is the same behavior spine as an HTTP route: @Query/@Subscription
|
|
441
476
|
// READ, @Mutation CHANGES STATE. We map them onto the graph's verbs (get = read,
|
|
442
477
|
// post = mutating) so every downstream pass — guard proof, blast radius, polarity —
|
package/src/ubg/resolve.js
CHANGED
|
@@ -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
|
|
415
|
-
if (!
|
|
416
|
-
return classMethodBundle(cls,
|
|
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
|