sparda-mcp 0.62.0 → 0.64.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 CHANGED
@@ -35,42 +35,47 @@ npx sparda-mcp badge # a README badge: proven · coverage% · routes
35
35
  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.
36
36
 
37
37
  > [!IMPORTANT]
38
- > **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:
38
+ > **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:
39
+ >
39
40
  > ```bash
40
41
  > node bench/repro.mjs # → bench/route-proof.json
41
42
  > ```
42
- > 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.)
43
+ >
44
+ > 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.)
43
45
 
44
46
  **What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
45
47
 
46
- | Command | What it does |
47
- |---|---|
48
- | **`prove`** | *The whole trust verdict in one gesture* — proof + coverage + a shareable seal (`--json` / `--markdown`) |
49
- | **`apocalypse`** | *Prove the deploy* — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
50
- | **`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. |
51
- | **`badge`** | *The shareable artifact* — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
52
- | **`dossier`** | *The public report* — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
53
- | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
54
- | **`timeless`** | *Time-travel* — record a production request, replay it byte-identically, export the bug as a test |
55
- | **`mirror`** | *Execute the graph* — serve the compiled behavior over HTTP with no framework and no source |
56
- | **`init` / `dev`** | *Runtime, optional* — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
48
+ | Command | What it does |
49
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + a shareable seal (`--json` / `--markdown`) |
51
+ | **`apocalypse`** | _Prove the deploy_ — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
52
+ | **`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. |
53
+ | **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
54
+ | **`dossier`** | _The public report_ — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
55
+ | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
56
+ | **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
57
+ | **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
58
+ | **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
57
59
 
58
- 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.
60
+ 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.
59
61
 
60
- **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.
62
+ **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.
61
63
 
62
64
  ## Optional: expose the graph to AI clients (MCP runtime)
63
65
 
64
66
  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.
65
67
 
66
68
  1. **Scan + inject** — run once, from your app's directory:
69
+
67
70
  ```bash
68
71
  npx sparda-mcp init
69
72
  ```
73
+
70
74
  SPARDA parses your routes (AST), generates a marked `/mcp` router, injects it into
71
75
  your app (with a backup), and writes `sparda.json`. Every step is reversible.
72
76
 
73
77
  2. **Start your app, then start the bridge:**
78
+
74
79
  ```bash
75
80
  npx sparda-mcp dev
76
81
  ```
@@ -94,25 +99,31 @@ Beyond proving, SPARDA can turn your running app into a live MCP server — the
94
99
  ## Try the Standalone Demo
95
100
 
96
101
  To see SPARDA in action instantly without modifying your codebase:
102
+
97
103
  ```bash
98
104
  npx sparda-mcp demo
99
105
  ```
106
+
100
107
  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.
101
108
 
102
109
  ## Black Box Report
103
110
 
104
111
  SPARDA is designed as a local organism. To see what it remembers and how much compute it has recycled:
112
+
105
113
  ```bash
106
114
  npx sparda-mcp report
107
115
  ```
116
+
108
117
  This prints a terminal dashboard aggregating your exposed tools, write opt-ins, proof journal decisions, and crystallized composite tools.
109
118
 
110
119
  To write a self-contained, offline HTML dashboard at `.sparda/report.html`, append the `--html` flag:
120
+
111
121
  ```bash
112
122
  npx sparda-mcp report --html
113
123
  ```
114
124
 
115
125
  To output raw JSON for integration:
126
+
116
127
  ```bash
117
128
  npx sparda-mcp report --json
118
129
  ```
@@ -120,28 +131,35 @@ npx sparda-mcp report --json
120
131
  ## Deployment Proof: Apocalypse
121
132
 
122
133
  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:
134
+
123
135
  ```bash
124
136
  npx sparda-mcp apocalypse
125
137
  ```
138
+
126
139
  This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
127
- * **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
128
- * **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
129
- * **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).
130
- * **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).
131
- * **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
140
+
141
+ - **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
142
+ - **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
143
+ - **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).
144
+ - **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).
145
+ - **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
132
146
 
133
147
  To save your current graph as a safe baseline:
148
+
134
149
  ```bash
135
150
  npx sparda-mcp apocalypse --save-baseline
136
151
  ```
152
+
137
153
  Subsequent runs will diff the candidate graph against this baseline to detect regression vectors:
138
- * Deletion of any security `guard` (Critical).
139
- * Deletion of a database SQL invariant (High).
140
- * API blast radius expansion (Medium).
154
+
155
+ - Deletion of any security `guard` (Critical).
156
+ - Deletion of a database SQL invariant (High).
157
+ - API blast radius expansion (Medium).
141
158
 
142
159
  If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
143
160
 
144
161
  **One step in your workflow — findings land in the GitHub Security tab (SARIF):**
162
+
145
163
  ```yaml
146
164
  - uses: zyx77550/sparda@main
147
165
  with:
@@ -159,11 +177,13 @@ npx sparda-mcp timeless export <id> # the production bug is now a vitest test
159
177
  ```
