@nifrajs/cli 1.13.0 → 2.0.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 (47) hide show
  1. package/README.md +17 -2
  2. package/dist/check.d.ts +17 -6
  3. package/dist/check.d.ts.map +1 -1
  4. package/dist/check.js +256 -15
  5. package/dist/check.js.map +1 -1
  6. package/dist/cli.d.ts +2 -3
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +58 -71
  9. package/dist/cli.js.map +1 -1
  10. package/dist/doctor.d.ts.map +1 -1
  11. package/dist/doctor.js +108 -48
  12. package/dist/doctor.js.map +1 -1
  13. package/dist/mcp-http.js +1 -1
  14. package/dist/mcp-http.js.map +1 -1
  15. package/dist/mcp.d.ts +2 -0
  16. package/dist/mcp.d.ts.map +1 -1
  17. package/dist/mcp.js +42 -0
  18. package/dist/mcp.js.map +1 -1
  19. package/dist/recipes/2.0.0.d.ts +9 -0
  20. package/dist/recipes/2.0.0.d.ts.map +1 -0
  21. package/dist/recipes/2.0.0.js +25 -0
  22. package/dist/recipes/2.0.0.js.map +1 -0
  23. package/dist/recipes/index.d.ts +7 -0
  24. package/dist/recipes/index.d.ts.map +1 -1
  25. package/dist/recipes/index.js +2 -0
  26. package/dist/recipes/index.js.map +1 -1
  27. package/dist/sync-manifest.d.ts +33 -0
  28. package/dist/sync-manifest.d.ts.map +1 -0
  29. package/dist/sync-manifest.js +97 -0
  30. package/dist/sync-manifest.js.map +1 -0
  31. package/dist/upgrade.d.ts +23 -2
  32. package/dist/upgrade.d.ts.map +1 -1
  33. package/dist/upgrade.js +144 -13
  34. package/dist/upgrade.js.map +1 -1
  35. package/docs/examples.json +69 -21
  36. package/docs/llms-full.txt +1065 -104
  37. package/docs/types.json +880 -175
  38. package/package.json +9 -9
  39. package/src/check.ts +251 -18
  40. package/src/cli.ts +57 -73
  41. package/src/doctor.ts +133 -49
  42. package/src/mcp-http.ts +1 -1
  43. package/src/mcp.ts +52 -0
  44. package/src/recipes/2.0.0.ts +26 -0
  45. package/src/recipes/index.ts +10 -0
  46. package/src/sync-manifest.ts +111 -0
  47. package/src/upgrade.ts +163 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "1.13.0",
3
+ "version": "2.0.0",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,14 +41,14 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "1.13.0",
45
- "@nifrajs/core": "1.13.0",
46
- "@nifrajs/mcp": "1.13.0",
47
- "@nifrajs/runner": "1.13.0",
48
- "@nifrajs/schema": "1.13.0",
49
- "@nifrajs/testing": "1.13.0",
50
- "@nifrajs/web": "1.13.0",
51
- "create-nifra": "1.13.0"
44
+ "@nifrajs/client": "2.0.0",
45
+ "@nifrajs/core": "2.0.0",
46
+ "@nifrajs/mcp": "2.0.0",
47
+ "@nifrajs/runner": "2.0.0",
48
+ "@nifrajs/schema": "2.0.0",
49
+ "@nifrajs/testing": "2.0.0",
50
+ "@nifrajs/web": "2.0.0",
51
+ "create-nifra": "2.0.0"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
package/src/check.ts CHANGED
@@ -25,9 +25,6 @@ export interface SourceFinding {
25
25
  readonly line: number
26
26
  readonly snippet: string
27
27
  }
