@nifrajs/cli 2.2.0 → 2.4.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 (135) 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 +336 -87
  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 +148 -39
  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.d.ts.map +1 -1
  24. package/dist/doctor.js +12 -8
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/learn.d.ts +32 -0
  37. package/dist/learn.d.ts.map +1 -0
  38. package/dist/learn.js +127 -0
  39. package/dist/learn.js.map +1 -0
  40. package/dist/levels-tool.d.ts +8 -8
  41. package/dist/levels-tool.d.ts.map +1 -1
  42. package/dist/levels-tool.js +33 -30
  43. package/dist/levels-tool.js.map +1 -1
  44. package/dist/load.d.ts +6 -6
  45. package/dist/load.d.ts.map +1 -1
  46. package/dist/load.js +15 -7
  47. package/dist/load.js.map +1 -1
  48. package/dist/manifest-tool.d.ts +1 -1
  49. package/dist/manifest-tool.d.ts.map +1 -1
  50. package/dist/manifest-tool.js +4 -2
  51. package/dist/manifest-tool.js.map +1 -1
  52. package/dist/mcp-app-tool.d.ts +14 -0
  53. package/dist/mcp-app-tool.d.ts.map +1 -0
  54. package/dist/mcp-app-tool.js +80 -0
  55. package/dist/mcp-app-tool.js.map +1 -0
  56. package/dist/mcp-docs-tools.d.ts +1 -1
  57. package/dist/mcp-docs-tools.d.ts.map +1 -1
  58. package/dist/mcp-docs-tools.js +25 -9
  59. package/dist/mcp-docs-tools.js.map +1 -1
  60. package/dist/mcp-http.d.ts +14 -10
  61. package/dist/mcp-http.d.ts.map +1 -1
  62. package/dist/mcp-http.js +30 -16
  63. package/dist/mcp-http.js.map +1 -1
  64. package/dist/mcp-protocol.d.ts +2 -2
  65. package/dist/mcp-protocol.js +2 -2
  66. package/dist/mcp-render.d.ts +3 -3
  67. package/dist/mcp-render.js +6 -6
  68. package/dist/mcp-run.d.ts +2 -2
  69. package/dist/mcp-run.js +2 -2
  70. package/dist/mcp.d.ts +28 -14
  71. package/dist/mcp.d.ts.map +1 -1
  72. package/dist/mcp.js +263 -45
  73. package/dist/mcp.js.map +1 -1
  74. package/dist/openapi-tool.d.ts +1 -1
  75. package/dist/openapi-tool.js +2 -2
  76. package/dist/pipeline-guard.d.ts +1 -1
  77. package/dist/pipeline-guard.js +1 -1
  78. package/dist/port.d.ts +9 -9
  79. package/dist/port.js +27 -27
  80. package/dist/recipes/1.8.0.d.ts +2 -2
  81. package/dist/recipes/1.8.0.js +5 -5
  82. package/dist/recipes/index.d.ts +2 -2
  83. package/dist/recipes/index.js +1 -1
  84. package/dist/scaffold.d.ts +3 -3
  85. package/dist/scaffold.js +8 -8
  86. package/dist/sync-routes.d.ts +28 -0
  87. package/dist/sync-routes.d.ts.map +1 -0
  88. package/dist/sync-routes.js +55 -0
  89. package/dist/sync-routes.js.map +1 -0
  90. package/dist/types-search.d.ts +5 -5
  91. package/dist/types-search.js +7 -7
  92. package/dist/upgrade.d.ts +20 -9
  93. package/dist/upgrade.d.ts.map +1 -1
  94. package/dist/upgrade.js +117 -19
  95. package/dist/upgrade.js.map +1 -1
  96. package/dist/verification.d.ts +51 -0
  97. package/dist/verification.d.ts.map +1 -0
  98. package/dist/verification.js +80 -0
  99. package/dist/verification.js.map +1 -0
  100. package/docs/examples.json +174 -90
  101. package/docs/llms-full.txt +1771 -748
  102. package/docs/types.json +2027 -3089
  103. package/package.json +22 -11
  104. package/src/assure.ts +17 -18
  105. package/src/capabilities-tool.ts +82 -16
  106. package/src/check.ts +410 -104
  107. package/src/cli.ts +161 -41
  108. package/src/diff-tool.ts +8 -8
  109. package/src/docs-search.ts +6 -6
  110. package/src/doctor.ts +18 -10
  111. package/src/examples.ts +7 -7
  112. package/src/init-agents.ts +17 -17
  113. package/src/internal/typescript-import.ts +33 -0
  114. package/src/introspect.ts +28 -28
  115. package/src/learn.ts +152 -0
  116. package/src/levels-tool.ts +37 -31
  117. package/src/load.ts +21 -12
  118. package/src/manifest-tool.ts +4 -2
  119. package/src/mcp-app-tool.ts +86 -0
  120. package/src/mcp-docs-tools.ts +26 -9
  121. package/src/mcp-http.ts +31 -18
  122. package/src/mcp-protocol.ts +2 -2
  123. package/src/mcp-render.ts +6 -6
  124. package/src/mcp-run.ts +2 -2
  125. package/src/mcp.ts +319 -45
  126. package/src/openapi-tool.ts +2 -2
  127. package/src/pipeline-guard.ts +1 -1
  128. package/src/port.ts +31 -31
  129. package/src/recipes/1.8.0.ts +5 -5
  130. package/src/recipes/index.ts +2 -2
  131. package/src/scaffold.ts +8 -8
  132. package/src/sync-routes.ts +69 -0
  133. package/src/types-search.ts +8 -8
  134. package/src/upgrade.ts +137 -19
  135. package/src/verification.ts +123 -0
package/src/check.ts 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,7 +18,11 @@
18
18
 
19
19
  import { existsSync, readFileSync } from "node:fs"
20
20
  import { dirname, isAbsolute, join, resolve } from "node:path"
21
+ import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
21
22
  import { Glob } from "bun"