160
178
 
161
179
  Recording is two lines in your app (ESM), with deterministic sampling and GDPR redaction built in:
180
+
162
181
  ```js
163
182
  import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
164
- const box = getFlightBox(); box.arm();
165
- app.use(box.middleware({ sample: 100 })); // 1 request in 100; passwords/tokens redacted by default
166
- const db = box.wrapClient(pgPool); // your query client, tapped
183
+ const box = getFlightBox();
184
+ box.arm();
185
+ app.use(box.middleware({ sample: 100 })); // 1 request in 100; passwords/tokens redacted by default
186
+ const db = box.wrapClient(pgPool); // your query client, tapped
167
187
  ```
168
188
 
169
189
  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).
@@ -180,7 +200,7 @@ npx sparda-mcp heal <flightId> --check --expect '{"status":404}'
180
200
 
181
201
  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:
182
202
 
183
- 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.
203
+ 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.
184
204
  2. **Compiler laws** — `verify` still passes: the graph is still sound and deterministic.
185
205
  3. **No regression** — `apocalypse` diff against the frozen pre-fix graph: zero new critical/high findings, no guard removed, no blast radius grown.
186
206
 
@@ -188,7 +208,7 @@ The brief is built from the graph itself — it hands the fixer the handler's `f
188
208
  ✓ HEALED & PROVEN — same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.
