sparda-mcp 0.5.4 → 0.7.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 +4 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +42 -1
- package/src/commands/init.js +42 -13
- package/src/commands/negentropy.js +234 -0
- package/src/commands/remove.js +17 -0
- package/src/commands/seed.js +282 -0
- package/src/commands/sync.js +29 -16
- package/src/detect.js +41 -1
- package/src/generator/express.js +2 -1
- package/src/generator/nextjs.js +113 -0
- package/src/index.js +12 -1
- package/src/parser/nextjs.js +248 -0
- package/src/server/stdio.js +1 -1
- package/templates/nextjs-router.txt +383 -0
package/README.md
CHANGED
|
@@ -157,7 +157,10 @@ protocol. The live, per-project tool list always comes from `sparda_get_context`
|
|
|
157
157
|
runtime, so the guidance never goes stale.
|
|
158
158
|
|
|
159
159
|
## Supported frameworks
|
|
160
|
-
|
|
160
|
+
|
|
161
|
+
- **Next.js App Router (13/14/15)** — file-based injection. Since Next.js uses file-system routing, SPARDA simply creates a catch-all route handler under `app/mcp/[...sparda]/route.js`. **Nothing in your existing codebase's code is touched**; running `remove` simply deletes the generated file.
|
|
162
|
+
- **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection.
|
|
163
|
+
- **FastAPI** (Python >= 3.9) — AST-based router injection.
|
|
161
164
|
|
|
162
165
|
## Security posture (honest)
|
|
163
166
|
- 4 runtime dependencies, exact-pinned.
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { detectStack } from '../detect.js';
|
|
5
|
+
import { buildNegentropy, renderNegentropy } from './negentropy.js';
|
|
5
6
|
|
|
6
7
|
// Returns { healthy } so the CLI can exit non-zero on any ✗ — scripts/CI can
|
|
7
8
|
// gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
|
|
@@ -26,9 +27,12 @@ export async function runDoctor(opts) {
|
|
|
26
27
|
fail(` ✗ ${e.message}`);
|
|
27
28
|
}
|
|
28
29
|
const manifest = path.join(opts.cwd, 'sparda.json');
|
|
30
|
+
let liveStats = null; // captured for the negentropy pass
|
|
31
|
+
let parsedManifest = null;
|
|
29
32
|
if (fs.existsSync(manifest)) {
|
|
30
33
|
try {
|
|
31
34
|
const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
35
|
+
parsedManifest = m;
|
|
32
36
|
const tools = Object.values(m.tools ?? {});
|
|
33
37
|
console.log(
|
|
34
38
|
` ✓ sparda.json valid (${tools.length} tools, ${tools.filter((t) => t.enabled).length} enabled)`,
|
|
@@ -67,6 +71,7 @@ export async function runDoctor(opts) {
|
|
|
67
71
|
.then((x) => (x.ok ? x.json() : null))
|
|
68
72
|
.catch(() => null);
|
|
69
73
|
if (s) {
|
|
74
|
+
liveStats = s;
|
|
70
75
|
const totals = Object.values(s.stats ?? {}).reduce(
|
|
71
76
|
(a, t) => ({ calls: a.calls + t.calls, errors: a.errors + t.errors }),
|
|
72
77
|
{ calls: 0, errors: 0 },
|
|
@@ -90,7 +95,7 @@ export async function runDoctor(opts) {
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
} catch {
|
|
93
|
-
const startCmd = m.framework === '
|
|
98
|
+
const startCmd = m.framework === 'fastapi' ? 'fastapi dev' : 'npm run dev';
|
|
94
99
|
fail(
|
|
95
100
|
` ✗ Host app on :${m.port} — NOT reachable\n → start it with: ${startCmd}`,
|
|
96
101
|
);
|
|
@@ -102,5 +107,41 @@ export async function runDoctor(opts) {
|
|
|
102
107
|
} else {
|
|
103
108
|
console.log(' · sparda.json not found (run `npx sparda-mcp init`)');
|
|
104
109
|
}
|
|
110
|
+
|
|
111
|
+
// ── negentropy pass (opt-in: doctor --app) — R3.1, deterministic ─────────
|
|
112
|
+
if (opts.app && parsedManifest) {
|
|
113
|
+
console.log('');
|
|
114
|
+
let currentRoutes = null;
|
|
115
|
+
try {
|
|
116
|
+
if (stack?.framework === 'express') {
|
|
117
|
+
const { parseExpressProject } = await import('../parser/express.js');
|
|
118
|
+
currentRoutes = parseExpressProject(opts.cwd, stack.entryFile).routes;
|
|
119
|
+
} else if (stack?.framework === 'nextjs') {
|
|
120
|
+
const { parseNextProject } = await import('../parser/nextjs.js');
|
|
121
|
+
currentRoutes = parseNextProject(opts.cwd, stack.entryFile).routes;
|
|
122
|
+
} else if (stack?.framework === 'fastapi') {
|
|
123
|
+
const { parseFastAPIProject } = await import('../parser/fastapi.js');
|
|
124
|
+
currentRoutes = parseFastAPIProject(
|
|
125
|
+
opts.cwd,
|
|
126
|
+
stack.entryFile,
|
|
127
|
+
stack.pythonCmd,
|
|
128
|
+
).routes;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
currentRoutes = null; // drift becomes "not measurable", never a guess
|
|
132
|
+
}
|
|
133
|
+
const result = buildNegentropy({
|
|
134
|
+
manifest: parsedManifest,
|
|
135
|
+
currentRoutes,
|
|
136
|
+
live: liveStats,
|
|
137
|
+
detectedPort: stack?.port ?? null,
|
|
138
|
+
cwd: opts.cwd,
|
|
139
|
+
});
|
|
140
|
+
const { lines, failing } = renderNegentropy(result);
|
|
141
|
+
for (const l of lines) console.log(l);
|
|
142
|
+
if (failing) healthy = false;
|
|
143
|
+
} else if (opts.app) {
|
|
144
|
+
console.log('\n · negentropy scan skipped — no sparda.json to compare against');
|
|
145
|
+
}
|
|
105
146
|
return { healthy };
|
|
106
147
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -5,9 +5,11 @@ import * as p from '@clack/prompts';
|
|
|
5
5
|
import { detectStack } from '../detect.js';
|
|
6
6
|
import { parseExpressProject } from '../parser/express.js';
|
|
7
7
|
import { parseFastAPIProject } from '../parser/fastapi.js';
|
|
8
|
+
import { parseNextProject } from '../parser/nextjs.js';
|
|
8
9
|
import { sanitizeDescription } from '../security/sanitize.js';
|
|
9
10
|
import { generateExpress } from '../generator/express.js';
|
|
10
11
|
import { generateFastAPI } from '../generator/fastapi.js';
|
|
12
|
+
import { generateNext } from '../generator/nextjs.js';
|
|
11
13
|
import { c, gradient, colorizeJson } from '../ui/style.js';
|
|
12
14
|
|
|
13
15
|
const VERSION = JSON.parse(
|
|
@@ -39,6 +41,13 @@ export async function runInit(opts) {
|
|
|
39
41
|
s.stop(
|
|
40
42
|
`Stack detected: ${c.cyan('FastAPI')} — entry: ${stack.entryFile}, port: ${stack.port}`,
|
|
41
43
|
);
|
|
44
|
+
} else if (stack.framework === 'nextjs') {
|
|
45
|
+
const res = parseNextProject(opts.cwd, stack.entryFile);
|
|
46
|
+
routes = res.routes;
|
|
47
|
+
skipped = res.skipped;
|
|
48
|
+
s.stop(
|
|
49
|
+
`Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
|
|
50
|
+
);
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
// Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
|
|
@@ -46,7 +55,11 @@ export async function runInit(opts) {
|
|
|
46
55
|
// behavior byte-identical. Executing the host app has side-effects, so it is
|
|
47
56
|
// gated behind --probe, warned on stderr, and degrades to static-only on any
|
|
48
57
|
// failure — a probe error must never break init (R1).
|
|
49
|
-
if (opts.probe) {
|
|
58
|
+
if (opts.probe && stack.framework === 'nextjs') {
|
|
59
|
+
p.log.warn(
|
|
60
|
+
'--probe is not supported on Next.js (file-based routing needs no runtime probe).',
|
|
61
|
+
);
|
|
62
|
+
} else if (opts.probe) {
|
|
50
63
|
p.log.warn(
|
|
51
64
|
"--probe runs your app to observe routes the static scan missed. Use only on code you trust (it triggers your app's import side-effects).",
|
|
52
65
|
);
|
|
@@ -77,7 +90,10 @@ export async function runInit(opts) {
|
|
|
77
90
|
if (routes.length === 0) {
|
|
78
91
|
throw Object.assign(new Error('0 routes extracted.'), {
|
|
79
92
|
code: 'USER',
|
|
80
|
-
hint:
|
|
93
|
+
hint:
|
|
94
|
+
stack.framework === 'nextjs'
|
|
95
|
+
? `SPARDA found Next.js but no route.js handlers under ${stack.entryFile}/. Page components are not API routes — SPARDA exposes route handlers only.`
|
|
96
|
+
: `SPARDA found ${stack.framework === 'express' ? 'Express' : 'FastAPI'} but no literal-path routes. Dynamic routing is not supported in v0.`,
|
|
81
97
|
});
|
|
82
98
|
}
|
|
83
99
|
|
|
@@ -118,7 +134,9 @@ export async function runInit(opts) {
|
|
|
118
134
|
);
|
|
119
135
|
|
|
120
136
|
let inject = true;
|
|
121
|
-
|
|
137
|
+
// Next.js is file-based: SPARDA adds one route-handler file and touches
|
|
138
|
+
// nothing else — there is no injection decision to make.
|
|
139
|
+
if (!opts.yes && stack.framework !== 'nextjs') {
|
|
122
140
|
const answer = await p.select({
|
|
123
141
|
message: `Inject the MCP router into ${stack.entryFile}?`,
|
|
124
142
|
options: [
|
|
@@ -145,14 +163,21 @@ export async function runInit(opts) {
|
|
|
145
163
|
port: stack.port,
|
|
146
164
|
routes,
|
|
147
165
|
})
|
|
148
|
-
:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
166
|
+
: stack.framework === 'nextjs'
|
|
167
|
+
? generateNext({
|
|
168
|
+
cwd: opts.cwd,
|
|
169
|
+
appDir: stack.entryFile,
|
|
170
|
+
port: stack.port,
|
|
171
|
+
routes,
|
|
172
|
+
})
|
|
173
|
+
: generateFastAPI({
|
|
174
|
+
cwd: opts.cwd,
|
|
175
|
+
entryFile: stack.entryFile,
|
|
176
|
+
port: stack.port,
|
|
177
|
+
routes,
|
|
178
|
+
entryAppVars,
|
|
179
|
+
pythonCmd: stack.pythonCmd,
|
|
180
|
+
});
|
|
156
181
|
|
|
157
182
|
fs.mkdirSync(path.join(opts.cwd, '.sparda'), { recursive: true });
|
|
158
183
|
const scanReport = { routes, skipped };
|
|
@@ -163,7 +188,11 @@ export async function runInit(opts) {
|
|
|
163
188
|
);
|
|
164
189
|
|
|
165
190
|
p.log.success(`Generated ${result.routerFile}`);
|
|
166
|
-
if (
|
|
191
|
+
if (result.injection.fileBased)
|
|
192
|
+
p.log.success(
|
|
193
|
+
'File-based injection: your code was not modified (remove deletes the file)',
|
|
194
|
+
);
|
|
195
|
+
else if (inject && result.injection.injected)
|
|
167
196
|
p.log.success(`Injected into ${stack.entryFile} (backup: .sparda/backup/)`);
|
|
168
197
|
else if (result.injection.manual) {
|
|
169
198
|
p.note(
|
|
@@ -187,7 +216,7 @@ export async function runInit(opts) {
|
|
|
187
216
|
p.outro(`${c.green(`Done in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)}
|
|
188
217
|
|
|
189
218
|
Next steps:
|
|
190
|
-
1. Start your app: ${c.cyan(stack.framework === '
|
|
219
|
+
1. Start your app: ${c.cyan(stack.framework === 'fastapi' ? 'fastapi dev' : 'npm run dev')}
|
|
191
220
|
2. Start the MCP bridge: ${c.cyan('npx sparda-mcp dev')}
|
|
192
221
|
3. Add to Claude Desktop config (claude_desktop_config.json):
|
|
193
222
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// commands/negentropy.js — the Maxwell's demon pass (`doctor --app`, R3.1).
|
|
2
|
+
// Detects rot, deterministically and with zero LLM: schema drift (the code
|
|
3
|
+
// moved, the manifest didn't), stale/unsynced tools, dead current, chronic
|
|
4
|
+
// antigens the immune system keeps diagnosing but nobody fixes, and zombie
|
|
5
|
+
// config. Every finding names its repair. Honest by construction: a gauge
|
|
6
|
+
// that cannot be measured says so instead of guessing.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
// same fingerprint the generators stamp into sparding.toolFingerprints —
|
|
12
|
+
// method|path|params, sha256/8. Divergence = the route's shape changed.
|
|
13
|
+
export function fingerprintFor(tool) {
|
|
14
|
+
const raw = `${tool.method}|${tool.path}|${JSON.stringify(tool.params ?? [])}`;
|
|
15
|
+
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// routes (parser output) → the tool shape the generators build, keyed the
|
|
19
|
+
// same way, so fingerprints are comparable across init/sync/doctor.
|
|
20
|
+
export function toolShapeOf(route) {
|
|
21
|
+
return {
|
|
22
|
+
method: route.method.toUpperCase(),
|
|
23
|
+
path: route.path,
|
|
24
|
+
params: route.params ?? [],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pure core. inputs:
|
|
29
|
+
// manifest — parsed sparda.json (required)
|
|
30
|
+
// currentRoutes — parser output for the code as it is NOW, or null (parse failed)
|
|
31
|
+
// live — /mcp/stats payload, or null (host down)
|
|
32
|
+
// detectedPort — what detect.js sees now, or null
|
|
33
|
+
// cwd — for file existence checks (router present?)
|
|
34
|
+
export function buildNegentropy({ manifest, currentRoutes, live, detectedPort, cwd }) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
const add = (kind, severity, title, detail, fix) =>
|
|
37
|
+
findings.push({ kind, severity, title, detail, fix });
|
|
38
|
+
|
|
39
|
+
const tools = manifest.tools ?? {};
|
|
40
|
+
const byKey = new Map(
|
|
41
|
+
Object.entries(tools).map(([name, t]) => [`${t.method} ${t.path}`, name]),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// ── drift: manifest vs the code as parsed right now ──────────────────────
|
|
45
|
+
if (currentRoutes) {
|
|
46
|
+
const currentKeys = new Map(
|
|
47
|
+
currentRoutes.map((r) => [`${r.method.toUpperCase()} ${r.path}`, r]),
|
|
48
|
+
);
|
|
49
|
+
for (const [key, name] of byKey) {
|
|
50
|
+
if (!currentKeys.has(key)) {
|
|
51
|
+
add(
|
|
52
|
+
'drift',
|
|
53
|
+
'high',
|
|
54
|
+
`stale tool: ${name}`,
|
|
55
|
+
`${key} is in sparda.json but no longer exists in the code — the AI sees a ghost route`,
|
|
56
|
+
'npx sparda-mcp sync',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const [key] of currentKeys) {
|
|
61
|
+
if (!byKey.has(key)) {
|
|
62
|
+
add(
|
|
63
|
+
'drift',
|
|
64
|
+
'medium',
|
|
65
|
+
`unsynced route: ${key}`,
|
|
66
|
+
'exists in the code but not in sparda.json — invisible to the AI',
|
|
67
|
+
'npx sparda-mcp sync',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const storedFps = manifest.sparding?.toolFingerprints ?? {};
|
|
72
|
+
for (const [key, name] of byKey) {
|
|
73
|
+
const route = currentKeys.get(key);
|
|
74
|
+
if (!route || !storedFps[name]) continue;
|
|
75
|
+
const fp = fingerprintFor(toolShapeOf(route));
|
|
76
|
+
if (fp !== storedFps[name]) {
|
|
77
|
+
add(
|
|
78
|
+
'drift',
|
|
79
|
+
'medium',
|
|
80
|
+
`shape drift: ${name}`,
|
|
81
|
+
`route structure changed since the last sync (fingerprint ${storedFps[name]} → ${fp}) — params the AI knows may be wrong`,
|
|
82
|
+
'npx sparda-mcp sync',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
add(
|
|
88
|
+
'drift',
|
|
89
|
+
'info',
|
|
90
|
+
'drift not measurable',
|
|
91
|
+
'the current code could not be re-parsed — drift against the manifest is unknown',
|
|
92
|
+
'fix the parse error reported above, then re-run',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── dead current: enabled tools nobody exercised this session ────────────
|
|
97
|
+
if (live) {
|
|
98
|
+
const stats = live.stats ?? {};
|
|
99
|
+
const totalCalls = Object.values(stats).reduce((n, s) => n + (s.calls ?? 0), 0);
|
|
100
|
+
const uptime = live.uptimeSec ?? 0;
|
|
101
|
+
if (totalCalls > 0 && uptime >= 60) {
|
|
102
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
103
|
+
if (!t.enabled) continue;
|
|
104
|
+
if ((stats[name]?.calls ?? 0) === 0) {
|
|
105
|
+
add(
|
|
106
|
+
'dead',
|
|
107
|
+
'info',
|
|
108
|
+
`no current: ${name}`,
|
|
109
|
+
`${t.method} ${t.path} — zero calls since host start (${uptime}s, ${totalCalls} calls elsewhere). RAM gauge: this session only, not a lifetime verdict`,
|
|
110
|
+
'if this route is dead in the code too, delete it; if it matters, tell the AI about it (semantic pass)',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
add(
|
|
116
|
+
'dead',
|
|
117
|
+
'info',
|
|
118
|
+
'current not measurable yet',
|
|
119
|
+
`host up ${uptime}s with ${totalCalls} total calls — not enough observation to call any route dead`,
|
|
120
|
+
'let the app run under real usage, then re-run',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
for (const [name, q] of Object.entries(live.quarantine ?? {})) {
|
|
124
|
+
add(
|
|
125
|
+
'sick',
|
|
126
|
+
'high',
|
|
127
|
+
`quarantined: ${name}`,
|
|
128
|
+
`the immune system is protecting this route (${q.reason ?? 'repeated 5xx'})`,
|
|
129
|
+
'fix the underlying failure; quarantine lifts itself (half-open probe)',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
add(
|
|
134
|
+
'dead',
|
|
135
|
+
'info',
|
|
136
|
+
'live gauges unavailable',
|
|
137
|
+
'host not running — dead-current and quarantine checks need the app up',
|
|
138
|
+
'start the app, then re-run npx sparda-mcp doctor --app',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── chronic antigens: diagnosed again and again, never fixed ─────────────
|
|
143
|
+
const failures = Object.entries(manifest.sparding?.failures ?? {})
|
|
144
|
+
.map(([sig, f]) => ({ sig, count: f.count ?? 0, lesson: f.lesson ?? '' }))
|
|
145
|
+
.filter((f) => f.count >= 3)
|
|
146
|
+
.sort((a, b) => b.count - a.count)
|
|
147
|
+
.slice(0, 3);
|
|
148
|
+
for (const f of failures) {
|
|
149
|
+
add(
|
|
150
|
+
'sick',
|
|
151
|
+
'medium',
|
|
152
|
+
`recurring failure: ${f.sig}`,
|
|
153
|
+
`${f.count} occurrences${f.lesson ? ` — lesson on file: ${f.lesson}` : ''}`,
|
|
154
|
+
f.sig.includes('write_disabled')
|
|
155
|
+
? 'the AI keeps hitting a write-safety wall: enable the tool deliberately, or leave it and the block keeps doing its job'
|
|
156
|
+
: 'the diagnosis exists in the journal; the fix does not — repair the route',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const antibodies = Object.entries(manifest.immune?.antibodies ?? {})
|
|
160
|
+
.map(([sig, a]) => ({ sig, hits: a.hits ?? 0, diagnosis: a.diagnosis ?? '' }))
|
|
161
|
+
.filter((a) => a.hits >= 3)
|
|
162
|
+
.sort((a, b) => b.hits - a.hits)
|
|
163
|
+
.slice(0, 3);
|
|
164
|
+
for (const a of antibodies) {
|
|
165
|
+
add(
|
|
166
|
+
'sick',
|
|
167
|
+
'medium',
|
|
168
|
+
`chronic antigen: ${a.sig}`,
|
|
169
|
+
`${a.hits} zero-token diagnoses served — the memory works, the wound stays open${a.diagnosis ? ` (${a.diagnosis.slice(0, 100)})` : ''}`,
|
|
170
|
+
'apply the cached diagnosis for real; the antibody then goes quiet on its own',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── zombie config ─────────────────────────────────────────────────────────
|
|
175
|
+
if (detectedPort && manifest.port && detectedPort !== manifest.port) {
|
|
176
|
+
add(
|
|
177
|
+
'zombie',
|
|
178
|
+
'high',
|
|
179
|
+
'port drift',
|
|
180
|
+
`sparda.json says :${manifest.port} but the app now runs on :${detectedPort} — the bridge and the proxy point at the wrong door`,
|
|
181
|
+
'npx sparda-mcp sync (re-detects and regenerates)',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
for (const f of manifest.generatedFiles ?? []) {
|
|
185
|
+
const abs = path.resolve(cwd ?? '.', f);
|
|
186
|
+
if (!fs.existsSync(abs)) {
|
|
187
|
+
add(
|
|
188
|
+
'zombie',
|
|
189
|
+
'high',
|
|
190
|
+
'router file missing',
|
|
191
|
+
`${f} is recorded in sparda.json but absent on disk — the organism has a memory of a body it no longer has`,
|
|
192
|
+
'npx sparda-mcp init --yes (regenerates; carry-over keeps your state)',
|
|
193
|
+
);
|
|
194
|
+
} else if (
|
|
195
|
+
manifest.localKey &&
|
|
196
|
+
!fs.readFileSync(abs, 'utf8').includes(manifest.localKey)
|
|
197
|
+
) {
|
|
198
|
+
add(
|
|
199
|
+
'zombie',
|
|
200
|
+
'high',
|
|
201
|
+
'router/manifest key mismatch',
|
|
202
|
+
`${f} does not carry the manifest's localKey — a stale router from an older init is still wired in`,
|
|
203
|
+
'npx sparda-mcp init --yes',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const summary = { drift: 0, dead: 0, sick: 0, zombie: 0 };
|
|
209
|
+
for (const f of findings) summary[f.kind] += 1;
|
|
210
|
+
const actionable = findings.filter((f) => f.severity !== 'info');
|
|
211
|
+
return { findings, summary, actionable: actionable.length };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// terminal rendering, doctor-style: ✗ fails CI, ⚠ warns, · informs
|
|
215
|
+
export function renderNegentropy(result) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
let failing = false;
|
|
218
|
+
lines.push(' Negentropy scan (doctor --app)');
|
|
219
|
+
if (result.findings.length === 0) {
|
|
220
|
+
lines.push(' ✓ no rot detected — manifest, code and gauges agree');
|
|
221
|
+
return { lines, failing };
|
|
222
|
+
}
|
|
223
|
+
for (const f of result.findings) {
|
|
224
|
+
const glyph = f.severity === 'high' ? '✗' : f.severity === 'medium' ? '⚠' : '·';
|
|
225
|
+
if (f.severity === 'high') failing = true;
|
|
226
|
+
lines.push(` ${glyph} [${f.kind}] ${f.title}`);
|
|
227
|
+
lines.push(` ${f.detail}`);
|
|
228
|
+
lines.push(` → ${f.fix}`);
|
|
229
|
+
}
|
|
230
|
+
lines.push(
|
|
231
|
+
` ${result.actionable ? '⚠' : '·'} ${result.findings.length} finding(s) — drift ${result.summary.drift} · dead ${result.summary.dead} · sick ${result.summary.sick} · zombie ${result.summary.zombie}`,
|
|
232
|
+
);
|
|
233
|
+
return { lines, failing };
|
|
234
|
+
}
|
package/src/commands/remove.js
CHANGED
|
@@ -61,6 +61,23 @@ export async function runRemove(opts) {
|
|
|
61
61
|
console.log(`✓ Deleted ${f}`);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
// nextjs: the generated route lives in its own directory chain
|
|
65
|
+
// (app/mcp/[...sparda]/) — prune the now-empty dirs up to the app root so
|
|
66
|
+
// the tree is byte-identical, not just the git diff.
|
|
67
|
+
if (manifest.framework === 'nextjs') {
|
|
68
|
+
const stopAt = path.resolve(opts.cwd, manifest.entryFile ?? '.');
|
|
69
|
+
for (const f of manifest.generatedFiles ?? []) {
|
|
70
|
+
let dir = path.dirname(path.resolve(opts.cwd, f));
|
|
71
|
+
while (dir.startsWith(stopAt) && dir !== stopAt) {
|
|
72
|
+
try {
|
|
73
|
+
fs.rmdirSync(dir); // throws if not empty — that is the stop condition
|
|
74
|
+
} catch {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
dir = path.dirname(dir);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
64
81
|
if (revertGitignore(opts.cwd, manifest.gitignore))
|
|
65
82
|
console.log('✓ Reverted .gitignore edit');
|
|
66
83
|
if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
|