@nifrajs/cli 3.2.0 → 3.5.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 (119) hide show
  1. package/dist/check-diagnostics.d.ts +3 -15
  2. package/dist/check-diagnostics.d.ts.map +1 -1
  3. package/dist/check-diagnostics.js +149 -713
  4. package/dist/check-diagnostics.js.map +1 -1
  5. package/dist/check-scan.d.ts +9 -0
  6. package/dist/check-scan.d.ts.map +1 -1
  7. package/dist/check-scan.js +32 -0
  8. package/dist/check-scan.js.map +1 -1
  9. package/dist/check.d.ts +1 -0
  10. package/dist/check.d.ts.map +1 -1
  11. package/dist/check.js +63 -8
  12. package/dist/check.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +46 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/command-catalog.d.ts +4 -1
  17. package/dist/command-catalog.d.ts.map +1 -1
  18. package/dist/command-catalog.js +28 -7
  19. package/dist/command-catalog.js.map +1 -1
  20. package/dist/contracts.d.ts +3 -2
  21. package/dist/contracts.d.ts.map +1 -1
  22. package/dist/contracts.js +4 -3
  23. package/dist/contracts.js.map +1 -1
  24. package/dist/diagnostics.d.ts +86 -0
  25. package/dist/diagnostics.d.ts.map +1 -1
  26. package/dist/diagnostics.js +85 -0
  27. package/dist/diagnostics.js.map +1 -1
  28. package/dist/introspect.d.ts +1 -3
  29. package/dist/introspect.d.ts.map +1 -1
  30. package/dist/introspect.js +1 -47
  31. package/dist/introspect.js.map +1 -1
  32. package/dist/load.d.ts +5 -0
  33. package/dist/load.d.ts.map +1 -1
  34. package/dist/load.js.map +1 -1
  35. package/dist/mcp-context.d.ts.map +1 -1
  36. package/dist/mcp-context.js +4 -5
  37. package/dist/mcp-context.js.map +1 -1
  38. package/dist/mcp-exec.d.ts +1 -0
  39. package/dist/mcp-exec.d.ts.map +1 -1
  40. package/dist/mcp-exec.js +133 -25
  41. package/dist/mcp-exec.js.map +1 -1
  42. package/dist/mcp-http.d.ts.map +1 -1
  43. package/dist/mcp-http.js +4 -1
  44. package/dist/mcp-http.js.map +1 -1
  45. package/dist/mcp-io.d.ts +12 -0
  46. package/dist/mcp-io.d.ts.map +1 -1
  47. package/dist/mcp-io.js +68 -0
  48. package/dist/mcp-io.js.map +1 -1
  49. package/dist/mcp-path.d.ts +10 -0
  50. package/dist/mcp-path.d.ts.map +1 -0
  51. package/dist/mcp-path.js +51 -0
  52. package/dist/mcp-path.js.map +1 -0
  53. package/dist/mcp-reflect.d.ts.map +1 -1
  54. package/dist/mcp-reflect.js +2 -2
  55. package/dist/mcp-reflect.js.map +1 -1
  56. package/dist/mcp-render.d.ts.map +1 -1
  57. package/dist/mcp-render.js +62 -43
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-root.d.ts.map +1 -1
  60. package/dist/mcp-root.js +14 -2
  61. package/dist/mcp-root.js.map +1 -1
  62. package/dist/mcp-run.d.ts.map +1 -1
  63. package/dist/mcp-run.js +82 -51
  64. package/dist/mcp-run.js.map +1 -1
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.d.ts.map +1 -1
  67. package/dist/mcp.js +84 -29
  68. package/dist/mcp.js.map +1 -1
  69. package/dist/project-facts.d.ts +23 -4
  70. package/dist/project-facts.d.ts.map +1 -1
  71. package/dist/project-facts.js +12 -2
  72. package/dist/project-facts.js.map +1 -1
  73. package/dist/review.d.ts +21 -0
  74. package/dist/review.d.ts.map +1 -0
  75. package/dist/review.js +819 -0
  76. package/dist/review.js.map +1 -0
  77. package/dist/rules/legacy.d.ts +3 -1
  78. package/dist/rules/legacy.d.ts.map +1 -1
  79. package/dist/rules/legacy.js +796 -34
  80. package/dist/rules/legacy.js.map +1 -1
  81. package/dist/scaffold.d.ts.map +1 -1
  82. package/dist/scaffold.js +23 -4
  83. package/dist/scaffold.js.map +1 -1
  84. package/dist/verification-plan.d.ts.map +1 -1
  85. package/dist/verification-plan.js +4 -0
  86. package/dist/verification-plan.js.map +1 -1
  87. package/dist/work-graph.d.ts +3 -0
  88. package/dist/work-graph.d.ts.map +1 -1
  89. package/dist/work-graph.js +4 -3
  90. package/dist/work-graph.js.map +1 -1
  91. package/docs/examples.json +10 -4
  92. package/docs/llms-full.txt +500 -46
  93. package/docs/types.json +1123 -82
  94. package/package.json +10 -9
  95. package/src/check-diagnostics.ts +181 -796
  96. package/src/check-scan.ts +36 -0
  97. package/src/check.ts +65 -12
  98. package/src/cli.ts +53 -7
  99. package/src/command-catalog.ts +33 -7
  100. package/src/contracts.ts +12 -3
  101. package/src/diagnostics.ts +179 -0
  102. package/src/introspect.ts +3 -55
  103. package/src/load.ts +5 -0
  104. package/src/mcp-context.ts +4 -6
  105. package/src/mcp-exec.ts +142 -30
  106. package/src/mcp-http.ts +4 -1
  107. package/src/mcp-io.ts +73 -0
  108. package/src/mcp-path.ts +50 -0
  109. package/src/mcp-reflect.ts +3 -2
  110. package/src/mcp-render.ts +70 -40
  111. package/src/mcp-root.ts +13 -2
  112. package/src/mcp-run.ts +90 -50
  113. package/src/mcp.ts +87 -27
  114. package/src/project-facts.ts +37 -8
  115. package/src/review.ts +983 -0
  116. package/src/rules/legacy.ts +915 -35
  117. package/src/scaffold.ts +29 -4
  118. package/src/verification-plan.ts +14 -0
  119. package/src/work-graph.ts +13 -3