189
209
  ```
190
210
 
191
- 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.
211
+ 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.
192
212
 
193
213
  ## Any Backend On Earth: OpenAPI Lowering
194
214
 
@@ -233,7 +253,7 @@ To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte
233
253
  5. **Nothing leaves your machine.** No telemetry to us, no cloud, local key auth, 4 exact-pinned dependencies.
234
254
  6. **What it learns is never lost.** Diagnoses, descriptions, settings — versioned with your git, surviving every re-init.
235
255
 
236
- What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
256
+ What we _don't_ promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
237
257
 
238
258
  ## How it works
239
259
 
@@ -250,34 +270,52 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
250
270
  ## What SPARDA gives your AI
251
271
 
252
272
  ### Operate, not just read
273
+
253
274
  Every route becomes a tool that runs against your live process — real auth, real data,
254
275
  warm connections. One call to **`sparda_get_context`** hands the AI the whole living
255
276
  picture: enabled tools, suggested workflows, runtime telemetry, quarantine state, and
256
277
  immune memory — so every session resumes where the last one stopped.
257
278
 
279
+ ### Prove the edit before you commit — the one check an LLM can't do to itself
280
+
281
+ The AI just edited a route. Did it quietly drop a guard? It calls **`sparda_prove`** and
282
+ finds out **now**, not in a CI run later. The tool recompiles the app to its behavior graph,
283
+ discharges the same static obligations as `sparda apocalypse`, and returns a deterministic
284
+ verdict — the exact word the CLI and badge emit, so it can never over-claim (a low-coverage
285
+ clean app reads `SURFACE`, never a bare `PROVEN`). Save a baseline once
286
+ (`sparda apocalypse --save-baseline`) and every later `sparda_prove` flags any finding with
287
+ `regression: true` — the guard your edit removed, the route it dropped, the blast radius it
288
+ grew. That's _"AI writes. SPARDA proves."_ inside the edit loop. Clients that list MCP prompts
289
+ also get the **`prove-my-edit`** workflow.
290
+
258
291
  ### Write-safety: the AI can't write until you say so
292
+
259
293
  - Writes (POST/PUT/DELETE) ship **disabled**. Enable them per tool in `sparda.json`; your choice survives every re-init.
260
294
  - 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.
261
295
  - When your client supports MCP elicitation, that confirmation prompt appears **in the AI's own UI**.
262
296
  - **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.
263
297
 
264
298
  ### Your app defends itself — zero LLM on the hot path
299
+
265
300
  - **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.
266
301
  - **Latency & anomaly flags.** The router learns each route's baseline and flags deviations locally, in a few lines of math.
267
302
  - **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.
268
303
 
269
304
  ### A free intelligence layer, zero API key
305
+
270
306
  On first connection your AI client's own model (via MCP sampling) rewrites raw routes
271
307
  into business-language tool descriptions and proposes multi-step workflows — cached in
272
308
  `sparda.json` and exposed as MCP prompts. Nothing to configure, nothing to pay.
273
309
 
274
310
  ### It gets cheaper the more you use it
311
+
275
312
  - **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.
276
313
  - **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.
277
314
 
278
315
  ### Tools nobody wrote — Labs, opt-in, default OFF
316
+
279
317
  Turn it on with `"labs": { "recordSequences": true }` in `sparda.json`. SPARDA then
280
- notices when one tool's output feeds the next tool's input and records the *circuit*
318
+ notices when one tool's output feeds the next tool's input and records the _circuit_
281
319
  structure only (tool names, argument names, counts), never your data. A read-only
282
320
  circuit seen enough times **crystallizes into a composite tool**, announced
283
321
  mid-session: one call runs the whole chain, auto-feeding each step from the previous
@@ -285,11 +323,13 @@ step's real response. Write routes are never absorbed — their per-call confirm
285
323
  always stands.
286
324
 
287
325
  ### Living context & telemetry
326
+
288
327
  `GET /mcp/stats` (per-tool calls/errors, tool "purity", quarantine state) and
289
328
  `GET /mcp/events` (errors, latency anomalies, cached diagnoses) expose exactly what
290
329
  your app is doing — surfaced to the AI as live notifications.
291
330
 
292
331
  ## Built for AI clients: the bundled Skill
332
+
293
333
  SPARDA ships with an Agent Skill ([`SKILL.md`](./SKILL.md)) that teaches any compatible
294
334
  AI client how to drive a SPARDA server to its **full potential** — call
295
335
  `sparda_get_context` first, exploit response recycling, honor quarantine, prefer
@@ -307,6 +347,7 @@ runtime, so the guidance never goes stale.
307
347
  - **FastAPI** (Python >= 3.9) — AST-based router injection.
308
348
 
309
349
  ## Security posture (honest)
350
+
310
351
  - 4 runtime dependencies, exact-pinned.
311
352
  - **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.
312
353
  - Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
@@ -316,18 +357,21 @@ runtime, so the guidance never goes stale.
316
357
  Full threat model and known gaps: [docs/SECURITY.md](./docs/SECURITY.md).
317
358
 
318
359
  ## Documentation
360
+
319
361
  - [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — how `init`, the injected router, and the bridge fit together, plus the `sparda.json` schema.
320
362
  - [docs/SECURITY.md](./docs/SECURITY.md) — threat model, defenses, and honest known gaps.
321
363
  - [docs/TESTING.md](./docs/TESTING.md) — how the promises above are kept honest in CI.
322
364
  - [docs/ERRORS.md](./docs/ERRORS.md) — the error knowledge base.
323
365
 
324
366
  ## Beyond the open core
367
+
325
368
  SPARDA is free, including in production (see License). Team-scale capabilities —
326
369
  fine-grained per-person access policies and a signed, tamper-evident audit log — are
327
370
  planned for a future paid tier. The open core stands on its own; nothing here is
328
371
  crippled to upsell you.
329
372
 
330
373
  ## License
374
+
331
375
  [Business Source License 1.1](./LICENSE) — free to use, including in production.
332
376
  You may not resell SPARDA or offer it as a competing commercial service.
333
377
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
6
6
  "type": "module",
@@ -45,8 +45,12 @@ export async function runApocalypse(opts) {
45
45
  const findings = [...staticFindings, ...diffFindings];
46
46
  // the honesty companion: where does the proof stop? (see `sparda blindspots`)
47
47
  const blind = surveyBlindspots(canonical, report);
48
- // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN
49
- const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
48
+ // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN;
49
+ // and any high-risk blind spot pulls a bare PROVEN down to PARTIAL (E-047, the giant-test rung)
50
+ const verdict = verdictOf(findings, canonical, {
51
+ coverage: blind.coverage.ratio,
52
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
53
+ });
50
54
 
51
55
  if (opts.sarif) {
52
56
  const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
@@ -16,7 +16,10 @@ export async function runBadge(opts) {
16
16
  const canonical = canonicalizeGraph(graph);
17
17
  const { findings } = checkGraph(canonical);
18
18
  const blind = surveyBlindspots(canonical, report);
19
- const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
19
+ const verdict = verdictOf(findings, canonical, {
20
+ coverage: blind.coverage.ratio,
21
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
22
+ });
20
23
  const cov = Math.round(blind.coverage.ratio * 100);
21
24
  const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
22
25
 
@@ -21,7 +21,10 @@ export async function runDossier(opts) {
21
21
  const { findings, polarity } = checkGraph(canonical);
22
22
  const capsule = buildCapsule(canonical);
23
23
  const blindspots = surveyBlindspots(canonical, compiled.report);
24
- const verdict = verdictOf(findings, canonical, { coverage: blindspots.coverage.ratio });
24
+ const verdict = verdictOf(findings, canonical, {
25
+ coverage: blindspots.coverage.ratio,
26
+ blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
27
+ });
25
28
 
26
29
  const data = {
27
30
  app: path.basename(path.resolve(opts.cwd)) || 'app',
@@ -48,6 +48,17 @@ export async function runInit(opts) {
48
48
  s.stop(
49
49
  `Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
50
50
  );
51
+ } else {
52
+ s.stop(
53
+ `Stack detected: ${c.cyan(stack.framework)} — but AST parsing is not supported yet.`,
54
+ );
55
+ throw Object.assign(
56
+ new Error(`AST extraction for ${stack.framework} is not yet supported.`),
57
+ {
58
+ code: 'USER',
59
+ hint: `SPARDA detected ${stack.framework}, but currently only supports Express, FastAPI, and Next.js for AST parsing. NestJS and others are on the roadmap!`,
60
+ },
61
+ );
51
62
  }
