@nifrajs/cli 1.12.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 +131 -61
  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 +933 -181
  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 +160 -61
  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/README.md CHANGED
@@ -6,8 +6,8 @@ The `nifra` CLI — zero-config dev/build/start for a nifra app, plus the agent
6
6
 
7
7
  ```
8
8
  nifra dev [--port <n>] [--poll] True-HMR dev server (Vite + nifra SSR).
9
- nifra build [--out <dir>] Content-hashed client bundle + manifest.json.
10
- nifra start [--port <n>] [--out <dir>] Serve the built app (client + SSR) on Bun.
9
+ nifra build [--out <dir>] [--target <t>] Complete deploy directory; target defaults to Bun.
10
+ nifra start [--port <n>] [--out <dir>] Run the generated Bun server at <out>/server.js.
11
11
  nifra context Print this project's API + page routes + conventions.
12
12
  nifra mcp MCP server (stdio) exposing this project to a coding agent.
13
13
  nifra check [--json] Gate: typecheck + lints; --json includes fixes/suggestions.
@@ -72,6 +72,17 @@ For hand-rolled own-API `fetch()` calls, `nifra check` stays conservative: simpl
72
72
  that match a statically visible Nifra route get an exact typed-client rewrite diff; dynamic URLs, custom
73
73
  headers, bodies, query strings, or ambiguous routes fall back to manual steps.
74
74
 
75
+ When a library such as Better Auth intentionally owns a mounted prefix outside the Nifra contract,
76
+ declare that prefix in `nifra.check.json` so its relative calls do not keep CI red:
77
+
78
+ ```json
79
+ { "externalMounts": ["/auth"] }
80
+ ```
81
+
82
+ The match is segment-anchored (`/auth` never suppresses `/authors`) and traversal paths are rejected.
83
+ The normalized prefixes are echoed in human, JSON, and MCP results so the lint bypass stays auditable.
84
+ This suppresses only the typed-client diagnostic; it does not mount or authorize the external handler.
85
+
75
86
  ## For AI coding agents
76
87
 
77
88
  **`nifra context`** prints this project's actual surface — API routes (from `backend.routes()`), page
@@ -98,6 +109,10 @@ an agent can act on the project, not just read about it. Core tools:
98
109
  - **`nifra_check`** — run the drift gate and get the structured `{ ok, typecheck, diagnostics[] }`
99
110
  result with suggestions, so the agent verifies its own work and fixes each diagnostic before finishing.
100
111
  - **`nifra_assure`** — evaluate `nifra.assurance.ts` and return the complete per-route evidence report.
112
+ - **`nifra_levels`** - compute the cumulative verification ladder (L0 typed contract, L1 route
113
+ assurance, L2 capability lockfile, L3 trust manifest, L4 contract invariants) and return
114
+ `{ achieved, levels[] }`, so an agent can see what its change proves and read why a level it lost
115
+ no longer holds.
101
116
  - **`nifra_doctor`** — catch imported packages missing from `package.json`; with `autoFix:true`, write
102
117
  safe dependency entries when a version can be inferred locally and return exact `bun add` commands
103
118
  for anything that needs a package-manager decision.
package/dist/check.d.ts CHANGED
@@ -20,8 +20,6 @@ export interface SourceFinding {
20
20
  readonly line: number;
21
21
  readonly snippet: string;
22
22
  }
23
- /** @deprecated kept for back-compat with existing tests; prefer {@link SourceFinding}. */
24
- export type FetchFinding = SourceFinding;
25
23
  /** A server-only-import finding, carrying the offending module specifier on top of the base location so
26
24
  * the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
27
25
  export interface ServerImportFinding extends SourceFinding {
@@ -43,8 +41,17 @@ export interface StaticRouteFinding extends SourceFinding {
43
41
  * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
44
42
  */
45
43
  export declare function stripComments(src: string): string;
46
- /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. */
47
- export declare function scanFetchText(file: string, content: string): SourceFinding[];
44
+ /** Blank comments and every quoted/template literal while preserving offsets. Unlike
45
+ * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
46
+ * executable code, then inspect the original source for the call's literal argument. That prevents
47
+ * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
48
+ export declare function codePositionMask(src: string): string;
49
+ /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
50
+ * are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
51
+ * `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
52
+ * inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
53
+ * skipped too. */
54
+ export declare function scanFetchText(file: string, content: string, externalMounts?: readonly string[]): SourceFinding[];
48
55
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
49
56
  export declare function scanStaticRouteText(file: string, content: string): StaticRouteFinding[];