package/src/introspect.ts CHANGED
@@ -7,74 +7,22 @@
7
7
 
8
8
  import { reservedKeyFor } from "@nifrajs/client"
9
9
  import {
10
- type ProjectEvidenceSchema,
11
10
  type ProjectEvidenceSchemaPart,
12
11
  type ProjectEvidenceSnapshot,
12
+ reflectedRoutesFromEvidence,
13
13
  snapshotProjectEvidence,
14
14
  } from "@nifrajs/core/evidence"
15
- import type { ReflectedRoute, SchemaReflection } from "@nifrajs/core/reflection"
15
+ import type { ReflectedRoute } from "@nifrajs/core/reflection"
16
16
  import type { Manifest } from "@nifrajs/web"
17
17
  import { discoverRoutes } from "@nifrajs/web/fs"
18
18
  import type { LoadedApp } from "./load.ts"
19
19
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
20
20
 
21
21
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
22
- export function backendRoutes(backend: unknown): ReflectedRoute[] {
22
+ export function backendRoutes(backend: unknown): readonly ReflectedRoute[] {
23
23
  return reflectedRoutesFromEvidence(snapshotProjectEvidence(backend))
24
24
  }
25
25
 
26
- function schemaPartToReflection(
27
- value: ProjectEvidenceSchemaPart | undefined,
28
- ): SchemaReflection | undefined {
29
- return value === undefined
30
- ? undefined
31
- : { standard: undefined, jsonSchema: value.jsonSchema, fields: value.fields }
32
- }
33
-
34
- function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
35
- if (schema === undefined) return undefined
36
- const errors = Object.fromEntries(
37
- Object.entries(schema.errors ?? {}).map(([status, value]) => [
38
- status,
39
- schemaPartToReflection(value) as SchemaReflection,
40
- ]),
41
- )
42
- const headers = schemaPartToReflection(schema.headers)
43
- const body = schemaPartToReflection(schema.body)
44
- const query = schemaPartToReflection(schema.query)
45
- const params = schemaPartToReflection(schema.params)
46
- const response = schemaPartToReflection(schema.response)
47
- const sse = schemaPartToReflection(schema.sse)
48
- return {
49
- ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
50
- ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
51
- ...(headers !== undefined ? { headers } : {}),
52
- ...(body !== undefined ? { body } : {}),
53
- ...(query !== undefined ? { query } : {}),
54
- ...(params !== undefined ? { params } : {}),
55
- ...(response !== undefined ? { response } : {}),
56
- ...(Object.keys(errors).length > 0 ? { errors } : {}),
57
- ...(sse !== undefined ? { sse } : {}),
58
- }
59
- }
60
-
61
- /** Adapt the canonical token-only snapshot to the legacy reflection view used by Markdown helpers. */
62
- export function reflectedRoutesFromEvidence(evidence: ProjectEvidenceSnapshot): ReflectedRoute[] {
63
- return evidence.routes.map((route) => {
64
- const schema = schemaFromEvidence(route.schema)
65
- return {
66
- method: route.method,
67
- path: route.path,
68
- ...(schema !== undefined ? { schema } : {}),
69
- ...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
70
- ...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
71
- ...(route.family === true ? { family: true } : {}),
72
- ...(route.classification !== undefined ? { classification: route.classification } : {}),
73
- ...(route.tool !== undefined ? { tool: route.tool } : {}),
74
- }
75
- })
76
- }
77
-
78
26
  interface JsonSchemaNode {
79
27
  readonly type?: unknown
80
28
  readonly properties?: Record<string, unknown>
package/src/load.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { existsSync } from "node:fs"
16
16
  import { isAbsolute, resolve } from "node:path"
17
+ import type { CssLoadingMode } from "@nifrajs/web"
17
18
  import { checkPipelineSeparation } from "./pipeline-guard.ts"
18
19
 
19
20
  /**
@@ -60,6 +61,10 @@ export interface NifraFramework {
60
61
  readonly publicDir?: string | false
61
62
  /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
62
63
  readonly publicEnvPrefix?: string
64
+ /** Vite production CSS output policy. `false` emits one aggregate stylesheet for the whole client. */
65
+ readonly cssCodeSplit?: boolean
66
+ /** Framework-owned SSR stylesheet activation. Deferred mode is intended for aggregate CSS. */
67
+ readonly cssLoading?: CssLoadingMode
63
68
  }
