@nifrajs/cli 2.13.0 → 3.1.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/dist/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +260 -49
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +130 -414
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +250 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1032 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +82 -9
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +127 -355
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +29 -44
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +956 -56
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +312 -65
- package/src/cli.ts +140 -405
- package/src/command-catalog.ts +1435 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +134 -12
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +163 -420
- package/src/project-facts.ts +86 -0
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +30 -44
- package/src/rules/security.ts +14 -14
- package/src/workspace-link.ts +80 -0
package/src/mcp.ts
CHANGED
|
@@ -41,10 +41,17 @@ import { basename, isAbsolute, relative, resolve, sep } from "node:path"
|
|
|
41
41
|
import { fileURLToPath } from "node:url"
|
|
42
42
|
import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
|
|
43
43
|
import { Glob } from "bun"
|
|
44
|
+
import {
|
|
45
|
+
type CommandCatalogEntry,
|
|
46
|
+
type CommandCtx,
|
|
47
|
+
type CommandSpec,
|
|
48
|
+
commandCatalog,
|
|
49
|
+
commandMcpName,
|
|
50
|
+
findCommandSpec,
|
|
51
|
+
} from "./command-catalog.ts"
|
|
44
52
|
import { collectContractProof } from "./contract-proof.ts"
|
|
45
53
|
import { loadDocsCorpus } from "./docs-search.ts"
|
|
46
54
|
import { loadExamplesCorpus } from "./examples.ts"
|
|
47
|
-
import { describeProject } from "./introspect.ts"
|
|
48
55
|
import type { LoadAppOptions, LoadedApp } from "./load.ts"
|
|
49
56
|
import { detectMonorepo, loadMonorepoApps } from "./load.ts"
|
|
50
57
|
import { docsTools } from "./mcp-docs-tools.ts"
|
|
@@ -76,7 +83,6 @@ import {
|
|
|
76
83
|
type ToolingDrift,
|
|
77
84
|
} from "./mcp-root.ts"
|
|
78
85
|
import { loadTypesCorpus } from "./types-search.ts"
|
|
79
|
-
import { collectProjectWorkGraph } from "./work-graph.ts"
|
|
80
86
|
|
|
81
87
|
/** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
|
|
82
88
|
* dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
|
|
@@ -98,6 +104,8 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
|
|
|
98
104
|
* within one attention span rather than after minutes of polling.
|
|
99
105
|
*/
|
|
100
106
|
const CHILD_TIMEOUT_MS = 30_000
|
|
107
|
+
/** Maximum stdout or stderr retained from a project subprocess. The process is killed at the limit. */
|
|
108
|
+
export const CHILD_OUTPUT_MAX_BYTES = 1_048_576
|
|
101
109
|
|
|
102
110
|
/** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
|
|
103
111
|
* buffer an unrelated loopback service just because a caller supplied its port. */
|
|
@@ -166,6 +174,47 @@ const timeoutMessage = (label: string, ms: number): string =>
|
|
|
166
174
|
`that keeps the event loop alive (a database pool, a Redis client, an interval) opened during import ` +
|
|
167
175
|
`rather than lazily. Check for top-level connections in the app entry or anything it imports.`
|
|
168
176
|
|
|
177
|
+
export interface BoundedOutput {
|
|
178
|
+
readonly text: string
|
|
179
|
+
readonly truncated: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Read child output incrementally and cancel as soon as the byte budget is crossed. */
|
|
183
|
+
export async function readBoundedStream(
|
|
184
|
+
stream: ReadableStream<Uint8Array>,
|
|
185
|
+
maxBytes = CHILD_OUTPUT_MAX_BYTES,
|
|
186
|
+
onLimit?: () => void,
|
|
187
|
+
): Promise<BoundedOutput> {
|
|
188
|
+
const reader = stream.getReader()
|
|
189
|
+
const decoder = new TextDecoder()
|
|
190
|
+
const chunks: string[] = []
|
|
191
|
+
let totalBytes = 0
|
|
192
|
+
try {
|
|
193
|
+
for (;;) {
|
|
194
|
+
const { done, value } = await reader.read()
|
|
195
|
+
if (done) {
|
|
196
|
+
chunks.push(decoder.decode())
|
|
197
|
+
return { text: chunks.join(""), truncated: false }
|
|
198
|
+
}
|
|
199
|
+
if (value === undefined) continue
|
|
200
|
+
const remaining = maxBytes - totalBytes
|
|
201
|
+
if (remaining <= 0 || value.byteLength > remaining) {
|
|
202
|
+
if (remaining > 0) {
|
|
203
|
+
chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }))
|
|
204
|
+
chunks.push(decoder.decode())
|
|
205
|
+
}
|
|
206
|
+
await reader.cancel()
|
|
207
|
+
onLimit?.()
|
|
208
|
+
return { text: chunks.join(""), truncated: true }
|
|
209
|
+
}
|
|
210
|
+
totalBytes += value.byteLength
|
|
211
|
+
chunks.push(decoder.decode(value, { stream: true }))
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
reader.releaseLock()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
169
218
|
async function spawnChild(
|
|
170
219
|
child: "mcp-run" | "mcp-render" | "mcp-ws",
|
|
171
220
|
cwd: string,
|
|
@@ -179,30 +228,46 @@ async function spawnChild(
|
|
|
179
228
|
stdout: "pipe",
|
|
180
229
|
stderr: "pipe",
|
|
181
230
|
})
|
|
182
|
-
|
|
231
|
+
let killed = false
|
|
232
|
+
const kill = (): void => {
|
|
233
|
+
if (killed) return
|
|
234
|
+
killed = true
|
|
235
|
+
proc.kill()
|
|
236
|
+
}
|
|
237
|
+
const abort = (): void => kill()
|
|
183
238
|
signal?.addEventListener("abort", abort, { once: true })
|
|
184
239
|
let timedOut = false
|
|
185
240
|
const timer = setTimeout(() => {
|
|
186
241
|
timedOut = true
|
|
187
|
-
|
|
242
|
+
kill()
|
|
188
243
|
}, CHILD_TIMEOUT_MS)
|
|
244
|
+
let outputExceeded = false
|
|
189
245
|
try {
|
|
190
246
|
proc.stdin.write(JSON.stringify(input))
|
|
191
247
|
await proc.stdin.end()
|
|
192
248
|
const [out, err] = await Promise.all([
|
|
193
|
-
|
|
194
|
-
|
|
249
|
+
readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
|
|
250
|
+
outputExceeded = true
|
|
251
|
+
kill()
|
|
252
|
+
}),
|
|
253
|
+
readBoundedStream(proc.stderr, CHILD_OUTPUT_MAX_BYTES, () => {
|
|
254
|
+
outputExceeded = true
|
|
255
|
+
kill()
|
|
256
|
+
}),
|
|
195
257
|
])
|
|
196
258
|
await proc.exited
|
|
197
259
|
if (signal?.aborted) {
|
|
198
260
|
const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
|
|
199
261
|
return `${label} cancelled${reason}.`
|
|
200
262
|
}
|
|
263
|
+
if (outputExceeded) {
|
|
264
|
+
return `${label} output exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes and was terminated.`
|
|
265
|
+
}
|
|
201
266
|
// A killed child may still have flushed a complete result before the timer fired; prefer real
|
|
202
267
|
// output over the timeout message so a slow-but-successful render is not reported as a failure.
|
|
203
|
-
const text = out.trim()
|
|
268
|
+
const text = out.text.trim()
|
|
204
269
|
if (timedOut && text === "") return timeoutMessage(label, CHILD_TIMEOUT_MS)
|
|
205
|
-
return text || `${label} failed:\n${err.trim() || "(no output)"}`
|
|
270
|
+
return text || `${label} failed:\n${err.text.trim() || "(no output)"}`
|
|
206
271
|
} finally {
|
|
207
272
|
clearTimeout(timer)
|
|
208
273
|
signal?.removeEventListener("abort", abort)
|
|
@@ -339,7 +404,23 @@ export class WarmWorker {
|
|
|
339
404
|
for (;;) {
|
|
340
405
|
const { done, value } = await reader.read()
|
|
341
406
|
if (done) break
|
|
407
|
+
if (value !== undefined && value.byteLength > CHILD_OUTPUT_MAX_BYTES) {
|
|
408
|
+
this.stderrBuffer = boundedAppend(
|
|
409
|
+
this.stderrBuffer,
|
|
410
|
+
`warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
|
|
411
|
+
)
|
|
412
|
+
this.stop()
|
|
413
|
+
return
|
|
414
|
+
}
|
|
342
415
|
this.stdoutBuffer += decoder.decode(value, { stream: true })
|
|
416
|
+
if (this.stdoutBuffer.length > CHILD_OUTPUT_MAX_BYTES) {
|
|
417
|
+
this.stderrBuffer = boundedAppend(
|
|
418
|
+
this.stderrBuffer,
|
|
419
|
+
`warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
|
|
420
|
+
)
|
|
421
|
+
this.stop()
|
|
422
|
+
return
|
|
423
|
+
}
|
|
343
424
|
let nl = this.stdoutBuffer.indexOf("\n")
|
|
344
425
|
while (nl !== -1) {
|
|
345
426
|
const line = this.stdoutBuffer.slice(0, nl).trim()
|
|
@@ -746,13 +827,85 @@ function dirError(dir: string | undefined): string {
|
|
|
746
827
|
}
|
|
747
828
|
|
|
748
829
|
/** Build the project-scoped tools for `cwd`. */
|
|
830
|
+
export interface CommandMcpToolOptions {
|
|
831
|
+
readonly cwd: string
|
|
832
|
+
readonly loadAppCached?: () => Promise<LoadedApp>
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Adapt one executable catalog spec to its MCP descriptor and project-scoped handler. */
|
|
836
|
+
export function toMcpTool(
|
|
837
|
+
source: CommandSpec<unknown, unknown> | CommandCatalogEntry,
|
|
838
|
+
options: CommandMcpToolOptions,
|
|
839
|
+
): McpTool {
|
|
840
|
+
const spec = "run" in source ? source : findCommandSpec(source.name)
|
|
841
|
+
if (spec === undefined) throw new Error(`missing command spec for ${source.name}`)
|
|
842
|
+
const entry = "run" in source ? undefined : source
|
|
843
|
+
const { cwd } = options
|
|
844
|
+
const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
|
|
845
|
+
return {
|
|
846
|
+
name: commandMcpName(entry?.name ?? spec.name),
|
|
847
|
+
description: entry?.summary ?? spec.summary,
|
|
848
|
+
inputSchema: entry?.inputSchema ?? spec.input.jsonSchema,
|
|
849
|
+
handler: async (args: Record<string, unknown>, context: McpToolContext) => {
|
|
850
|
+
const raw = { ...args }
|
|
851
|
+
const dir = raw.dir
|
|
852
|
+
if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
|
|
853
|
+
const target = resolveProjectDir(cwd, dir as string | undefined)
|
|
854
|
+
if (target === null) return dirError(dir as string | undefined)
|
|
855
|
+
if (typeof raw.config === "string") {
|
|
856
|
+
const config = resolve(target, raw.config)
|
|
857
|
+
if (config !== target && !config.startsWith(`${target}${sep}`))
|
|
858
|
+
return JSON.stringify(
|
|
859
|
+
{ ok: false, error: "config must stay inside the selected project directory" },
|
|
860
|
+
null,
|
|
861
|
+
2,
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
delete raw.dir
|
|
865
|
+
let input: unknown
|
|
866
|
+
try {
|
|
867
|
+
input = spec.input.parse(raw)
|
|
868
|
+
} catch (error) {
|
|
869
|
+
return JSON.stringify(
|
|
870
|
+
{ ok: false, error: error instanceof Error ? error.message : String(error) },
|
|
871
|
+
null,
|
|
872
|
+
2,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
const commandContext: CommandCtx = {
|
|
876
|
+
cwd: target,
|
|
877
|
+
signal: context.signal,
|
|
878
|
+
progress: () => context.reportProgress?.(0.5, 1),
|
|
879
|
+
...(target === cwd ? { loadApp: loadAppCached } : {}),
|
|
880
|
+
}
|
|
881
|
+
const output = await spec.run(input, commandContext)
|
|
882
|
+
const value = spec.json?.(output, input) ?? output
|
|
883
|
+
return typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
884
|
+
},
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export function catalogProjectTools(
|
|
889
|
+
cwd: string,
|
|
890
|
+
loadAppCached: () => Promise<LoadedApp> = createCachedAppLoader(cwd),
|
|
891
|
+
): McpTool[] {
|
|
892
|
+
return commandCatalog
|
|
893
|
+
.filter((entry) => entry.transports.includes("mcp"))
|
|
894
|
+
.map((entry) => {
|
|
895
|
+
const spec = findCommandSpec(entry.name)
|
|
896
|
+
if (spec === undefined) throw new Error(`missing command spec for ${entry.name}`)
|
|
897
|
+
return toMcpTool(spec, { cwd, loadAppCached })
|
|
898
|
+
})
|
|
899
|
+
}
|
|
900
|
+
|
|
749
901
|
export function projectTools(
|
|
750
902
|
cwd: string,
|
|
751
903
|
loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
|
|
752
904
|
): McpTool[] {
|
|
753
905
|
const warmRun = createWarmHandler("mcp-run", cwd, "run")
|
|
754
906
|
const warmRender = createWarmHandler("mcp-render", cwd, "render")
|
|
755
|
-
|
|
907
|
+
const catalogTools = catalogProjectTools(cwd, () => loadAppCached())
|
|
908
|
+
const legacyTools: McpTool[] = [
|
|
756
909
|
{
|
|
757
910
|
name: "nifra_contract_proof",
|
|
758
911
|
description:
|
|
@@ -809,90 +962,6 @@ export function projectTools(
|
|
|
809
962
|
}
|
|
810
963
|
},
|
|
811
964
|
},
|
|
812
|
-
{
|
|
813
|
-
name: "nifra_prove",
|
|
814
|
-
description:
|
|
815
|
-
"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.",
|
|
816
|
-
inputSchema: {
|
|
817
|
-
type: "object",
|
|
818
|
-
properties: {
|
|
819
|
-
files: {
|
|
820
|
-
type: "array",
|
|
821
|
-
items: { type: "string" },
|
|
822
|
-
description: "Project-relative changed files. Omit for a graph overview.",
|
|
823
|
-
},
|
|
824
|
-
minLevel: {
|
|
825
|
-
type: "integer",
|
|
826
|
-
minimum: 0,
|
|
827
|
-
maximum: 4,
|
|
828
|
-
description: "Required proof level for the stop condition. Defaults to 1.",
|
|
829
|
-
},
|
|
830
|
-
},
|
|
831
|
-
additionalProperties: false,
|
|
832
|
-
},
|
|
833
|
-
handler: async (args) => {
|
|
834
|
-
const files = args.files
|
|
835
|
-
const minLevel = args.minLevel
|
|
836
|
-
if (
|
|
837
|
-
(files !== undefined &&
|
|
838
|
-
(!Array.isArray(files) || files.some((file) => typeof file !== "string"))) ||
|
|
839
|
-
(minLevel !== undefined &&
|
|
840
|
-
(typeof minLevel !== "number" ||
|
|
841
|
-
!Number.isInteger(minLevel) ||
|
|
842
|
-
minLevel < 0 ||
|
|
843
|
-
minLevel > 4))
|
|
844
|
-
) {
|
|
845
|
-
return JSON.stringify(
|
|
846
|
-
{
|
|
847
|
-
ok: false,
|
|
848
|
-
error: "files must be strings and minLevel must be an integer from 0 to 4",
|
|
849
|
-
},
|
|
850
|
-
null,
|
|
851
|
-
2,
|
|
852
|
-
)
|
|
853
|
-
}
|
|
854
|
-
try {
|
|
855
|
-
return JSON.stringify(
|
|
856
|
-
await collectProjectWorkGraph(cwd, {
|
|
857
|
-
...(files === undefined ? {} : { changedFiles: files }),
|
|
858
|
-
...(minLevel === undefined ? {} : { minLevel }),
|
|
859
|
-
}),
|
|
860
|
-
null,
|
|
861
|
-
2,
|
|
862
|
-
)
|
|
863
|
-
} catch (error) {
|
|
864
|
-
return JSON.stringify(
|
|
865
|
-
{ ok: false, error: error instanceof Error ? error.message : "work graph failed" },
|
|
866
|
-
null,
|
|
867
|
-
2,
|
|
868
|
-
)
|
|
869
|
-
}
|
|
870
|
-
},
|
|
871
|
-
},
|
|
872
|
-
{
|
|
873
|
-
name: "nifra_context",
|
|
874
|
-
description:
|
|
875
|
-
"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).",
|
|
876
|
-
inputSchema: {
|
|
877
|
-
type: "object",
|
|
878
|
-
properties: {
|
|
879
|
-
path: {
|
|
880
|
-
type: "string",
|
|
881
|
-
description: "Only routes whose path/pattern starts with this prefix.",
|
|
882
|
-
},
|
|
883
|
-
kind: {
|
|
884
|
-
type: "string",
|
|
885
|
-
enum: ["api", "pages"],
|
|
886
|
-
description: "Limit to API routes or page routes.",
|
|
887
|
-
},
|
|
888
|
-
},
|
|
889
|
-
additionalProperties: false,
|
|
890
|
-
},
|
|
891
|
-
handler: async (args) => {
|
|
892
|
-
const filter = args as { path?: string; kind?: "api" | "pages" }
|
|
893
|
-
return describeProject(await loadAppCached(), filter)
|
|
894
|
-
},
|
|
895
|
-
},
|
|
896
965
|
{
|
|
897
966
|
name: "nifra_explain",
|
|
898
967
|
description:
|
|
@@ -1063,26 +1132,6 @@ export function projectTools(
|
|
|
1063
1132
|
}
|
|
1064
1133
|
},
|
|
1065
1134
|
},
|
|
1066
|
-
{
|
|
1067
|
-
name: "nifra_routes",
|
|
1068
|
-
description:
|
|
1069
|
-
"List this project's API routes as STRUCTURED JSON - each `{ method, path, call, body?, query?, response? }`, where `call` is the exact typed-client call form and the shapes are compact TS-typed contracts. For programmatic use (list_routes / get_route_schema) instead of parsing the nifra_context Markdown. No args = every route; pass `path` (a path or prefix like /api/orders) to narrow to those routes.",
|
|
1070
|
-
inputSchema: {
|
|
1071
|
-
type: "object",
|
|
1072
|
-
properties: {
|
|
1073
|
-
path: {
|
|
1074
|
-
type: "string",
|
|
1075
|
-
description: "Only routes whose path starts with this prefix (omit for all routes).",
|
|
1076
|
-
},
|
|
1077
|
-
},
|
|
1078
|
-
additionalProperties: false,
|
|
1079
|
-
},
|
|
1080
|
-
handler: async (args) => {
|
|
1081
|
-
const { routesToJson } = await import("./introspect.ts")
|
|
1082
|
-
const { path } = args as { path?: string }
|
|
1083
|
-
return JSON.stringify(routesToJson(await loadAppCached(), path), null, 2)
|
|
1084
|
-
},
|
|
1085
|
-
},
|
|
1086
1135
|
{
|
|
1087
1136
|
name: "nifra_openapi",
|
|
1088
1137
|
description:
|
|
@@ -1279,211 +1328,6 @@ export function projectTools(
|
|
|
1279
1328
|
return `${status}\n\n${renderScaffold(path, framework)}`
|
|
1280
1329
|
},
|
|
1281
1330
|
},
|
|
1282
|
-
{
|
|
1283
|
-
name: "nifra_check",
|
|
1284
|
-
description:
|
|
1285
|
-
'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.',
|
|
1286
|
-
inputSchema: {
|
|
1287
|
-
type: "object",
|
|
1288
|
-
properties: {
|
|
1289
|
-
lintsOnly: {
|
|
1290
|
-
type: "boolean",
|
|
1291
|
-
description: "Skip tsc; run only the near-instant source lints (inner-loop mode).",
|
|
1292
|
-
},
|
|
1293
|
-
dir: {
|
|
1294
|
-
type: "string",
|
|
1295
|
-
description:
|
|
1296
|
-
'Run the check in this subdirectory (relative to the project root), e.g. "app" or "packages/api". Use it when the MCP server\'s root is a monorepo but you want to check just one app. Default: the project root.',
|
|
1297
|
-
},
|
|
1298
|
-
},
|
|
1299
|
-
additionalProperties: false,
|
|
1300
|
-
},
|
|
1301
|
-
handler: async (args, context) => {
|
|
1302
|
-
const { collectCheckResult } = await import("./check.ts")
|
|
1303
|
-
const opts = args as { lintsOnly?: boolean; dir?: string }
|
|
1304
|
-
const target = resolveProjectDir(cwd, opts.dir)
|
|
1305
|
-
if (target === null) return dirError(opts.dir)
|
|
1306
|
-
const result = await collectCheckResult(target, {
|
|
1307
|
-
lintsOnly: opts.lintsOnly ?? false,
|
|
1308
|
-
signal: context.signal,
|
|
1309
|
-
// Bound the result so a large project can't emit an MCP message big enough to break the stdio
|
|
1310
|
-
// transport (`-32000: Connection closed`). If `truncated` comes back, fix the shown diagnostics
|
|
1311
|
-
// and re-run. The scan already skips gitignored trees (walkSource), so this is the safety net.
|
|
1312
|
-
maxDiagnostics: 100,
|
|
1313
|
-
})
|
|
1314
|
-
return JSON.stringify(
|
|
1315
|
-
result.structuredDiagnostics === undefined
|
|
1316
|
-
? result
|
|
1317
|
-
: { ...result, diagnostics: result.structuredDiagnostics },
|
|
1318
|
-
null,
|
|
1319
|
-
2,
|
|
1320
|
-
)
|
|
1321
|
-
},
|
|
1322
|
-
},
|
|
1323
|
-
{
|
|
1324
|
-
name: "nifra_fix",
|
|
1325
|
-
description:
|
|
1326
|
-
"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.",
|
|
1327
|
-
inputSchema: {
|
|
1328
|
-
type: "object",
|
|
1329
|
-
properties: {
|
|
1330
|
-
code: {
|
|
1331
|
-
type: "string",
|
|
1332
|
-
description:
|
|
1333
|
-
"Apply the registered recipe for one stable diagnostic code when available.",
|
|
1334
|
-
},
|
|
1335
|
-
},
|
|
1336
|
-
additionalProperties: false,
|
|
1337
|
-
},
|
|
1338
|
-
handler: async (args, context) => {
|
|
1339
|
-
const { collectCheckResult } = await import("./check.ts")
|
|
1340
|
-
const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
|
|
1341
|
-
const { applyDoctorAutoFix } = await import("./doctor.ts")
|
|
1342
|
-
const { resolveInsideProject } = await import("./project-path.ts")
|
|
1343
|
-
const { writeFile, readFile } = await import("node:fs/promises")
|
|
1344
|
-
|
|
1345
|
-
let doctorResult: Awaited<ReturnType<typeof applyDoctorAutoFix>> | null = null
|
|
1346
|
-
try {
|
|
1347
|
-
doctorResult = await applyDoctorAutoFix(cwd)
|
|
1348
|
-
} catch {
|
|
1349
|
-
// ignore doctor errors
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
const checkResult = await collectCheckResult(cwd, {
|
|
1353
|
-
lintsOnly: true,
|
|
1354
|
-
signal: context.signal,
|
|
1355
|
-
maxDiagnostics: 100,
|
|
1356
|
-
})
|
|
1357
|
-
|
|
1358
|
-
const requestedCode = (args as { code?: unknown }).code
|
|
1359
|
-
const recipeFixed: string[] = []
|
|
1360
|
-
if (typeof requestedCode === "string") {
|
|
1361
|
-
for (const diagnostic of checkResult.structuredDiagnostics ?? []) {
|
|
1362
|
-
if (diagnostic.code !== requestedCode) continue
|
|
1363
|
-
recipeFixed.push(...(await applyDiagnosticRecipe(cwd, diagnostic)))
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
const fixed: Array<{ file: string; line: number; title: string }> = []
|
|
1368
|
-
|
|
1369
|
-
for (const diag of checkResult.diagnostics) {
|
|
1370
|
-
if (diag.file && diag.line && diag.suggestion?.kind === "edit" && diag.suggestion.diff) {
|
|
1371
|
-
try {
|
|
1372
|
-
const diffLines = diag.suggestion.diff.split("\n")
|
|
1373
|
-
const beforeLine = diffLines.find((l) => l.startsWith("-"))?.slice(1)
|
|
1374
|
-
const afterLine = diffLines.find((l) => l.startsWith("+"))?.slice(1)
|
|
1375
|
-
if (beforeLine !== undefined && afterLine !== undefined) {
|
|
1376
|
-
const filePath = await resolveInsideProject(cwd, diag.file)
|
|
1377
|
-
if (filePath === undefined) continue
|
|
1378
|
-
const content = await readFile(filePath, "utf-8")
|
|
1379
|
-
const lines = content.split("\n")
|
|
1380
|
-
const idx = diag.line - 1
|
|
1381
|
-
if (lines[idx] === beforeLine) {
|
|
1382
|
-
lines[idx] = afterLine
|
|
1383
|
-
await writeFile(filePath, lines.join("\n"), "utf-8")
|
|
1384
|
-
fixed.push({
|
|
1385
|
-
file: diag.file,
|
|
1386
|
-
line: diag.line,
|
|
1387
|
-
title: diag.suggestion.title,
|
|
1388
|
-
})
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
} catch {
|
|
1392
|
-
// ignore edit errors
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
const finalResult = await collectCheckResult(cwd, {
|
|
1398
|
-
lintsOnly: false,
|
|
1399
|
-
signal: context.signal,
|
|
1400
|
-
maxDiagnostics: 100,
|
|
1401
|
-
})
|
|
1402
|
-
|
|
1403
|
-
return JSON.stringify(
|
|
1404
|
-
{
|
|
1405
|
-
ok: finalResult.ok,
|
|
1406
|
-
fixed,
|
|
1407
|
-
recipeFixed,
|
|
1408
|
-
doctorFixed: doctorResult?.fixed ?? [],
|
|
1409
|
-
remainingDiagnostics: finalResult.diagnostics,
|
|
1410
|
-
},
|
|
1411
|
-
null,
|
|
1412
|
-
2,
|
|
1413
|
-
)
|
|
1414
|
-
},
|
|
1415
|
-
},
|
|
1416
|
-
{
|
|
1417
|
-
name: "nifra_assure",
|
|
1418
|
-
description:
|
|
1419
|
-
"Evaluate nifra.assurance.ts and return the complete route-assurance report: every reflected route's first matching policy rule, enforcement evidence, missing/forbidden evidence, and the fail-closed ok bit. Use after adding or changing routes/security middleware; fix every finding before finishing.",
|
|
1420
|
-
inputSchema: {
|
|
1421
|
-
type: "object",
|
|
1422
|
-
properties: {
|
|
1423
|
-
config: {
|
|
1424
|
-
type: "string",
|
|
1425
|
-
description:
|
|
1426
|
-
"Config path relative to the selected project directory. Default: nifra.assurance.ts.",
|
|
1427
|
-
},
|
|
1428
|
-
bundle: {
|
|
1429
|
-
type: "boolean",
|
|
1430
|
-
description: "Return the single gate bundle with verdict and explicit skipped gates.",
|
|
1431
|
-
},
|
|
1432
|
-
strict: {
|
|
1433
|
-
type: "boolean",
|
|
1434
|
-
description: "Treat warning diagnostics as failures in the bundle.",
|
|
1435
|
-
},
|
|
1436
|
-
hydration: {
|
|
1437
|
-
type: "boolean",
|
|
1438
|
-
description: "Run the hydration assurance gate.",
|
|
1439
|
-
},
|
|
1440
|
-
interact: {
|
|
1441
|
-
type: "boolean",
|
|
1442
|
-
description: "Run declared hydration probes after the client mounts.",
|
|
1443
|
-
},
|
|
1444
|
-
dir: {
|
|
1445
|
-
type: "string",
|
|
1446
|
-
description:
|
|
1447
|
-
'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
|
|
1448
|
-
},
|
|
1449
|
-
},
|
|
1450
|
-
additionalProperties: false,
|
|
1451
|
-
},
|
|
1452
|
-
handler: async (args) => {
|
|
1453
|
-
const opts = args as {
|
|
1454
|
-
config?: string
|
|
1455
|
-
dir?: string
|
|
1456
|
-
bundle?: boolean
|
|
1457
|
-
strict?: boolean
|
|
1458
|
-
hydration?: boolean
|
|
1459
|
-
interact?: boolean
|
|
1460
|
-
}
|
|
1461
|
-
const target = resolveProjectDir(cwd, opts.dir)
|
|
1462
|
-
if (target === null) return dirError(opts.dir)
|
|
1463
|
-
const config = opts.config === undefined ? undefined : resolve(target, opts.config)
|
|
1464
|
-
if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
|
|
1465
|
-
return JSON.stringify(
|
|
1466
|
-
{ ok: false, error: "config must stay inside the selected project directory" },
|
|
1467
|
-
null,
|
|
1468
|
-
2,
|
|
1469
|
-
)
|
|
1470
|
-
}
|
|
1471
|
-
const { collectAssuranceReport, collectAssureBundle } = await import("./assure.ts")
|
|
1472
|
-
if (opts.bundle === true || opts.strict === true || opts.hydration === true) {
|
|
1473
|
-
return JSON.stringify(
|
|
1474
|
-
await collectAssureBundle(target, {
|
|
1475
|
-
...(config === undefined ? {} : { config }),
|
|
1476
|
-
...(opts.strict === undefined ? {} : { strict: opts.strict }),
|
|
1477
|
-
...(opts.hydration === undefined ? {} : { hydration: opts.hydration }),
|
|
1478
|
-
...(opts.interact === undefined ? {} : { interact: opts.interact }),
|
|
1479
|
-
}),
|
|
1480
|
-
null,
|
|
1481
|
-
2,
|
|
1482
|
-
)
|
|
1483
|
-
}
|
|
1484
|
-
return JSON.stringify(await collectAssuranceReport(target, config), null, 2)
|
|
1485
|
-
},
|
|
1486
|
-
},
|
|
1487
1331
|
{
|
|
1488
1332
|
name: "nifra_hydrate",
|
|
1489
1333
|
description: "Run the hydration assurance gate and return stable NF-H diagnostics.",
|
|
@@ -1507,109 +1351,8 @@ export function projectTools(
|
|
|
1507
1351
|
)
|
|
1508
1352
|
},
|
|
1509
1353
|
},
|
|
1510
|
-
{
|
|
1511
|
-
name: "nifra_replay",
|
|
1512
|
-
description: "Validate and dispatch a token-only replay metadata file.",
|
|
1513
|
-
inputSchema: {
|
|
1514
|
-
type: "object",
|
|
1515
|
-
properties: {
|
|
1516
|
-
file: { type: "string", description: "Replay file relative to the project root." },
|
|
1517
|
-
},
|
|
1518
|
-
required: ["file"],
|
|
1519
|
-
additionalProperties: false,
|
|
1520
|
-
},
|
|
1521
|
-
handler: async (args) => {
|
|
1522
|
-
const file = (args as { file?: unknown }).file
|
|
1523
|
-
if (typeof file !== "string" || file.trim() === "")
|
|
1524
|
-
return JSON.stringify({ ok: false, error: "file is required" })
|
|
1525
|
-
const { runReplay } = await import("./replay.ts")
|
|
1526
|
-
try {
|
|
1527
|
-
return JSON.stringify(await runReplay(cwd, file), null, 2)
|
|
1528
|
-
} catch (error) {
|
|
1529
|
-
return JSON.stringify(
|
|
1530
|
-
{ ok: false, error: error instanceof Error ? error.message : String(error) },
|
|
1531
|
-
null,
|
|
1532
|
-
2,
|
|
1533
|
-
)
|
|
1534
|
-
}
|
|
1535
|
-
},
|
|
1536
|
-
},
|
|
1537
|
-
{
|
|
1538
|
-
name: "nifra_levels",
|
|
1539
|
-
description:
|
|
1540
|
-
"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.",
|
|
1541
|
-
inputSchema: {
|
|
1542
|
-
type: "object",
|
|
1543
|
-
properties: {
|
|
1544
|
-
config: {
|
|
1545
|
-
type: "string",
|
|
1546
|
-
description:
|
|
1547
|
-
"Assurance config path relative to the selected project directory. Default: nifra.assurance.ts.",
|
|
1548
|
-
},
|
|
1549
|
-
seed: {
|
|
1550
|
-
type: "number",
|
|
1551
|
-
description: "Deterministic seed for the L4 invariant run. Default 1.",
|
|
1552
|
-
},
|
|
1553
|
-
dir: {
|
|
1554
|
-
type: "string",
|
|
1555
|
-
description:
|
|
1556
|
-
'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
|
|
1557
|
-
},
|
|
1558
|
-
},
|
|
1559
|
-
additionalProperties: false,
|
|
1560
|
-
},
|
|
1561
|
-
handler: async (args) => {
|
|
1562
|
-
const opts = args as { config?: string; seed?: number; dir?: string }
|
|
1563
|
-
const target = resolveProjectDir(cwd, opts.dir)
|
|
1564
|
-
if (target === null) return dirError(opts.dir)
|
|
1565
|
-
if (opts.seed !== undefined && !Number.isSafeInteger(opts.seed)) {
|
|
1566
|
-
return JSON.stringify({ ok: false, error: "seed must be a safe integer" }, null, 2)
|
|
1567
|
-
}
|
|
1568
|
-
const config = opts.config === undefined ? undefined : resolve(target, opts.config)
|
|
1569
|
-
if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
|
|
1570
|
-
return JSON.stringify(
|
|
1571
|
-
{ ok: false, error: "config must stay inside the selected project directory" },
|
|
1572
|
-
null,
|
|
1573
|
-
2,
|
|
1574
|
-
)
|
|
1575
|
-
}
|
|
1576
|
-
const { collectVerificationLevels } = await import("./levels-tool.ts")
|
|
1577
|
-
return JSON.stringify(
|
|
1578
|
-
await collectVerificationLevels(target, {
|
|
1579
|
-
...(opts.config === undefined ? {} : { config: opts.config }),
|
|
1580
|
-
...(opts.seed === undefined ? {} : { seed: opts.seed }),
|
|
1581
|
-
}),
|
|
1582
|
-
null,
|
|
1583
|
-
2,
|
|
1584
|
-
)
|
|
1585
|
-
},
|
|
1586
|
-
},
|
|
1587
|
-
{
|
|
1588
|
-
name: "nifra_doctor",
|
|
1589
|
-
description:
|
|
1590
|
-
"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.",
|
|
1591
|
-
inputSchema: {
|
|
1592
|
-
type: "object",
|
|
1593
|
-
properties: {
|
|
1594
|
-
autoFix: {
|
|
1595
|
-
type: "boolean",
|
|
1596
|
-
description:
|
|
1597
|
-
"When true, write safe package.json fixes using only locally inferred versions. Does not run install or use the network.",
|
|
1598
|
-
},
|
|
1599
|
-
},
|
|
1600
|
-
additionalProperties: false,
|
|
1601
|
-
},
|
|
1602
|
-
handler: async (args) => {
|
|
1603
|
-
const opts = args as { autoFix?: boolean }
|
|
1604
|
-
const { applyDoctorAutoFix, collectDoctorResult } = await import("./doctor.ts")
|
|
1605
|
-
return JSON.stringify(
|
|
1606
|
-
opts.autoFix === true ? await applyDoctorAutoFix(cwd) : await collectDoctorResult(cwd),
|
|
1607
|
-
null,
|
|
1608
|
-
2,
|
|
1609
|
-
)
|
|
1610
|
-
},
|
|
1611
|
-
},
|
|
1612
1354
|
]
|
|
1355
|
+
return [...catalogTools, ...legacyTools]
|
|
1613
1356
|
}
|
|
1614
1357
|
|
|
1615
1358
|
type ToolBackend = {
|