23
+ import type * as TSApi from "typescript"
24
+ import type { CapabilityProjectReport } from "./capabilities-tool.ts"
25
+ import { importTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
22
26
 
23
27
  export interface SourceFinding {
24
28
  readonly file: string
@@ -32,10 +36,10 @@ export interface ServerImportFinding extends SourceFinding {
32
36
  readonly specifier: string
33
37
  }
34
38
 
35
- // `fetch( <ws> ('|"|`) / (not /)` — a string/template arg starting with a single `/` is a relative URL,
39
+ // `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
36
40
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
37
41
  // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
38
- // alone on purpose — undecidable from source, and flagging it would punish legitimate external calls.
42
+ // alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
39
43
  const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
40
44
  const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
41
45
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
@@ -46,7 +50,7 @@ const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
46
50
  const IGNORED =
47
51
  /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/
48
52
 
49
- // A file under `routes/` — a page module bundled for the browser, where a server-only import is unsafe.
53
+ // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
50
54
  const ROUTE_FILE = /(^|\/)routes\//
51
55
 
52
56
  // Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
@@ -224,13 +228,13 @@ function scanRoutePattern(
224
228
  }
225
229
 
226
230
  /**
227
- * Blank, with spaces (newlines preserved, so every byte offset — and line number — is unchanged):
231
+ * Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
228
232
  * - `//` line comments and block comments;
229
- * - the CONTENTS of backtick template literals — code-as-text (doc `CodeBlock` examples, code
233
+ * - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
230
234
  * generators), never a real statement to lint.
231
235
  * Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
232
236
  * char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
233
- * could mis-skip — in practice the constructs these scanners look for sit before any such regex.
237
+ * could mis-skip - in practice the constructs these scanners look for sit before any such regex.
234
238
  * Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
235
239
  */
236
240
  export function stripComments(src: string): string {
@@ -449,10 +453,203 @@ export function scanStaticRouteText(file: string, content: string): StaticRouteF
449
453
  ].sort(bySite)
450
454
  }
451
455
 
452
- // `client("` / `client('` / `client(\`` — a URL-first call WITHOUT the `<typeof app>` generic:
456
+ /**
457
+ * SQL built by interpolating a value into the statement text. The value is not a parameter, it is
458
+ * STATEMENT, so anything the caller controls can end the literal and continue as SQL.
459
+ *
460
+ * Two things this deliberately does not flag, because both are safe and flagging them would train
461
+ * people to ignore the rule:
462
+ *
463
+ * - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
464
+ * substitutions separately and binds them; that is the parameterised form, not a bypass of it.
465
+ * - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
466
+ * argued for.
467
+ *
468
+ * A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
469
+ * escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
470
+ * is to take a statement as text, and a substitution in one is the exact thing the name warns about.
471
+ */
472
+ const SQL_METHODS = new Set([
473
+ "query",
474
+ "queryRaw",
475
+ "prepare",
476
+ "exec",
477
+ "execute",
478
+ "run",
479
+ "unsafe",
480
+ "$queryRawUnsafe",
481
+ "$executeRawUnsafe",
482
+ "raw",
483
+ ])
484
+ const SQL_KEYWORD =
485
+ /\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
486
+ const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
487
+
488
+ interface SqlExpressionShape {
489
+ readonly staticText: string
490
+ readonly dynamic: boolean
491
+ readonly concatenated: boolean
492
+ readonly literal: boolean
493
+ readonly parameterizedTag: boolean
494
+ }
495
+
496
+ /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
497
+ function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpressionShape {
498
+ if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression)
499
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
500
+ return {
501
+ staticText: node.text,
502
+ dynamic: false,
503
+ concatenated: false,
504
+ literal: true,
505
+ parameterizedTag: false,
506
+ }
507
+ }
508
+ if (ts.isTaggedTemplateExpression(node)) {
509
+ const tag = node.tag
510
+ const parameterizedTag =
511
+ (ts.isIdentifier(tag) && tag.text === "sql") ||
512
+ (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
513
+ const template = node.template
514
+ const staticText = ts.isTemplateExpression(template)
515
+ ? [template.head.text, ...template.templateSpans.map((span) => span.literal.text)].join("")
516
+ : template.text
517
+ // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
518
+ // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
519
+ // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
520
+ // deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
521
+ // same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
522
+ //
523
+ // Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
524
+ // a name invented for Nifra's own adapters - a second forgeable name that no user has a
525
+ // convention for, added so Nifra's source would pass its own rule. Names earn their place here by
526
+ // being what drivers already call the thing, not by being convenient for the framework.
527
+ //
528
+ // Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
529
+ // is treated as plain interpolation.
530
+ return {
531
+ staticText,
532
+ dynamic: ts.isTemplateExpression(template) && !parameterizedTag,
533
+ concatenated: false,
534
+ literal: !ts.isTemplateExpression(template),
535
+ parameterizedTag,
536
+ }
537
+ }
538
+ if (ts.isTemplateExpression(node)) {
539
+ return {
540
+ staticText: [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(""),
541
+ dynamic: true,
542
+ concatenated: false,
543
+ literal: false,
544
+ parameterizedTag: false,
545
+ }
546
+ }
547
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
548
+ const left = sqlExpressionShape(ts, node.left)
549
+ const right = sqlExpressionShape(ts, node.right)
550
+ return {
551
+ staticText: left.staticText + right.staticText,
552
+ dynamic: left.dynamic || right.dynamic || !left.literal || !right.literal,
553
+ concatenated: true,
554
+ literal: left.literal && right.literal,
555
+ parameterizedTag: false,
556
+ }
557
+ }
558
+ return {
559
+ staticText: "",
560
+ dynamic: true,
561
+ concatenated: false,
562
+ literal: false,
563
+ parameterizedTag: false,
564
+ }
565
+ }
566
+
567
+ function sqlMethod(
568
+ ts: TypeScriptApi,
569
+ call: TSApi.CallExpression,
570
+ ): { method: string; receiver: string } | undefined {
571
+ if (ts.isPropertyAccessExpression(call.expression)) {
572
+ const method = call.expression.name.text
573
+ return SQL_METHODS.has(method)
574
+ ? { method, receiver: call.expression.expression.getText() }
575
+ : undefined
576
+ }
577
+ if (
578
+ ts.isElementAccessExpression(call.expression) &&
579
+ call.expression.argumentExpression !== undefined &&
580
+ ts.isStringLiteral(call.expression.argumentExpression)
581
+ ) {
582
+ const method = call.expression.argumentExpression.text
583
+ return SQL_METHODS.has(method)
584
+ ? { method, receiver: call.expression.expression.getText() }
585
+ : undefined
586
+ }
587
+ return undefined
588
+ }
589
+
590
+ const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
591
+
592
+ /** Scan one file's text for SQL assembled by interpolation. Pure + line-accurate. */
593
+ export function scanInterpolatedSql(
594
+ file: string,
595
+ content: string,
596
+ ts: TypeScriptApi,
597
+ ): SourceFinding[] {
598
+ const out: SourceFinding[] = []
599
+ const lines = content.split("\n")
600
+ const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
601
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
602
+ // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
603
+ // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
604
+ const parseDiagnostics = (
605
+ source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
606
+ ).parseDiagnostics
607
+ if (parseDiagnostics !== undefined && parseDiagnostics.length > 0) return out
608
+ const visit = (node: TSApi.Node): void => {
609
+ if (ts.isCallExpression(node)) {
610
+ const sink = sqlMethod(ts, node)
611
+ const argument = node.arguments[0]
612
+ if (sink !== undefined && argument !== undefined) {
613
+ const shape = sqlExpressionShape(ts, argument)
614
+ const unsafeEscape =
615
+ ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
616
+ const assembledSql =
617
+ shape.dynamic &&
618
+ (SQL_KEYWORD.test(shape.staticText) ||
619
+ (shape.concatenated && SQL_RECEIVER.test(sink.receiver)))
620
+ if (unsafeEscape || assembledSql) {
621
+ const index = node.getStart(source)
622
+ const line = source.getLineAndCharacterOfPosition(index).line + 1
623
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
624
+ }
625
+ }
626
+ }
627
+ ts.forEachChild(node, visit)
628
+ }
629
+ visit(source)
630
+ return out
631
+ }
632
+
633
+ /**
634
+ * Collect interpolated-SQL findings across the project.
635
+ *
636
+ * Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
637
+ * with the compiler, so without it there is nothing to report and, more to the point, no basis for
638
+ * reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
639
+ * answer a security rule must never give when it did not run.
640
+ */
641
+ export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
642
+ const ts = await importTypeScript()
643
+ if (ts === undefined) return undefined
644
+ const out: SourceFinding[] = []
645
+ await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts)))
646
+ return out.sort(bySite)
647
+ }
648
+
649
+ // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
453
650
  // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