52
63
 
53
64
  // Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
@@ -19,7 +19,10 @@ export async function runProve(opts) {
19
19
 
20
20
  const { findings, obligations } = checkGraph(canonical);
21
21
  const blind = surveyBlindspots(canonical, report);
22
- const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
22
+ const verdict = verdictOf(findings, canonical, {
23
+ coverage: blind.coverage.ratio,
24
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
25
+ });
23
26
  const capsule = buildCapsule(canonical);
24
27
  const prints = fingerprintGraph(canonical);
25
28
  // one app-level seal: a content address over every route's behavior hash — the same
@@ -62,11 +62,15 @@ export function reviewGraphs(baseGraph, candidateGraph) {
62
62
  // how much of the changed app SPARDA could actually see — and whether this PR moved
63
63
  // that boundary. A green review over a graph the PR made 20% blinder is not the same
64
64
  // as a green review at full sight; the reviewer must be able to tell them apart.
65
- const candCov = surveyBlindspots(candidateGraph).coverage.ratio;
65
+ const candBlind = surveyBlindspots(candidateGraph);
66
+ const candCov = candBlind.coverage.ratio;
66
67
  const baseCov = surveyBlindspots(baseGraph).coverage.ratio;
67
68
 
68
69
  return {
69
- verdict: verdictOf(findings, candidateGraph, { coverage: candCov }),
70
+ verdict: verdictOf(findings, candidateGraph, {
71
+ coverage: candCov,
72
+ blindHigh: candBlind.byRisk.critical + candBlind.byRisk.high,
73
+ }),
70
74
  obligations,
71
75
  findings,
72
76
  endpoints: { added, removed: removedEps },
@@ -26,8 +26,39 @@ import { createSpardaEngine } from './engine.js';
26
26
  import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
27
27
  import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
28
28
  import { resolveSpardaKey } from '../generator/manifest.js';
29
+ import { compileUBG } from '../ubg/compile.js';
30
+ import { canonicalizeGraph } from '../ubg/schema.js';
31
+ import { checkGraph, diffGraphs, verdictOf, verdictState } from '../ubg/apocalypse.js';
32
+ import { surveyBlindspots } from '../ubg/blindspots.js';
29
33
 
30
34
  const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
35
+
36
+ // Built-in MCP prompts (workflows) served by every SPARDA server, regardless of the app's own
37
+ // inferred workflows. `prove-my-edit` is the discoverability surface for `sparda_prove`: the
38
+ // workflow an editing agent lists and follows to check its OWN change before committing — the
39
+ // one check an LLM cannot do to itself by re-reading its code.
40
+ export const BUILTIN_WORKFLOWS = [
41
+ {
42
+ name: 'prove-my-edit',
43
+ description:
44
+ "Prove an edit didn't break a guard before you commit — the check an LLM can't do to itself by re-reading its own code.",
45
+ steps: [
46
+ 'Call sparda_prove. If you just edited one route, pass route with its method+path (e.g. "DELETE /orders") to focus the finding list — the verdict still reflects the whole app.',
47
+ 'Read the verdict: PROVEN / PARTIAL are safe to commit. SURFACE / NO_PROOF mean SPARDA could not resolve enough to prove it — treat that as "unknown", never as a pass.',
48
+ 'Fix every finding with regression:true first — that edit removed a guard, dropped a route, or grew the blast radius vs the last proven baseline.',
49
+ 'If baselined is false, run `sparda apocalypse --save-baseline` once on a known-good state so future sparda_prove calls can catch regressions.',
50
+ ],
51
+ },
52
+ ];
53
+
54
+ // App-inferred workflows (carry-over, sacred) win on a name clash; built-ins fill the rest.
55
+ export function mergeWorkflows(appWorkflows) {
56
+ const out = [...(appWorkflows ?? [])];
57
+ for (const b of BUILTIN_WORKFLOWS) {
58
+ if (!out.some((w) => w.name === b.name)) out.push(b);
59
+ }
60
+ return out;
61
+ }
31
62
  // Advertised MCP server version — read from package.json so it can never drift from the
32
63
  // published package (it was pinned at a stale '0.5.2' for many releases).
33
64
  const SPARDA_VERSION = (() => {
@@ -340,6 +371,21 @@ export async function startStdioBridge({ cwd, portOverride }) {
340
371
  'Call this FIRST. Returns the full living context of this app: every tool with its description, known workflows, runtime telemetry (per-tool calls/errors/latency), quarantined tools, and the immune memory of past diagnosed failures. Lets any AI session resume exactly where the previous one stopped.',
341
372
  inputSchema: { type: 'object', properties: {} },
342
373
  },
374
+ {
375
+ name: 'sparda_prove',
376
+ description:
377
+ 'Prove this app is safe to deploy — NOW, before you commit. Compiles the current source to its behavior graph and discharges the static proof obligations (unguarded mutation, non-atomic aggregate write, unvalidated constrained write). If a baseline was saved (`sparda apocalypse --save-baseline`), it ALSO diffs against it: any finding flagged `regression:true` means your edit removed a guard, dropped a route, or grew the blast radius vs the last proven state — the check an LLM cannot do to itself by re-reading code. Returns a deterministic verdict (PROVEN / PARTIAL / SURFACE / NO_PROOF / RISKY / NOT_PROVEN), the coverage % it resolved, and every finding with its route. The verdict is the exact word `sparda apocalypse` and the badge emit; it never over-claims (a low-coverage clean app reads SURFACE/PARTIAL, never a bare PROVEN). Pass `route` (e.g. "DELETE /orders") to focus the finding list on the route you just edited — the verdict still reflects the whole app.',
378
+ inputSchema: {
379
+ type: 'object',
380
+ properties: {
381
+ route: {
382
+ type: 'string',
383
+ description:
384
+ 'Optional. Substring of a route label (method + path, e.g. "POST /invoices") to filter the findings to the route you just edited. Omit to see the whole app.',
385
+ },
386
+ },
387
+ },
388
+ },
343
389
  {
344
390
  name: 'sparda_confirm',
345
391
  description:
@@ -360,14 +406,14 @@ export async function startStdioBridge({ cwd, portOverride }) {
360
406
  }));
361
407
 
362
408
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({
363
- prompts: (manifest.semantic?.workflows ?? []).map((w) => ({
409
+ prompts: mergeWorkflows(manifest.semantic?.workflows).map((w) => ({
364
410
  name: w.name,
365
411
  description: w.description,
366
412
  })),
367
413
  }));
368
414
 
369
415
  server.setRequestHandler(GetPromptRequestSchema, async (req) => {
370
- const w = (manifest.semantic?.workflows ?? []).find(
416
+ const w = mergeWorkflows(manifest.semantic?.workflows).find(
371
417
  (x) => x.name === req.params.name,
372
418
  );
373
419
  if (!w) throw new Error(`unknown prompt: ${req.params.name}`);
@@ -411,6 +457,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
411
457
  ),
412
458
  );
413
459
  }
460
+ if (name === 'sparda_prove') {
461
+ return text(JSON.stringify(proveApp(cwd, { route: args.route }), null, 2));
462
+ }
414
463
  if (name === 'sparda_get_context') {
415
464
  const headers = hostHeaders(key, ctx);
416
465
  const [stats, events] = await Promise.all([
@@ -1081,6 +1130,79 @@ function text(t) {
1081
1130
  return { content: [{ type: 'text', text: t }] };
1082
1131
  }
1083
1132
 
1133
+ // The proof layer, served live over MCP — same compile + obligations as `sparda apocalypse`,
1134
+ // so an AI can prove its own edit structurally the moment it writes it, not a CI run later.
1135
+ // Reuses `verdictState` verbatim: this tool physically cannot show a word the CLI/badge won't
1136
+ // (the no-false-PROVEN invariant is one function, shared). Read-only (write:false); an
1137
+ // AI-initiated call, never on the host's request path, so Law 1 holds.
1138
+ // opts.route — substring; keep only findings on routes whose label matches (focus the
1139
+ // answer on the route the AI just edited, e.g. "DELETE /orders").
1140
+ export function proveApp(cwd, { route } = {}) {
1141
+ let canonical, report;
1142
+ try {
1143
+ const compiled = compileUBG(cwd, { write: false });
1144
+ canonical = canonicalizeGraph(compiled.graph);
1145
+ report = compiled.report;
1146
+ } catch (err) {
1147
+ // A parser gap is NOT a pass — say so honestly, never a silent green.
1148
+ return {
1149
+ verdict: 'NO_PROOF',
1150
+ provable: false,
1151
+ note: `SPARDA could not compile this app's surface (${err.message}). An uncompiled app proves nothing — this is NOT a pass.`,
1152
+ };
1153
+ }
1154
+ const { findings: staticFindings, obligations } = checkGraph(canonical);
1155
+
1156
+ // Baseline diff — the regression check that IS apocalypse's edge over a plain linter: did
1157
+ // this edit REMOVE a guard, drop a route, or grow the blast radius vs the last saved proof?
1158
+ // Only if a baseline was recorded (`sparda apocalypse --save-baseline`); absent → static only.
1159
+ let diffFindings = [];
1160
+ let baselined = false;
1161
+ const baselinePath = path.join(cwd, '.sparda', 'ubg.baseline.json');
1162
+ if (fs.existsSync(baselinePath)) {
1163
+ try {
1164
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
1165
+ diffFindings = diffGraphs(baseline, canonical).findings;
1166
+ baselined = true;
1167
+ } catch {
1168
+ // unreadable baseline → degrade to static-only, never crash the AI's call
1169
+ }
1170
+ }
1171
+
1172
+ const all = [...staticFindings, ...diffFindings];
1173
+ const label = (id) => canonical.nodes.find((n) => n.id === id)?.label ?? id;
1174
+ let findings = all.map((f) => ({
1175
+ severity: f.severity,
1176
+ rule: f.rule,
1177
+ route: label(f.entrypoint),
1178
+ regression: diffFindings.includes(f),
1179
+ message: f.message,
1180
+ }));
1181
+ if (route) {
1182
+ const needle = route.toLowerCase();
1183
+ findings = findings.filter((f) => f.route.toLowerCase().includes(needle));
1184
+ }
1185
+
1186
+ const blind = surveyBlindspots(canonical, report);
1187
+ // The verdict word always reflects the WHOLE app (a route filter narrows the finding list,
1188
+ // never the safety claim — an AI must never read "PROVEN" because it hid the rest).
1189
+ const verdict = verdictOf(all, canonical, {
1190
+ coverage: blind.coverage.ratio,
1191
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
1192
+ });
1193
+ return {
1194
+ verdict: verdictState(verdict),
1195
+ provable: verdict.provable,
1196
+ coverage: Math.round(blind.coverage.ratio * 100) / 100,
1197
+ baselined,
1198
+ obligations,
1199
+ counts: verdict.counts,
1200
+ findings,
1201
+ blindspots: { surface: blind.surface, coverage: blind.coverage.ratio },
1202
+ ...(route ? { scopedTo: route } : {}),
1203
+ };
1204
+ }
1205
+
1084
1206
  // AI-facing message when the Signal-2 gate (Brief #1) refuses a `sparda_confirm`. Each reason
1085
1207
  // tells the model what to do next without implying it can approve the write itself.
1086
1208
  function signal2Denial(reason) {
@@ -488,7 +488,7 @@ const COVERAGE_FLOOR = 0.05;
488
488
  // finding, never changes the CI gate; a PARTIAL app is still clean, just qualified.
489
489
  const COVERAGE_COMPLETE = 0.6;
490
490
 
491
- export function verdictOf(findings, graph, { coverage } = {}) {
491
+ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
492
492
  const counts = { critical: 0, high: 0, medium: 0, info: 0 };
493
493
  for (const f of findings) counts[f.severity]++;
494
494
  // Advisory findings (BOLA/IDOR, ADR-058) are absence-based and FP-prone: they point a
@@ -528,9 +528,20 @@ export function verdictOf(findings, graph, { coverage } = {}) {
528
528
  // A clean whole-app proof below the completeness bar is PARTIAL — proved, but not over
529
529
  // the whole surface. Only meaningful when coverage was measured (whole-app run); a partial
530
530
  // graph (heal delta, coverage undefined) is never labelled partial.
531
+ //
532
+ // E-047 (the blind-spot rung, from the cal.com giant test): coverage is a RATIO, so on a
533
+ // huge app it can clear the bar (cal.com/api/v2: 71%) while the ABSOLUTE count of high-risk
534
+ // blind spots is large (46 guarded mutations whose writes never resolved). A bare "PROVEN"
535
+ // over 46 unproven high-risk mutations over-claims. So a clean app is also PARTIAL when any
536
+ // high-severity blind spot remains — the label carries the uncertainty (metrology's error
537
+ // bars), independent of the ratio. Sound: only ever SOFTENS PROVEN→PARTIAL, never masks a
538
+ // finding, never touches the CI gate (`safe`). blindHigh defaults to 0, so a caller that
539
+ // did not survey blind spots (heal delta) is unaffected.
531
540
  const clean = provable && !surfaceOnly && hardCount === 0;
532
541
  const partial =
533
- clean && coverage != null && entrypoints > 0 && coverage < COVERAGE_COMPLETE;
542
+ clean &&
543
+ entrypoints > 0 &&
544
+ ((coverage != null && coverage < COVERAGE_COMPLETE) || blindHigh > 0);
534
545
  // `safe` is the CI gate (block a risky deploy): a surface-only app has no
535
546
  // critical/high findings and is NOT risky, so it does not fail the gate — it just
536
547
  // isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
@@ -140,6 +140,8 @@ const moduleCache = new Map(); // absFile -> module facts (parse once per compil
140
140
  export function clearModuleCache() {
141
141
  moduleCache.clear();
142
142
  tsconfigCache.clear();
143
+ workspaceCache.clear();
144
+ workspaceRootOf.clear();
143
145
  }
144
146
 
145
147
  // absFile → { ast, functions: Map<name,{node,line,exported}>, imports: Map<local,abs>, error }
@@ -357,7 +359,159 @@ export function resolveRelImport(fromFile, spec) {
357
359
  // cross-module hop (controller → service → repository) dead-ends and effects behind
358
360
  // DI are invisible. A bare npm package (`kysely`, `@nestjs/common`) simply resolves
359
361
  // to nothing here (no matching file under the project), which is correct.
360
- return resolveAliasedImport(fromFile, clean(spec));
362
+ //
363
+ // The WORKSPACE fallback (the mycorrhizal network): a monorepo app's real mutation
364
+ // logic lives in shared workspace packages it imports by NAME, not by path — cal.com's
365
+ // `this.svc.updateEventType()` delegates to `@calcom/platform-libraries` → `@calcom/trpc`
366
+ // → `prisma.update()`, three packages away and entirely outside the analyzed app dir. A
367
+ // tsconfig alias can't reach them (they're resolved via the workspace, not `paths`). So
368
+ // when the alias miss, map the `@scope/pkg[/subpath]` specifier to the package's real
369
+ // directory under the workspace and resolve into it. Trees share nutrients across the
370
+ // fungal network, not just their own root ball — the schema/effect code is a shared
371
+ // nutrient drawn from the workspace, not the app's own folder.
372
+ return (
373
+ resolveAliasedImport(fromFile, clean(spec)) ??
374
+ resolveWorkspaceImport(fromFile, clean(spec))
375
+ );
376
+ }
377
+
378
+ // name -> absolute package dir, for every package in the workspace that owns `fromFile`.
379
+ // Cached per workspace root (built once, ~100 package.json reads on a giant monorepo).
380
+ const workspaceCache = new Map(); // rootDir -> Map(name -> dir)
381
+ const workspaceRootOf = new Map(); // dir -> rootDir | null
382
+
383
+ // walk up to the monorepo root: the nearest ancestor whose package.json declares
384
+ // `workspaces`, or that carries a pnpm-workspace.yaml. `null` = not a workspace.
385
+ function findWorkspaceRoot(fromFile) {
386
+ let dir = path.dirname(fromFile);
387
+ const chain = [];
388
+ for (let i = 0; i < 40; i++) {
389
+ if (workspaceRootOf.has(dir)) {
390
+ const v = workspaceRootOf.get(dir);
391
+ for (const d of chain) workspaceRootOf.set(d, v);
392
+ return v;
393
+ }
394
+ chain.push(dir);
395
+ let root = null;
396
+ try {
397
+ const pj = path.join(dir, 'package.json');
398
+ if (fs.existsSync(pj)) {
399
+ const pkg = JSON.parse(fs.readFileSync(pj, 'utf8'));
400
+ if (pkg.workspaces) root = dir;
401
+ }
402
+ if (!root && fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) root = dir;
403
+ } catch {
404
+ // unreadable manifest — keep walking up
405
+ }
406
+ if (root) {
407
+ for (const d of chain) workspaceRootOf.set(d, root);
408
+ return root;
409
+ }
410
+ const parent = path.dirname(dir);
411
+ if (parent === dir) break;
412
+ dir = parent;
413
+ }
414
+ for (const d of chain) workspaceRootOf.set(d, null);
415
+ return null;
416
+ }
417
+
418
+ // the workspace's package globs, from package.json `workspaces` (array or {packages})
419
+ // or a minimal pnpm-workspace.yaml parse (the `- '...'` list under `packages:`).
420
+ function workspaceGlobs(root) {
421
+ const globs = [];
422
+ try {
423
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
424
+ const ws = pkg.workspaces;
425
+ if (Array.isArray(ws)) globs.push(...ws);
426
+ else if (ws && Array.isArray(ws.packages)) globs.push(...ws.packages);
427
+ } catch {
428
+ // no/invalid root package.json — fall through to pnpm
429
+ }
430
+ try {
431
+ const yml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
432
+ for (const m of yml.matchAll(/^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/gm)) globs.push(m[1]);
433
+ } catch {
434
+ // no pnpm-workspace.yaml
435
+ }
436
+ return globs;
437
+ }
438
+
439
+ // expand one workspace glob to concrete package dirs. Supports the two forms real
440
+ // workspaces use: an exact path (`packages/app-store`) and a trailing `/*` (one level of
441
+ // subdirs, e.g. `packages/*`, `packages/platform/*`). `**` is treated as a single level —
442
+ // good enough for every workspace layout in the wild, and never unbounded.
443
+ function expandGlob(root, glob) {
444
+ const g = glob.replace(/\/\*\*$/, '/*');
445
+ const star = g.indexOf('*');
446
+ if (star === -1) return [path.resolve(root, g)];
447
+ const baseRel = g.slice(0, star).replace(/\/$/, '');
448
+ const baseDir = path.resolve(root, baseRel);
449
+ try {
450
+ return fs
451
+ .readdirSync(baseDir, { withFileTypes: true })
452
+ .filter((e) => e.isDirectory())
453
+ .map((e) => path.join(baseDir, e.name));
454
+ } catch {
455
+ return [];
456
+ }
457
+ }
458
+
459
+ export function workspacePackages(fromFile) {
460
+ const root = findWorkspaceRoot(fromFile);
461
+ if (!root) return null;
462
+ if (workspaceCache.has(root)) return workspaceCache.get(root);
463
+ const map = new Map();
464
+ for (const glob of workspaceGlobs(root)) {
465
+ for (const dir of expandGlob(root, glob)) {
466
+ try {
467
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
468
+ if (typeof pkg.name === 'string' && !map.has(pkg.name)) map.set(pkg.name, dir);
469
+ } catch {
470
+ // not a package (no/invalid package.json) — skip
471
+ }
472
+ }
473
+ }
474
+ workspaceCache.set(root, map);
475
+ return map;
476
+ }
477
+
478
+ // the entry file of a package with no subpath import (`@calcom/trpc`): its declared
479
+ // source/main/module, else the conventional src/index or index.
480
+ function packageEntry(dir) {
481
+ const fields = [];
482
+ try {
483
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
484
+ for (const f of [pkg.source, pkg.module, pkg.main])
485
+ if (typeof f === 'string') fields.push(f);
486
+ } catch {
487
+ // no package.json fields — conventions below still apply
488
+ }
489
+ for (const f of [...fields, 'src/index', 'index']) {
490
+ const hit = firstModuleFile(path.resolve(dir, f.replace(/\.(m?[jt]s|cjs)$/, '')));
491
+ if (hit) return hit;
492
+ }
493
+ return null;
494
+ }
495
+
496
+ // resolve `@scope/pkg/subpath` (or `pkg/subpath`) to a real source file under the
497
+ // workspace. Longest package-name match wins (`@calcom/platform-libraries` beats a
498
+ // hypothetical `@calcom/platform`), then the subpath resolves against the package dir.
499
+ function resolveWorkspaceImport(fromFile, spec) {
500
+ if (spec.startsWith('.') || spec.startsWith('/')) return null;
501
+ const map = workspacePackages(fromFile);
502
+ if (!map) return null;
503
+ let best = null;
504
+ for (const name of map.keys()) {
505
+ if (
506
+ (spec === name || spec.startsWith(name + '/')) &&
507
+ name.length > (best?.length ?? -1)
508
+ )
509
+ best = name;
510
+ }
511
+ if (!best) return null;
512
+ const dir = map.get(best);
513
+ const sub = spec.length > best.length ? spec.slice(best.length + 1) : '';
514
+ return sub ? firstModuleFile(path.resolve(dir, sub)) : packageEntry(dir);
361
515
  }
362
516
 
363
517
  // probe the standard TS/JS extensions + index files for a resolved base path.
package/src/ubg/prisma.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { cmp } from './schema.js';
13
+ import { workspacePackages } from './extract.js';
13
14
 
14
15
  const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma', 'db/schema.prisma'];
15
16
  // Prisma's `prismaSchemaFolder` layout (stable since 6.x): the schema is SPLIT across many
@@ -55,6 +56,35 @@ function collectSchemaFiles(cwd, fileCandidates, dirCandidates) {
55
56
  return [];
56
57
  }
57
58
 
59
+ // P4 (E-048, the mycorrhizal network for STATE): a monorepo app usually declares NO schema of
60
+ // its own — it depends on a shared `@scope/prisma`-style workspace package that owns the whole
61
+ // schema (cal.com: apps/web has 0 tables locally; `@calcom/prisma` has 100 models). Without
62
+ // this the app's entire state layer is invisible and every mutation reasons against nothing.
63
+ // When the app dir yields no schema, look through its workspace DEPENDENCIES for the package
64
+ // that owns one — the same name→dir map the module resolver already builds. Schema-ish deps are
65
+ // tried first (a `prisma`/`db` package is the DB package), then any remaining workspace dep.
66
+ function workspaceSchemaFiles(cwd, fileCandidates, dirCandidates) {
67
+ let deps;
68
+ try {
69
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
70
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
71
+ } catch {
72
+ return [];
73
+ }
74
+ const map = workspacePackages(path.join(cwd, 'package.json'));
75
+ if (!map) return [];
76
+ const inWorkspace = Object.keys(deps).filter((n) => map.has(n));
77
+ const ordered = [
78
+ ...inWorkspace.filter((n) => /prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
79
+ ...inWorkspace.filter((n) => !/prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
80
+ ];
81
+ for (const name of ordered) {
82
+ const files = collectSchemaFiles(map.get(name), fileCandidates, dirCandidates);
83
+ if (files.length) return files;
84
+ }
85
+ return [];
86
+ }
87
+
58
88
  const TYPE_MAP = {
59
89
  string: 'string',
60
90
  int: 'number',
@@ -78,7 +108,11 @@ export function parsePrismaSchemas(cwd) {
78
108
  } catch {
79
109
  // no package.json / unparsable — the default candidates stand
80
110
  }
81
- const files = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
111
+ // local first; then the workspace dependency that owns the shared schema (P4)
112
+ const local = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
113
+ const files = local.length
114
+ ? local
115
+ : workspaceSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
82
116
  if (!files.length) return { tables: [], skipped, sourceFile: null };
83
117
 
84
118
  // Read + strip comments once per file; keep the relative path for correct per-table locs.