64
69
 
65
70
  /**
@@ -7,9 +7,10 @@
7
7
 
8
8
  import { realpathSync } from "node:fs"
9
9
  import { readFile } from "node:fs/promises"
10
- import { basename, isAbsolute, relative, resolve, sep } from "node:path"
10
+ import { basename, isAbsolute, relative, resolve } from "node:path"
11
11
  import type { LoadedApp } from "./load.ts"
12
12
  import { createCachedAppLoader } from "./mcp-exec.ts"
13
+ import { resolveMcpProjectPath } from "./mcp-path.ts"
13
14
  import type { McpPrompt, McpResource, McpServerFeatures, McpTool } from "./mcp-protocol.ts"
14
15
 
15
16
  function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
@@ -31,11 +32,8 @@ async function readProjectFile(
31
32
  relativeFile: string,
32
33
  maxChars: number,
33
34
  ): Promise<string> {
34
- const target = resolve(cwd, relativeFile)
35
- const root = resolve(cwd)
36
- if (target !== root && !target.startsWith(`${root}${sep}`)) {
37
- throw new Error(`refusing to read outside project root: ${relativeFile}`)
38
- }
35
+ const target = resolveMcpProjectPath(cwd, relativeFile)
36
+ if (target === null) throw new Error(`refusing to read outside project root: ${relativeFile}`)
39
37
  try {
40
38
  const text = await readFile(target, "utf8")
41
39
  return text.length <= maxChars
package/src/mcp-exec.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { stat } from "node:fs/promises"
10
- import { resolve, sep } from "node:path"
10
+ import { resolve } from "node:path"
11
11
  import { fileURLToPath } from "node:url"
12
12
  import { Glob } from "bun"
13
13
  import {
@@ -30,6 +30,7 @@ import {
30
30
  } from "./mcp-context.ts"
31
31
  import { docsTools } from "./mcp-docs-tools.ts"
32
32
  import {
33
+ CHILD_INPUT_MAX_BYTES,
33
34
  CHILD_OUTPUT_MAX_BYTES,
34
35
  CHILD_TIMEOUT_MS,
35
36
  LOCAL_TOOL_FETCH_TIMEOUT_MS,
@@ -39,6 +40,7 @@ import {
39
40
  timeoutMessage,
40
41
  validateLocalPort,
41
42
  } from "./mcp-io.ts"
43
+ import { mcpProjectPathError, resolveMcpProjectPath } from "./mcp-path.ts"
42
44
  import type { McpTool, McpToolContext } from "./mcp-protocol.ts"
43
45
  import { loadTypesCorpus } from "./types-search.ts"
44
46
 
@@ -54,6 +56,7 @@ export function toMcpTool(
54
56
  const entry = "run" in source ? undefined : source
55
57
  const { cwd } = options
56
58
  const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
59
+ const pathFields = ["config", "out", "lockfile", "before", "after", "baseline", "file"] as const
57
60
  return {
58
61
  name: commandMcpName(entry?.name ?? spec.name),
59
62
  description: entry?.summary ?? spec.summary,
@@ -64,14 +67,28 @@ export function toMcpTool(
64
67
  if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
65
68
  const target = resolveProjectDir(cwd, dir as string | undefined)
66
69
  if (target === null) return dirError(dir as string | undefined)
67
- if (typeof raw.config === "string") {
68
- const config = resolve(target, raw.config)
69
- if (config !== target && !config.startsWith(`${target}${sep}`))
70
- return JSON.stringify(
71
- { ok: false, error: "config must stay inside the selected project directory" },
72
- null,
73
- 2,
74
- )
70
+ for (const field of pathFields) {
71
+ const value = raw[field]
72
+ if (typeof value !== "string") continue
73
+ const safe = resolveMcpProjectPath(target, value)
74
+ if (safe === null)
75
+ return JSON.stringify({ ok: false, error: mcpProjectPathError(field, value) }, null, 2)
76
+ raw[field] = safe
77
+ }
78
+ if (Array.isArray(raw.files)) {
79
+ const safeFiles: string[] = []
80
+ for (const value of raw.files) {
81
+ if (typeof value !== "string") continue
82
+ const safe = resolveMcpProjectPath(target, value)
83
+ if (safe === null)
84
+ return JSON.stringify(
85
+ { ok: false, error: mcpProjectPathError("files", value) },
86
+ null,
87
+ 2,
88
+ )
89
+ safeFiles.push(safe)
90
+ }
91
+ raw.files = safeFiles
75
92
  }
76
93
  delete raw.dir
77
94
  let input: unknown
@@ -194,10 +211,21 @@ export function projectTools(
194
211
  if (baseline !== undefined && baseline.trim() === "") {
195
212
  return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2)
196
213
  }
214
+ const safeBaseline =
215
+ baseline === undefined ? undefined : resolveMcpProjectPath(target, baseline)
216
+ if (baseline !== undefined && safeBaseline === null) {
217
+ return JSON.stringify(
218
+ { ok: false, error: mcpProjectPathError("baseline", baseline) },
219
+ null,
220
+ 2,
221
+ )
222
+ }
197
223
  try {
198
224
  return JSON.stringify(
199
225
  await collectContractProof(target, {
200
- ...(baseline === undefined ? {} : { baselinePath: baseline }),
226
+ ...(safeBaseline === undefined || safeBaseline === null
227
+ ? {}
228
+ : { baselinePath: safeBaseline }),
201
229
  ...(raw.check === undefined ? {} : { check: raw.check === true }),
202
230
  }),
203
231
  null,
@@ -641,6 +669,15 @@ export async function spawnChild(
641
669
  signal?: AbortSignal,
642
670
  ): Promise<string> {
643
671
  if (signal?.aborted) return `${label} cancelled before it started.`
672
+ let encodedInput: string
673
+ try {
674
+ encodedInput = JSON.stringify(input)
675
+ } catch {
676
+ return `${label} input is not serializable.`
677
+ }
678
+ if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
679
+ return `${label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes.`
680
+ if (signal?.aborted) return `${label} cancelled${cancellationSuffix(signal)}.`
644
681
  const proc = Bun.spawn(["bun", childPath(child), cwd], {
645
682
  stdin: "pipe",
646
683
  stdout: "pipe",
@@ -661,8 +698,11 @@ export async function spawnChild(
661
698
  }, CHILD_TIMEOUT_MS)
662
699
  let outputExceeded = false
663
700
  try {
664
- proc.stdin.write(JSON.stringify(input))
665
- await proc.stdin.end()
701
+ if (signal?.aborted) kill()
702
+ else {
703
+ proc.stdin.write(encodedInput)
704
+ await proc.stdin.end()
705
+ }
666
706
  const [out, err] = await Promise.all([
667
707
  readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
668
708
  outputExceeded = true
@@ -675,7 +715,7 @@ export async function spawnChild(
675
715
  ])
676
716
  await proc.exited
677
717
  if (signal?.aborted) {
678
- const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
718
+ const reason = cancellationSuffix(signal)
679
719
  return `${label} cancelled${reason}.`
680
720
  }
681
721
  if (outputExceeded) {
@@ -696,6 +736,7 @@ const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
696
736
  const WARM_RUN_IGNORED =
697
737
  /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
698
738
  const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"] as const
739
+ const MAX_WARM_PENDING = 64
699
740
 
700
741
  async function warmRunFingerprint(cwd: string): Promise<string> {
701
742
  const parts: string[] = []
@@ -743,6 +784,7 @@ export class WarmWorker {
743
784
  }
744
785
  >()
745
786
  private stdoutBuffer = ""
787
+ private stdoutBufferBytes = 0
746
788
  private stderrBuffer = ""
747
789
  private nextId = 0
748
790
  private closed = false
@@ -780,7 +822,18 @@ export class WarmWorker {
780
822
  async request(input: unknown, signal?: AbortSignal): Promise<string> {
781
823
  if (this.closed) throw new Error(`warm ${this.label} worker is closed`)
782
824
  if (signal?.aborted) return `${this.label} cancelled before it started.`
825
+ if (this.pending.size >= MAX_WARM_PENDING)
826
+ throw new Error(`warm ${this.label} worker request limit reached`)
783
827
  const id = ++this.nextId
828
+ let encodedInput: string
829
+ try {
830
+ encodedInput = JSON.stringify({ id, input })
831
+ } catch {
832
+ throw new Error(`warm ${this.label} input is not serializable`)
833
+ }
834
+ if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
835
+ throw new Error(`warm ${this.label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes`)
836
+ if (signal?.aborted) return `${this.label} cancelled${cancellationSuffix(signal)}.`
784
837
  return new Promise((resolve, reject) => {
785
838
  const abort = (): void => {
786
839
  // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
@@ -789,7 +842,8 @@ export class WarmWorker {
789
842
  // cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
790
843
  this.pending.delete(id)
791
844
  clearTimeout(timer)
792
- const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
845
+ signal?.removeEventListener("abort", abort)
846
+ const reason = cancellationSuffix(signal)
793
847
  resolve(`${this.label} cancelled${reason}.`)
794
848
  }
795
849
  // Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
@@ -807,8 +861,15 @@ export class WarmWorker {
807
861
  }
808
862
  signal?.addEventListener("abort", abort, { once: true })
809
863
  this.pending.set(id, { resolve, reject, cleanup })
864
+ // JSON serialization above can run user-defined `toJSON` hooks. Recheck after installing the
865
+ // listener and pending entry so an abort during that synchronous work cannot enqueue a request
866
+ // after cancellation, and so an already-aborted signal does not retain the listener.
867
+ if (signal?.aborted) {
868
+ abort()
869
+ return
870
+ }
810
871
  try {
811
- this.proc.stdin.write(`${JSON.stringify({ id, input })}\n`)
872
+ this.proc.stdin.write(`${encodedInput}\n`)
812
873
  } catch (err) {
813
874
  this.pending.delete(id)
814
875
  cleanup()
@@ -831,8 +892,10 @@ export class WarmWorker {
831
892
  this.stop()
832
893
  return
833
894
  }
834
- this.stdoutBuffer += decoder.decode(value, { stream: true })
835
- if (this.stdoutBuffer.length > CHILD_OUTPUT_MAX_BYTES) {
895
+ const text = decoder.decode(value, { stream: true })
896
+ this.stdoutBuffer += text
897
+ this.stdoutBufferBytes += new TextEncoder().encode(text).byteLength
898
+ if (this.stdoutBufferBytes > CHILD_OUTPUT_MAX_BYTES) {
836
899
  this.stderrBuffer = boundedAppend(
837
900
  this.stderrBuffer,
838
901
  `warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
@@ -844,6 +907,7 @@ export class WarmWorker {
844
907
  while (nl !== -1) {
845
908
  const line = this.stdoutBuffer.slice(0, nl).trim()
846
909
  this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1)
910
+ this.stdoutBufferBytes = new TextEncoder().encode(this.stdoutBuffer).byteLength
847
911
  nl = this.stdoutBuffer.indexOf("\n")
848
912
  if (line !== "") this.handleLine(line)
849
913
  }
@@ -861,25 +925,69 @@ export class WarmWorker {
861
925
  }
862
926
 
863
927
  private handleLine(line: string): void {
864
- let message: { id?: unknown; output?: unknown }
928
+ let message: unknown
865
929
  try {
866
- message = JSON.parse(line) as { id?: unknown; output?: unknown }
930
+ message = JSON.parse(line) as unknown
867
931
  } catch {
868
932
  this.stderrBuffer = boundedAppend(
869
933
  this.stderrBuffer,
870
934
  `invalid warm ${this.label} worker line: ${line}\n`,
871
935
  )
936
+ this.stop()
937
+ return
938
+ }
939
+ if (
940
+ message === null ||
941
+ typeof message !== "object" ||
942
+ Array.isArray(message) ||
943
+ typeof (message as { id?: unknown }).id !== "number" ||
944
+ !Number.isSafeInteger((message as { id: number }).id)
945
+ ) {
946
+ this.stderrBuffer = boundedAppend(
947
+ this.stderrBuffer,
948
+ `invalid warm ${this.label} worker response envelope\n`,
949
+ )
950
+ this.stop()
872
951
  return
873
952
  }
874
- if (typeof message.id !== "number") return
875
- const pending = this.pending.get(message.id)
953
+ const response = message as { id: number; output?: unknown }
954
+ const pending = this.pending.get(response.id)
876
955
  if (pending === undefined) return
877
- this.pending.delete(message.id)
956
+ if (!Object.hasOwn(response, "output")) {
957
+ this.pending.delete(response.id)
958
+ pending.cleanup()
959
+ pending.reject(new Error(`warm ${this.label} worker response is malformed`))
960
+ this.stop()
961
+ return
962
+ }
963
+ let output: string
964
+ try {
965
+ output = JSON.stringify(response.output, null, 2)
966
+ } catch {
967
+ this.pending.delete(response.id)
968
+ pending.cleanup()
969
+ pending.reject(new Error(`warm ${this.label} worker response is not serializable`))
970
+ this.stop()
971
+ return
972
+ }
973
+ if (new TextEncoder().encode(output).byteLength > CHILD_OUTPUT_MAX_BYTES) {
974
+ this.pending.delete(response.id)
975
+ pending.cleanup()
976
+ pending.reject(new Error(`warm ${this.label} worker response exceeded the size limit`))
977
+ this.stop()
978
+ return
979
+ }
980
+ this.pending.delete(response.id)
878
981
  pending.cleanup()
879
- pending.resolve(JSON.stringify(message.output, null, 2))
982
+ pending.resolve(output)
880
983
  }
881
984
  }
882
985
 
986
+ function cancellationSuffix(signal: AbortSignal | undefined): string {
987
+ const reason = signal?.reason
988
+ return typeof reason === "string" && reason.length > 0 ? `: ${reason.slice(0, 512)}` : ""
989
+ }
990
+
883
991
  /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
884
992
  * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
885
993
  * `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
@@ -892,17 +1000,21 @@ export function createWarmHandler(
892
1000
  let worker: WarmWorker | undefined
893
1001
  return async (input, signal) => {
894
1002
  const fingerprint = await warmRunFingerprint(cwd)
895
- if (worker === undefined || worker.fingerprint !== fingerprint) {
896
- worker?.stop()
897
- worker = new WarmWorker(child, cwd, fingerprint, label)
1003
+ let current = worker
1004
+ if (current === undefined || current.fingerprint !== fingerprint) {
1005
+ current?.stop()
1006
+ current = new WarmWorker(child, cwd, fingerprint, label)
1007
+ worker = current
898
1008
  }
899
1009
  try {
900
- return await worker.request(input, signal)
1010
+ return await current.request(input, signal)
901
1011
  } catch {
902
- worker.stop()
903
- worker = undefined
1012
+ // A source change can replace the shared worker while this request is in flight. Only clear
1013
+ // the slot if this request still owns it; an old request must never tear down the replacement.
1014
+ current.stop()
1015
+ if (worker === current) worker = undefined
904
1016
  if (signal?.aborted) {
905
- const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
1017
+ const reason = cancellationSuffix(signal)
906
1018
  return `${label} cancelled${reason}.`
907
1019
  }
908
1020
  return spawnChild(child, cwd, input, label, signal)
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "3.2.0"
32
+ const VERSION = "3.5.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>
@@ -69,6 +69,9 @@ export function handleMcpHttp(request: Request): Promise<Response> {
69
69
  instructions:
70
70
  "nifra's own documentation, runnable examples, and API types - for building with the nifra framework. Call nifra_docs / nifra_example / nifra_types / nifra_learn to learn it without a local checkout.",
71
71
  },
72
+ // This is the intentionally public, secret-free docs server. Other MCP mounts use the
73
+ // transport's same-origin default unless they explicitly opt into public cross-origin access.
74
+ allowAnyOrigin: true,
72
75
  })
73
76
  }
74
77
 
package/src/mcp-io.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  export const CHILD_TIMEOUT_MS = 30_000
9
9
  /** Maximum stdout or stderr retained from a project subprocess. The process is killed at the limit. */