454
651
  // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
455
- // contract form `client(contract, url)` starts with an identifier, not a quote — also unmatched.
652
+ // contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
456
653
  const CLIENT_CALL = /(?<![.\w])client\s*\(/g
457
654
 
458
655
  /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
@@ -496,14 +693,14 @@ const REMOVED_IMPORTS: ReadonlyArray<{
496
693
  specifier: "@nifrajs/budget",
497
694
  since: "2.0",
498
695
  replacement:
499
- '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',
696
+ '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',
500
697
  },
501
698
  {
502
699
  specifier: "@nifrajs/core/ws",
503
700
  since: "2.0",
504
701
  sideEffectOnly: true,
505
702
  replacement:
506
- 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime — register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
703
+ 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
507
704
  },
508
705
  ]
509
706
 
@@ -555,7 +752,7 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
555
752
  }
556
753
 
557
754
  // ---------------------------------------------------------------------------------------------------
558
- // #4.4 — TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
755
+ // #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
559
756
  // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
560
757
  // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
561
758
  // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
@@ -564,14 +761,14 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
564
761
  // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
565
762
  // ---------------------------------------------------------------------------------------------------
566
763
 
567
- // Server-only specifiers that are TERMINAL sinks — a bare `node:`/`bun:` builtin or a known server-only
764
+ // Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
568
765
  // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
569
- // as SERVER_ONLY, minus the relative `../db` arm — a relative `db` module IS local source we resolve.)
766
+ // as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
570
767
  const SERVER_ONLY_SINK =
571
768
  /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
572
769
  // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
573
770
  const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
574
- // The explicit poison-import marker (`@nifrajs/web/server-only`) — a module opting into the client-leak
771
+ // The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
575
772
  // guard. A resolved file whose source carries this side-effect import is a server-only sink.
576
773
  const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
577
774
  // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
@@ -579,13 +776,13 @@ const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
579
776
  const TRANSITIVE_MAX_DEPTH = 8
580
777
  const TRANSITIVE_MAX_VISITED = 200
581
778
 
582
- /** A relative module specifier (`./x`, `../y`) — the only kind we resolve + walk into (a bare specifier
779
+ /** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
583
780
  * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
584
781
  const isRelativeSpecifier = (spec: string): boolean =>
585
782
  spec.startsWith("./") || spec.startsWith("../")
586
783
 
587
- // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT — the outer
588
- // per-route loop and the inner BFS both scan imports — and a single shared global-flag regex carries
784
+ // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
785
+ // per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
589
786
  // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
590
787
  // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
591
788
  const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags)
@@ -613,7 +810,7 @@ export function parseStaticImports(content: string): string[] {
613
810
  }
614
811
 
615
812
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
616
- * server-only package), or `undefined` if it isn't a by-name sink. Pure — the label is the specifier
813
+ * server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
617
814
  * itself (it's already the actionable name). */