28
- /** @deprecated kept for back-compat with existing tests; prefer {@link SourceFinding}. */
29
- export type FetchFinding = SourceFinding
30
-
31
28
  /** A server-only-import finding, carrying the offending module specifier on top of the base location so
32
29
  * the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
33
30
  export interface ServerImportFinding extends SourceFinding {
@@ -39,7 +36,7 @@ export interface ServerImportFinding extends SourceFinding {
39
36
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
40
37
  // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
41
38
  // alone on purpose — undecidable from source, and flagging it would punish legitimate external calls.
42
- const OWN_API_FETCH = /(?<![.\w])fetch\s*\(\s*['"`]\/(?!\/)/g
39
+ const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
43
40
  const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
44
41
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
45
42
  const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
@@ -279,12 +276,163 @@ export function stripComments(src: string): string {
279
276
  return out.join("")
280
277
  }
281
278
 
282
- /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. */
283
- export function scanFetchText(file: string, content: string): SourceFinding[] {
279
+ /** Blank comments and every quoted/template literal while preserving offsets. Unlike
280
+ * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
281
+ * executable code, then inspect the original source for the call's literal argument. That prevents
282
+ * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
283
+ export function codePositionMask(src: string): string {
284
+ const out = src.split("")
285
+ const n = src.length
286
+ let i = 0
287
+ const blank = (a: number, b: number): void => {
288
+ for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
289
+ }
290
+ while (i < n) {
291
+ const c = src[i]
292
+ const d = src[i + 1]
293
+ if (c === "/" && d === "/") {
294
+ let j = i + 2
295
+ while (j < n && src[j] !== "\n") j++
296
+ blank(i, j)
297
+ i = j
298
+ } else if (c === "/" && d === "*") {
299
+ let j = i + 2
300
+ while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
301
+ j = Math.min(j + 2, n)
302
+ blank(i, j)
303
+ i = j
304
+ } else if (c === "'" || c === '"' || c === "`") {
305
+ const quote = c
306
+ let j = i + 1
307
+ while (j < n && src[j] !== quote) {
308
+ if (src[j] === "\\") j++
309
+ j++
310
+ }
311
+ j = Math.min(j + 1, n)
312
+ blank(i, j)
313
+ i = j
314
+ } else {
315
+ i++
316
+ }
317
+ }
318
+ return out.join("")
319
+ }
320
+
321
+ const firstArgumentIndex = (content: string, callEnd: number): number => {
322
+ let index = callEnd
323
+ while (/\s/.test(content[index] ?? "")) index++
324
+ return index
325
+ }
326
+
327
+ // Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
328
+ // stopping at the first quote / query / hash / template-expression boundary, and test it against the
329
+ // allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
330
+ // is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
331
+ function matchesExternalMount(content: string, start: number, mounts: readonly string[]): boolean {
332
+ let end = start
333
+ while (end < content.length) {
334
+ const ch = content[end]
335
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#") break
336
+ if (ch === "$" && content[end + 1] === "{") break
337
+ end++
338
+ }
339
+ const path = content.slice(start, end)
340
+ // A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
341
+ // `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
342
+ // fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
343
+ try {
344
+ if (path.split("/").some((segment) => decodeURIComponent(segment) === "..")) return false
345
+ } catch {
346
+ // A malformed escape is not a prefix we can prove stays inside the external mount.
347
+ return false
348
+ }
349
+ return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
350
+ }
351
+
352
+ function findTopLevelPropertyColon(src: string): number | undefined {
353
+ let depth = 0
354
+ let quote: '"' | "'" | "`" | undefined
355
+ for (let i = 0; i < src.length; i++) {
356
+ const c = src[i]
357
+ if (quote !== undefined) {
358
+ if (c === "\\") {
359
+ i++
360
+ continue
361
+ }
362
+ if (c === quote) quote = undefined
363
+ continue
364
+ }
365
+ if (c === '"' || c === "'" || c === "`") {
366
+ quote = c
367
+ continue
368
+ }
369
+ if (c === "(" || c === "{" || c === "[") depth++
370
+ else if (c === ")" || c === "}" || c === "]") depth--
371
+ else if (c === ":" && depth === 0) return i
372
+ }
373
+ return undefined
374
+ }
375
+
376
+ function parseStaticPropertyName(raw: string): string | undefined {
377
+ const name = raw.trim()
378
+ if (IDENT.test(name)) return name
379
+ const quoted = parseQuotedLiteral(name)
380
+ if (quoted !== undefined) return quoted
381
+ if (name.startsWith("[") && name.endsWith("]")) {
382
+ return parseQuotedLiteral(name.slice(1, -1).trim())
383
+ }
384
+ return undefined
385
+ }
386
+
387
+ /** Return a statically named property from a plain object literal. Deliberately does not follow
388
+ * spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
389
+ * transport rather than an arbitrary same-origin API request. */
390
+ function staticObjectProperty(raw: string | undefined, name: string): string | undefined {
391
+ if (raw === undefined) return undefined
392
+ const object = raw.trim()
393
+ if (!object.startsWith("{") || !object.endsWith("}")) return undefined
394
+ const properties = splitTopLevelArgs(object.slice(1, -1))
395
+ if (properties === undefined) return undefined
396
+ for (const property of properties) {
397
+ const colon = findTopLevelPropertyColon(property)
398
+ if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name) continue
399
+ return property.slice(colon + 1).trim()
400
+ }
401
+ return undefined
402
+ }
403
+
404
+ function hasLiteralNifraDataHeader(content: string, openParen: number): boolean {
405
+ const closeParen = findMatchingParen(content, openParen)
406
+ if (closeParen === undefined) return false
407
+ const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen))
408
+ if (args === undefined || args.length !== 2) return false
409
+ const headers = staticObjectProperty(args[1], "headers")
410
+ return staticObjectProperty(headers, "x-nifra-data") !== undefined
411
+ }
412
+
413
+ /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
414
+ * are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
415
+ * `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
416
+ * inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
417
+ * skipped too. */
418
+ export function scanFetchText(
419
+ file: string,
420
+ content: string,
421
+ externalMounts: readonly string[] = [],
422
+ ): SourceFinding[] {
284
423
  const out: SourceFinding[] = []
285
424
  const lines = content.split("\n")
286
- OWN_API_FETCH.lastIndex = 0
287
- for (let m = OWN_API_FETCH.exec(content); m !== null; m = OWN_API_FETCH.exec(content)) {
425
+ const code = codePositionMask(content)
426
+ GLOBAL_FETCH_CALL.lastIndex = 0
427
+ for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
428
+ const argument = firstArgumentIndex(content, m.index + m[0].length)
429
+ const quote = content[argument]
430
+ if (quote !== "'" && quote !== '"' && quote !== "`") continue
431
+ if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
432
+ const openParen = m.index + m[0].lastIndexOf("(")
433
+ if (hasLiteralNifraDataHeader(content, openParen)) continue
434
+ if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
435
+ continue
288
436
  const line = lineAt(content, m.index)
