@nifrajs/cli 2.10.0 → 2.12.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 +7 -1
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +133 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +741 -148
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +21 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +288 -73
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +38 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +189 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +60 -5
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +10 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +360 -27
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +1105 -27
- package/docs/types.json +1772 -137
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +923 -169
- package/src/cli.ts +297 -76
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/dev-bun-config.ts +57 -14
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +286 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +81 -6
- package/src/load.ts +11 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +440 -34
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
package/dist/mcp.js
CHANGED
|
@@ -25,24 +25,31 @@
|
|
|
25
25
|
* - `nifra_inspect` - read the running dev server's recent request traces (method/path/status/
|
|
26
26
|
* duration/ISR) from the DevTools plugin: what your requests ACTUALLY did, not a guess.
|
|
27
27
|
*
|
|
28
|
-
* Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]
|
|
29
|
-
* project
|
|
28
|
+
* Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]` (or
|
|
29
|
+
* `["mcp", "<dir>"]` to pin the project directory explicitly). The server does NOT silently trust its
|
|
30
|
+
* spawn directory: the root is resolved via `./mcp-root.ts` (marker walk-up, the client's MCP `roots`,
|
|
31
|
+
* fail-closed tools when no nifra project is found) and announced in `initialize` + on every project
|
|
32
|
+
* tool result. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
|
|
30
33
|
* standard MCP progress notifications and request cancellation - no SDK dependency, the same
|
|
31
34
|
* minimal-surface choice as the rest of nifra. The pure dispatch lives in `./mcp-protocol.ts`; this
|
|
32
35
|
* module is the I/O shell (stdin loop, tool wiring, the run subprocess).
|
|
33
36
|
*/
|
|
37
|
+
import { realpathSync } from "node:fs";
|
|
34
38
|
import { readFile, stat } from "node:fs/promises";
|
|
35
|
-
import { basename, resolve, sep } from "node:path";
|
|
39
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
36
40
|
import { fileURLToPath } from "node:url";
|
|
37
41
|
import { reflectRoutes } from "@nifrajs/core/reflection";
|
|
38
42
|
import { Glob } from "bun";
|
|
43
|
+
import { collectContractProof } from "./contract-proof.js";
|
|
39
44
|
import { loadDocsCorpus } from "./docs-search.js";
|
|
40
45
|
import { loadExamplesCorpus } from "./examples.js";
|
|
41
46
|
import { describeProject } from "./introspect.js";
|
|
42
47
|
import { detectMonorepo, loadMonorepoApps } from "./load.js";
|
|
43
48
|
import { docsTools } from "./mcp-docs-tools.js";
|
|
44
49
|
import { createMcpProtocolState, handleRpc, rpcError, } from "./mcp-protocol.js";
|
|
50
|
+
import { applyClientRoots, detectToolingDrift, driftNote, pathsFromRootsResult, resolveRootState, rootInstructions, rootVerdict, } from "./mcp-root.js";
|
|
45
51
|
import { loadTypesCorpus } from "./types-search.js";
|
|
52
|
+
import { collectProjectWorkGraph } from "./work-graph.js";
|
|
46
53
|
/** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
|
|
47
54
|
* dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
|
|
48
55
|
function childPath(name) {
|
|
@@ -552,7 +559,35 @@ export function resolveProjectDir(root, dir) {
|
|
|
552
559
|
if (dir === undefined || dir === "")
|
|
553
560
|
return root;
|
|
554
561
|
const target = resolve(root, dir);
|
|
555
|
-
|
|
562
|
+
let canonicalRoot;
|
|
563
|
+
try {
|
|
564
|
+
canonicalRoot = realpathSync(root);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
const lexical = relative(root, target);
|
|
568
|
+
return lexical === "" || (!lexical.startsWith("..") && !isAbsolute(lexical)) ? target : null;
|
|
569
|
+
}
|
|
570
|
+
let probe = target;
|
|
571
|
+
const missing = [];
|
|
572
|
+
while (true) {
|
|
573
|
+
try {
|
|
574
|
+
const canonicalProbe = realpathSync(probe);
|
|
575
|
+
const rel = relative(canonicalRoot, canonicalProbe);
|
|
576
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
577
|
+
return null;
|
|
578
|
+
return missing.length === 0 ? canonicalProbe : resolve(canonicalProbe, ...missing.reverse());
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
const parent = resolve(probe, "..");
|
|
582
|
+
if (parent === probe)
|
|
583
|
+
return null;
|
|
584
|
+
// `basename`, not a slice past `parent`: when `parent` is the filesystem root its trailing
|
|
585
|
+
// separator is part of it, so `parent.length + 1` eats the first character of the segment and
|
|
586
|
+
// the path gets rebuilt wrong.
|
|
587
|
+
missing.push(basename(probe));
|
|
588
|
+
probe = parent;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
556
591
|
}
|
|
557
592
|
/** Consistent error string for a `dir` that escapes the project root. */
|
|
558
593
|
function dirError(dir) {
|
|
@@ -563,6 +598,100 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
563
598
|
const warmRun = createWarmHandler("mcp-run", cwd, "run");
|
|
564
599
|
const warmRender = createWarmHandler("mcp-render", cwd, "render");
|
|
565
600
|
return [
|
|
601
|
+
{
|
|
602
|
+
name: "nifra_contract_proof",
|
|
603
|
+
description: "Compare the current route contract with its snapshot and join each changed route to its route-assurance and capability evidence. Returns hasBreaking as the gate. The typed-contract check is lazy and runs only when check is true.",
|
|
604
|
+
inputSchema: {
|
|
605
|
+
type: "object",
|
|
606
|
+
properties: {
|
|
607
|
+
baseline: {
|
|
608
|
+
type: "string",
|
|
609
|
+
description: "Snapshot path relative to the selected project directory. Defaults to api-snapshot.json.",
|
|
610
|
+
},
|
|
611
|
+
check: {
|
|
612
|
+
type: "boolean",
|
|
613
|
+
description: "Also run the typed-contract check. Default false to keep the diff lazy.",
|
|
614
|
+
},
|
|
615
|
+
dir: {
|
|
616
|
+
type: "string",
|
|
617
|
+
description: "Project subdirectory to inspect.",
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
additionalProperties: false,
|
|
621
|
+
},
|
|
622
|
+
handler: async (args) => {
|
|
623
|
+
const raw = args;
|
|
624
|
+
if (raw.dir !== undefined && typeof raw.dir !== "string") {
|
|
625
|
+
return JSON.stringify({ ok: false, error: "dir must be a string" }, null, 2);
|
|
626
|
+
}
|
|
627
|
+
if (raw.baseline !== undefined && typeof raw.baseline !== "string") {
|
|
628
|
+
return JSON.stringify({ ok: false, error: "baseline must be a string" }, null, 2);
|
|
629
|
+
}
|
|
630
|
+
const dir = raw.dir;
|
|
631
|
+
const baseline = raw.baseline;
|
|
632
|
+
const target = resolveProjectDir(cwd, dir);
|
|
633
|
+
if (target === null)
|
|
634
|
+
return dirError(dir);
|
|
635
|
+
if (baseline !== undefined && baseline.trim() === "") {
|
|
636
|
+
return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2);
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
return JSON.stringify(await collectContractProof(target, {
|
|
640
|
+
...(baseline === undefined ? {} : { baselinePath: baseline }),
|
|
641
|
+
...(raw.check === undefined ? {} : { check: raw.check === true }),
|
|
642
|
+
}), null, 2);
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
return JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2);
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: "nifra_prove",
|
|
651
|
+
description: "Build the static verification work graph for this project. Pass changed files to get only impacted routes and the cheapest proof plan. The result includes a serializable evidence bundle and a machine-checkable stop condition. Requires a fresh build and never probes a running application.",
|
|
652
|
+
inputSchema: {
|
|
653
|
+
type: "object",
|
|
654
|
+
properties: {
|
|
655
|
+
files: {
|
|
656
|
+
type: "array",
|
|
657
|
+
items: { type: "string" },
|
|
658
|
+
description: "Project-relative changed files. Omit for a graph overview.",
|
|
659
|
+
},
|
|
660
|
+
minLevel: {
|
|
661
|
+
type: "integer",
|
|
662
|
+
minimum: 0,
|
|
663
|
+
maximum: 4,
|
|
664
|
+
description: "Required proof level for the stop condition. Defaults to 1.",
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
additionalProperties: false,
|
|
668
|
+
},
|
|
669
|
+
handler: async (args) => {
|
|
670
|
+
const files = args.files;
|
|
671
|
+
const minLevel = args.minLevel;
|
|
672
|
+
if ((files !== undefined &&
|
|
673
|
+
(!Array.isArray(files) || files.some((file) => typeof file !== "string"))) ||
|
|
674
|
+
(minLevel !== undefined &&
|
|
675
|
+
(typeof minLevel !== "number" ||
|
|
676
|
+
!Number.isInteger(minLevel) ||
|
|
677
|
+
minLevel < 0 ||
|
|
678
|
+
minLevel > 4))) {
|
|
679
|
+
return JSON.stringify({
|
|
680
|
+
ok: false,
|
|
681
|
+
error: "files must be strings and minLevel must be an integer from 0 to 4",
|
|
682
|
+
}, null, 2);
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
return JSON.stringify(await collectProjectWorkGraph(cwd, {
|
|
686
|
+
...(files === undefined ? {} : { changedFiles: files }),
|
|
687
|
+
...(minLevel === undefined ? {} : { minLevel }),
|
|
688
|
+
}), null, 2);
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
return JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "work graph failed" }, null, 2);
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
},
|
|
566
695
|
{
|
|
567
696
|
name: "nifra_context",
|
|
568
697
|
description: "Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer - cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
|
|
@@ -916,7 +1045,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
916
1045
|
},
|
|
917
1046
|
{
|
|
918
1047
|
name: "nifra_check",
|
|
919
|
-
description: 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
|
|
1048
|
+
description: 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[], pipeline? }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. `pipeline` answers "which bundler does this app run on" (bun|vite, for dev AND build alike) without starting a server, and its `pipeline` diagnostics catch the hazards of having two: a plugin in the slot the other bundler reads (accepted, then never called), a dev toolchain imported by the file `nifra build` bundles into the production server, and `conditions` that cannot reach Bun\'s dev client bundle. Read it before adding a plugin or a compiler. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
|
|
920
1049
|
inputSchema: {
|
|
921
1050
|
type: "object",
|
|
922
1051
|
properties: {
|
|
@@ -937,14 +1066,17 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
937
1066
|
const target = resolveProjectDir(cwd, opts.dir);
|
|
938
1067
|
if (target === null)
|
|
939
1068
|
return dirError(opts.dir);
|
|
940
|
-
|
|
1069
|
+
const result = await collectCheckResult(target, {
|
|
941
1070
|
lintsOnly: opts.lintsOnly ?? false,
|
|
942
1071
|
signal: context.signal,
|
|
943
1072
|
// Bound the result so a large project can't emit an MCP message big enough to break the stdio
|
|
944
1073
|
// transport (`-32000: Connection closed`). If `truncated` comes back, fix the shown diagnostics
|
|
945
1074
|
// and re-run. The scan already skips gitignored trees (walkSource), so this is the safety net.
|
|
946
1075
|
maxDiagnostics: 100,
|
|
947
|
-
})
|
|
1076
|
+
});
|
|
1077
|
+
return JSON.stringify(result.structuredDiagnostics === undefined
|
|
1078
|
+
? result
|
|
1079
|
+
: { ...result, diagnostics: result.structuredDiagnostics }, null, 2);
|
|
948
1080
|
},
|
|
949
1081
|
},
|
|
950
1082
|
{
|
|
@@ -952,14 +1084,20 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
952
1084
|
description: "Automatically fix diagnostic lints (such as rewriting hand-rolled fetch() calls to the typed nifra client, adding generic types to client factory calls, and resolving dependency drift in package.json). Runs diagnostics, applies all mechanical edit suggestions, applies doctor dependency fixes, and returns the remaining unresolved diagnostics.",
|
|
953
1085
|
inputSchema: {
|
|
954
1086
|
type: "object",
|
|
955
|
-
properties: {
|
|
1087
|
+
properties: {
|
|
1088
|
+
code: {
|
|
1089
|
+
type: "string",
|
|
1090
|
+
description: "Apply the registered recipe for one stable diagnostic code when available.",
|
|
1091
|
+
},
|
|
1092
|
+
},
|
|
956
1093
|
additionalProperties: false,
|
|
957
1094
|
},
|
|
958
|
-
handler: async (
|
|
1095
|
+
handler: async (args, context) => {
|
|
959
1096
|
const { collectCheckResult } = await import("./check.js");
|
|
1097
|
+
const { applyDiagnosticRecipe } = await import("./fix-recipes.js");
|
|
960
1098
|
const { applyDoctorAutoFix } = await import("./doctor.js");
|
|
1099
|
+
const { resolveInsideProject } = await import("./project-path.js");
|
|
961
1100
|
const { writeFile, readFile } = await import("node:fs/promises");
|
|
962
|
-
const { resolve } = await import("node:path");
|
|
963
1101
|
let doctorResult = null;
|
|
964
1102
|
try {
|
|
965
1103
|
doctorResult = await applyDoctorAutoFix(cwd);
|
|
@@ -972,6 +1110,15 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
972
1110
|
signal: context.signal,
|
|
973
1111
|
maxDiagnostics: 100,
|
|
974
1112
|
});
|
|
1113
|
+
const requestedCode = args.code;
|
|
1114
|
+
const recipeFixed = [];
|
|
1115
|
+
if (typeof requestedCode === "string") {
|
|
1116
|
+
for (const diagnostic of checkResult.structuredDiagnostics ?? []) {
|
|
1117
|
+
if (diagnostic.code !== requestedCode)
|
|
1118
|
+
continue;
|
|
1119
|
+
recipeFixed.push(...(await applyDiagnosticRecipe(cwd, diagnostic)));
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
975
1122
|
const fixed = [];
|
|
976
1123
|
for (const diag of checkResult.diagnostics) {
|
|
977
1124
|
if (diag.file && diag.line && diag.suggestion?.kind === "edit" && diag.suggestion.diff) {
|
|
@@ -980,7 +1127,9 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
980
1127
|
const beforeLine = diffLines.find((l) => l.startsWith("-"))?.slice(1);
|
|
981
1128
|
const afterLine = diffLines.find((l) => l.startsWith("+"))?.slice(1);
|
|
982
1129
|
if (beforeLine !== undefined && afterLine !== undefined) {
|
|
983
|
-
const filePath =
|
|
1130
|
+
const filePath = await resolveInsideProject(cwd, diag.file);
|
|
1131
|
+
if (filePath === undefined)
|
|
1132
|
+
continue;
|
|
984
1133
|
const content = await readFile(filePath, "utf-8");
|
|
985
1134
|
const lines = content.split("\n");
|
|
986
1135
|
const idx = diag.line - 1;
|
|
@@ -1008,6 +1157,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
1008
1157
|
return JSON.stringify({
|
|
1009
1158
|
ok: finalResult.ok,
|
|
1010
1159
|
fixed,
|
|
1160
|
+
recipeFixed,
|
|
1011
1161
|
doctorFixed: doctorResult?.fixed ?? [],
|
|
1012
1162
|
remainingDiagnostics: finalResult.diagnostics,
|
|
1013
1163
|
}, null, 2);
|
|
@@ -1023,6 +1173,22 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
1023
1173
|
type: "string",
|
|
1024
1174
|
description: "Config path relative to the selected project directory. Default: nifra.assurance.ts.",
|
|
1025
1175
|
},
|
|
1176
|
+
bundle: {
|
|
1177
|
+
type: "boolean",
|
|
1178
|
+
description: "Return the single gate bundle with verdict and explicit skipped gates.",
|
|
1179
|
+
},
|
|
1180
|
+
strict: {
|
|
1181
|
+
type: "boolean",
|
|
1182
|
+
description: "Treat warning diagnostics as failures in the bundle.",
|
|
1183
|
+
},
|
|
1184
|
+
hydration: {
|
|
1185
|
+
type: "boolean",
|
|
1186
|
+
description: "Run the hydration assurance gate.",
|
|
1187
|
+
},
|
|
1188
|
+
interact: {
|
|
1189
|
+
type: "boolean",
|
|
1190
|
+
description: "Run declared hydration probes after the client mounts.",
|
|
1191
|
+
},
|
|
1026
1192
|
dir: {
|
|
1027
1193
|
type: "string",
|
|
1028
1194
|
description: 'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
|
|
@@ -1039,10 +1205,62 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
1039
1205
|
if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
|
|
1040
1206
|
return JSON.stringify({ ok: false, error: "config must stay inside the selected project directory" }, null, 2);
|
|
1041
1207
|
}
|
|
1042
|
-
const { collectAssuranceReport } = await import("./assure.js");
|
|
1208
|
+
const { collectAssuranceReport, collectAssureBundle } = await import("./assure.js");
|
|
1209
|
+
if (opts.bundle === true || opts.strict === true || opts.hydration === true) {
|
|
1210
|
+
return JSON.stringify(await collectAssureBundle(target, {
|
|
1211
|
+
...(config === undefined ? {} : { config }),
|
|
1212
|
+
...(opts.strict === undefined ? {} : { strict: opts.strict }),
|
|
1213
|
+
...(opts.hydration === undefined ? {} : { hydration: opts.hydration }),
|
|
1214
|
+
...(opts.interact === undefined ? {} : { interact: opts.interact }),
|
|
1215
|
+
}), null, 2);
|
|
1216
|
+
}
|
|
1043
1217
|
return JSON.stringify(await collectAssuranceReport(target, config), null, 2);
|
|
1044
1218
|
},
|
|
1045
1219
|
},
|
|
1220
|
+
{
|
|
1221
|
+
name: "nifra_hydrate",
|
|
1222
|
+
description: "Run the hydration assurance gate and return stable NF-H diagnostics.",
|
|
1223
|
+
inputSchema: {
|
|
1224
|
+
type: "object",
|
|
1225
|
+
properties: {
|
|
1226
|
+
dir: { type: "string", description: "Project subdirectory to inspect." },
|
|
1227
|
+
interact: { type: "boolean", description: "Run declared hydration probes." },
|
|
1228
|
+
},
|
|
1229
|
+
additionalProperties: false,
|
|
1230
|
+
},
|
|
1231
|
+
handler: async (args) => {
|
|
1232
|
+
const opts = args;
|
|
1233
|
+
const target = resolveProjectDir(cwd, opts.dir);
|
|
1234
|
+
if (target === null)
|
|
1235
|
+
return dirError(opts.dir);
|
|
1236
|
+
const { runHydrationAssurance } = await import("./assure-hydration.js");
|
|
1237
|
+
return JSON.stringify(await runHydrationAssurance(target, { interact: opts.interact === true }), null, 2);
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
name: "nifra_replay",
|
|
1242
|
+
description: "Validate and dispatch a token-only replay metadata file.",
|
|
1243
|
+
inputSchema: {
|
|
1244
|
+
type: "object",
|
|
1245
|
+
properties: {
|
|
1246
|
+
file: { type: "string", description: "Replay file relative to the project root." },
|
|
1247
|
+
},
|
|
1248
|
+
required: ["file"],
|
|
1249
|
+
additionalProperties: false,
|
|
1250
|
+
},
|
|
1251
|
+
handler: async (args) => {
|
|
1252
|
+
const file = args.file;
|
|
1253
|
+
if (typeof file !== "string" || file.trim() === "")
|
|
1254
|
+
return JSON.stringify({ ok: false, error: "file is required" });
|
|
1255
|
+
const { runReplay } = await import("./replay.js");
|
|
1256
|
+
try {
|
|
1257
|
+
return JSON.stringify(await runReplay(cwd, file), null, 2);
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
return JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2);
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
},
|
|
1046
1264
|
{
|
|
1047
1265
|
name: "nifra_levels",
|
|
1048
1266
|
description: "Compute this project's verification ladder and return { achieved, levels[] }, where each level carries { level, name, ok, reasons[] }. The ladder is cumulative: L0 typed contract (nifra check), L1 route assurance, L2 reviewed capability lockfile, L3 route trust manifest in sync, L4 contract invariants, so a level only holds when every level below it holds, and `achieved` is -1 when even L0 fails. This is the single gate that reports what a change actually proves: run it after finishing work, and treat any `reasons` on a level the project already claimed as a regression to fix.",
|
|
@@ -1085,7 +1303,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
|
|
|
1085
1303
|
},
|
|
1086
1304
|
{
|
|
1087
1305
|
name: "nifra_doctor",
|
|
1088
|
-
description: "Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
|
|
1306
|
+
description: "Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Also reports `pipeline`: which bundler (bun|vite) this app's dev AND build phases run on and why, plus config hazards that only exist because there are two - a plugin sitting in the slot the other bundler reads (accepted, then never called), a dev toolchain imported by the file `nifra build` bundles into the production server (builds clean, dies at startup), and `conditions` that cannot reach the client bundle Bun's dev server serves. Returns { ok, ran, findings[], pipeline?, fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
|
|
1089
1307
|
inputSchema: {
|
|
1090
1308
|
type: "object",
|
|
1091
1309
|
properties: {
|
|
@@ -1261,12 +1479,17 @@ async function appDeclaredTools(loader) {
|
|
|
1261
1479
|
return [];
|
|
1262
1480
|
}
|
|
1263
1481
|
}
|
|
1264
|
-
/**
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1482
|
+
/** Tool names that never touch the project - they serve the bundled docs/examples/types corpus, so
|
|
1483
|
+
* they stay available (and unstamped) whatever the root state is. */
|
|
1484
|
+
const PROJECT_FREE_TOOLS = new Set(["nifra_docs", "nifra_example", "nifra_types", "nifra_learn"]);
|
|
1485
|
+
/** The id of this server's own `roots/list` request to the client. A fixed, namespaced string: the
|
|
1486
|
+
* server only ever has one roots request in flight, and no response to a CLIENT-initiated request can
|
|
1487
|
+
* carry it (clients pick their own ids; this one is recognisably ours). */
|
|
1488
|
+
const ROOTS_REQUEST_ID = "nifra:roots/list";
|
|
1489
|
+
async function createProjectContext(root) {
|
|
1490
|
+
const monorepo = await detectMonorepo(root);
|
|
1268
1491
|
if (monorepo) {
|
|
1269
|
-
const appEntries = await loadMonorepoApps(
|
|
1492
|
+
const appEntries = await loadMonorepoApps(root, monorepo);
|
|
1270
1493
|
const allResources = [];
|
|
1271
1494
|
const allPrompts = [];
|
|
1272
1495
|
for (const { name, cwd: appCwd } of appEntries) {
|
|
@@ -1275,22 +1498,118 @@ export async function runMcpServer(cwd, version) {
|
|
|
1275
1498
|
allResources.push(...(ns.features.resources ?? []));
|
|
1276
1499
|
allPrompts.push(...(ns.features.prompts ?? []));
|
|
1277
1500
|
}
|
|
1278
|
-
|
|
1501
|
+
return {
|
|
1502
|
+
monorepo,
|
|
1503
|
+
features: { resources: allResources, prompts: allPrompts },
|
|
1504
|
+
loadAppCached: createCachedAppLoader(root),
|
|
1505
|
+
};
|
|
1279
1506
|
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1507
|
+
const loadAppCached = createCachedAppLoader(root);
|
|
1508
|
+
return {
|
|
1509
|
+
monorepo,
|
|
1510
|
+
features: await backendFeatures(loadAppCached, projectFeatures(root, loadAppCached)),
|
|
1511
|
+
loadAppCached,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Wrap every project-scoped tool with the root guard: a bad root ({@link rootVerdict} `blocked`) makes
|
|
1516
|
+
* the tool refuse with the remediation message instead of answering about whatever directory the
|
|
1517
|
+
* server happened to start in; a good root stamps the result with `[nifra] project root: …` so the
|
|
1518
|
+
* agent (and the human reading its transcript) can see WHICH project answered. Docs tools pass
|
|
1519
|
+
* through untouched - they serve the bundled corpus, not the project.
|
|
1520
|
+
*/
|
|
1521
|
+
export function guardTools(tools, verdict) {
|
|
1522
|
+
return tools.map((tool) => {
|
|
1523
|
+
if (PROJECT_FREE_TOOLS.has(tool.name))
|
|
1524
|
+
return tool;
|
|
1525
|
+
return {
|
|
1526
|
+
...tool,
|
|
1527
|
+
handler: async (args, context) => {
|
|
1528
|
+
if (verdict.blocked !== undefined) {
|
|
1529
|
+
return { content: [{ type: "text", text: verdict.blocked }], isError: true };
|
|
1530
|
+
}
|
|
1531
|
+
const result = await tool.handler(args, context);
|
|
1532
|
+
const note = { type: "text", text: verdict.note };
|
|
1533
|
+
if (typeof result === "string") {
|
|
1534
|
+
return { content: [{ type: "text", text: result }, note] };
|
|
1535
|
+
}
|
|
1536
|
+
return { ...result, content: [...(result.content ?? []), note] };
|
|
1537
|
+
},
|
|
1538
|
+
};
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
/** Whether the client's `initialize` params declare the `roots` capability - the precondition for
|
|
1542
|
+
* this server sending it a `roots/list` request. */
|
|
1543
|
+
export function clientSupportsRoots(params) {
|
|
1544
|
+
const capabilities = params?.capabilities;
|
|
1545
|
+
if (typeof capabilities !== "object" || capabilities === null)
|
|
1546
|
+
return false;
|
|
1547
|
+
const roots = capabilities.roots;
|
|
1548
|
+
return typeof roots === "object" && roots !== null;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout.
|
|
1552
|
+
*
|
|
1553
|
+
* The project root is resolved defensively rather than assumed (`./mcp-root.ts`): explicit
|
|
1554
|
+
* `nifra mcp <dir>` beats a cwd guess, a cwd guess walks up to the nearest nifra marker, the client's
|
|
1555
|
+
* MCP `roots` (requested after the handshake, re-requested on `roots/list_changed`) can correct an
|
|
1556
|
+
* unambiguous wrong guess, and anything still unresolved fails closed per tool call. The effective
|
|
1557
|
+
* root is announced in the `initialize` instructions and stamped on every project tool result.
|
|
1558
|
+
*/
|
|
1559
|
+
export async function runMcpServer(cwd, version, explicitDir) {
|
|
1560
|
+
const requested = explicitDir !== undefined ? resolve(cwd, explicitDir) : cwd;
|
|
1561
|
+
if (explicitDir !== undefined && !(await stat(requested).catch(() => null))?.isDirectory()) {
|
|
1562
|
+
throw new Error(`nifra mcp: directory not found: ${requested}`);
|
|
1283
1563
|
}
|
|
1284
|
-
|
|
1564
|
+
let rootState = await resolveRootState(requested, explicitDir !== undefined);
|
|
1565
|
+
let ctx = await createProjectContext(rootState.root);
|
|
1566
|
+
// The CLI answering may not be the nifra the project builds with (a globally installed server, a
|
|
1567
|
+
// stale agent config). Recomputed whenever the root moves, since a different project can install a
|
|
1568
|
+
// different version.
|
|
1569
|
+
let drift = await detectToolingDrift(rootState.root, version);
|
|
1285
1570
|
const serverInfo = { name: "nifra", version };
|
|
1286
1571
|
const state = createMcpProtocolState();
|
|
1287
1572
|
const send = (message) => {
|
|
1288
1573
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
1289
1574
|
};
|
|
1575
|
+
let rootsSupported = false;
|
|
1576
|
+
const requestRoots = () => {
|
|
1577
|
+
if (!rootsSupported)
|
|
1578
|
+
return;
|
|
1579
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: ROOTS_REQUEST_ID, method: "roots/list" })}\n`);
|
|
1580
|
+
};
|
|
1581
|
+
const onRootsAnswer = async (message) => {
|
|
1582
|
+
const result = message.result;
|
|
1583
|
+
// An error response (or a malformed one) carries no roots: keep the current state - the absence
|
|
1584
|
+
// of workspace data is not a mismatch, and never a reason to move the root.
|
|
1585
|
+
if (result === undefined)
|
|
1586
|
+
return;
|
|
1587
|
+
const next = await applyClientRoots(rootState, pathsFromRootsResult(result));
|
|
1588
|
+
if (next.root !== rootState.root) {
|
|
1589
|
+
ctx = await createProjectContext(next.root);
|
|
1590
|
+
drift = await detectToolingDrift(next.root, version);
|
|
1591
|
+
}
|
|
1592
|
+
rootState = next;
|
|
1593
|
+
};
|
|
1594
|
+
// Root updates are serialized against tool dispatch: a message read AFTER the client's roots answer
|
|
1595
|
+
// must see the adopted root, even though dispatches themselves run concurrently.
|
|
1596
|
+
let rootsUpdate = Promise.resolve();
|
|
1290
1597
|
const dispatch = async (message) => {
|
|
1598
|
+
// The client's answer to OUR `roots/list` request - a response (id, no method), which the pure
|
|
1599
|
+
// dispatch would reject as an unknown method. Intercept it before handleRpc ever sees it.
|
|
1600
|
+
if (message.method === undefined && message.id === ROOTS_REQUEST_ID) {
|
|
1601
|
+
// Swallow failures: a bad answer must not poison the chain and 500 every later dispatch.
|
|
1602
|
+
rootsUpdate = onRootsAnswer(message).catch(() => { });
|
|
1603
|
+
await rootsUpdate;
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
await rootsUpdate;
|
|
1607
|
+
if (message.method === "initialize") {
|
|
1608
|
+
rootsSupported = clientSupportsRoots(message.params);
|
|
1609
|
+
}
|
|
1291
1610
|
let activeTools;
|
|
1292
|
-
if (monorepo) {
|
|
1293
|
-
const appEntries = await loadMonorepoApps(
|
|
1611
|
+
if (ctx.monorepo) {
|
|
1612
|
+
const appEntries = await loadMonorepoApps(rootState.root, ctx.monorepo);
|
|
1294
1613
|
const allTools = [];
|
|
1295
1614
|
for (const { name, cwd: appCwd } of appEntries) {
|
|
1296
1615
|
const loader = createCachedAppLoader(appCwd);
|
|
@@ -1302,16 +1621,30 @@ export async function runMcpServer(cwd, version) {
|
|
|
1302
1621
|
}
|
|
1303
1622
|
else {
|
|
1304
1623
|
activeTools = [
|
|
1305
|
-
...projectTools(
|
|
1306
|
-
...(await appDeclaredTools(loadAppCached)),
|
|
1624
|
+
...projectTools(rootState.root, ctx.loadAppCached),
|
|
1625
|
+
...(await appDeclaredTools(ctx.loadAppCached)),
|
|
1307
1626
|
];
|
|
1308
1627
|
}
|
|
1628
|
+
const verdict = await rootVerdict(rootState);
|
|
1629
|
+
activeTools = guardTools(activeTools, drift === undefined || verdict.blocked !== undefined
|
|
1630
|
+
? verdict
|
|
1631
|
+
: { ...verdict, note: `${verdict.note}\n${driftNote(drift)}` });
|
|
1632
|
+
const features = {
|
|
1633
|
+
...ctx.features,
|
|
1634
|
+
instructions: rootInstructions(rootState, drift),
|
|
1635
|
+
};
|
|
1309
1636
|
const response = await handleRpc(message, activeTools, serverInfo, features, {
|
|
1310
1637
|
state,
|
|
1311
1638
|
sendNotification: send,
|
|
1312
1639
|
});
|
|
1313
1640
|
if (response)
|
|
1314
1641
|
send(response);
|
|
1642
|
+
// Ask for the client's workspace roots once the handshake completes, and again whenever the
|
|
1643
|
+
// client says they changed. The answer comes back through the interception above.
|
|
1644
|
+
if (message.method === "notifications/initialized" ||
|
|
1645
|
+
message.method === "notifications/roots/list_changed") {
|
|
1646
|
+
requestRoots();
|
|
1647
|
+
}
|
|
1315
1648
|
};
|
|
1316
1649
|
const decoder = new TextDecoder();
|
|
1317
1650
|
let buffer = "";
|