618
815
  export function directSinkSpecifier(spec: string): string | undefined {
619
816
  return SERVER_ONLY_SINK.test(spec) ? spec : undefined
@@ -622,7 +819,7 @@ export function directSinkSpecifier(spec: string): string | undefined {
622
819
  /** One transitive server-only finding: the route module, the route's offending top-level import (the
623
820
  * first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
624
821
  export interface TransitiveServerImportFinding extends ServerImportFinding {
625
- /** `[routeFile, ...as-written specifiers…, sink]` — the shortest path the walk found. Length 2 means
822
+ /** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
626
823
  * the route imports the sink directly (same as the regex scan's direct edge). */
627
824
  readonly chain: readonly string[]
628
825
  /** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
@@ -645,7 +842,7 @@ export type ModuleReader = (absPath: string) => string | undefined
645
842
  * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
646
843
  * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
647
844
  * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
648
- * — pure given those, so it's unit-testable with a fake graph.
845
+ * - pure given those, so it's unit-testable with a fake graph.
649
846
  */
650
847
  export function walkServerOnlyChain(
651
848
  routeFile: string,
@@ -674,11 +871,11 @@ export function walkServerOnlyChain(
674
871
  const sink = directSinkSpecifier(spec)
675
872
  if (sink !== undefined) return [...node.chain, sink]
676
873
  // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
677
- // specifier that isn't a known sink is a leaf — don't follow it into node_modules.
874
+ // specifier that isn't a known sink is a leaf - don't follow it into node_modules.
678
875
  if (!isRelativeSpecifier(spec)) continue
679
876
  const abs = resolve(node.abs, spec)
680
877
  if (abs === undefined || seen.has(abs)) continue
681
- // (c) A resolved `*.server` module is a server-only sink by the `.server` convention — the chain
878
+ // (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
682
879
  // ends at it (named by the as-written specifier).
683
880
  if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
684
881
  const content = read(abs)
@@ -699,8 +896,8 @@ export function walkServerOnlyChain(
699
896
  /**
700
897
  * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
701
898
  * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
702
- * finding carries the full chain; otherwise — when the first hop is itself a direct by-name sink the
703
- * regex scan already flags, or when a relative import can't be resolved — it falls back to the DIRECT
899
+ * finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
900
+ * regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
704
901
  * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
705
902
  * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
706
903
  */
@@ -721,21 +918,21 @@ export function resolveServerOnlyChains(
721
918
  if (flaggedSpecifiers.has(specifier)) continue
722
919
  const line = lineAt(content, index)
723
920
  const snippet = (lines[line - 1] ?? "").trim()
724
- // (1) A direct by-name sink (`node:fs`, `postgres`) — the route imports it itself; chain is the
921
+ // (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
725
922
  // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
726
923
  if (directSinkSpecifier(specifier) !== undefined) {
727
924
  flaggedSpecifiers.add(specifier)
728
925
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
729
926
  continue
730
927
  }
731
- // (2) A relative local import — try the transitive walk from the resolved dependency. If it reaches
928
+ // (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
732
929
  // a sink, emit the full chain rooted at THIS route's import line.
733
930
  if (isRelativeSpecifier(specifier)) {
734
931
  const abs = resolve(file, specifier)
735
932
  if (abs === undefined) {
736
933
  // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
737
- // flags directly, fall back to the direct edge (precise resolve impossible — say so via
738
- // `fallback`). Any other unresolvable relative import we can't assert is server-only — skip it.
934
+ // flags directly, fall back to the direct edge (precise resolve impossible - say so via
935
+ // `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
739
936
  if (SERVER_ONLY.test(specifier)) {
740
937
  flaggedSpecifiers.add(specifier)
741
938
  out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
@@ -766,7 +963,7 @@ export function resolveServerOnlyChains(
766
963
  continue
767
964
  }
768
965
  // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
769
- // found no deeper sink (e.g. the file wasn't readable past the first hop) — fall back to the
966
+ // found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
770
967
  // direct edge so we still surface the known-server-only convention rather than going silent.
771
968
  if (SERVER_ONLY.test(specifier)) {
772
969
  flaggedSpecifiers.add(specifier)
@@ -778,7 +975,7 @@ export function resolveServerOnlyChains(
778
975
  }
779
976
 
780
977
  // A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
781
- // typed client infer `data: never` (`Jsonify<Response>` is `never`) — so frontend/backend drift detection
978
+ // typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
782
979
  // silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
783
980
  // (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
784
981
  const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g
@@ -816,12 +1013,12 @@ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
816
1013
  /** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
817
1014
  * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
818
1015
  /**
819
- * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call — so `.gitignore`
1016
+ * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
820
1017
  * (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
821
1018
  * is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
822
- * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full — 40k+
1019
+ * that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
823
1020
  * findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
824
- * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex — never throws, never blocks.
1021
+ * repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
825
1022
  */
826
1023
  async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<string>> {
827
1024
  if (rels.length === 0) return new Set()
@@ -852,7 +1049,7 @@ export async function walkSource(
852
1049
  cwd: string,
853
1050
  visit: (rel: string, content: string) => void,
854
1051
  ): Promise<void> {
855
- // List candidates first (cheap — no reads), drop the built-in ignores, then exclude gitignored paths in
1052
+ // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
856
1053
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
857
1054
  const rels: string[] = []
858
1055
  for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
@@ -875,15 +1072,15 @@ export async function scanProject(cwd: string): Promise<SourceFinding[]> {
875
1072
  return out.sort(bySite)
876
1073
  }
877
1074
 
878
- // #7 — server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
1075
+ // #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
879
1076
  // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
880
- // regenerated, the worker serves a stale route table — a silent edge break that no other check catches.
1077
+ // regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
881
1078
  // We diff each committed manifest's imported route files against the live `routes/` tree.
882
1079
 
883
- // The marker comment `generateServerManifest` emits at the top of the file — identifies a generated
1080
+ // The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
884
1081
  // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
885
1082
  const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
886
- // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) — used to
1083
+ // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
887
1084
  // locate the routes dir relative to the manifest, and to strip to route-relative keys.
888
1085
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
889
1086
  // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
@@ -892,7 +1089,7 @@ const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
892
1089
  export interface ManifestDriftFinding {
893
1090
  /** The committed server-manifest file (relative to cwd). */
894
1091
  readonly file: string
895
- /** Route files in `routes/` missing from the manifest (stale manifest — these routes won't serve). */
1092
+ /** Route files in `routes/` missing from the manifest (stale manifest - these routes won't serve). */
896
1093
  readonly missing: readonly string[]
897
1094
  /** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
898
1095
  readonly extra: readonly string[]
@@ -914,12 +1111,12 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
914
1111
  for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
915
1112
  if (IGNORED.test(rel)) continue
916
1113
  const source = await Bun.file(join(cwd, rel)).text()
917
- if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest — skip
1114
+ if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest - skip
918
1115
  const prefixMatch = ROUTES_PREFIX.exec(source)
919
1116
  if (prefixMatch?.[1] === undefined) continue // no `routes/` imports → nothing to diff (empty app)
920
1117
  const routesPrefix = prefixMatch[1]
921
1118
  const manifestFiles = parseManifestRouteFiles(source, routesPrefix)
922
- // The routes dir sits at `<manifest dir>/<prefix>` — resolve it relative to the manifest file.
1119
+ // The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
923
1120
  const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
924
1121
  const routesDir = join(cwd, manifestDir, routesPrefix)
925
1122
  let discovered: string[]
@@ -930,7 +1127,7 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
930
1127
  .map((f) => f.replaceAll("\\", "/"))
931
1128
  .filter((f) => ROUTE_FILE_EXT.test(f))
932
1129
  } catch {
933
- continue // routes dir gone/unreadable — not a drift we can assess
1130
+ continue // routes dir gone/unreadable - not a drift we can assess
934
1131
  }
935
1132
  const drift = diffManifestRoutes(manifestFiles, discovered)
936
1133
  if (!isManifestInSync(drift)) {
@@ -995,7 +1192,7 @@ async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckRe
995
1192
  // `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
996
1193
  const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
997
1194
 
998
- /** A single machine-readable check failure — the unit an agent (or CI) acts on. */
1195
+ /** A single machine-readable check failure - the unit an agent (or CI) acts on. */
999
1196
  export interface CheckDiagnostic {
1000
1197
  readonly rule:
1001
1198
  | "typecheck"
@@ -1003,6 +1200,7 @@ export interface CheckDiagnostic {
1003
1200
  | "untyped-client"
1004
1201
  | "server-only-import"
1005
1202
  | "removed-import"
1203
+ | "interpolated-sql"
1006
1204
  | "response-route"
1007
1205
  | "undeclared-dependency"
1008
1206
  | "duplicate-install"
@@ -1011,14 +1209,14 @@ export interface CheckDiagnostic {
1011
1209
  | "capability-assurance"
1012
1210
  | "capability-config"
1013
1211
  | "check-config"
1014
- /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
1212
+ /** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
1015
1213
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
1016
1214
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
1017
1215
  readonly severity: "error" | "warning"
1018
1216
  readonly file?: string
1019
1217
  readonly line?: number
1020
1218
  readonly message: string
1021
- /** The canonical, rule-level fix — clean of the per-occurrence snippet, so an agent can apply it
1219
+ /** The canonical, rule-level fix - clean of the per-occurrence snippet, so an agent can apply it
1022
1220
  * directly. Set for the lint rules (they have one correct fix); omitted for `typecheck` (the fix is
1023
1221
  * specific to each type error). */
1024
1222
  readonly fix?: string
@@ -1029,12 +1227,12 @@ export interface CheckDiagnostic {
1029
1227
  * The import chain that pulls server-only code into the browser bundle, as display labels
1030
1228
  * `[routeFile, …as-written specifiers…, sink]`. Set only on `server-only-import`.
1031
1229
  *
1032
- * #4.4: this is now the FULL **transitive** chain — a bounded import-resolution walk (`Bun.resolveSync`
1230
+ * #4.4: this is now the FULL **transitive** chain - a bounded import-resolution walk (`Bun.resolveSync`
1033
1231
  * from each file's dir, BFS the local module graph) follows `route → ../data → ../db → node:crypto`,
1034
1232
  * matching the build leak-guard's depth (`detectNodeBuiltinsInClient` in `@nifrajs/web/build`). A
1035
1233
  * length-2 chain (`[routeFile, specifier]`) means the route imports the sink directly. When a hop can't
1036
1234
  * be resolved precisely (a bare pkg, a tsconfig path alias), the walk degrades to the honest direct
1037
- * edge rather than fabricating a deeper path — never a lie.
1235
+ * edge rather than fabricating a deeper path - never a lie.
1038
1236
  */
1039
1237
  readonly chain?: readonly string[]
1040
1238
  }
@@ -1048,7 +1246,7 @@ export interface CheckSuggestion {
1048
1246
  readonly steps?: readonly string[]
1049
1247
  }
1050
1248
 
1051
- /** The structured result of a full check — what `--json` prints and the `nifra_check` MCP tool returns. */
1249
+ /** The structured result of a full check - what `--json` prints and the `nifra_check` MCP tool returns. */
1052
1250
  export interface CheckResult {
1053
1251
  readonly ok: boolean
1054
1252
  readonly typecheck: "pass" | "fail" | "skipped"
@@ -1057,12 +1255,31 @@ export interface CheckResult {
1057
1255
  * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
1058
1256
  * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
1059
1257
  readonly externalMounts?: readonly string[]
1060
- /** Set only when the caller passed `maxDiagnostics` and there were more — `diagnostics` then holds the
1258
+ /** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
1061
1259
  * first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
1062
1260
  * message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
1063
1261
  readonly truncated?: { readonly shown: number; readonly total: number }
1064
1262
  }
1065
1263
 
1264
+ /**
1265
+ * Pre-resolved route-assurance inputs, so the same reflection that `nifra assure` / `nifra levels`
1266
+ * already ran can feed `check`'s capability + trust-manifest diagnostics instead of a second pass.
1267
+ * Supplied by {@link collectProjectVerification}. When omitted, `collectCheckResult` loads and computes
1268
+ * these itself (the standalone `nifra_check` MCP path); the two routes produce byte-identical results.
1269
+ */
1270
+ export interface CheckAssuranceContext {
1271
+ /** Whether `nifra.assurance.ts` exists: the gate for running the assurance-fed diagnostics at all. */
1272
+ readonly present: boolean
1273
+ /** The loaded config, when it loaded. Absent when the file is missing or {@link error} is set. */
1274
+ readonly config?: AssuranceConfig
1275
+ /** The failure from loading/evaluating the config, surfaced as a `capability-config` diagnostic. */
1276
+ readonly error?: unknown
1277
+ /** `evaluateRouteAssurance` over the config's source + policy (drives the trust-manifest check). */
1278
+ readonly routeAssurance?: AssuranceReport
1279
+ /** Static capability provenance, when the config declares a capabilities policy. */
1280
+ readonly capability?: CapabilityProjectReport
1281
+ }
1282
+
1066
1283
  /** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
1067
1284
  * the app is built or even importable, preserving check's pre-`loadApp` invariant. */
1068
1285
  interface CheckConfig {
@@ -1091,21 +1308,25 @@ async function loadCheckConfig(cwd: string): Promise<{ config: CheckConfig; erro
1091
1308
  }
1092
1309
 
1093
1310
  const UNTYPED_CLIENT_HINT =
1094
- 'client("…") without a type argument — write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
1311
+ 'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
1095
1312
  const FETCH_HINT =
1096
- "hand-rolled fetch() to your own API — call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
1313
+ "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
1097
1314
  const SERVER_IMPORT_HINT =
1098
- "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"
1315
+ "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"
1099
1316
  const RESPONSE_ROUTE_HINT =
1100
1317
  "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"
1101
1318
  const UNDECLARED_DEP_HINT =
1102
- "imported package is not declared in package.json dependencies — run bun add to declare it"
1319
+ "imported package is not declared in package.json dependencies - run bun add to declare it"
1320
+ const SQL_COMPILER_MISSING_HINT =
1321
+ "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`"
1322
+ const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}"
1323
+ 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`
1103
1324
  const MANIFEST_DRIFT_HINT =
1104
- "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"
1325
+ "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"
1105
1326
  const TRUST_MANIFEST_DRIFT_HINT =
1106
- "nifra.manifest.json is missing, invalid, or out of sync — run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
1327
+ "nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
1107
1328
  const CAPABILITY_HINT =
1108
- "effect/capability assurance failed — align the route declaration with approved adapter provenance; never bypass an owned effect seam"
1329
+ "effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam"
1109
1330
 
1110
1331
  function oneLineDiff(file: string, line: number, before: string, after: string): string {
1111
1332
  return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
@@ -1207,11 +1428,11 @@ function serverImportSuggestion(
1207
1428
  ): CheckSuggestion {
1208
1429
  const sink = chain[chain.length - 1] ?? specifier
1209
1430
  // Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
1210
- // ../db → node:crypto`) and which module to cut — not just the route's own top-level import.
1431
+ // ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
1211
1432
  const chainStep =
1212
1433
  chain.length > 2
1213
1434
  ? fallback
1214
- ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely — trace it from \`${specifier}\`).`
1435
+ ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
1215
1436
  : `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.`
1216
1437
  : undefined
1217
1438
  return {
@@ -1248,6 +1469,13 @@ export async function collectCheckResult(
1248
1469
  /** Cap the returned `diagnostics` to this many (adds {@link CheckResult.truncated}). The MCP tool sets
1249
1470
  * it so a huge project can't produce a transport-breaking result; the CLI leaves it unset (all shown). */
1250
1471
  readonly maxDiagnostics?: number
1472
+ /** How the interpolated-SQL rule gets its compiler. Injectable so the "not installed" path - the
1473
+ * one where a security rule reports that it did not run - is testable on a machine that has it. */
1474
+ readonly loadTypeScript?: () => Promise<TypeScriptApi | undefined>
1475
+ /** Route-assurance inputs already computed by {@link collectProjectVerification}. When present,
1476
+ * the capability + trust-manifest diagnostics reuse them instead of loading/reflecting a second
1477
+ * time; when absent, they are computed here (unchanged standalone behavior). */
1478
+ readonly assurance?: CheckAssuranceContext
1251
1479
  } = {},
1252
1480
  ): Promise<CheckResult> {
1253
1481
  const fetches: SourceFinding[] = []
@@ -1256,14 +1484,19 @@ export async function collectCheckResult(
1256
1484
  const removedImports: SourceFinding[] = []
1257
1485
  const serverImports: TransitiveServerImportFinding[] = []
1258
1486
  const responseRoutes: SourceFinding[] = []
1487
+ const interpolatedSql: SourceFinding[] = []
1259
1488
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
1260
- // (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
1489
+ // (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
1261
1490
  const routeModules: Array<{ rel: string; content: string }> = []
1262
1491
  // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1263
1492
  // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1264
1493
  const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd)
1494
+ // The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
1495
+ // once, before the walk, so a project without it is told the rule did not run rather than being
1496
+ // handed a clean report the rule never produced.
1497
+ const sqlCompiler = await (opts.loadTypeScript ?? importTypeScript)()
1265
1498
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
1266
- // lints — the agent inner-loop mode; the full gate stays the definition of done.
1499
+ // lints - the agent inner-loop mode; the full gate stays the definition of done.
1267
1500
  const [tc, _, dr, manifestDrift] = await Promise.all([
1268
1501
  opts.lintsOnly
1269
1502
  ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
@@ -1275,6 +1508,8 @@ export async function collectCheckResult(
1275
1508
  removedImports.push(...scanRemovedImports(rel, content))
1276
1509
  if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
1277
1510
  responseRoutes.push(...scanResponseRoutes(rel, content))
1511
+ if (sqlCompiler !== undefined)
1512
+ interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler))
1278
1513
  }),
1279
1514
  import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
1280
1515
  scanServerManifestDrift(cwd),
@@ -1288,7 +1523,7 @@ export async function collectCheckResult(
1288
1523
  const resolveModule: ModuleResolver = (fromFile, specifier) => {
1289
1524
  try {
1290
1525
  // `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
1291
- // deeper hops the walk takes (it carries resolved absolute paths) — resolve the dir for each.
1526
+ // deeper hops the walk takes (it carries resolved absolute paths) - resolve the dir for each.
1292
1527
  const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile)
1293
1528
  return Bun.resolveSync(specifier, dirname(fromAbs))
1294
1529
  } catch {
@@ -1360,7 +1595,7 @@ export async function collectCheckResult(
1360
1595
  severity: "error",
1361
1596
  file: f.file,
1362
1597
  line: f.line,
1363
- message: `${f.snippet} — ${FETCH_HINT}`,
1598
+ message: `${f.snippet} - ${FETCH_HINT}`,
1364
1599
  fix: FETCH_HINT,
1365
1600
  suggestion: ownFetchSuggestion(f, routes),
1366
1601
  })
@@ -1376,7 +1611,7 @@ export async function collectCheckResult(
1376
1611
  severity: "error",
1377
1612
  file: f.file,
1378
1613
  line: f.line,
1379
- message: `${f.snippet} — removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
1614
+ message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
1380
1615
  fix: entry?.replacement ?? "see the changelog",
1381
1616
  })
1382
1617
  }
@@ -1386,14 +1621,14 @@ export async function collectCheckResult(
1386
1621
  severity: "error",
1387
1622
  file: f.file,
1388
1623
  line: f.line,
1389
- message: `${f.snippet} — ${UNTYPED_CLIENT_HINT}`,
1624
+ message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
1390
1625
  fix: UNTYPED_CLIENT_HINT,
1391
1626
  suggestion: untypedClientSuggestion(f),
1392
1627
  })
1393
1628
  }
1394
1629
  for (const f of serverImports.sort(bySite)) {
1395
- // #4.4: the FULL transitive chain the import-resolution walk found — `route → ../data → ../db →
1396
- // node:crypto`, matching the build leak-guard's depth — instead of just the direct edge. The chain's
1630
+ // #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
1631
+ // node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
1397
1632
  // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
1398
1633
  // (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
1399
1634
  const chain = f.chain
@@ -1403,20 +1638,55 @@ export async function collectCheckResult(
1403
1638
  severity: "error",
1404
1639
  file: f.file,
1405
1640
  line: f.line,
1406
- 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}`,
1641
+ 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}`,
1407
1642
  fix: SERVER_IMPORT_HINT,
1408
1643
  chain,
1409
1644
  suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
1410
1645
  })
1411
1646
  }
1412
- // Advisory — surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
1647
+ if (sqlCompiler === undefined) {
1648
+ // A security rule that could not run must say so. Silence here is indistinguishable from a clean
1649
+ // result, and this is the one rule whose clean result means "no SQL injection was found".
1650
+ diagnostics.push({
1651
+ rule: "interpolated-sql",
1652
+ severity: "warning",
1653
+ message: SQL_COMPILER_MISSING_HINT,
1654
+ fix: SQL_COMPILER_MISSING_HINT,
1655
+ suggestion: {
1656
+ kind: "command",
1657
+ title: "Install TypeScript so the SQL rule can run",
1658
+ command: ["bun add -d typescript"],
1659
+ },
1660
+ })
1661
+ }
1662
+ for (const f of interpolatedSql.sort(bySite)) {
1663
+ diagnostics.push({
1664
+ rule: "interpolated-sql",
1665
+ severity: "error",
1666
+ file: f.file,
1667
+ line: f.line,
1668
+ message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
1669
+ fix: "bind the value as a parameter instead of interpolating it into the statement",
1670
+ suggestion: {
1671
+ kind: "manual",
1672
+ title: "Bind the value instead of interpolating it",
1673
+ steps: [
1674
+ "Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
1675
+ "Pass the value as an argument alongside the statement, so the driver binds it.",
1676
+ `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
1677
+ "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.",
1678
+ ],
1679
+ },
1680
+ })
1681
+ }
1682
+ // Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
1413
1683
  for (const f of responseRoutes.sort(bySite)) {
1414
1684
  diagnostics.push({
1415
1685
  rule: "response-route",
1416
1686
  severity: "warning",
1417
1687
  file: f.file,
1418
1688
  line: f.line,
1419
- message: `${f.snippet} — ${RESPONSE_ROUTE_HINT}`,
1689
+ message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
1420
1690
  fix: RESPONSE_ROUTE_HINT,
1421
1691
  suggestion: responseRouteSuggestion(),
1422
1692
  })
@@ -1428,7 +1698,7 @@ export async function collectCheckResult(
1428
1698
  severity: "error",
1429
1699
  file: f.file,
1430
1700
  line: f.line,
1431
- message: `imports ${f.package} which is not declared in package.json — ${UNDECLARED_DEP_HINT}`,
1701
+ message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
1432
1702
  fix: `add ${f.package} to package.json dependencies`,
1433
1703
  suggestion: {
1434
1704
  kind: "command",
@@ -1439,10 +1709,14 @@ export async function collectCheckResult(
1439
1709
  }
1440
1710
  for (const finding of dr.duplicateInstalls) {
1441
1711
  const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
1712
+ const identityHint =
1713
+ finding.package === "@nifrajs/core"
1714
+ ? " (a second @nifrajs/core collapses `typeof backend` to `any` at `.merge()` - usually that error's root cause)"
1715
+ : ""
1442
1716
  diagnostics.push({
1443
1717
  rule: "duplicate-install",
1444
1718
  severity: "error",
1445
- message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe`,
1719
+ message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe${identityHint}`,
1446
1720
  fix: "align dependency ranges and reinstall from the workspace root",
1447
1721
  suggestion: {
1448
1722
  kind: "manual",
@@ -1456,7 +1730,7 @@ export async function collectCheckResult(
1456
1730
  })
1457
1731
  }
1458
1732
  }
1459
- // #7: a committed server-manifest.ts that drifted from routes/ — name the exact missing/extra routes.
1733
+ // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
1460
1734
  for (const f of manifestDrift) {
1461
1735
  const parts: string[] = []
1462
1736
  if (f.missing.length > 0) parts.push(`missing from manifest: ${f.missing.join(", ")}`)
@@ -1465,13 +1739,13 @@ export async function collectCheckResult(
1465
1739
  rule: "server-manifest-drift",
1466
1740
  severity: "error",
1467
1741
  file: f.file,
1468
- message: `${f.file} drifted from routes/ (${parts.join("; ")}) — ${MANIFEST_DRIFT_HINT}`,
1742
+ message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
1469
1743
  fix: MANIFEST_DRIFT_HINT,
1470
1744
  suggestion: {
1471
1745
  kind: "manual",
1472
1746
  title: "Regenerate the committed server manifest",
1473
1747
  steps: [
1474
- "Re-run your build (`nifra build --target <t>` or your build script) — it regenerates server-manifest.ts from the current routes/.",
1748
+ "Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
1475
1749
  "Commit the updated server-manifest.ts.",
1476
1750
  ],
1477
1751
  },
@@ -1481,24 +1755,32 @@ export async function collectCheckResult(
1481
1755
  // G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
1482
1756
  // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
1483
1757
  // without nifra.assurance.ts retain the historical scan and hot path unchanged.
1758
+ const provided = opts.assurance
1484
1759
  const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
1485
- if (existsSync(assuranceConfigPath)) {
1760
+ if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
1486
1761
  try {
1487
- const { loadAssuranceConfig } = await import("./assure.ts")
1488
- const config = await loadAssuranceConfig(cwd)
1489
- let capabilityReport:
1490
- | Awaited<
1491
- ReturnType<typeof import("./capabilities-tool.ts")["collectCapabilityProjectReport"]>
1492
- >["report"]
1493
- | undefined
1494
- if (config.capabilities !== undefined) {
1495
- const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
1496
- const project = await collectCapabilityProjectReport(
1497
- cwd,
1498
- config.source,
1499
- config.capabilities,
1500
- )
1501
- capabilityReport = project.report
1762
+ // Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
1763
+ // standalone path, loaded + reflected here. Both branches yield the same config + evidence.
1764
+ let config: AssuranceConfig
1765
+ let project: CapabilityProjectReport | undefined
1766
+ let routeAssurance: AssuranceReport | undefined
1767
+ if (provided !== undefined) {
1768
+ // A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
1769
+ // capability-config diagnostic the standalone catch produces.
1770
+ if (provided.error !== undefined) throw provided.error
1771
+ config = provided.config as AssuranceConfig
1772
+ project = provided.capability
1773
+ routeAssurance = provided.routeAssurance
1774
+ } else {
1775
+ const { loadAssuranceConfig } = await import("./assure.ts")
1776
+ config = await loadAssuranceConfig(cwd)
1777
+ if (config.capabilities !== undefined) {
1778
+ const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
1779
+ project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
1780
+ }
1781
+ }
1782
+ const capabilityReport = project?.report
1783
+ if (config.capabilities !== undefined && project !== undefined) {
1502
1784
  for (const finding of project.report.findings) {
1503
1785
  const violation =
1504
1786
  finding.code === "forbidden-effect-import"
@@ -1507,11 +1789,22 @@ export async function collectCheckResult(
1507
1789
  candidate.method === finding.method && candidate.path === finding.path,
1508
1790
  )
1509
1791
  : undefined
1792
+ const truncation =
1793
+ finding.code === "provenance-truncated"
1794
+ ? project.truncations.find(
1795
+ (candidate) =>
1796
+ candidate.method === finding.method && candidate.path === finding.path,
1797
+ )
1798
+ : undefined
1510
1799
  diagnostics.push({
1511
1800
  rule: "capability-assurance",
1512
1801
  severity: "error",
1513
- ...(violation !== undefined ? { file: violation.module, chain: violation.chain } : {}),
1514
- message: `${finding.message} — ${CAPABILITY_HINT}`,
1802
+ ...(violation !== undefined
1803
+ ? { file: violation.module, chain: violation.chain }
1804
+ : truncation !== undefined
1805
+ ? { chain: truncation.chain }
1806
+ : {}),
1807
+ message: `${finding.message} - ${CAPABILITY_HINT}`,
1515
1808
  fix: CAPABILITY_HINT,
1516
1809
  suggestion: {
1517
1810
  kind: "manual",
@@ -1519,7 +1812,7 @@ export async function collectCheckResult(
1519
1812
  steps: [
1520
1813
  "Route effectful work through an import listed in capabilities.provenance.imports.",
1521
1814
  "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
1522
- "For domain writes, add the request-idempotency or durable-command adapter required by the capability definition.",
1815
+ "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.",
1523
1816
  "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
1524
1817
  ],
1525
1818
  },
@@ -1527,11 +1820,20 @@ export async function collectCheckResult(
1527
1820
  }
1528
1821
  }
1529
1822
  if (config.manifest !== undefined) {
1530
- const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance")
1531
1823
  const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
1532
1824
  "@nifrajs/core/manifest"
1533
1825
  )
1534
- const assurance = evaluateRouteAssurance(config.source, config.policy)
1826
+ const assurance =
1827
+ routeAssurance ??
1828
+ (await import("@nifrajs/core/assurance")).evaluateRouteAssurance(
1829
+ config.source,
1830
+ config.policy,
1831
+ {
1832
+ ...(config.capabilities !== undefined
1833
+ ? { definitions: config.capabilities.definitions }
1834
+ : {}),
1835
+ },
1836
+ )
1535
1837
  const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
1536
1838
  let message: string | undefined
1537
1839
  if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
@@ -1561,7 +1863,7 @@ export async function collectCheckResult(
1561
1863
  rule: "manifest-drift",
1562
1864
  severity: "error",
1563
1865
  file: path,
1564
- message: `${message} — ${TRUST_MANIFEST_DRIFT_HINT}`,
1866
+ message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
1565
1867
  fix: TRUST_MANIFEST_DRIFT_HINT,
1566
1868
  suggestion: {
1567
1869
  kind: "command",
@@ -1593,7 +1895,7 @@ export async function collectCheckResult(
1593
1895
  }
1594
1896
 
1595
1897
  // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
1596
- // message that breaks the stdio transport. `ok` reflects the FULL set — truncation never flips it.
1898
+ // message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
1597
1899
  const total = diagnostics.length
1598
1900
  const max = opts.maxDiagnostics
1599
1901
  const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics
@@ -1613,7 +1915,10 @@ export async function runCheck(
1613
1915
  cwd: string,
1614
1916
  opts: { readonly json?: boolean; readonly lintsOnly?: boolean } = {},
1615
1917
  ): Promise<boolean> {
1616
- const result = await collectCheckResult(cwd, { lintsOnly: opts.lintsOnly ?? false })
1918
+ // The check view over the one project verification: the same collector `assure` and `levels` read.
1919
+ const { collectProjectVerification } = await import("./verification.ts")
1920
+ const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
1921
+ const result = await verification.check()
1617
1922
  if (opts.json) {
1618
1923
  console.log(JSON.stringify(result, null, 2))
1619
1924
  return result.ok
@@ -1624,7 +1929,7 @@ export async function runCheck(
1624
1929
  result.typecheck === "pass"
1625
1930
  ? "✓ typecheck passed"
1626
1931
  : result.typecheck === "fail"
1627
- ? "✗ typecheck failed — the frontend/backend contract is broken"
1932
+ ? "✗ typecheck failed - the frontend/backend contract is broken"
1628
1933
  : "• typecheck skipped (no tsconfig / typescript not installed)",
1629
1934
  )
1630
1935
  if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
@@ -1639,6 +1944,7 @@ export async function runCheck(
1639
1944
  ["typed-client", "hand-rolled fetch() to your own API"],
1640
1945
  ["untyped-client", 'client("…") missing its <typeof app> type argument'],
1641
1946
  ["server-only-import", "server-only import in a route module"],
1947
+ ["interpolated-sql", "SQL built by interpolating a value into the statement"],
1642
1948
  ["response-route", "route returns a raw Response (typed client → data: never)"],
1643
1949
  ["undeclared-dependency", "undeclared dependency in package.json"],
1644
1950
  ["duplicate-install", "duplicate identity-sensitive dependency install"],