50
57
  /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
@@ -100,7 +107,7 @@ export declare function resolveServerOnlyChains(file: string, content: string, r
100
107
  * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
101
108
  export declare function scanResponseRoutes(file: string, content: string): SourceFinding[];
102
109
  export declare function walkSource(cwd: string, visit: (rel: string, content: string) => void): Promise<void>;
103
- /** Collect own-API `fetch()` findings across the project (kept for back-compat + reuse). */
110
+ /** Collect own-API `fetch()` findings across the project. */
104
111
  export declare function scanProject(cwd: string): Promise<SourceFinding[]>;
105
112
  export interface ManifestDriftFinding {
106
113
  /** The committed server-manifest file (relative to cwd). */
@@ -121,7 +128,7 @@ export interface ManifestDriftFinding {
121
128
  export declare function scanServerManifestDrift(cwd: string): Promise<ManifestDriftFinding[]>;
122
129
  /** A single machine-readable check failure — the unit an agent (or CI) acts on. */
123
130
  export interface CheckDiagnostic {
124
- readonly rule: "typecheck" | "typed-client" | "untyped-client" | "server-only-import" | "response-route" | "undeclared-dependency" | "duplicate-install" | "server-manifest-drift" | "manifest-drift" | "capability-assurance" | "capability-config";
131
+ readonly rule: "typecheck" | "typed-client" | "untyped-client" | "server-only-import" | "response-route" | "undeclared-dependency" | "duplicate-install" | "server-manifest-drift" | "manifest-drift" | "capability-assurance" | "capability-config" | "check-config";
125
132
  /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
126
133
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
127
134
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
@@ -162,6 +169,10 @@ export interface CheckResult {
162
169
  readonly ok: boolean;
163
170
  readonly typecheck: "pass" | "fail" | "skipped";
164
171
  readonly diagnostics: readonly CheckDiagnostic[];
172
+ /** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
173
+ * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
174
+ * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
175
+ readonly externalMounts?: readonly string[];
165
176
  /** Set only when the caller passed `maxDiagnostics` and there were more — `diagnostics` then holds the
166
177
  * first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
167
178
  * message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"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"}
package/dist/check.js CHANGED
@@ -22,7 +22,7 @@ import { Glob } from "bun";
22
22
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
23
23
  // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
24
24
  // alone on purpose — undecidable from source, and flagging it would punish legitimate external calls.
25
- const OWN_API_FETCH = /(?<![.\w])fetch\s*\(\s*['"`]\/(?!\/)/g;
25
+ const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
26
26
  const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
27
27
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
28
28
  const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
@@ -253,12 +253,180 @@ export function stripComments(src) {
253
253
  }
254
254
  return out.join("");
255
255
  }
256
- /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. */
257
- export function scanFetchText(file, content) {
256
+ /** Blank comments and every quoted/template literal while preserving offsets. Unlike
257
+ * {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
258
+ * executable code, then inspect the original source for the call's literal argument. That prevents
259
+ * documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
260
+ export function codePositionMask(src) {
261
+ const out = src.split("");
262
+ const n = src.length;
263
+ let i = 0;
264
+ const blank = (a, b) => {
265
+ for (let k = a; k < b; k++)
266
+ if (out[k] !== "\n")
267
+ out[k] = " ";
268
+ };
269
+ while (i < n) {
270
+ const c = src[i];
271
+ const d = src[i + 1];
272
+ if (c === "/" && d === "/") {
273
+ let j = i + 2;
274
+ while (j < n && src[j] !== "\n")
275
+ j++;
276
+ blank(i, j);
277
+ i = j;
278
+ }
279
+ else if (c === "/" && d === "*") {
280
+ let j = i + 2;
281
+ while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
282
+ j++;
283
+ j = Math.min(j + 2, n);
284
+ blank(i, j);
285
+ i = j;
286
+ }
287
+ else if (c === "'" || c === '"' || c === "`") {
288
+ const quote = c;
289
+ let j = i + 1;
290
+ while (j < n && src[j] !== quote) {
291
+ if (src[j] === "\\")
292
+ j++;
293
+ j++;
294
+ }
295
+ j = Math.min(j + 1, n);
296
+ blank(i, j);
297
+ i = j;
298
+ }
299
+ else {
300
+ i++;
301
+ }
302
+ }
303
+ return out.join("");
304
+ }
305
+ const firstArgumentIndex = (content, callEnd) => {
306
+ let index = callEnd;
307
+ while (/\s/.test(content[index] ?? ""))
308
+ index++;
309
+ return index;
310
+ };
311
+ // Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
312
+ // stopping at the first quote / query / hash / template-expression boundary, and test it against the
313
+ // allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
314
+ // is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
315
+ function matchesExternalMount(content, start, mounts) {
316
+ let end = start;
317
+ while (end < content.length) {
318
+ const ch = content[end];
319
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#")
320
+ break;
321
+ if (ch === "$" && content[end + 1] === "{")
322
+ break;
323
+ end++;
324
+ }
325
+ const path = content.slice(start, end);
326
+ // A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
327
+ // `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
328
+ // fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
329
+ try {
330
+ if (path.split("/").some((segment) => decodeURIComponent(segment) === ".."))
331
+ return false;
332
+ }
333
+ catch {
334
+ // A malformed escape is not a prefix we can prove stays inside the external mount.
335
+ return false;
336
+ }
337
+ return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
338
+ }
339
+ function findTopLevelPropertyColon(src) {
340
+ let depth = 0;
341
+ let quote;
342
+ for (let i = 0; i < src.length; i++) {
343
+ const c = src[i];
344
+ if (quote !== undefined) {
345
+ if (c === "\\") {
346
+ i++;
347
+ continue;
348
+ }
349
+ if (c === quote)
350
+ quote = undefined;
351
+ continue;
352
+ }
353
+ if (c === '"' || c === "'" || c === "`") {
354
+ quote = c;
355
+ continue;
356
+ }
357
+ if (c === "(" || c === "{" || c === "[")
358
+ depth++;
359
+ else if (c === ")" || c === "}" || c === "]")
360
+ depth--;
361
+ else if (c === ":" && depth === 0)
362
+ return i;
363
+ }
364
+ return undefined;
365
+ }
366
+ function parseStaticPropertyName(raw) {
367
+ const name = raw.trim();
368
+ if (IDENT.test(name))
369
+ return name;
370
+ const quoted = parseQuotedLiteral(name);
371
+ if (quoted !== undefined)
372
+ return quoted;
373
+ if (name.startsWith("[") && name.endsWith("]")) {
374
+ return parseQuotedLiteral(name.slice(1, -1).trim());
375
+ }
376
+ return undefined;
377
+ }
378
+ /** Return a statically named property from a plain object literal. Deliberately does not follow
379
+ * spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
380
+ * transport rather than an arbitrary same-origin API request. */
381
+ function staticObjectProperty(raw, name) {
382
+ if (raw === undefined)
383
+ return undefined;
384
+ const object = raw.trim();
385
+ if (!object.startsWith("{") || !object.endsWith("}"))
386
+ return undefined;
387
+ const properties = splitTopLevelArgs(object.slice(1, -1));
388
+ if (properties === undefined)
389
+ return undefined;
390
+ for (const property of properties) {
391
+ const colon = findTopLevelPropertyColon(property);
392
+ if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name)
393
+ continue;
394
+ return property.slice(colon + 1).trim();
395
+ }
396
+ return undefined;
397
+ }
398
+ function hasLiteralNifraDataHeader(content, openParen) {
399
+ const closeParen = findMatchingParen(content, openParen);
400
+ if (closeParen === undefined)
401
+ return false;
402
+ const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen));
403
+ if (args === undefined || args.length !== 2)
404
+ return false;
405
+ const headers = staticObjectProperty(args[1], "headers");
406
+ return staticObjectProperty(headers, "x-nifra-data") !== undefined;
407
+ }
408
+ /** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
409
+ * are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
410
+ * `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
411
+ * inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
412
+ * skipped too. */
413
+ export function scanFetchText(file, content, externalMounts = []) {
258
414
  const out = [];
259
415
  const lines = content.split("\n");
260
- OWN_API_FETCH.lastIndex = 0;
261
- for (let m = OWN_API_FETCH.exec(content); m !== null; m = OWN_API_FETCH.exec(content)) {
416
+ const code = codePositionMask(content);
417
+ GLOBAL_FETCH_CALL.lastIndex = 0;
418
+ for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
419
+ const argument = firstArgumentIndex(content, m.index + m[0].length);
420
+ const quote = content[argument];
421
+ if (quote !== "'" && quote !== '"' && quote !== "`")
422
+ continue;
423
+ if (content[argument + 1] !== "/" || content[argument + 2] === "/")
424
+ continue;
425
+ const openParen = m.index + m[0].lastIndexOf("(");
426
+ if (hasLiteralNifraDataHeader(content, openParen))
427
+ continue;
428
+ if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
429
+ continue;
262
430
  const line = lineAt(content, m.index);
263
431
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
264
432
  }
@@ -278,13 +446,18 @@ export function scanStaticRouteText(file, content) {
278
446
  // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
279
447
  // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
280
448
  // contract form `client(contract, url)` starts with an identifier, not a quote — also unmatched.
281
- const UNTYPED_CLIENT = /(?<![.\w])client\s*\(\s*['"`]/g;
449
+ const CLIENT_CALL = /(?<![.\w])client\s*\(/g;
282
450
  /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
283
451
  export function scanUntypedClient(file, content) {
284
452
  const out = [];
285
453
  const lines = content.split("\n");
286
- UNTYPED_CLIENT.lastIndex = 0;
287
- for (let m = UNTYPED_CLIENT.exec(content); m !== null; m = UNTYPED_CLIENT.exec(content)) {
454
+ const code = codePositionMask(content);
455
+ CLIENT_CALL.lastIndex = 0;
456
+ for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
457
+ const argument = firstArgumentIndex(content, m.index + m[0].length);
458
+ const quote = content[argument];
459
+ if (quote !== "'" && quote !== '"' && quote !== "`")
460
+ continue;
288
461
  const line = lineAt(content, m.index);
289
462
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
290
463
  }
@@ -298,8 +471,12 @@ export function scanServerOnlyImports(file, content) {
298
471
  return [];
299
472
  const out = [];
300
473
  const lines = content.split("\n");
474
+ const code = stripComments(content);
475
+ const positions = codePositionMask(content);
301
476
  STATIC_IMPORT.lastIndex = 0;
302
- for (let m = STATIC_IMPORT.exec(content); m !== null; m = STATIC_IMPORT.exec(content)) {
477
+ for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
478
+ if (positions[m.index] === " ")
479
+ continue;
303
480
  const specifier = m[1] ?? "";
304
481
  if (!SERVER_ONLY.test(specifier))
305
482
  continue;
@@ -345,7 +522,11 @@ const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.f
345
522
  function staticImportEdges(content) {
346
523
  const edges = [];
347
524
  const re = staticImportRegex();
348
- for (let m = re.exec(content); m !== null; m = re.exec(content)) {
525
+ const code = stripComments(content);
526
+ const positions = codePositionMask(content);
527
+ for (let m = re.exec(code); m !== null; m = re.exec(code)) {
528
+ if (positions[m.index] === " ")
529
+ continue;
349
530
  if (m[1] !== undefined)
350
531
  edges.push({ specifier: m[1], index: m.index });
351
532
  }
@@ -511,10 +692,22 @@ export function scanResponseRoutes(file, content) {
511
692
  RESPONSE_RETURN.lastIndex = 0;
512
693
  for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
513
694
  const line = lineAt(content, m.index);
514
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
695
+ // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
696
+ // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
697
+ // ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
698
+ // leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
699
+ // `code` (so it never affects the match); the raw `lines` still carry it.
700
+ const thisLine = lines[line - 1] ?? "";
701
+ const aboveLine = lines[line - 2] ?? "";
702
+ const aboveSuppresses = aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA);
703
+ if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses)
704
+ continue;
705
+ out.push({ file, line, snippet: thisLine.trim() });
515
706
  }
516
707
  return out;
517
708
  }
709
+ /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
710
+ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response";
518
711
  /** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
519
712
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
520
713
  /**
@@ -565,7 +758,7 @@ export async function walkSource(cwd, visit) {
565
758
  }
566
759
  }
567
760
  const bySite = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
568
- /** Collect own-API `fetch()` findings across the project (kept for back-compat + reuse). */
761
+ /** Collect own-API `fetch()` findings across the project. */
569
762
  export async function scanProject(cwd) {
570
763
  const out = [];
571
764
  await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)));
@@ -671,10 +864,32 @@ async function typecheck(cwd, signal) {
671
864
  }
672
865
  // `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
673
866
  const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/;
867
+ /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed → empty plus a parse
868
+ * `error` the caller surfaces as a `warning` diagnostic (never throws, never blocks the gate). Mount
869
+ * entries are normalized to bare path prefixes (`/auth/**` and `/auth/` both become `/auth`). */
870
+ async function loadCheckConfig(cwd) {
871
+ const path = join(cwd, "nifra.check.json");
872
+ if (!existsSync(path))
873
+ return { config: { externalMounts: [] } };
874
+ try {
875
+ const parsed = JSON.parse(await Bun.file(path).text());
876
+ const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : [];
877
+ const externalMounts = raw
878
+ .filter((m) => typeof m === "string" && m.startsWith("/"))
879
+ .map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/");
880
+ return { config: { externalMounts } };
881
+ }
882
+ catch (error) {
883
+ return {
884
+ config: { externalMounts: [] },
885
+ error: error instanceof Error ? error.message : String(error),
886
+ };
887
+ }
888
+ }
674
889
  const UNTYPED_CLIENT_HINT = 'client("…") without a type argument — write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift';
675
890
  const FETCH_HINT = "hand-rolled fetch() to your own API — call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift";
676
891
  const SERVER_IMPORT_HINT = "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";
677
- const RESPONSE_ROUTE_HINT = "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)";
892
+ const RESPONSE_ROUTE_HINT = "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";
678
893
  const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies — run bun add to declare it";
679
894
  const MANIFEST_DRIFT_HINT = "server-manifest.ts is out of sync with routes/ — re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it";
680
895
  const TRUST_MANIFEST_DRIFT_HINT = "nifra.manifest.json is missing, invalid, or out of sync — run `nifra manifest emit`, review it, and commit the regenerated trust artifact";
@@ -794,7 +1009,8 @@ function responseRouteSuggestion() {
794
1009
  title: "Preserve typed-client response inference",
795
1010
  steps: [
796
1011
  "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
797
- "If this route must return a raw Response (redirect, file, stream), declare an explicit response schema or accept the warning.",
1012
+ "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`.",
1013
+ "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.",
798
1014
  ],
799
1015
  };
800
1016
  }
@@ -809,6 +1025,9 @@ export async function collectCheckResult(cwd, opts = {}) {
809
1025
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
810
1026
  // (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
811
1027
  const routeModules = [];
1028
+ // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1029
+ // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1030
+ const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd);
812
1031
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
813
1032
  // lints — the agent inner-loop mode; the full gate stays the definition of done.
814
1033
  const [tc, _, dr, manifestDrift] = await Promise.all([
@@ -816,7 +1035,7 @@ export async function collectCheckResult(cwd, opts = {}) {
816
1035
  ? Promise.resolve({ ran: false, ok: true, note: "skipped (lintsOnly)" })
817
1036
  : typecheck(cwd, opts.signal),
818
1037
  walkSource(cwd, (rel, content) => {
819
- fetches.push(...scanFetchText(rel, content));
1038
+ fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts));
820
1039
  staticRoutes.push(...scanStaticRouteText(rel, content));
821
1040
  untypedClients.push(...scanUntypedClient(rel, content));
822
1041
  if (ROUTE_FILE.test(rel))
@@ -854,6 +1073,15 @@ export async function collectCheckResult(cwd, opts = {}) {
854
1073
  serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule));
855
1074
  }
856
1075
  const diagnostics = [];
1076
+ if (checkConfigError !== undefined) {
1077
+ diagnostics.push({
1078
+ rule: "check-config",
1079
+ severity: "warning",
1080
+ file: "nifra.check.json",
1081
+ message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
1082
+ fix: "Fix the JSON syntax in nifra.check.json",
1083
+ });
1084
+ }
857
1085
  if (tc.ran && !tc.ok) {
858
1086
  const lines = (tc.output ?? "").split("\n");
859
1087
  let matched = false;
@@ -1114,6 +1342,9 @@ export async function collectCheckResult(cwd, opts = {}) {
1114
1342
  ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
1115
1343
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
1116
1344
  diagnostics: shown,
1345
+ ...(checkConfig.externalMounts.length > 0
1346
+ ? { externalMounts: checkConfig.externalMounts }
1347
+ : {}),
1117
1348
  ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
1118
1349
  };
1119
1350
  }
@@ -1130,6 +1361,9 @@ export async function runCheck(cwd, opts = {}) {
1130
1361
  : result.typecheck === "fail"
1131
1362
  ? "✗ typecheck failed — the frontend/backend contract is broken"
1132
1363
  : "• typecheck skipped (no tsconfig / typescript not installed)");
1364
+ if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
1365
+ console.log(`• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`);
1366
+ }
1133
1367
  const counts = (rule) => result.diagnostics.filter((d) => d.rule === rule);
1134
1368
  for (const [rule, label] of [
1135
1369
  ["typecheck", "typecheck"],
@@ -1143,6 +1377,7 @@ export async function runCheck(cwd, opts = {}) {
1143
1377
  ["manifest-drift", "versioned trust manifest drift"],
1144
1378
  ["capability-assurance", "effect/capability assurance"],
1145
1379
  ["capability-config", "capability assurance config"],
1380
+ ["check-config", "nifra.check.json"],
1146
1381
  ]) {
1147
1382
  const ds = counts(rule);
1148
1383
  if (rule === "response-route") {
@@ -1171,6 +1406,12 @@ export async function runCheck(cwd, opts = {}) {
1171
1406
  }
1172
1407
  }
1173
1408
  console.log(result.ok ? "\n✓ check passed" : "\n✗ check failed");
1409
+ // Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
1410
+ // `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
1411
+ // one-line tip in the human report only (the `--json` path returns above, unaffected).
1412
+ if (!existsSync(join(cwd, ".mcp.json"))) {
1413
+ console.log("\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).");
1414
+ }
1174
1415
  return result.ok;
1175
1416
  }
1176
1417
  //# sourceMappingURL=check.js.map