10
10
  export const CHILD_OUTPUT_MAX_BYTES = 1_048_576
11
+ /** Maximum serialized request sent to a project subprocess or warm worker. */
12
+ export const CHILD_INPUT_MAX_BYTES = 4 * 1024 * 1024
11
13
 
12
14
  /** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
13
15
  * buffer an unrelated loopback service just because a caller supplied its port. */
@@ -81,6 +83,77 @@ export interface BoundedOutput {
81
83
  readonly truncated: boolean
82
84
  }
83
85
 
86
+ export type BoundedLine =
87
+ | { readonly kind: "line"; readonly text: string }
88
+ | { readonly kind: "too-large" }
89
+
90
+ function decodeLine(chunks: readonly Uint8Array[], totalBytes: number): string {
91
+ const bytes = new Uint8Array(totalBytes)
92
+ let offset = 0
93
+ for (const chunk of chunks) {
94
+ bytes.set(chunk, offset)
95
+ offset += chunk.byteLength
96
+ }
97
+ return new TextDecoder().decode(bytes)
98
+ }
99
+
100
+ /** Read newline-delimited input without retaining a line larger than the configured byte budget. */
101
+ export async function* readBoundedLines(
102
+ stream: ReadableStream<Uint8Array>,
103
+ maxBytes = CHILD_INPUT_MAX_BYTES,
104
+ ): AsyncGenerator<BoundedLine> {
105
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
106
+ throw new RangeError("maxBytes must be a positive safe integer")
107
+ const chunks: Uint8Array[] = []
108
+ let totalBytes = 0
109
+ let tooLarge = false
110
+ for await (const raw of stream) {
111
+ const value = raw as Uint8Array
112
+ let start = 0
113
+ while (start < value.byteLength) {
114
+ const newline = value.indexOf(0x0a, start)
115
+ const end = newline === -1 ? value.byteLength : newline
116
+ const segment = value.subarray(start, end)
117
+ if (!tooLarge) {
118
+ if (totalBytes + segment.byteLength > maxBytes) {
119
+ tooLarge = true
120
+ chunks.length = 0
121
+ totalBytes = 0
122
+ } else if (segment.byteLength > 0) {
123
+ chunks.push(segment)
124
+ totalBytes += segment.byteLength
125
+ }
126
+ }
127
+ if (newline === -1) break
128
+ if (tooLarge) yield { kind: "too-large" }
129
+ else yield { kind: "line", text: decodeLine(chunks, totalBytes) }
130
+ chunks.length = 0
131
+ totalBytes = 0
132
+ tooLarge = false
133
+ start = newline + 1
134
+ }
135
+ }
136
+ if (tooLarge) yield { kind: "too-large" }
137
+ else if (totalBytes > 0) yield { kind: "line", text: decodeLine(chunks, totalBytes) }
138
+ }
139
+
140
+ /** Serialize a JSON value only when its UTF-8 representation fits within the byte budget. */
141
+ export function serializeBoundedJson(
142
+ value: unknown,
143
+ maxBytes: number,
144
+ space?: string | number,
145
+ ): string | undefined {
146
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return undefined
147
+ let text: string | undefined
148
+ try {
149
+ text = JSON.stringify(value, null, space)
150
+ } catch {
151
+ return undefined
152
+ }
153
+ if (text === undefined || new TextEncoder().encode(text).byteLength > maxBytes) return undefined
154
+ return text
155
+ }
156
+
84
157
  /** Read child output incrementally and cancel as soon as the byte budget is crossed. */
