@nifrajs/cli 2.2.0 → 2.3.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 (117) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +61 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +332 -86
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +30 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +132 -36
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +3 -3
  23. package/dist/doctor.js +8 -8
  24. package/dist/examples.d.ts +5 -5
  25. package/dist/examples.js +5 -5
  26. package/dist/init-agents.d.ts +14 -14
  27. package/dist/init-agents.js +14 -14
  28. package/dist/internal/typescript-import.d.ts +24 -0
  29. package/dist/internal/typescript-import.d.ts.map +1 -0
  30. package/dist/internal/typescript-import.js +32 -0
  31. package/dist/internal/typescript-import.js.map +1 -0
  32. package/dist/introspect.d.ts +12 -12
  33. package/dist/introspect.js +24 -24
  34. package/dist/levels-tool.d.ts +8 -8
  35. package/dist/levels-tool.d.ts.map +1 -1
  36. package/dist/levels-tool.js +33 -30
  37. package/dist/levels-tool.js.map +1 -1
  38. package/dist/load.d.ts +6 -6
  39. package/dist/load.d.ts.map +1 -1
  40. package/dist/load.js +15 -7
  41. package/dist/load.js.map +1 -1
  42. package/dist/manifest-tool.d.ts +1 -1
  43. package/dist/manifest-tool.d.ts.map +1 -1
  44. package/dist/manifest-tool.js +4 -2
  45. package/dist/manifest-tool.js.map +1 -1
  46. package/dist/mcp-docs-tools.d.ts +1 -1
  47. package/dist/mcp-docs-tools.js +9 -9
  48. package/dist/mcp-http.d.ts +5 -5
  49. package/dist/mcp-http.js +7 -7
  50. package/dist/mcp-protocol.d.ts +2 -2
  51. package/dist/mcp-protocol.js +2 -2
  52. package/dist/mcp-render.d.ts +3 -3
  53. package/dist/mcp-render.js +6 -6
  54. package/dist/mcp-run.d.ts +2 -2
  55. package/dist/mcp-run.js +2 -2
  56. package/dist/mcp.d.ts +14 -14
  57. package/dist/mcp.d.ts.map +1 -1
  58. package/dist/mcp.js +75 -45
  59. package/dist/mcp.js.map +1 -1
  60. package/dist/openapi-tool.d.ts +1 -1
  61. package/dist/openapi-tool.js +2 -2
  62. package/dist/pipeline-guard.d.ts +1 -1
  63. package/dist/pipeline-guard.js +1 -1
  64. package/dist/port.d.ts +9 -9
  65. package/dist/port.js +27 -27
  66. package/dist/recipes/1.8.0.d.ts +2 -2
  67. package/dist/recipes/1.8.0.js +5 -5
  68. package/dist/recipes/index.d.ts +2 -2
  69. package/dist/recipes/index.js +1 -1
  70. package/dist/scaffold.d.ts +3 -3
  71. package/dist/scaffold.js +8 -8
  72. package/dist/sync-routes.d.ts +28 -0
  73. package/dist/sync-routes.d.ts.map +1 -0
  74. package/dist/sync-routes.js +55 -0
  75. package/dist/sync-routes.js.map +1 -0
  76. package/dist/types-search.d.ts +5 -5
  77. package/dist/types-search.js +7 -7
  78. package/dist/upgrade.d.ts +7 -7
  79. package/dist/upgrade.js +12 -12
  80. package/dist/verification.d.ts +51 -0
  81. package/dist/verification.d.ts.map +1 -0
  82. package/dist/verification.js +80 -0
  83. package/dist/verification.js.map +1 -0
  84. package/docs/examples.json +174 -90
  85. package/docs/llms-full.txt +1696 -747
  86. package/docs/types.json +1892 -3105
  87. package/package.json +22 -11
  88. package/src/assure.ts +17 -18
  89. package/src/capabilities-tool.ts +82 -16
  90. package/src/check.ts +405 -103
  91. package/src/cli.ts +141 -38
  92. package/src/diff-tool.ts +8 -8
  93. package/src/docs-search.ts +6 -6
  94. package/src/doctor.ts +10 -10
  95. package/src/examples.ts +7 -7
  96. package/src/init-agents.ts +17 -17
  97. package/src/internal/typescript-import.ts +33 -0
  98. package/src/introspect.ts +28 -28
  99. package/src/levels-tool.ts +37 -31
  100. package/src/load.ts +21 -12
  101. package/src/manifest-tool.ts +4 -2
  102. package/src/mcp-docs-tools.ts +9 -9
  103. package/src/mcp-http.ts +7 -7
  104. package/src/mcp-protocol.ts +2 -2
  105. package/src/mcp-render.ts +6 -6
  106. package/src/mcp-run.ts +2 -2
  107. package/src/mcp.ts +82 -45
  108. package/src/openapi-tool.ts +2 -2
  109. package/src/pipeline-guard.ts +1 -1
  110. package/src/port.ts +31 -31
  111. package/src/recipes/1.8.0.ts +5 -5
  112. package/src/recipes/index.ts +2 -2
  113. package/src/scaffold.ts +8 -8
  114. package/src/sync-routes.ts +69 -0
  115. package/src/types-search.ts +8 -8
  116. package/src/upgrade.ts +12 -12
  117. package/src/verification.ts +123 -0
