@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.
Files changed (193) hide show
  1. package/README.md +7 -1
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. 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"]`, run in the
29
- * project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
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
- return target === root || target.startsWith(root + sep) ? target : null;
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
- return JSON.stringify(await collectCheckResult(target, {
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
- }), null, 2);
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 (_args, context) => {
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 = resolve(cwd, diag.file);
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
- /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
1265
- export async function runMcpServer(cwd, version) {
1266
- let features;
1267
- const monorepo = await detectMonorepo(cwd);
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(cwd, monorepo);
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
- features = { resources: allResources, prompts: allPrompts };
1501
+ return {
1502
+ monorepo,
1503
+ features: { resources: allResources, prompts: allPrompts },
1504
+ loadAppCached: createCachedAppLoader(root),
1505
+ };
1279
1506
  }
1280
- else {
1281
- const loadAppCached = createCachedAppLoader(cwd);
1282
- features = await backendFeatures(loadAppCached, projectFeatures(cwd, loadAppCached));
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
- const loadAppCached = createCachedAppLoader(cwd);
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(cwd, monorepo);
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(cwd, loadAppCached),
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 = "";