85
158
  export async function readBoundedStream(
86
159
  stream: ReadableStream<Uint8Array>,
@@ -0,0 +1,50 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { basename, isAbsolute, relative, resolve } from "node:path"
3
+
4
+ /**
5
+ * Resolve an MCP path without allowing lexical or symlink escapes from the selected project.
6
+ *
7
+ * MCP callers are not equivalent to a local CLI user: the selected checkout is their filesystem
8
+ * capability boundary. The nearest-existing-ancestor walk also supports output files that do not
9
+ * exist yet while still canonicalizing every existing path component.
10
+ */
11
+ export function resolveMcpProjectPath(root: string, value: string): string | null {
12
+ if (value.length === 0 || isAbsolute(value)) return null
13
+
14
+ const lexicalRoot = resolve(root)
15
+ const target = resolve(lexicalRoot, value)
16
+ const lexicalRelative = relative(lexicalRoot, target)
17
+ if (lexicalRelative.startsWith("..") || isAbsolute(lexicalRelative)) return null
18
+
19
+ let canonicalRoot: string
20
+ try {
21
+ canonicalRoot = realpathSync(lexicalRoot)
22
+ } catch {
23
+ return null
24
+ }
25
+
26
+ let probe = target
27
+ const missing: string[] = []
28
+ while (true) {
29
+ try {
30
+ const canonicalProbe = realpathSync(probe)
31
+ const canonicalRelative = relative(canonicalRoot, canonicalProbe)
32
+ if (canonicalRelative.startsWith("..") || isAbsolute(canonicalRelative)) return null
33
+
34
+ const candidate = resolve(canonicalProbe, ...missing.reverse())
35
+ const candidateRelative = relative(canonicalRoot, candidate)
36
+ return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
37
+ } catch (error) {
38
+ if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT"))
39
+ return null
40
+ const parent = resolve(probe, "..")
41
+ if (parent === probe) return null
42
+ missing.push(basename(probe))
43
+ probe = parent
44
+ }
45
+ }
46
+ }
47
+
48
+ export function mcpProjectPathError(label: string, value: string): string {
49
+ return `${label} must be a relative path inside the selected project directory: ${value}`
50
+ }
@@ -5,7 +5,8 @@
5
5
  * about stdio, HTTP, root trust, or project loading, so the reflection seam is independently testable.
6
6
  */
7
7
 
8
- import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
8
+ import { reflectedRoutesFromEvidence, snapshotProjectEvidence } from "@nifrajs/core/evidence"
9
+ import type { ReflectedRoute } from "@nifrajs/core/reflection"
9
10
  import type {
10
11
  McpPrompt,
11
12
  McpPromptMessage,
@@ -24,7 +25,7 @@ type ToolBackend = {
24
25
  export function extractBackendTools(backend: unknown): McpTool[] {
25
26
  const b = backend as ToolBackend | null
26
27
  if (!b || typeof b.routes !== "function") return []
27
- const routes = reflectRoutes(b)
28
+ const routes = reflectedRoutesFromEvidence(snapshotProjectEvidence(b))
28
29
 
29
30
  return routes
30
31
  .filter(