289
437
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
290
438
  }
@@ -305,14 +453,18 @@ export function scanStaticRouteText(file: string, content: string): StaticRouteF
305
453
  // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
306
454
  // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
307
455
  // contract form `client(contract, url)` starts with an identifier, not a quote — also unmatched.
308
- const UNTYPED_CLIENT = /(?<![.\w])client\s*\(\s*['"`]/g
456
+ const CLIENT_CALL = /(?<![.\w])client\s*\(/g
309
457
 
310
458
  /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
311
459
  export function scanUntypedClient(file: string, content: string): SourceFinding[] {
312
460
  const out: SourceFinding[] = []
313
461
  const lines = content.split("\n")
314
- UNTYPED_CLIENT.lastIndex = 0
315
- for (let m = UNTYPED_CLIENT.exec(content); m !== null; m = UNTYPED_CLIENT.exec(content)) {
462
+ const code = codePositionMask(content)
463
+ CLIENT_CALL.lastIndex = 0
464
+ for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
465
+ const argument = firstArgumentIndex(content, m.index + m[0].length)
466
+ const quote = content[argument]
467
+ if (quote !== "'" && quote !== '"' && quote !== "`") continue
316
468
  const line = lineAt(content, m.index)
317
469
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
318
470
  }
@@ -326,8 +478,11 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
326
478
  if (!ROUTE_FILE.test(file)) return []
327
479
  const out: ServerImportFinding[] = []
328
480
  const lines = content.split("\n")
481
+ const code = stripComments(content)
482
+ const positions = codePositionMask(content)
329
483
  STATIC_IMPORT.lastIndex = 0
330
- for (let m = STATIC_IMPORT.exec(content); m !== null; m = STATIC_IMPORT.exec(content)) {
484
+ for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
485
+ if (positions[m.index] === " ") continue
331
486
  const specifier = m[1] ?? ""
332
487
  if (!SERVER_ONLY.test(specifier)) continue
333
488
  const line = lineAt(content, m.index)
@@ -379,7 +534,10 @@ const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_
379
534
  function staticImportEdges(content: string): Array<{ specifier: string; index: number }> {
380
535
  const edges: Array<{ specifier: string; index: number }> = []
381
536
  const re = staticImportRegex()
382
- for (let m = re.exec(content); m !== null; m = re.exec(content)) {
537
+ const code = stripComments(content)
538
+ const positions = codePositionMask(content)
539
+ for (let m = re.exec(code); m !== null; m = re.exec(code)) {
540
+ if (positions[m.index] === " ") continue
383
541
  if (m[1] !== undefined) edges.push({ specifier: m[1], index: m.index })
384
542
  }
385
543
  return edges
@@ -574,11 +732,24 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
574
732
  RESPONSE_RETURN.lastIndex = 0
575
733
  for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
576
734
  const line = lineAt(content, m.index)
577
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
735
+ // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
736
+ // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
737
+ // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
738
+ // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
739
+ // `code` (so it never affects the match); the raw `lines` still carry it.
740
+ const thisLine = lines[line - 1] ?? ""
741
+ const aboveLine = lines[line - 2] ?? ""
742
+ const aboveSuppresses =
743
+ aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
744
+ if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
745
+ out.push({ file, line, snippet: thisLine.trim() })
578
746
  }
579
747
  return out
580
748
  }
581
749
 
750
+ /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
751
+ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
752
+
582
753
  /** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
583
754
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
584
755
  /**
@@ -634,7 +805,7 @@ export async function walkSource(
634
805
  const bySite = (a: SourceFinding, b: SourceFinding): number =>
635
806
  a.file.localeCompare(b.file) || a.line - b.line
636
807
 
637
- /** Collect own-API `fetch()` findings across the project (kept for back-compat + reuse). */
808
+ /** Collect own-API `fetch()` findings across the project. */
638
809
  export async function scanProject(cwd: string): Promise<SourceFinding[]> {
639
810
  const out: SourceFinding[] = []
640
811
  await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)))
@@ -775,6 +946,7 @@ export interface CheckDiagnostic {
775
946
  | "manifest-drift"
776
947
  | "capability-assurance"
777
948
  | "capability-config"
949
+ | "check-config"
778
950
  /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
779
951
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
780
952
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
@@ -817,12 +989,43 @@ export interface CheckResult {
817
989
  readonly ok: boolean
818
990
  readonly typecheck: "pass" | "fail" | "skipped"
819
991
  readonly diagnostics: readonly CheckDiagnostic[]
992
+ /** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
993
+ * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
994
+ * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
995
+ readonly externalMounts?: readonly string[]
820
996
  /** Set only when the caller passed `maxDiagnostics` and there were more — `diagnostics` then holds the
821
997
  * first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
822
998
  * message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
823
999
  readonly truncated?: { readonly shown: number; readonly total: number }
824
1000
  }
825
1001
 
1002
+ /** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
1003
+ * the app is built or even importable, preserving check's pre-`loadApp` invariant. */
1004
+ interface CheckConfig {
1005
+ readonly externalMounts: readonly string[]
1006
+ }
1007
+
1008
+ /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed → empty plus a parse
1009
+ * `error` the caller surfaces as a `warning` diagnostic (never throws, never blocks the gate). Mount
1010
+ * entries are normalized to bare path prefixes (`/auth/**` and `/auth/` both become `/auth`). */
1011
+ async function loadCheckConfig(cwd: string): Promise<{ config: CheckConfig; error?: string }> {
1012
+ const path = join(cwd, "nifra.check.json")
1013
+ if (!existsSync(path)) return { config: { externalMounts: [] } }
1014
+ try {
1015
+ const parsed = JSON.parse(await Bun.file(path).text()) as { externalMounts?: unknown }
1016
+ const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : []
1017
+ const externalMounts = raw
1018
+ .filter((m): m is string => typeof m === "string" && m.startsWith("/"))
1019
+ .map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/")
1020
+ return { config: { externalMounts } }
1021
+ } catch (error) {
1022
+ return {
1023
+ config: { externalMounts: [] },
1024
+ error: error instanceof Error ? error.message : String(error),
1025
+ }
1026
+ }
1027
+ }
1028
+
826
1029
  const UNTYPED_CLIENT_HINT =
827
1030
  'client("…") without a type argument — write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
828
1031
  const FETCH_HINT =
@@ -830,7 +1033,7 @@ const FETCH_HINT =
830
1033
  const SERVER_IMPORT_HINT =
831
1034
  "server-only import in a route module (bundled for the browser) — reach it via c.db / ctx.api inside a loader, never a top-level import"
832
1035
  const RESPONSE_ROUTE_HINT =
833
- "route handler returns a raw Response — the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you), or add `{ response: t.… }` to the route if a raw Response is intended (file/redirect/stream)"
1036
+ "route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
834
1037
  const UNDECLARED_DEP_HINT =
835
1038
  "imported package is not declared in package.json dependencies — run bun add to declare it"
836
1039
  const MANIFEST_DRIFT_HINT =
@@ -965,7 +1168,8 @@ function responseRouteSuggestion(): CheckSuggestion {
965
1168
  title: "Preserve typed-client response inference",
966
1169
  steps: [
967
1170
  "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
968
- "If this route must return a raw Response (redirect, file, stream), declare an explicit response schema or accept the warning.",
1171
+ "For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
1172
+ "If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect raw-response` comment above the return to mark it intentional and silence this advisory.",
969
1173
  ],
970
1174
  }
971
1175
  }
@@ -990,6 +1194,9 @@ export async function collectCheckResult(
990
1194
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
991
1195
  // (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
992
1196
  const routeModules: Array<{ rel: string; content: string }> = []
1197
+ // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1198
+ // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1199
+ const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd)
993
1200
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
994
1201
  // lints — the agent inner-loop mode; the full gate stays the definition of done.
995
1202
  const [tc, _, dr, manifestDrift] = await Promise.all([
@@ -997,7 +1204,7 @@ export async function collectCheckResult(
997
1204
  ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
998
1205
  : typecheck(cwd, opts.signal),
999
1206
  walkSource(cwd, (rel, content) => {
1000
- fetches.push(...scanFetchText(rel, content))
1207
+ fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
1001
1208
  staticRoutes.push(...scanStaticRouteText(rel, content))
1002
1209
  untypedClients.push(...scanUntypedClient(rel, content))
1003
1210
  if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
@@ -1034,6 +1241,15 @@ export async function collectCheckResult(
1034
1241
  }
1035
1242
 
1036
1243
  const diagnostics: CheckDiagnostic[] = []
1244
+ if (checkConfigError !== undefined) {
1245
+ diagnostics.push({
1246
+ rule: "check-config",
1247
+ severity: "warning",
1248
+ file: "nifra.check.json",
1249
+ message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
1250
+ fix: "Fix the JSON syntax in nifra.check.json",
1251
+ })
1252
+ }
1037
1253
  if (tc.ran && !tc.ok) {
1038
1254
  const lines = (tc.output ?? "").split("\n")
1039
1255
  let matched = false
@@ -1304,6 +1520,9 @@ export async function collectCheckResult(
1304
1520
  ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
1305
1521
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
1306
1522
  diagnostics: shown,
1523
+ ...(checkConfig.externalMounts.length > 0
1524
+ ? { externalMounts: checkConfig.externalMounts }
1525
+ : {}),
1307
1526
  ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
1308
1527
  }
1309
1528
  }
@@ -1327,6 +1546,11 @@ export async function runCheck(
1327
1546
  ? "✗ typecheck failed — the frontend/backend contract is broken"
1328
1547
  : "• typecheck skipped (no tsconfig / typescript not installed)",
1329
1548
  )
1549
+ if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
1550
+ console.log(
1551
+ `• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
1552
+ )
1553
+ }
1330
1554
  const counts = (rule: CheckDiagnostic["rule"]): CheckDiagnostic[] =>
1331
1555
  result.diagnostics.filter((d) => d.rule === rule)
1332
1556
  for (const [rule, label] of [
@@ -1341,6 +1565,7 @@ export async function runCheck(
1341
1565
  ["manifest-drift", "versioned trust manifest drift"],
1342
1566
  ["capability-assurance", "effect/capability assurance"],
1343
1567
  ["capability-config", "capability assurance config"],
1568
+ ["check-config", "nifra.check.json"],
1344
1569
  ] as const) {
1345
1570
  const ds = counts(rule)
1346
1571
  if (rule === "response-route") {
@@ -1369,5 +1594,13 @@ export async function runCheck(
1369
1594
  }
1370
1595
  }
1371
1596
  console.log(result.ok ? "\n✓ check passed" : "\n✗ check failed")
1597
+ // Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
1598
+ // `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
1599
+ // one-line tip in the human report only (the `--json` path returns above, unaffected).
1600
+ if (!existsSync(join(cwd, ".mcp.json"))) {
1601
+ console.log(
1602
+ "\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).",
1603
+ )
1604
+ }
1372
1605
  return result.ok
1373
1606
  }