package/dist/check.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * `nifra check` — the agent's (and CI's) definition of done. It makes the guarantees that keep a nifra
2
+ * `nifra check` - the agent's (and CI's) definition of done. It makes the guarantees that keep a nifra
3
3
  * app drift-proof actually *fire*, instead of relying on the agent to remember them:
4
4
  *
5
- * 1. **typecheck** (`tsc --noEmit`) — the frontend↔backend contract is compiler-enforced. The typed
5
+ * 1. **typecheck** (`tsc --noEmit`) - the frontend↔backend contract is compiler-enforced. The typed
6
6
  * client derives request + response types from the routes, so a shape mismatch is a type error.
7
- * 2. **typed-client lint** — flags hand-rolled `fetch()` to this app's *own* API (a relative URL),
7
+ * 2. **typed-client lint** - flags hand-rolled `fetch()` to this app's *own* API (a relative URL),
8
8
  * which bypasses `client<typeof app>` so the compiler can't see the drift.
9
- * 3. **server-only-import lint** — flags a top-level import of server-only code (a DB driver, `node:`/
9
+ * 3. **server-only-import lint** - flags a top-level import of server-only code (a DB driver, `node:`/
10
10
  * `bun:` builtins, the `./db` module) into a `routes/` page module. Those modules are bundled for
11
- * the browser too, so the import ships server code to the client and breaks the build — the #1
11
+ * the browser too, so the import ships server code to the client and breaks the build - the #1
12
12
  * full-stack footgun. Reach server resources via `c.db` / `ctx.api`, never a top-level import.
13
13
  *
14
14
  * `collectCheckResult` returns a structured, machine-readable result (consumed by `--json` and the
@@ -18,10 +18,11 @@
18
18
  import { existsSync, readFileSync } from "node:fs";
19
19
  import { dirname, isAbsolute, join, resolve } from "node:path";
20
20
  import { Glob } from "bun";
21
- // `fetch( <ws> ('|"|`) / (not /)` — a string/template arg starting with a single `/` is a relative URL,
21
+ import { importTypeScript } from "./internal/typescript-import.js";
22
+ // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
22
23
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
23
24
  // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
24
- // alone on purpose — undecidable from source, and flagging it would punish legitimate external calls.
25
+ // alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
25
26
  const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
26
27
  const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
27
28
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
@@ -29,7 +30,7 @@ const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
29
30
  // Don't scan deps, build output, generated client entries, or tests (which legitimately drive `fetch`).
30
31
  // `dist(-<runtime>)?` also covers per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel).
31
32
  const IGNORED = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
32
- // A file under `routes/` — a page module bundled for the browser, where a server-only import is unsafe.
33
+ // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
33
34
  const ROUTE_FILE = /(^|\/)routes\//;
34
35
  // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
35
36
  // DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
@@ -192,13 +193,13 @@ function scanRoutePattern(file, content, code, pattern) {
192
193
  return out;
193
194
  }
194
195
  /**
195
- * Blank, with spaces (newlines preserved, so every byte offset — and line number — is unchanged):
196
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
196
197
  * - `//` line comments and block comments;
197
- * - the CONTENTS of backtick template literals — code-as-text (doc `CodeBlock` examples, code
198
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
198
199
  * generators), never a real statement to lint.
199
200
  * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
200
201
  * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
201
- * could mis-skip — in practice the constructs these scanners look for sit before any such regex.
202
+ * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
202
203
  * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
203
204
  */
204
205
  export function stripComments(src) {
@@ -442,10 +443,177 @@ export function scanStaticRouteText(file, content) {
442
443
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
443
444
  ].sort(bySite);
444
445
  }
445
- // `client("` / `client('` / `client(\`` — a URL-first call WITHOUT the `<typeof app>` generic:
446
+ /**
447
+ * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
448
+ * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
449
+ *
450
+ * Two things this deliberately does not flag, because both are safe and flagging them would train
451
+ * people to ignore the rule:
452
+ *
453
+ * - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
454
+ * substitutions separately and binds them; that is the parameterised form, not a bypass of it.
455
+ * - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
456
+ * argued for.
457
+ *
458
+ * A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
459
+ * escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
460
+ * is to take a statement as text, and a substitution in one is the exact thing the name warns about.
461
+ */
462
+ const SQL_METHODS = new Set([
463
+ "query",
464
+ "queryRaw",
465
+ "prepare",
466
+ "exec",
467
+ "execute",
468
+ "run",
469
+ "unsafe",
470
+ "$queryRawUnsafe",
471
+ "$executeRawUnsafe",
472
+ "raw",
473
+ ]);
474
+ const SQL_KEYWORD = /\b(select|insert\s+into|update|delete\s+from|drop\s+table|create\s+table|alter\s+table|truncate|from|where|values|set|join|union)\b/i;
475
+ const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
476
+ /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
477
+ function sqlExpressionShape(ts, node) {
478
+ if (ts.isParenthesizedExpression(node))
479
+ return sqlExpressionShape(ts, node.expression);
480
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
481
+ return {
482
+ staticText: node.text,
483
+ dynamic: false,
484
+ concatenated: false,
485
+ literal: true,
486
+ parameterizedTag: false,
487
+ };
488
+ }
489
+ if (ts.isTaggedTemplateExpression(node)) {
490
+ const tag = node.tag;
491
+ const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
492
+ (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
493
+ const template = node.template;
494
+ const staticText = ts.isTemplateExpression(template)
495
+ ? [template.head.text, ...template.templateSpans.map((span) => span.literal.text)].join("")
496
+ : template.text;
497
+ // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
498
+ // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
499
+ // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
500
+ // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
501
+ // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
502
+ //
503
+ // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
504
+ // a name invented for Nifra's own adapters - a second forgeable name that no user has a
505
+ // convention for, added so Nifra's source would pass its own rule. Names earn their place here by
506
+ // being what drivers already call the thing, not by being convenient for the framework.
507
+ //
508
+ // Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
509
+ // is treated as plain interpolation.
510
+ return {
511
+ staticText,
512
+ dynamic: ts.isTemplateExpression(template) && !parameterizedTag,
513
+ concatenated: false,
514
+ literal: !ts.isTemplateExpression(template),
515
+ parameterizedTag,
516
+ };
517
+ }
518
+ if (ts.isTemplateExpression(node)) {
519
+ return {
520
+ staticText: [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(""),
521
+ dynamic: true,
522
+ concatenated: false,
523
+ literal: false,
524
+ parameterizedTag: false,
525
+ };
526
+ }
527
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
528
+ const left = sqlExpressionShape(ts, node.left);
529
+ const right = sqlExpressionShape(ts, node.right);
530
+ return {
531
+ staticText: left.staticText + right.staticText,
532
+ dynamic: left.dynamic || right.dynamic || !left.literal || !right.literal,
533
+ concatenated: true,
534
+ literal: left.literal && right.literal,
535
+ parameterizedTag: false,
536
+ };
537
+ }
538
+ return {
539
+ staticText: "",
540
+ dynamic: true,
541
+ concatenated: false,
542
+ literal: false,
543
+ parameterizedTag: false,
544
+ };
545
+ }
546
+ function sqlMethod(ts, call) {
547
+ if (ts.isPropertyAccessExpression(call.expression)) {
548
+ const method = call.expression.name.text;
549
+ return SQL_METHODS.has(method)
550
+ ? { method, receiver: call.expression.expression.getText() }
551
+ : undefined;
552
+ }
553
+ if (ts.isElementAccessExpression(call.expression) &&
554
+ call.expression.argumentExpression !== undefined &&
555
+ ts.isStringLiteral(call.expression.argumentExpression)) {
556
+ const method = call.expression.argumentExpression.text;
557
+ return SQL_METHODS.has(method)
558
+ ? { method, receiver: call.expression.expression.getText() }
559
+ : undefined;
560
+ }
561
+ return undefined;
562
+ }
563
+ const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
564
+ /** Scan one file's text for SQL assembled by interpolation. Pure + line-accurate. */
565
+ export function scanInterpolatedSql(file, content, ts) {
566
+ const out = [];
567
+ const lines = content.split("\n");
568
+ const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
569
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
570
+ // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
571
+ // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
572
+ const parseDiagnostics = source.parseDiagnostics;
573
+ if (parseDiagnostics !== undefined && parseDiagnostics.length > 0)
574
+ return out;
575
+ const visit = (node) => {
576
+ if (ts.isCallExpression(node)) {
577
+ const sink = sqlMethod(ts, node);
578
+ const argument = node.arguments[0];
579
+ if (sink !== undefined && argument !== undefined) {
580
+ const shape = sqlExpressionShape(ts, argument);
581
+ const unsafeEscape = ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag;
582
+ const assembledSql = shape.dynamic &&
583
+ (SQL_KEYWORD.test(shape.staticText) ||
584
+ (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
585
+ if (unsafeEscape || assembledSql) {
586
+ const index = node.getStart(source);
587
+ const line = source.getLineAndCharacterOfPosition(index).line + 1;
588
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
589
+ }
590
+ }
591
+ }
592
+ ts.forEachChild(node, visit);
593
+ };
594
+ visit(source);
595
+ return out;
596
+ }
597
+ /**
598
+ * Collect interpolated-SQL findings across the project.
599
+ *
600
+ * Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
601
+ * with the compiler, so without it there is nothing to report and, more to the point, no basis for
602
+ * reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
603
+ * answer a security rule must never give when it did not run.
604
+ */
605
+ export async function scanProjectSql(cwd) {
606
+ const ts = await importTypeScript();
607
+ if (ts === undefined)
608
+ return undefined;
609
+ const out = [];
610
+ await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts)));
611
+ return out.sort(bySite);
612
+ }
613
+ // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
446
614
  // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
447
615
  // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
448
- // contract form `client(contract, url)` starts with an identifier, not a quote — also unmatched.
616
+ // contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
449
617
  const CLIENT_CALL = /(?<![.\w])client\s*\(/g;
450
618
  /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
451
619
  export function scanUntypedClient(file, content) {
@@ -479,13 +647,13 @@ const REMOVED_IMPORTS = [
479
647
  {
480
648
  specifier: "@nifrajs/budget",
481
649
  since: "2.0",
482
- replacement: 'import from "@nifrajs/core/budget" — the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
650
+ replacement: 'import from "@nifrajs/core/budget" - the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
483
651
  },
484
652
  {
485
653
  specifier: "@nifrajs/core/ws",
486
654
  since: "2.0",
487
655
  sideEffectOnly: true,
488
- replacement: 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime — register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
656
+ replacement: 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
489
657
  },
490
658
  ];
491
659
  /** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
@@ -535,7 +703,7 @@ export function scanServerOnlyImports(file, content) {
535
703
  return out;
536
704
  }
537
705
  // ---------------------------------------------------------------------------------------------------
538
- // #4.4 — TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
706
+ // #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
539
707
  // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
540
708
  // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
541
709
  // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
@@ -543,24 +711,24 @@ export function scanServerOnlyImports(file, content) {
543
711
  // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
544
712
  // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
545
713
  // ---------------------------------------------------------------------------------------------------
546
- // Server-only specifiers that are TERMINAL sinks — a bare `node:`/`bun:` builtin or a known server-only
714
+ // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
547
715
  // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
548
- // as SERVER_ONLY, minus the relative `../db` arm — a relative `db` module IS local source we resolve.)
716
+ // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
549
717
  const SERVER_ONLY_SINK = /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/;
550
718
  // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
551
719
  const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
552
- // The explicit poison-import marker (`@nifrajs/web/server-only`) — a module opting into the client-leak
720
+ // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
553
721
  // guard. A resolved file whose source carries this side-effect import is a server-only sink.
554
722
  const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/;
555
723
  // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
556
724
  // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
557
725
  const TRANSITIVE_MAX_DEPTH = 8;
558
726
  const TRANSITIVE_MAX_VISITED = 200;
559
- /** A relative module specifier (`./x`, `../y`) — the only kind we resolve + walk into (a bare specifier
727
+ /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
560
728
  * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
561
729
  const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
562
- // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT — the outer
563
- // per-route loop and the inner BFS both scan imports — and a single shared global-flag regex carries
730
+ // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
731
+ // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
564
732
  // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
565
733
  // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
566
734
  const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags);
@@ -587,7 +755,7 @@ export function parseStaticImports(content) {
587
755
  return staticImportEdges(content).map((e) => e.specifier);
588
756
  }
589
757
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
590
- * server-only package), or `undefined` if it isn't a by-name sink. Pure — the label is the specifier
758
+ * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
591
759
  * itself (it's already the actionable name). */
592
760
  export function directSinkSpecifier(spec) {
593
761
  return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
@@ -600,7 +768,7 @@ export function directSinkSpecifier(spec) {
600
768
  * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
601
769
  * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
602
770
  * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
603
- * — pure given those, so it's unit-testable with a fake graph.
771
+ * - pure given those, so it's unit-testable with a fake graph.
604
772
  */
605
773
  export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
606
774
  const seen = new Set([routeFile]);
@@ -618,13 +786,13 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
618
786
  if (sink !== undefined)
619
787
  return [...node.chain, sink];
620
788
  // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
621
- // specifier that isn't a known sink is a leaf — don't follow it into node_modules.
789
+ // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
622
790
  if (!isRelativeSpecifier(spec))
623
791
  continue;
624
792
  const abs = resolve(node.abs, spec);
625
793
  if (abs === undefined || seen.has(abs))
626
794
  continue;
627
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention — the chain
795
+ // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
628
796
  // ends at it (named by the as-written specifier).
629
797
  if (SERVER_MODULE_FILE.test(abs))
630
798
  return [...node.chain, spec];
@@ -648,8 +816,8 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
648
816
  /**
649
817
  * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
650
818
  * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
651
- * finding carries the full chain; otherwise — when the first hop is itself a direct by-name sink the
652
- * regex scan already flags, or when a relative import can't be resolved — it falls back to the DIRECT
819
+ * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
820
+ * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
653
821
  * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
654
822
  * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
655
823
  */
@@ -667,21 +835,21 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
667
835
  continue;
668
836
  const line = lineAt(content, index);
669
837
  const snippet = (lines[line - 1] ?? "").trim();
670
- // (1) A direct by-name sink (`node:fs`, `postgres`) — the route imports it itself; chain is the
838
+ // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
671
839
  // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
672
840
  if (directSinkSpecifier(specifier) !== undefined) {
673
841
  flaggedSpecifiers.add(specifier);
674
842
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
675
843
  continue;
676
844
  }
677
- // (2) A relative local import — try the transitive walk from the resolved dependency. If it reaches
845
+ // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
678
846
  // a sink, emit the full chain rooted at THIS route's import line.
679
847
  if (isRelativeSpecifier(specifier)) {
680
848
  const abs = resolve(file, specifier);
681
849
  if (abs === undefined) {
682
850
  // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
683
- // flags directly, fall back to the direct edge (precise resolve impossible — say so via
684
- // `fallback`). Any other unresolvable relative import we can't assert is server-only — skip it.
851
+ // flags directly, fall back to the direct edge (precise resolve impossible - say so via
852
+ // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
685
853
  if (SERVER_ONLY.test(specifier)) {
686
854
  flaggedSpecifiers.add(specifier);
687
855
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
@@ -713,7 +881,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
713
881
  continue;
714
882
  }
715
883
  // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
716
- // found no deeper sink (e.g. the file wasn't readable past the first hop) — fall back to the
884
+ // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
717
885
  // direct edge so we still surface the known-server-only convention rather than going silent.
718
886
  if (SERVER_ONLY.test(specifier)) {
719
887
  flaggedSpecifiers.add(specifier);
@@ -724,7 +892,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
724
892
  return out;
725
893
  }
726
894
  // A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
727
- // typed client infer `data: never` (`Jsonify<Response>` is `never`) — so frontend/backend drift detection
895
+ // typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
728
896
  // silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
729
897
  // (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
730
898
  const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g;
@@ -760,12 +928,12 @@ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response";
760
928
  /** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
761
929
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
762
930
  /**
763
- * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call — so `.gitignore`
931
+ * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
764
932
  * (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
765
933
  * is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
766
- * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full — 40k+
934
+ * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
767
935
  * findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
768
- * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex — never throws, never blocks.
936
+ * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
769
937
  */
770
938
  async function gitIgnored(cwd, rels) {
771
939
  if (rels.length === 0)
@@ -792,7 +960,7 @@ async function gitIgnored(cwd, rels) {
792
960
  }
793
961
  }
794
962
  export async function walkSource(cwd, visit) {
795
- // List candidates first (cheap — no reads), drop the built-in ignores, then exclude gitignored paths in
963
+ // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
796
964
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
797
965
  const rels = [];
798
966
  for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
@@ -813,14 +981,14 @@ export async function scanProject(cwd) {
813
981
  await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)));
814
982
  return out.sort(bySite);
815
983
  }
816
- // #7 — server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
984
+ // #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
817
985
  // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
818
- // regenerated, the worker serves a stale route table — a silent edge break that no other check catches.
986
+ // regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
819
987
  // We diff each committed manifest's imported route files against the live `routes/` tree.
820
- // The marker comment `generateServerManifest` emits at the top of the file — identifies a generated
988
+ // The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
821
989
  // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
822
990
  const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
823
- // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) — used to
991
+ // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
824
992
  // locate the routes dir relative to the manifest, and to strip to route-relative keys.
825
993
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
826
994
  // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
@@ -841,13 +1009,13 @@ export async function scanServerManifestDrift(cwd) {
841
1009
  continue;
842
1010
  const source = await Bun.file(join(cwd, rel)).text();
843
1011
  if (!source.includes(GENERATED_MARKER))
844
- continue; // not a generated manifest — skip
1012
+ continue; // not a generated manifest - skip
845
1013
  const prefixMatch = ROUTES_PREFIX.exec(source);
846
1014
  if (prefixMatch?.[1] === undefined)
847
1015
  continue; // no `routes/` imports → nothing to diff (empty app)
848
1016
  const routesPrefix = prefixMatch[1];
849
1017
  const manifestFiles = parseManifestRouteFiles(source, routesPrefix);
850
- // The routes dir sits at `<manifest dir>/<prefix>` — resolve it relative to the manifest file.
1018
+ // The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
851
1019
  const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
852
1020
  const routesDir = join(cwd, manifestDir, routesPrefix);
853
1021
  let discovered;
@@ -857,7 +1025,7 @@ export async function scanServerManifestDrift(cwd) {
857
1025
  .filter((f) => ROUTE_FILE_EXT.test(f));
858
1026
  }
859
1027
  catch {
860
- continue; // routes dir gone/unreadable — not a drift we can assess
1028
+ continue; // routes dir gone/unreadable - not a drift we can assess
861
1029
  }
862
1030
  const drift = diffManifestRoutes(manifestFiles, discovered);
863
1031
  if (!isManifestInSync(drift)) {
@@ -935,14 +1103,17 @@ async function loadCheckConfig(cwd) {
935
1103
  };
936
1104
  }
937
1105
  }
938
- const UNTYPED_CLIENT_HINT = 'client("…") without a type argument — write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift';
939
- const FETCH_HINT = "hand-rolled fetch() to your own API — call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift";
940
- 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";
1106
+ const UNTYPED_CLIENT_HINT = 'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift';
1107
+ const FETCH_HINT = "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift";
1108
+ 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";
941
1109
  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";
942
- const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies — run bun add to declare it";
943
- 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";
944
- 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";
945
- const CAPABILITY_HINT = "effect/capability assurance failed — align the route declaration with approved adapter provenance; never bypass an owned effect seam";
1110
+ const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies - run bun add to declare it";
1111
+ const SQL_COMPILER_MISSING_HINT = "the interpolated-SQL rule did NOT run - it parses source with the TypeScript compiler, which is an optional peer and is not installed here. This report says nothing about SQL injection either way. Install it with `bun add -d typescript`";
1112
+ const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}";
1113
+ const INTERPOLATED_SQL_HINT = `SQL built by interpolating a value into the statement text - the value becomes statement, not a parameter, so anything the caller controls can end the literal and continue as SQL. Pass it as a bound parameter (\`?\` / \`$1\` and an argument), or use your driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`), which binds the substitutions for you`;
1114
+ 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";
1115
+ 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";
1116
+ const CAPABILITY_HINT = "effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam";
946
1117
  function oneLineDiff(file, line, before, after) {
947
1118
  return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`;
948
1119
  }
@@ -1035,10 +1206,10 @@ function ownFetchSuggestion(f, routes) {
1035
1206
  function serverImportSuggestion(specifier, chain, fallback) {
1036
1207
  const sink = chain[chain.length - 1] ?? specifier;
1037
1208
  // Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
1038
- // ../db → node:crypto`) and which module to cut — not just the route's own top-level import.
1209
+ // ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
1039
1210
  const chainStep = chain.length > 2
1040
1211
  ? fallback
1041
- ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely — trace it from \`${specifier}\`).`
1212
+ ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
1042
1213
  : `Server-only code reaches this route transitively: \`${chain.join(" → ")}\`. The sink is \`${sink}\`; break the chain at the first hop (\`${specifier}\`) or move the sink behind the server boundary.`
1043
1214
  : undefined;
1044
1215
  return {
@@ -1072,14 +1243,19 @@ export async function collectCheckResult(cwd, opts = {}) {
1072
1243
  const removedImports = [];
1073
1244
  const serverImports = [];
1074
1245
  const responseRoutes = [];
1246
+ const interpolatedSql = [];
1075
1247
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
1076
- // (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
1248
+ // (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
1077
1249
  const routeModules = [];
1078
1250
  // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1079
1251
  // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1080
1252
  const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd);
1253
+ // The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
1254
+ // once, before the walk, so a project without it is told the rule did not run rather than being
1255
+ // handed a clean report the rule never produced.
1256
+ const sqlCompiler = await (opts.loadTypeScript ?? importTypeScript)();
1081
1257
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
1082
- // lints — the agent inner-loop mode; the full gate stays the definition of done.
1258
+ // lints - the agent inner-loop mode; the full gate stays the definition of done.
1083
1259
  const [tc, _, dr, manifestDrift] = await Promise.all([
1084
1260
  opts.lintsOnly
1085
1261
  ? Promise.resolve({ ran: false, ok: true, note: "skipped (lintsOnly)" })
@@ -1092,6 +1268,8 @@ export async function collectCheckResult(cwd, opts = {}) {
1092
1268
  if (ROUTE_FILE.test(rel))
1093
1269
  routeModules.push({ rel, content });
1094
1270
  responseRoutes.push(...scanResponseRoutes(rel, content));
1271
+ if (sqlCompiler !== undefined)
1272
+ interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler));
1095
1273
  }),
1096
1274
  import("./doctor.js").then((m) => m.collectDoctorResult(cwd)),
1097
1275
  scanServerManifestDrift(cwd),
@@ -1104,7 +1282,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1104
1282
  const resolveModule = (fromFile, specifier) => {
1105
1283
  try {
1106
1284
  // `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
1107
- // deeper hops the walk takes (it carries resolved absolute paths) — resolve the dir for each.
1285
+ // deeper hops the walk takes (it carries resolved absolute paths) - resolve the dir for each.
1108
1286
  const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile);
1109
1287
  return Bun.resolveSync(specifier, dirname(fromAbs));
1110
1288
  }
@@ -1177,7 +1355,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1177
1355
  severity: "error",
1178
1356
  file: f.file,
1179
1357
  line: f.line,
1180
- message: `${f.snippet} — ${FETCH_HINT}`,
1358
+ message: `${f.snippet} - ${FETCH_HINT}`,
1181
1359
  fix: FETCH_HINT,
1182
1360
  suggestion: ownFetchSuggestion(f, routes),
1183
1361
  });
@@ -1190,7 +1368,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1190
1368
  severity: "error",
1191
1369
  file: f.file,
1192
1370
  line: f.line,
1193
- message: `${f.snippet} — removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
1371
+ message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
1194
1372
  fix: entry?.replacement ?? "see the changelog",
1195
1373
  });
1196
1374
  }
@@ -1200,14 +1378,14 @@ export async function collectCheckResult(cwd, opts = {}) {
1200
1378
  severity: "error",
1201
1379
  file: f.file,
1202
1380
  line: f.line,
1203
- message: `${f.snippet} — ${UNTYPED_CLIENT_HINT}`,
1381
+ message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
1204
1382
  fix: UNTYPED_CLIENT_HINT,
1205
1383
  suggestion: untypedClientSuggestion(f),
1206
1384
  });
1207
1385
  }
1208
1386
  for (const f of serverImports.sort(bySite)) {
1209
- // #4.4: the FULL transitive chain the import-resolution walk found — `route → ../data → ../db →
1210
- // node:crypto`, matching the build leak-guard's depth — instead of just the direct edge. The chain's
1387
+ // #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
1388
+ // node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
1211
1389
  // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
1212
1390
  // (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
1213
1391
  const chain = f.chain;
@@ -1217,20 +1395,55 @@ export async function collectCheckResult(cwd, opts = {}) {
1217
1395
  severity: "error",
1218
1396
  file: f.file,
1219
1397
  line: f.line,
1220
- message: `${f.snippet} — server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${f.fallback ? " (direct edge — couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
1398
+ message: `${f.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${f.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
1221
1399
  fix: SERVER_IMPORT_HINT,
1222
1400
  chain,
1223
1401
  suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
1224
1402
  });
1225
1403
  }
1226
- // Advisory — surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
1404
+ if (sqlCompiler === undefined) {
1405
+ // A security rule that could not run must say so. Silence here is indistinguishable from a clean
1406
+ // result, and this is the one rule whose clean result means "no SQL injection was found".
1407
+ diagnostics.push({
1408
+ rule: "interpolated-sql",
1409
+ severity: "warning",
1410
+ message: SQL_COMPILER_MISSING_HINT,
1411
+ fix: SQL_COMPILER_MISSING_HINT,
1412
+ suggestion: {
1413
+ kind: "command",
1414
+ title: "Install TypeScript so the SQL rule can run",
1415
+ command: ["bun add -d typescript"],
1416
+ },
1417
+ });
1418
+ }
1419
+ for (const f of interpolatedSql.sort(bySite)) {
1420
+ diagnostics.push({
1421
+ rule: "interpolated-sql",
1422
+ severity: "error",
1423
+ file: f.file,
1424
+ line: f.line,
1425
+ message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
1426
+ fix: "bind the value as a parameter instead of interpolating it into the statement",
1427
+ suggestion: {
1428
+ kind: "manual",
1429
+ title: "Bind the value instead of interpolating it",
1430
+ steps: [
1431
+ "Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
1432
+ "Pass the value as an argument alongside the statement, so the driver binds it.",
1433
+ `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
1434
+ "An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
1435
+ ],
1436
+ },
1437
+ });
1438
+ }
1439
+ // Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
1227
1440
  for (const f of responseRoutes.sort(bySite)) {
1228
1441
  diagnostics.push({
1229
1442
  rule: "response-route",
1230
1443
  severity: "warning",
1231
1444
  file: f.file,
1232
1445
  line: f.line,
1233
- message: `${f.snippet} — ${RESPONSE_ROUTE_HINT}`,
1446
+ message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
1234
1447
  fix: RESPONSE_ROUTE_HINT,
1235
1448
  suggestion: responseRouteSuggestion(),
1236
1449
  });
@@ -1242,7 +1455,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1242
1455
  severity: "error",
1243
1456
  file: f.file,
1244
1457
  line: f.line,
1245
- message: `imports ${f.package} which is not declared in package.json — ${UNDECLARED_DEP_HINT}`,
1458
+ message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
1246
1459
  fix: `add ${f.package} to package.json dependencies`,
1247
1460
  suggestion: {
1248
1461
  kind: "command",
@@ -1270,7 +1483,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1270
1483
  });
1271
1484
  }
1272
1485
  }
1273
- // #7: a committed server-manifest.ts that drifted from routes/ — name the exact missing/extra routes.
1486
+ // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
1274
1487
  for (const f of manifestDrift) {
1275
1488
  const parts = [];
1276
1489
  if (f.missing.length > 0)
@@ -1281,13 +1494,13 @@ export async function collectCheckResult(cwd, opts = {}) {
1281
1494
  rule: "server-manifest-drift",
1282
1495
  severity: "error",
1283
1496
  file: f.file,
1284
- message: `${f.file} drifted from routes/ (${parts.join("; ")}) — ${MANIFEST_DRIFT_HINT}`,
1497
+ message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
1285
1498
  fix: MANIFEST_DRIFT_HINT,
1286
1499
  suggestion: {
1287
1500
  kind: "manual",
1288
1501
  title: "Regenerate the committed server manifest",
1289
1502
  steps: [
1290
- "Re-run your build (`nifra build --target <t>` or your build script) — it regenerates server-manifest.ts from the current routes/.",
1503
+ "Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
1291
1504
  "Commit the updated server-manifest.ts.",
1292
1505
  ],
1293
1506
  },
@@ -1296,25 +1509,50 @@ export async function collectCheckResult(cwd, opts = {}) {
1296
1509
  // G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
1297
1510
  // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
1298
1511
  // without nifra.assurance.ts retain the historical scan and hot path unchanged.
1512
+ const provided = opts.assurance;
1299
1513
  const assuranceConfigPath = join(cwd, "nifra.assurance.ts");
1300
- if (existsSync(assuranceConfigPath)) {
1514
+ if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
1301
1515
  try {
1302
- const { loadAssuranceConfig } = await import("./assure.js");
1303
- const config = await loadAssuranceConfig(cwd);
1304
- let capabilityReport;
1305
- if (config.capabilities !== undefined) {
1306
- const { collectCapabilityProjectReport } = await import("./capabilities-tool.js");
1307
- const project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
1308
- capabilityReport = project.report;
1516
+ // Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
1517
+ // standalone path, loaded + reflected here. Both branches yield the same config + evidence.
1518
+ let config;
1519
+ let project;
1520
+ let routeAssurance;
1521
+ if (provided !== undefined) {
1522
+ // A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
1523
+ // capability-config diagnostic the standalone catch produces.
1524
+ if (provided.error !== undefined)
1525
+ throw provided.error;
1526
+ config = provided.config;
1527
+ project = provided.capability;
1528
+ routeAssurance = provided.routeAssurance;
1529
+ }
1530
+ else {
1531
+ const { loadAssuranceConfig } = await import("./assure.js");
1532
+ config = await loadAssuranceConfig(cwd);
1533
+ if (config.capabilities !== undefined) {
1534
+ const { collectCapabilityProjectReport } = await import("./capabilities-tool.js");
1535
+ project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
1536
+ }
1537
+ }
1538
+ const capabilityReport = project?.report;
1539
+ if (config.capabilities !== undefined && project !== undefined) {
1309
1540
  for (const finding of project.report.findings) {
1310
1541
  const violation = finding.code === "forbidden-effect-import"
1311
1542
  ? project.violations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
1312
1543
  : undefined;
1544
+ const truncation = finding.code === "provenance-truncated"
1545
+ ? project.truncations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
1546
+ : undefined;
1313
1547
  diagnostics.push({
1314
1548
  rule: "capability-assurance",
1315
1549
  severity: "error",
1316
- ...(violation !== undefined ? { file: violation.module, chain: violation.chain } : {}),
1317
- message: `${finding.message} — ${CAPABILITY_HINT}`,
1550
+ ...(violation !== undefined
1551
+ ? { file: violation.module, chain: violation.chain }
1552
+ : truncation !== undefined
1553
+ ? { chain: truncation.chain }
1554
+ : {}),
1555
+ message: `${finding.message} - ${CAPABILITY_HINT}`,
1318
1556
  fix: CAPABILITY_HINT,
1319
1557
  suggestion: {
1320
1558
  kind: "manual",
@@ -1322,7 +1560,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1322
1560
  steps: [
1323
1561
  "Route effectful work through an import listed in capabilities.provenance.imports.",
1324
1562
  "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
1325
- "For domain writes, add the request-idempotency or durable-command adapter required by the capability definition.",
1563
+ "For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
1326
1564
  "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
1327
1565
  ],
1328
1566
  },
@@ -1330,9 +1568,13 @@ export async function collectCheckResult(cwd, opts = {}) {
1330
1568
  }
1331
1569
  }
1332
1570
  if (config.manifest !== undefined) {
1333
- const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance");
1334
1571
  const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import("@nifrajs/core/manifest");
1335
- const assurance = evaluateRouteAssurance(config.source, config.policy);
1572
+ const assurance = routeAssurance ??
1573
+ (await import("@nifrajs/core/assurance")).evaluateRouteAssurance(config.source, config.policy, {
1574
+ ...(config.capabilities !== undefined
1575
+ ? { definitions: config.capabilities.definitions }
1576
+ : {}),
1577
+ });
1336
1578
  const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json");
1337
1579
  let message;
1338
1580
  if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
@@ -1365,7 +1607,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1365
1607
  rule: "manifest-drift",
1366
1608
  severity: "error",
1367
1609
  file: path,
1368
- message: `${message} — ${TRUST_MANIFEST_DRIFT_HINT}`,
1610
+ message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
1369
1611
  fix: TRUST_MANIFEST_DRIFT_HINT,
1370
1612
  suggestion: {
1371
1613
  kind: "command",
@@ -1397,7 +1639,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1397
1639
  }
1398
1640
  }
1399
1641
  // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
1400
- // message that breaks the stdio transport. `ok` reflects the FULL set — truncation never flips it.
1642
+ // message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
1401
1643
  const total = diagnostics.length;
1402
1644
  const max = opts.maxDiagnostics;
1403
1645
  const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics;
@@ -1413,7 +1655,10 @@ export async function collectCheckResult(cwd, opts = {}) {
1413
1655
  }
1414
1656
  /** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
1415
1657
  export async function runCheck(cwd, opts = {}) {
1416
- const result = await collectCheckResult(cwd, { lintsOnly: opts.lintsOnly ?? false });
1658
+ // The check view over the one project verification: the same collector `assure` and `levels` read.
1659
+ const { collectProjectVerification } = await import("./verification.js");
1660
+ const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false });
1661
+ const result = await verification.check();
1417
1662
  if (opts.json) {
1418
1663
  console.log(JSON.stringify(result, null, 2));
1419
1664
  return result.ok;
@@ -1422,7 +1667,7 @@ export async function runCheck(cwd, opts = {}) {
1422
1667
  console.log(result.typecheck === "pass"
1423
1668
  ? "✓ typecheck passed"
1424
1669
  : result.typecheck === "fail"
1425
- ? "✗ typecheck failed — the frontend/backend contract is broken"
1670
+ ? "✗ typecheck failed - the frontend/backend contract is broken"
1426
1671
  : "• typecheck skipped (no tsconfig / typescript not installed)");
1427
1672
  if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
1428
1673
  console.log(`• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`);
@@ -1433,6 +1678,7 @@ export async function runCheck(cwd, opts = {}) {
1433
1678
  ["typed-client", "hand-rolled fetch() to your own API"],
1434
1679
  ["untyped-client", 'client("…") missing its <typeof app> type argument'],
1435
1680
  ["server-only-import", "server-only import in a route module"],
1681
+ ["interpolated-sql", "SQL built by interpolating a value into the statement"],
1436
1682
  ["response-route", "route returns a raw Response (typed client → data: never)"],
1437
1683
  ["undeclared-dependency", "undeclared dependency in package.json"],
1438
1684
  ["duplicate-install", "duplicate identity-sensitive dependency install"],