@nifrajs/cli 2.11.0 → 2.12.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 (177) hide show
  1. package/README.md +6 -0
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +125 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +706 -145
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +23 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +194 -10
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/diagnostics.d.ts +23 -0
  31. package/dist/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics.js +9 -0
  33. package/dist/diagnostics.js.map +1 -0
  34. package/dist/diff-tool.d.ts +2 -0
  35. package/dist/diff-tool.d.ts.map +1 -1
  36. package/dist/diff-tool.js +1 -1
  37. package/dist/diff-tool.js.map +1 -1
  38. package/dist/docs-search.d.ts +8 -3
  39. package/dist/docs-search.d.ts.map +1 -1
  40. package/dist/docs-search.js +36 -15
  41. package/dist/docs-search.js.map +1 -1
  42. package/dist/doctor.d.ts +28 -2
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +173 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/examples.d.ts +1 -2
  47. package/dist/examples.d.ts.map +1 -1
  48. package/dist/examples.js +37 -16
  49. package/dist/examples.js.map +1 -1
  50. package/dist/fix-recipes.d.ts +12 -0
  51. package/dist/fix-recipes.d.ts.map +1 -0
  52. package/dist/fix-recipes.js +106 -0
  53. package/dist/fix-recipes.js.map +1 -0
  54. package/dist/init-agents.d.ts.map +1 -1
  55. package/dist/init-agents.js +60 -4
  56. package/dist/init-agents.js.map +1 -1
  57. package/dist/internal/source-facts.d.ts +15 -0
  58. package/dist/internal/source-facts.d.ts.map +1 -0
  59. package/dist/internal/source-facts.js +82 -0
  60. package/dist/internal/source-facts.js.map +1 -0
  61. package/dist/internal/typescript-import.d.ts +9 -0
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +38 -0
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +6 -2
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +53 -4
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/load.d.ts +6 -0
  70. package/dist/load.d.ts.map +1 -1
  71. package/dist/load.js.map +1 -1
  72. package/dist/manifest-tool.d.ts.map +1 -1
  73. package/dist/manifest-tool.js +14 -9
  74. package/dist/manifest-tool.js.map +1 -1
  75. package/dist/mcp-docs-tools.d.ts.map +1 -1
  76. package/dist/mcp-docs-tools.js +3 -0
  77. package/dist/mcp-docs-tools.js.map +1 -1
  78. package/dist/mcp-http.js +1 -1
  79. package/dist/mcp-root.d.ts +83 -0
  80. package/dist/mcp-root.d.ts.map +1 -0
  81. package/dist/mcp-root.js +221 -0
  82. package/dist/mcp-root.js.map +1 -0
  83. package/dist/mcp-run.d.ts.map +1 -1
  84. package/dist/mcp-run.js +18 -4
  85. package/dist/mcp-run.js.map +1 -1
  86. package/dist/mcp.d.ts +27 -4
  87. package/dist/mcp.d.ts.map +1 -1
  88. package/dist/mcp.js +358 -25
  89. package/dist/mcp.js.map +1 -1
  90. package/dist/project-path.d.ts +3 -0
  91. package/dist/project-path.d.ts.map +1 -0
  92. package/dist/project-path.js +36 -0
  93. package/dist/project-path.js.map +1 -0
  94. package/dist/replay.d.ts +12 -0
  95. package/dist/replay.d.ts.map +1 -0
  96. package/dist/replay.js +36 -0
  97. package/dist/replay.js.map +1 -0
  98. package/dist/rules/codes.d.ts +42 -0
  99. package/dist/rules/codes.d.ts.map +1 -0
  100. package/dist/rules/codes.js +52 -0
  101. package/dist/rules/codes.js.map +1 -0
  102. package/dist/rules/comment-markers.d.ts +10 -0
  103. package/dist/rules/comment-markers.d.ts.map +1 -0
  104. package/dist/rules/comment-markers.js +24 -0
  105. package/dist/rules/comment-markers.js.map +1 -0
  106. package/dist/rules/index.d.ts +31 -0
  107. package/dist/rules/index.d.ts.map +1 -0
  108. package/dist/rules/index.js +133 -0
  109. package/dist/rules/index.js.map +1 -0
  110. package/dist/rules/legacy.d.ts +7 -0
  111. package/dist/rules/legacy.d.ts.map +1 -0
  112. package/dist/rules/legacy.js +85 -0
  113. package/dist/rules/legacy.js.map +1 -0
  114. package/dist/rules/routes.d.ts +12 -0
  115. package/dist/rules/routes.d.ts.map +1 -0
  116. package/dist/rules/routes.js +142 -0
  117. package/dist/rules/routes.js.map +1 -0
  118. package/dist/rules/security.d.ts +10 -0
  119. package/dist/rules/security.d.ts.map +1 -0
  120. package/dist/rules/security.js +471 -0
  121. package/dist/rules/security.js.map +1 -0
  122. package/dist/search-terms.d.ts +2 -0
  123. package/dist/search-terms.d.ts.map +1 -1
  124. package/dist/search-terms.js +5 -1
  125. package/dist/search-terms.js.map +1 -1
  126. package/dist/types-search.d.ts +1 -2
  127. package/dist/types-search.d.ts.map +1 -1
  128. package/dist/types-search.js +33 -16
  129. package/dist/types-search.js.map +1 -1
  130. package/dist/verification.d.ts +9 -0
  131. package/dist/verification.d.ts.map +1 -1
  132. package/dist/verification.js +17 -1
  133. package/dist/verification.js.map +1 -1
  134. package/dist/work-graph.d.ts +123 -0
  135. package/dist/work-graph.d.ts.map +1 -0
  136. package/dist/work-graph.js +465 -0
  137. package/dist/work-graph.js.map +1 -0
  138. package/docs/examples.json +32 -2
  139. package/docs/llms-full.txt +906 -15
  140. package/docs/types.json +1682 -130
  141. package/package.json +34 -9
  142. package/src/assure-hydration.ts +606 -0
  143. package/src/assure.ts +316 -2
  144. package/src/capabilities-tool.ts +80 -1
  145. package/src/check.ts +873 -168
  146. package/src/cli.ts +198 -11
  147. package/src/contract-proof.ts +194 -0
  148. package/src/contracts.ts +157 -0
  149. package/src/diagnostics.ts +32 -0
  150. package/src/diff-tool.ts +1 -1
  151. package/src/docs-search.ts +45 -15
  152. package/src/doctor.ts +259 -8
  153. package/src/examples.ts +43 -15
  154. package/src/fix-recipes.ts +126 -0
  155. package/src/init-agents.ts +61 -4
  156. package/src/internal/source-facts.ts +133 -0
  157. package/src/internal/typescript-import.ts +29 -0
  158. package/src/introspect.ts +75 -6
  159. package/src/load.ts +6 -0
  160. package/src/manifest-tool.ts +16 -10
  161. package/src/mcp-docs-tools.ts +3 -0
  162. package/src/mcp-http.ts +1 -1
  163. package/src/mcp-root.ts +271 -0
  164. package/src/mcp-run.ts +19 -4
  165. package/src/mcp.ts +438 -32
  166. package/src/project-path.ts +36 -0
  167. package/src/replay.ts +44 -0
  168. package/src/rules/codes.ts +57 -0
  169. package/src/rules/comment-markers.ts +26 -0
  170. package/src/rules/index.ts +175 -0
  171. package/src/rules/legacy.ts +93 -0
  172. package/src/rules/routes.ts +157 -0
  173. package/src/rules/security.ts +505 -0
  174. package/src/search-terms.ts +9 -4
  175. package/src/types-search.ts +39 -15
  176. package/src/verification.ts +18 -1
  177. package/src/work-graph.ts +613 -0
package/src/check.ts CHANGED
@@ -16,16 +16,24 @@
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync } from "node:fs"
20
- import { dirname, isAbsolute, join, resolve } from "node:path"
19
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
20
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path"
21
21
  import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
22
+ import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
22
23
  import { Glob } from "bun"
23
24
  import type * as TSApi from "typescript"
24
25
  import type { CapabilityProjectReport } from "./capabilities-tool.ts"
25
- import { importTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
26
+ import { type Diagnostic, diagnostic } from "./diagnostics.ts"
27
+ import { createSourceFacts, type SourceFacts } from "./internal/source-facts.ts"
28
+ import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
26
29
  // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
27
30
  // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
28
31
  import type { PipelineReport } from "./pipeline-report.ts"
32
+ import { RULE_CODES } from "./rules/codes.ts"
33
+ import { parseRulePacks, type RuleContext, runRuleRegistry, sourceIndex } from "./rules/index.ts"
34
+ import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.ts"
35
+ import { routeRules } from "./rules/routes.ts"
36
+ import { securityRules } from "./rules/security.ts"
29
37
 
30
38
  export interface SourceFinding {
31
39
  readonly file: string
@@ -236,6 +244,7 @@ function scanRoutePattern(
236
244
  content: string,
237
245
  code: string,
238
246
  pattern: RegExp,
247
+ facts?: SourceFacts,
239
248
  ): StaticRouteFinding[] {
240
249
  const out: StaticRouteFinding[] = []
241
250
  const lines = content.split("\n")
@@ -247,6 +256,16 @@ function scanRoutePattern(
247
256
  `${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
248
257
  )
249
258
  if (path === undefined || !path.startsWith("/") || path.startsWith("//")) continue
259
+ if (facts !== undefined) {
260
+ const source = facts.parse(file, content)
261
+ // A parseable source model is authoritative for candidate locations. If parsing is unavailable
262
+ // or the source is malformed, retain the lexical finding and fail closed.
263
+ if (
264
+ source !== undefined &&
265
+ facts.isRouteRegistrationAt(source, m.index, method, path) !== true
266
+ )
267
+ continue
268
+ }
250
269
  const line = lineAt(content, m.index)
251
270
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
252
271
  }
@@ -470,12 +489,16 @@ export function scanFetchText(
470
489
  }
471
490
 
472
491
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
473
- export function scanStaticRouteText(file: string, content: string): StaticRouteFinding[] {
492
+ export function scanStaticRouteText(
493
+ file: string,
494
+ content: string,
495
+ facts?: SourceFacts,
496
+ ): StaticRouteFinding[] {
474
497
  const code = stripComments(content)
475
498
  if (!isPotentialBackendSource(code)) return []
476
499
  return [
477
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
478
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
500
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
501
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
479
502
  ].sort(bySite)
480
503
  }
481
504
 
@@ -522,21 +545,228 @@ interface SqlExpressionShape {
522
545
  /** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
523
546
  type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
524
547
 
525
- /** Collect the file's top-level `const NAME = <initializer>` declarations. Destructured names, `let`,
526
- * `var`, and anything nested stay out on purpose: a module-scope `const` is the only form that
527
- * provably cannot carry request data. */
528
- function moduleConstsOf(ts: TypeScriptApi, source: TSApi.SourceFile): ModuleConsts {
529
- const map = new Map<string, TSApi.Expression>()
548
+ /** A name some other module exports, named exactly as the edge that reaches it writes it. */
549
+ interface ExportEdge {
550
+ readonly specifier: string
551
+ readonly exported: string
552
+ }
553
+
554
+ /**
555
+ * One module's provable module-scope bindings: its top-level `const` initializers plus the exact
556
+ * import/export edges this scan is allowed to follow.
557
+ *
558
+ * Everything absent from these maps stays unresolved, and an unresolved interpolation keeps flagging.
559
+ * That is the whole safety argument: resolution can only ever make this rule QUIETER, so a form the
560
+ * scan cannot prove must fall out as "unknown" rather than be guessed at. Deliberately absent:
561
+ * `export default`, `export let`/`var`, destructured or computed names, default and namespace imports,
562
+ * `export * as ns`, `require`, and dynamic `import()`.
563
+ */
564
+ interface SqlModuleScope {
565
+ /** The module's identity - an absolute path from the loader, or the entry file's path as given. Keys
566
+ * the parsed-scope cache and the cycle guard, and is the base its own specifiers resolve against. */
567
+ readonly id: string
568
+ readonly consts: ModuleConsts
569
+ /** Local name bound by `import { X as local }` → the edge that provides it. */
570
+ readonly imports: ReadonlyMap<string, ExportEdge>
571
+ /** Exported name → the local name it reads (`export { a as b }` → `b` → `a`). */
572
+ readonly localExports: ReadonlyMap<string, string>
573
+ /** Exported name → another module's export (`export { a as b } from "./m"`). */
574
+ readonly reExports: ReadonlyMap<string, ExportEdge>
575
+ /** `export * from "./m"` specifiers, in source order. */
576
+ readonly starExports: readonly string[]
577
+ }
578
+
579
+ /** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
580
+ * no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
581
+ * module-scope `const` is the only form that provably cannot carry request data. */
582
+ function sqlModuleScope(ts: TypeScriptApi, id: string, source: TSApi.SourceFile): SqlModuleScope {
583
+ const consts = new Map<string, TSApi.Expression>()
584
+ const imports = new Map<string, ExportEdge>()
585
+ const localExports = new Map<string, string>()
586
+ const reExports = new Map<string, ExportEdge>()
587
+ const starExports: string[] = []
530
588
  for (const statement of source.statements) {
531
- if (!ts.isVariableStatement(statement)) continue
532
- if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
533
- for (const declaration of statement.declarationList.declarations) {
534
- if (ts.isIdentifier(declaration.name) && declaration.initializer !== undefined) {
535
- map.set(declaration.name.text, declaration.initializer)
589
+ if (ts.isVariableStatement(statement)) {
590
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
591
+ const exported =
592
+ statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true
593
+ for (const declaration of statement.declarationList.declarations) {
594
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
595
+ consts.set(declaration.name.text, declaration.initializer)
596
+ if (exported) localExports.set(declaration.name.text, declaration.name.text)
597
+ }
598
+ continue
599
+ }
600
+ if (ts.isImportDeclaration(statement)) {
601
+ const clause = statement.importClause
602
+ if (clause === undefined || clause.isTypeOnly) continue
603
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
604
+ const bindings = clause.namedBindings
605
+ // Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
606
+ // whose contents this scan cannot prove, so it stays unbound and every use of it flags.
607
+ if (bindings === undefined || !ts.isNamedImports(bindings)) continue
608
+ for (const element of bindings.elements) {
609
+ if (element.isTypeOnly) continue
610
+ imports.set(element.name.text, {
611
+ specifier: statement.moduleSpecifier.text,
612
+ exported: element.propertyName?.text ?? element.name.text,
613
+ })
536
614
  }
615
+ continue
616
+ }
617
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue
618
+ const from = statement.moduleSpecifier
619
+ if (from !== undefined && !ts.isStringLiteral(from)) continue
620
+ const clause = statement.exportClause
621
+ if (clause === undefined) {
622
+ // No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
623
+ // NamespaceExport clause instead and falls out below, unresolved, which is what we want.
624
+ if (from !== undefined) starExports.push(from.text)
625
+ continue
626
+ }
627
+ if (!ts.isNamedExports(clause)) continue
628
+ for (const element of clause.elements) {
629
+ if (element.isTypeOnly) continue
630
+ const local = element.propertyName?.text ?? element.name.text
631
+ if (from === undefined) localExports.set(element.name.text, local)
632
+ else reExports.set(element.name.text, { specifier: from.text, exported: local })
537
633
  }
538
634
  }
539
- return map
635
+ return { id, consts, imports, localExports, reExports, starExports }
636
+ }
637
+
638
+ /** A module's identity + source, as resolved from a specifier. `id` must be stable and absolute: it
639
+ * keys the parsed-scope cache, seeds the cycle guard, and is what the module's own relative specifiers
640
+ * resolve against. */
641
+ export interface SqlModuleSource {
642
+ readonly id: string
643
+ readonly content: string
644
+ }
645
+
646
+ /**
647
+ * Resolves a specifier written in the module `fromModule` to the target's identity + source, or
648
+ * `undefined` when it cannot be resolved, read, or proved to be in-project source.
649
+ *
650
+ * Injected rather than built in, for the same reason {@link ModuleResolver} is: it keeps
651
+ * {@link scanInterpolatedSql} pure and filesystem-free, so the resolution rules can be unit-tested
652
+ * against a virtual module graph. {@link createProjectSqlImports} is the on-disk implementation.
653
+ */
654
+ export type SqlModuleLoader = (fromModule: string, specifier: string) => SqlModuleSource | undefined
655
+
656
+ /** Cross-module resolution state for one run: the loader plus a parsed-scope cache, so a fragments
657
+ * module imported by fifty callers is read and parsed once. A cached `undefined` is a REFUSAL (the
658
+ * module did not parse), cached so it is not re-attempted. Build with {@link createSqlImports}. */
659
+ export interface SqlImports {
660
+ readonly load: SqlModuleLoader
661
+ readonly scopes: Map<string, SqlModuleScope | undefined>
662
+ }
663
+
664
+ export const createSqlImports = (load: SqlModuleLoader): SqlImports => ({
665
+ load,
666
+ scopes: new Map(),
667
+ })
668
+
669
+ /** Where an expression's identifiers are read: the module the expression physically lives in, plus the
670
+ * run's cross-module state. `imports === undefined` is same-file-only resolution - the behavior before
671
+ * imported fragments were followed, and still what an unconfigured `scanInterpolatedSql` call does. */
672
+ interface SqlScope {
673
+ readonly module: SqlModuleScope
674
+ readonly imports: SqlImports | undefined
675
+ }
676
+
677
+ /** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
678
+ * a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
679
+ * parse contributes no findings and proves no constant. */
680
+ function parseSqlSource(
681
+ ts: TypeScriptApi,
682
+ file: string,
683
+ content: string,
684
+ ): TSApi.SourceFile | undefined {
685
+ const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
686
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
687
+ const parseDiagnostics = (
688
+ source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
689
+ ).parseDiagnostics
690
+ return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source
691
+ }
692
+
693
+ /** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
694
+ * already spends two, and the third leaves room for one more layer without letting the scan wander the
695
+ * module graph. Past the cap the name is unresolved, so the interpolation flags. */
696
+ const SQL_IMPORT_HOPS = 3
697
+
698
+ /** Load + parse the module a specifier names, through the run's cache. */
699
+ function loadSqlModuleScope(
700
+ ts: TypeScriptApi,
701
+ imports: SqlImports,
702
+ fromModule: string,
703
+ specifier: string,
704
+ ): SqlModuleScope | undefined {
705
+ const loaded = imports.load(fromModule, specifier)
706
+ if (loaded === undefined) return undefined
707
+ if (imports.scopes.has(loaded.id)) return imports.scopes.get(loaded.id)
708
+ const source = parseSqlSource(ts, loaded.id, loaded.content)
709
+ const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source)
710
+ imports.scopes.set(loaded.id, scope)
711
+ return scope
712
+ }
713
+
714
+ /** One exported name resolved to the `const` initializer behind it, together with the scope that
715
+ * initializer's OWN identifiers must be read in. */
716
+ interface ResolvedExport {
717
+ readonly expression: TSApi.Expression
718
+ readonly scope: SqlModuleScope
719
+ }
720
+
721
+ /**
722
+ * Follow an exported name to its `const` initializer, across re-export hops.
723
+ *
724
+ * Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
725
+ * `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
726
+ * barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
727
+ * a per-chain visited set, so a cyclic barrel terminates instead of recursing.
728
+ *
729
+ * Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
730
+ * picking one: guessing here would silence a real finding.
731
+ */
732
+ function resolveExportedConst(
733
+ ts: TypeScriptApi,
734
+ imports: SqlImports,
735
+ scope: SqlModuleScope,
736
+ name: string,
737
+ hops: number,
738
+ seen: ReadonlySet<string>,
739
+ ): ResolvedExport | undefined {
740
+ if (hops > SQL_IMPORT_HOPS) return undefined
741
+ const key = `${scope.id}#${name}`
742
+ if (seen.has(key)) return undefined
743
+ const nextSeen = new Set(seen).add(key)
744
+ const follow = (edge: ExportEdge): ResolvedExport | undefined => {
745
+ const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier)
746
+ return target === undefined
747
+ ? undefined
748
+ : resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen)
749
+ }
750
+ const local = scope.localExports.get(name)
751
+ if (local !== undefined) {
752
+ const initializer = scope.consts.get(local)
753
+ if (initializer !== undefined) return { expression: initializer, scope }
754
+ // `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
755
+ // written in two statements. Anything else the name could be (a `let`, a function, a class) has no
756
+ // initializer in `consts` and no import edge, so it lands on `undefined` and flags.
757
+ const via = scope.imports.get(local)
758
+ return via === undefined ? undefined : follow(via)
759
+ }
760
+ const reExported = scope.reExports.get(name)
761
+ if (reExported !== undefined) return follow(reExported)
762
+ let found: ResolvedExport | undefined
763
+ for (const specifier of scope.starExports) {
764
+ const hit = follow({ specifier, exported: name })
765
+ if (hit === undefined) continue
766
+ if (found !== undefined) return undefined
767
+ found = hit
768
+ }
769
+ return found
540
770
  }
541
771
 
542
772
  /** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
@@ -588,46 +818,67 @@ const CONST_RESOLUTION_DEPTH = 5
588
818
  * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
589
819
  * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
590
820
  * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
591
- * either still feeds the keyword scan), `as`/`satisfies` wrappers, and a same-file module-scope
592
- * `const` whose initializer resolves (recursively, depth-capped). Everything else - an imported
593
- * name, `let`, a parameter, a call, a member access, a shadowed name - stays unresolved and the
821
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
822
+ * initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
823
+ * `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
824
+ * call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
594
825
  * interpolation is flagged exactly as before.
595
826
  */
596
827
  function resolveStaticSqlText(
597
828
  ts: TypeScriptApi,
598
829
  node: TSApi.Expression,
599
- consts: ModuleConsts,
830
+ scope: SqlScope,
600
831
  depth: number,
601
832
  ): string | undefined {
602
833
  if (depth > CONST_RESOLUTION_DEPTH) return undefined
603
834
  if (ts.isParenthesizedExpression(node)) {
604
- return resolveStaticSqlText(ts, node.expression, consts, depth)
835
+ return resolveStaticSqlText(ts, node.expression, scope, depth)
605
836
  }
606
837
  if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
607
- return resolveStaticSqlText(ts, node.expression, consts, depth)
838
+ return resolveStaticSqlText(ts, node.expression, scope, depth)
608
839
  }
609
840
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
610
841
  if (ts.isNumericLiteral(node)) return node.text
611
842
  if (ts.isTemplateExpression(node)) {
612
843
  let text = node.head.text
613
844
  for (const span of node.templateSpans) {
614
- const part = resolveStaticSqlText(ts, span.expression, consts, depth + 1)
845
+ const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
615
846
  if (part === undefined) return undefined
616
847
  text += part + span.literal.text
617
848
  }
618
849
  return text
619
850
  }
620
851
  if (ts.isConditionalExpression(node)) {
621
- const whenTrue = resolveStaticSqlText(ts, node.whenTrue, consts, depth + 1)
622
- const whenFalse = resolveStaticSqlText(ts, node.whenFalse, consts, depth + 1)
852
+ const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1)
853
+ const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1)
623
854
  if (whenTrue === undefined || whenFalse === undefined) return undefined
624
855
  return `${whenTrue} ${whenFalse}`
625
856
  }
626
857
  if (ts.isIdentifier(node)) {
627
- const initializer = consts.get(node.text)
628
- if (initializer === undefined) return undefined
858
+ const initializer = scope.module.consts.get(node.text)
859
+ const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
860
+ // Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
861
+ // module const nor an import binding.
862
+ if (initializer === undefined && edge === undefined) return undefined
863
+ // Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
864
+ // of an import hop: an initializer in the imported module sits at module scope with nothing above
865
+ // it to shadow it, while a use inside a function in this file is still refused.
629
866
  if (isShadowedAt(ts, node, node.text)) return undefined
630
- return resolveStaticSqlText(ts, initializer, consts, depth + 1)
867
+ if (initializer !== undefined) return resolveStaticSqlText(ts, initializer, scope, depth + 1)
868
+ if (edge === undefined || scope.imports === undefined) return undefined
869
+ const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
870
+ if (target === undefined) return undefined
871
+ const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
872
+ if (exported === undefined) return undefined
873
+ // The imported initializer's own identifiers read THAT module's consts, never this file's. A
874
+ // caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
875
+ // which is the one way cross-module resolution could resolve to text nobody wrote.
876
+ return resolveStaticSqlText(
877
+ ts,
878
+ exported.expression,
879
+ { module: exported.scope, imports: scope.imports },
880
+ depth + 1,
881
+ )
631
882
  }
632
883
  return undefined
633
884
  }
@@ -637,13 +888,13 @@ function resolveStaticSqlText(
637
888
  function templateShape(
638
889
  ts: TypeScriptApi,
639
890
  template: TSApi.TemplateExpression,
640
- consts: ModuleConsts | undefined,
891
+ scope: SqlScope | undefined,
641
892
  ): { staticText: string; dynamic: boolean } {
642
893
  let staticText = template.head.text
643
894
  let dynamic = false
644
895
  for (const span of template.templateSpans) {
645
896
  const resolved =
646
- consts === undefined ? undefined : resolveStaticSqlText(ts, span.expression, consts, 0)
897
+ scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
647
898
  if (resolved === undefined) dynamic = true
648
899
  else staticText += resolved
649
900
  staticText += span.literal.text
@@ -655,9 +906,9 @@ function templateShape(
655
906
  function sqlExpressionShape(
656
907
  ts: TypeScriptApi,
657
908
  node: TSApi.Expression,
658
- consts?: ModuleConsts,
909
+ scope?: SqlScope,
659
910
  ): SqlExpressionShape {
660
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, consts)
911
+ if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
661
912
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
662
913
  return {
663
914
  staticText: node.text,
@@ -674,7 +925,7 @@ function sqlExpressionShape(
674
925
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
675
926
  const template = node.template
676
927
  const templated = ts.isTemplateExpression(template)
677
- ? templateShape(ts, template, parameterizedTag ? undefined : consts)
928
+ ? templateShape(ts, template, parameterizedTag ? undefined : scope)
678
929
  : { staticText: template.text, dynamic: false }
679
930
  const staticText = templated.staticText
680
931
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
@@ -699,7 +950,7 @@ function sqlExpressionShape(
699
950
  }
700
951
  }
701
952
  if (ts.isTemplateExpression(node)) {
702
- const templated = templateShape(ts, node, consts)
953
+ const templated = templateShape(ts, node, scope)
703
954
  return {
704
955
  staticText: templated.staticText,
705
956
  dynamic: templated.dynamic,
@@ -709,8 +960,8 @@ function sqlExpressionShape(
709
960
  }
710
961
  }
711
962
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
712
- const left = sqlExpressionShape(ts, node.left, consts)
713
- const right = sqlExpressionShape(ts, node.right, consts)
963
+ const left = sqlExpressionShape(ts, node.left, scope)
964
+ const right = sqlExpressionShape(ts, node.right, scope)
714
965
  return {
715
966
  staticText: left.staticText + right.staticText,
716
967
  // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
@@ -724,11 +975,11 @@ function sqlExpressionShape(
724
975
  }
725
976
  // A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
726
977
  // text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
727
- // `db.query(`${COLS} FROM t WHERE id = $1`)` pass while an imported or mutable name still flags.
978
+ // `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
728
979
  // `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
729
980
  // resolved const, because taking statement text from ANY variable is what their rule warns about.
730
- if (consts !== undefined) {
731
- const resolved = resolveStaticSqlText(ts, node, consts, 0)
981
+ if (scope !== undefined) {
982
+ const resolved = resolveStaticSqlText(ts, node, scope, 0)
732
983
  if (resolved !== undefined) {
733
984
  return {
734
985
  staticText: resolved,
@@ -773,32 +1024,35 @@ function sqlMethod(
773
1024
 
774
1025
  const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
775
1026
 
776
- /** Scan one file's text for SQL assembled by interpolation. Pure + line-accurate. */
1027
+ /**
1028
+ * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1029
+ *
1030
+ * `imports` opts into following a fragment `const` into another module. Without it the scan is
1031
+ * same-file only, which is what the unit tests exercise and what any caller that has no module graph
1032
+ * to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
1033
+ */
777
1034
  export function scanInterpolatedSql(
778
1035
  file: string,
779
1036
  content: string,
780
1037
  ts: TypeScriptApi,
1038
+ imports?: SqlImports,
781
1039
  ): SourceFinding[] {
782
1040
  const out: SourceFinding[] = []
783
1041
  const lines = content.split("\n")
784
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
785
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
786
1042
  // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
787
1043
  // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
788
- const parseDiagnostics = (
789
- source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
790
- ).parseDiagnostics
791
- if (parseDiagnostics !== undefined && parseDiagnostics.length > 0) return out
792
- // Module-scope consts, resolved once per file: an interpolated same-file `const` cannot carry
793
- // request data, so the shared-projection idiom (`const COLS = "id, name"` … `${COLS}`) stays quiet
794
- // while imported, mutable, parameter, and shadowed names flag exactly as before.
795
- const consts = moduleConstsOf(ts, source)
1044
+ const source = parseSqlSource(ts, file, content)
1045
+ if (source === undefined) return out
1046
+ // Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
1047
+ // text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
1048
+ // `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
1049
+ const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
796
1050
  const visit = (node: TSApi.Node): void => {
797
1051
  if (ts.isCallExpression(node)) {
798
1052
  const sink = sqlMethod(ts, node)
799
1053
  const argument = node.arguments[0]
800
1054
  if (sink !== undefined && argument !== undefined) {
801
- const shape = sqlExpressionShape(ts, argument, consts)
1055
+ const shape = sqlExpressionShape(ts, argument, scope)
802
1056
  const unsafeEscape =
803
1057
  ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
804
1058
  const assembledSql =
@@ -827,13 +1081,63 @@ export function scanInterpolatedSql(
827
1081
  * answer a security rule must never give when it did not run.
828
1082
  */
829
1083
  export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
830
- const ts = await importTypeScript()
1084
+ const ts = await importProjectTypeScript(cwd)
831
1085
  if (ts === undefined) return undefined
832
1086
  const out: SourceFinding[] = []
833
- await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts)))
1087
+ const imports = createProjectSqlImports(cwd)
1088
+ await walkSource(cwd, (rel, content) =>
1089
+ out.push(...scanInterpolatedSql(rel, content, ts, imports)),
1090
+ )
834
1091
  return out.sort(bySite)
835
1092
  }
836
1093
 
1094
+ /** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
1095
+ * initializer to read, so following one could only ever produce a "proved" the source never wrote. */
1096
+ const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/
1097
+ /** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
1098
+ const SQL_SOURCE_FILE = /\.[cm]?tsx?$/
1099
+
1100
+ /**
1101
+ * The on-disk loader for cross-module `const` resolution, scoped to one project.
1102
+ *
1103
+ * Deliberately narrow, because every widening here is a way for a real finding to go quiet:
1104
+ *
1105
+ * - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
1106
+ * this project changing, so "provably a `const` string" is not a property the project owns.
1107
+ * - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
1108
+ * under the project root: a symlink pointing out of the tree is not source this scan reads. The
1109
+ * `node_modules` / `dist` / build exclusions are the same ones the walk applies.
1110
+ * - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
1111
+ *
1112
+ * A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
1113
+ * flagged - the pre-feature answer.
1114
+ */
1115
+ export function createProjectSqlImports(cwd: string): SqlImports {
1116
+ const root = (() => {
1117
+ try {
1118
+ return realpathSync(resolve(cwd))
1119
+ } catch {
1120
+ return resolve(cwd)
1121
+ }
1122
+ })()
1123
+ return createSqlImports((fromModule, specifier) => {
1124
+ if (!isRelativeSpecifier(specifier)) return undefined
1125
+ try {
1126
+ // `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
1127
+ // every module reached through a hop (their ids are the resolved paths).
1128
+ const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule)
1129
+ const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)))
1130
+ if (!resolved.startsWith(root + sep)) return undefined
1131
+ const rel = resolved.slice(root.length + 1)
1132
+ if (IGNORED_DIR.test(rel)) return undefined
1133
+ if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved)) return undefined
1134
+ return { id: resolved, content: readFileSync(resolved, "utf8") }
1135
+ } catch {
1136
+ return undefined // unresolvable, unreadable, or outside the project
1137
+ }
1138
+ })
1139
+ }
1140
+
837
1141
  // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
838
1142
  // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
839
1143
  // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
@@ -922,7 +1226,11 @@ export function scanRemovedImports(file: string, content: string): SourceFinding
922
1226
  /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
923
1227
  * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
924
1228
  * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
925
- export function scanServerOnlyImports(file: string, content: string): ServerImportFinding[] {
1229
+ export function scanServerOnlyImports(
1230
+ file: string,
1231
+ content: string,
1232
+ facts?: SourceFacts,
1233
+ ): ServerImportFinding[] {
926
1234
  if (!ROUTE_FILE.test(file)) return []
927
1235
  const out: ServerImportFinding[] = []
928
1236
  const lines = content.split("\n")
@@ -933,6 +1241,13 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
933
1241
  if (positions[m.index] === " ") continue
934
1242
  const specifier = m[1] ?? ""
935
1243
  if (!SERVER_ONLY.test(specifier)) continue
1244
+ if (facts !== undefined) {
1245
+ const source = facts.parse(file, content)
1246
+ // Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
1247
+ // runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
1248
+ if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
1249
+ continue
1250
+ }
936
1251
  const line = lineAt(content, m.index)
937
1252
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
938
1253
  }
@@ -979,22 +1294,31 @@ const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_
979
1294
  * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
980
1295
  * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
981
1296
  * transitive walk. Pure. */
982
- function staticImportEdges(content: string): Array<{ specifier: string; index: number }> {
1297
+ function staticImportEdges(
1298
+ content: string,
1299
+ facts?: SourceFacts,
1300
+ file?: string,
1301
+ ): Array<{ specifier: string; index: number }> {
983
1302
  const edges: Array<{ specifier: string; index: number }> = []
984
1303
  const re = staticImportRegex()
985
1304
  const code = stripComments(content)
986
1305
  const positions = codePositionMask(content)
987
1306
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
988
1307
  if (positions[m.index] === " ") continue
989
- if (m[1] !== undefined) edges.push({ specifier: m[1], index: m.index })
1308
+ if (m[1] === undefined) continue
1309
+ if (facts !== undefined && file !== undefined) {
1310
+ const source = facts.parse(file, content)
1311
+ if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
1312
+ }
1313
+ edges.push({ specifier: m[1], index: m.index })
990
1314
  }
991
1315
  return edges
992
1316
  }
993
1317
 
994
1318
  /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
995
1319
  * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
996
- export function parseStaticImports(content: string): string[] {
997
- return staticImportEdges(content).map((e) => e.specifier)
1320
+ export function parseStaticImports(content: string, facts?: SourceFacts, file?: string): string[] {
1321
+ return staticImportEdges(content, facts, file).map((e) => e.specifier)
998
1322
  }
999
1323
 
1000
1324
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
@@ -1037,6 +1361,7 @@ export function walkServerOnlyChain(
1037
1361
  routeContent: string,
1038
1362
  resolve: ModuleResolver,
1039
1363
  read: ModuleReader,
1364
+ facts?: SourceFacts,
1040
1365
  ): readonly string[] | undefined {
1041
1366
  // Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
1042
1367
  // (route + the as-written specifier of each hop), and the file the imports resolve relative to.
@@ -1053,7 +1378,7 @@ export function walkServerOnlyChain(
1053
1378
  const next: Node[] = []
1054
1379
  for (const node of frontier) {
1055
1380
  if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
1056
- for (const spec of parseStaticImports(node.content)) {
1381
+ for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1057
1382
  // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1058
1383
  // since BFS reaches the nearest sink before any deeper one).
1059
1384
  const sink = directSinkSpecifier(spec)
@@ -1094,6 +1419,7 @@ export function resolveServerOnlyChains(
1094
1419
  content: string,
1095
1420
  resolve: ModuleResolver,
1096
1421
  read: ModuleReader,
1422
+ facts?: SourceFacts,
1097
1423
  ): TransitiveServerImportFinding[] {
1098
1424
  if (!ROUTE_FILE.test(file)) return []
1099
1425
  const lines = content.split("\n")
@@ -1102,7 +1428,7 @@ export function resolveServerOnlyChains(
1102
1428
  // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1103
1429
  // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1104
1430
  // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1105
- for (const { specifier, index } of staticImportEdges(content)) {
1431
+ for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1106
1432
  if (flaggedSpecifiers.has(specifier)) continue
1107
1433
  const line = lineAt(content, index)
1108
1434
  const snippet = (lines[line - 1] ?? "").trim()
@@ -1141,7 +1467,7 @@ export function resolveServerOnlyChains(
1141
1467
  continue
1142
1468
  }
1143
1469
  // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1144
- const subChain = walkServerOnlyChain(abs, depContent, resolve, read)
1470
+ const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
1145
1471
  if (subChain !== undefined) {
1146
1472
  flaggedSpecifiers.add(specifier)
1147
1473
  // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
@@ -1170,7 +1496,11 @@ const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*js
1170
1496
 
1171
1497
  /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
1172
1498
  * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
1173
- export function scanResponseRoutes(file: string, content: string): SourceFinding[] {
1499
+ export function scanResponseRoutes(
1500
+ file: string,
1501
+ content: string,
1502
+ facts?: SourceFacts,
1503
+ ): SourceFinding[] {
1174
1504
  // Strip comments + template literals first: a commented-out or doc-example `return new Response(`
1175
1505
  // must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
1176
1506
  const code = stripComments(content)
@@ -1179,6 +1509,12 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
1179
1509
  const lines = content.split("\n")
1180
1510
  RESPONSE_RETURN.lastIndex = 0
1181
1511
  for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
1512
+ if (facts !== undefined) {
1513
+ const source = facts.parse(file, content)
1514
+ // A parseable AST rejects `return new Response(` text that only lives in a normal string. On
1515
+ // malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
1516
+ if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
1517
+ }
1182
1518
  const line = lineAt(content, m.index)
1183
1519
  // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1184
1520
  // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
@@ -1337,16 +1673,42 @@ interface TypecheckResult {
1337
1673
  readonly note?: string
1338
1674
  readonly output?: string
1339
1675
  readonly cancelled?: boolean
1676
+ /** tsconfig.json exists but no `typescript` install was found walking up from cwd. The contract
1677
+ * gate could not run - the caller surfaces this as a FAILING diagnostic, never a silent skip. */
1678
+ readonly missingTypeScript?: boolean
1679
+ }
1680
+
1681
+ /**
1682
+ * Find the project's `tsc` the way module resolution would: `node_modules/typescript/bin/tsc` in
1683
+ * cwd, then each parent directory up to the filesystem root. A workspace package in a monorepo has
1684
+ * its TypeScript hoisted to the workspace root, so the literal `join(cwd, "node_modules", …)` probe
1685
+ * this replaces reported "typescript not installed" - and silently skipped the gate - whenever
1686
+ * `nifra check` ran from the package directory instead of the repo root.
1687
+ */
1688
+ function resolveTscBin(cwd: string): string | undefined {
1689
+ let dir = cwd
1690
+ while (true) {
1691
+ const candidate = join(dir, "node_modules", "typescript", "bin", "tsc")
1692
+ if (existsSync(candidate)) return candidate
1693
+ const parent = dirname(dir)
1694
+ if (parent === dir) return undefined
1695
+ dir = parent
1696
+ }
1340
1697
  }
1341
1698
 
1342
1699
  /** Run the project's own `tsc --noEmit`, if TypeScript + a tsconfig are present. Never auto-installs. */
1343
1700
  async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckResult> {
1344
1701
  const tsconfig = join(cwd, "tsconfig.json")
1345
- const tscBin = join(cwd, "node_modules", "typescript", "bin", "tsc")
1346
1702
  if (!(await Bun.file(tsconfig).exists()))
1347
1703
  return { ran: false, ok: true, note: "no tsconfig.json" }
1348
- if (!(await Bun.file(tscBin).exists())) {
1349
- return { ran: false, ok: true, note: "typescript not installed (run: bun add -d typescript)" }
1704
+ const tscBin = resolveTscBin(cwd)
1705
+ if (tscBin === undefined) {
1706
+ return {
1707
+ ran: false,
1708
+ ok: false,
1709
+ missingTypeScript: true,
1710
+ note: "typescript not installed (run: bun add -d typescript)",
1711
+ }
1350
1712
  }
1351
1713
  if (signal?.aborted) return { ran: true, ok: false, cancelled: true, output: "cancelled" }
1352
1714
  const proc = Bun.spawn(["bun", tscBin, "--noEmit", "-p", tsconfig], {
@@ -1388,27 +1750,13 @@ const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
1388
1750
 
1389
1751
  /** A single machine-readable check failure - the unit an agent (or CI) acts on. */
1390
1752
  export interface CheckDiagnostic {
1391
- readonly rule:
1392
- | "typecheck"
1393
- | "typed-client"
1394
- | "untyped-client"
1395
- | "server-only-import"
1396
- | "removed-import"
1397
- | "interpolated-sql"
1398
- | "response-route"
1399
- | "undeclared-dependency"
1400
- | "duplicate-install"
1401
- | "stale-workspace-dist"
1402
- | "pipeline"
1403
- | "server-manifest-drift"
1404
- | "manifest-drift"
1405
- | "capability-assurance"
1406
- | "capability-config"
1407
- | "check-config"
1753
+ readonly rule: string
1408
1754
  /** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
1409
1755
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
1410
1756
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
1411
- readonly severity: "error" | "warning"
1757
+ readonly severity: "error" | "warning" | "info"
1758
+ /** Stable diagnostic protocol code. The legacy `rule` remains for compatibility. */
1759
+ readonly code?: string
1412
1760
  readonly file?: string
1413
1761
  readonly line?: number
1414
1762
  readonly message: string
@@ -1431,6 +1779,8 @@ export interface CheckDiagnostic {
1431
1779
  * edge rather than fabricating a deeper path - never a lie.
1432
1780
  */
1433
1781
  readonly chain?: readonly string[]
1782
+ readonly evidence?: readonly string[]
1783
+ readonly verify?: string
1434
1784
  }
1435
1785
 
1436
1786
  export interface CheckSuggestion {
@@ -1446,7 +1796,12 @@ export interface CheckSuggestion {
1446
1796
  export interface CheckResult {
1447
1797
  readonly ok: boolean
1448
1798
  readonly typecheck: "pass" | "fail" | "skipped"
1799
+ /** Why `typecheck` is `"skipped"` (no tsconfig.json, typescript not installed, lints-only mode).
1800
+ * Absent when the typecheck ran. Echoed in the human report so a skip is never a dim mystery. */
1801
+ readonly typecheckNote?: string
1449
1802
  readonly diagnostics: readonly CheckDiagnostic[]
1803
+ /** Normalized diagnostics with stable codes for agents and external renderers. */
1804
+ readonly structuredDiagnostics?: readonly Diagnostic[]
1450
1805
  /**
1451
1806
  * Which bundler this app's phases run on, read statically from the config, and how nifra concluded
1452
1807
  * it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
@@ -1458,6 +1813,10 @@ export interface CheckResult {
1458
1813
  * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
1459
1814
  * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
1460
1815
  readonly externalMounts?: readonly string[]
1816
+ /** Active per-rule overrides from `nifra.check.json` `rules`, echoed verbatim so a retagged or
1817
+ * suppressed finding stays auditable in `--json`, the MCP tool, and the human report - config can
1818
+ * lower (or raise) the gate, but never invisibly. */
1819
+ readonly ruleOverrides?: Readonly<Record<string, RuleOverride>>
1461
1820
  /** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
1462
1821
  * first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
1463
1822
  * message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
@@ -1481,31 +1840,103 @@ export interface CheckAssuranceContext {
1481
1840
  readonly routeAssurance?: AssuranceReport
1482
1841
  /** Static capability provenance, when the config declares a capabilities policy. */
1483
1842
  readonly capability?: CapabilityProjectReport
1843
+ /** Canonical token-only evidence reused by manifest and other offline projections. */
1844
+ readonly evidence?: ProjectEvidenceSnapshot
1484
1845
  }
1485
1846
 
1486
1847
  /** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
1487
1848
  * the app is built or even importable, preserving check's pre-`loadApp` invariant. */
1488
1849
  interface CheckConfig {
1489
1850
  readonly externalMounts: readonly string[]
1851
+ readonly rules: Readonly<Record<string, RuleOverride>>
1852
+ }
1853
+
1854
+ /**
1855
+ * One entry of `nifra.check.json` `rules`, keyed by legacy rule name (`response-route`) or stable
1856
+ * NF- code (`NF-S002`) - one key retags the finding in both diagnostic views. `severity: "off"`
1857
+ * drops the rule's findings; `ignore` drops findings whose file matches any of the globs. Overrides
1858
+ * are applied centrally BEFORE `ok` is computed and echoed in the result and the human report -
1859
+ * configuration can lower (or raise) the gate, but never invisibly.
1860
+ */
1861
+ export interface RuleOverride {
1862
+ readonly severity?: "error" | "warn" | "info" | "off"
1863
+ readonly ignore?: readonly string[]
1490
1864
  }
1491
1865
 
1492
- /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed → empty plus a parse
1493
- * `error` the caller surfaces as a `warning` diagnostic (never throws, never blocks the gate). Mount
1494
- * entries are normalized to bare path prefixes (`/auth/**` and `/auth/` both become `/auth`). */
1495
- async function loadCheckConfig(cwd: string): Promise<{ config: CheckConfig; error?: string }> {
1866
+ const OVERRIDE_SEVERITIES: readonly string[] = ["error", "warn", "info", "off"]
1867
+
1868
+ /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed JSON → empty plus a
1869
+ * parse `error`; an invalid entry → skipped plus a `warnings` note the caller surfaces as a `warning`
1870
+ * diagnostic (never throws, never blocks the gate). Mount entries are normalized to bare path prefixes
1871
+ * (`/auth/**` and `/auth/` both become `/auth`). */
1872
+ async function loadCheckConfig(
1873
+ cwd: string,
1874
+ ): Promise<{ config: CheckConfig; error?: string; warnings: readonly string[] }> {
1496
1875
  const path = join(cwd, "nifra.check.json")
1497
- if (!existsSync(path)) return { config: { externalMounts: [] } }
1876
+ const empty: CheckConfig = { externalMounts: [], rules: {} }
1877
+ if (!existsSync(path)) return { config: empty, warnings: [] }
1498
1878
  try {
1499
- const parsed = JSON.parse(await Bun.file(path).text()) as { externalMounts?: unknown }
1879
+ const parsed = JSON.parse(await Bun.file(path).text()) as {
1880
+ externalMounts?: unknown
1881
+ rules?: unknown
1882
+ }
1500
1883
  const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : []
1501
1884
  const externalMounts = raw
1502
1885
  .filter((m): m is string => typeof m === "string" && m.startsWith("/"))
1503
1886
  .map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/")
1504
- return { config: { externalMounts } }
1887
+ const warnings: string[] = []
1888
+ const rules: Record<string, RuleOverride> = {}
1889
+ if (parsed.rules !== undefined) {
1890
+ if (
1891
+ typeof parsed.rules !== "object" ||
1892
+ parsed.rules === null ||
1893
+ Array.isArray(parsed.rules)
1894
+ ) {
1895
+ warnings.push('`rules` must be an object of { "<rule>": { severity?, ignore? } } - ignored')
1896
+ } else {
1897
+ for (const [rule, value] of Object.entries(parsed.rules)) {
1898
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1899
+ warnings.push(`rules["${rule}"] must be an object - ignored`)
1900
+ continue
1901
+ }
1902
+ const entry = value as { severity?: unknown; ignore?: unknown }
1903
+ const override: {
1904
+ severity?: NonNullable<RuleOverride["severity"]>
1905
+ ignore?: readonly string[]
1906
+ } = {}
1907
+ if (entry.severity !== undefined) {
1908
+ if (
1909
+ typeof entry.severity === "string" &&
1910
+ OVERRIDE_SEVERITIES.includes(entry.severity)
1911
+ ) {
1912
+ override.severity = entry.severity as NonNullable<RuleOverride["severity"]>
1913
+ } else {
1914
+ warnings.push(
1915
+ `rules["${rule}"].severity must be "error" | "warn" | "info" | "off" - ignored`,
1916
+ )
1917
+ }
1918
+ }
1919
+ if (entry.ignore !== undefined) {
1920
+ if (
1921
+ Array.isArray(entry.ignore) &&
1922
+ entry.ignore.every((g): g is string => typeof g === "string")
1923
+ ) {
1924
+ if (entry.ignore.length > 0) override.ignore = entry.ignore
1925
+ } else {
1926
+ warnings.push(`rules["${rule}"].ignore must be an array of file globs - ignored`)
1927
+ }
1928
+ }
1929
+ if (override.severity !== undefined || override.ignore !== undefined)
1930
+ rules[rule] = override
1931
+ }
1932
+ }
1933
+ }
1934
+ return { config: { externalMounts, rules }, warnings }
1505
1935
  } catch (error) {
1506
1936
  return {
1507
- config: { externalMounts: [] },
1937
+ config: empty,
1508
1938
  error: error instanceof Error ? error.message : String(error),
1939
+ warnings: [],
1509
1940
  }
1510
1941
  }
1511
1942
  }
@@ -1693,28 +2124,41 @@ export async function collectCheckResult(
1693
2124
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
1694
2125
  // (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
1695
2126
  const routeModules: Array<{ rel: string; content: string }> = []
2127
+ const sourceFiles: Array<{ file: string; content: string }> = []
1696
2128
  // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1697
2129
  // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1698
- const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd)
2130
+ const {
2131
+ config: checkConfig,
2132
+ error: checkConfigError,
2133
+ warnings: checkConfigWarnings,
2134
+ } = await loadCheckConfig(cwd)
1699
2135
  // The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
1700
2136
  // once, before the walk, so a project without it is told the rule did not run rather than being
1701
2137
  // handed a clean report the rule never produced.
1702
- const sqlCompiler = await (opts.loadTypeScript ?? importTypeScript)()
2138
+ const sqlCompiler = await (opts.loadTypeScript ?? (() => importProjectTypeScript(cwd)))()
2139
+ // AST facts are a lazy refinement of lexical candidates. The parser is not invoked for a file until
2140
+ // one of the route/import/response scanners finds something worth disambiguating, and the cache is
2141
+ // shared across those rules for this check run.
2142
+ const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler)
2143
+ // One loader + parse cache for the whole walk, so a shared `sql-fragments.ts` is read once no matter
2144
+ // how many route modules import a fragment out of it.
2145
+ const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
1703
2146
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
1704
2147
  // lints - the agent inner-loop mode; the full gate stays the definition of done.
1705
2148
  const [tc, _, dr, manifestDrift] = await Promise.all([
1706
2149
  opts.lintsOnly
1707
- ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
2150
+ ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "lints-only mode" })
1708
2151
  : typecheck(cwd, opts.signal),
1709
2152
  walkSource(cwd, (rel, content) => {
2153
+ sourceFiles.push({ file: rel, content })
1710
2154
  fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
1711
- staticRoutes.push(...scanStaticRouteText(rel, content))
2155
+ staticRoutes.push(...scanStaticRouteText(rel, content, sourceFacts))
1712
2156
  untypedClients.push(...scanUntypedClient(rel, content))
1713
2157
  removedImports.push(...scanRemovedImports(rel, content))
1714
2158
  if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
1715
- responseRoutes.push(...scanResponseRoutes(rel, content))
2159
+ responseRoutes.push(...scanResponseRoutes(rel, content, sourceFacts))
1716
2160
  if (sqlCompiler !== undefined)
1717
- interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler))
2161
+ interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler, sqlImports))
1718
2162
  }),
1719
2163
  import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
1720
2164
  scanServerManifestDrift(cwd),
@@ -1743,10 +2187,13 @@ export async function collectCheckResult(
1743
2187
  }
1744
2188
  }
1745
2189
  for (const { rel, content } of routeModules) {
1746
- serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule))
2190
+ serverImports.push(
2191
+ ...resolveServerOnlyChains(rel, content, resolveModule, readModule, sourceFacts),
2192
+ )
1747
2193
  }
1748
2194
 
1749
2195
  const diagnostics: CheckDiagnostic[] = []
2196
+ const structuredExtras: Diagnostic[] = []
1750
2197
  if (checkConfigError !== undefined) {
1751
2198
  diagnostics.push({
1752
2199
  rule: "check-config",
@@ -1756,6 +2203,32 @@ export async function collectCheckResult(
1756
2203
  fix: "Fix the JSON syntax in nifra.check.json",
1757
2204
  })
1758
2205
  }
2206
+ for (const warning of checkConfigWarnings) {
2207
+ diagnostics.push({
2208
+ rule: "check-config",
2209
+ severity: "warning",
2210
+ file: "nifra.check.json",
2211
+ message: `nifra.check.json: ${warning}`,
2212
+ fix: "Fix the entry in nifra.check.json",
2213
+ })
2214
+ }
2215
+ if (tc.missingTypeScript === true) {
2216
+ // A tsconfig with no reachable `typescript` install is a broken gate, not a benign skip: the
2217
+ // contract check the project asked for (by having a tsconfig) silently didn't run. Fail closed.
2218
+ diagnostics.push({
2219
+ rule: "typecheck",
2220
+ severity: "error",
2221
+ file: "tsconfig.json",
2222
+ message:
2223
+ "tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
2224
+ fix: "bun add -d typescript",
2225
+ suggestion: {
2226
+ kind: "command",
2227
+ title: "Install TypeScript so the contract gate can run",
2228
+ command: ["bun", "add", "-d", "typescript"],
2229
+ },
2230
+ })
2231
+ }
1759
2232
  if (tc.ran && !tc.ok) {
1760
2233
  const lines = (tc.output ?? "").split("\n")
1761
2234
  let matched = false
@@ -1946,6 +2419,7 @@ export async function collectCheckResult(
1946
2419
  ? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - rebuild ${f.package}`
1947
2420
  : `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - rebuild ${f.package}`,
1948
2421
  fix: `rebuild ${f.package}`,
2422
+ evidence: [f.package, f.distFile, f.sourceFile],
1949
2423
  suggestion: {
1950
2424
  kind: "manual",
1951
2425
  title: `Rebuild ${f.package}`,
@@ -1983,6 +2457,7 @@ export async function collectCheckResult(
1983
2457
  file: f.file,
1984
2458
  message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
1985
2459
  fix: MANIFEST_DRIFT_HINT,
2460
+ evidence: [...f.missing, ...f.extra],
1986
2461
  suggestion: {
1987
2462
  kind: "manual",
1988
2463
  title: "Regenerate the committed server manifest",
@@ -1998,6 +2473,7 @@ export async function collectCheckResult(
1998
2473
  // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
1999
2474
  // without nifra.assurance.ts retain the historical scan and hot path unchanged.
2000
2475
  const provided = opts.assurance
2476
+ let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
2001
2477
  const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
2002
2478
  if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
2003
2479
  try {
@@ -2006,6 +2482,7 @@ export async function collectCheckResult(
2006
2482
  let config: AssuranceConfig
2007
2483
  let project: CapabilityProjectReport | undefined
2008
2484
  let routeAssurance: AssuranceReport | undefined
2485
+ let evidence: ProjectEvidenceSnapshot | undefined
2009
2486
  if (provided !== undefined) {
2010
2487
  // A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
2011
2488
  // capability-config diagnostic the standalone catch produces.
@@ -2013,6 +2490,7 @@ export async function collectCheckResult(
2013
2490
  config = provided.config as AssuranceConfig
2014
2491
  project = provided.capability
2015
2492
  routeAssurance = provided.routeAssurance
2493
+ evidence = provided.evidence
2016
2494
  } else {
2017
2495
  const { loadAssuranceConfig } = await import("./assure.ts")
2018
2496
  config = await loadAssuranceConfig(cwd)
@@ -2021,6 +2499,7 @@ export async function collectCheckResult(
2021
2499
  project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
2022
2500
  }
2023
2501
  }
2502
+ applicationRulePacks = parseRulePacks(config.rulePacks)
2024
2503
  const capabilityReport = project?.report
2025
2504
  if (config.capabilities !== undefined && project !== undefined) {
2026
2505
  for (const finding of project.report.findings) {
@@ -2038,6 +2517,12 @@ export async function collectCheckResult(
2038
2517
  candidate.method === finding.method && candidate.path === finding.path,
2039
2518
  )
2040
2519
  : undefined
2520
+ // An unmatched seam is a policy defect, not a route defect: the fix is in the policy file,
2521
+ // so it gets its own steps instead of the route-side provenance guidance.
2522
+ const seamFix =
2523
+ finding.code === "unmatched-provenance-seam"
2524
+ ? "Write the seam exactly as the code imports it, or delete the rule."
2525
+ : undefined
2041
2526
  diagnostics.push({
2042
2527
  rule: "capability-assurance",
2043
2528
  severity: "error",
@@ -2046,18 +2531,30 @@ export async function collectCheckResult(
2046
2531
  : truncation !== undefined
2047
2532
  ? { chain: truncation.chain }
2048
2533
  : {}),
2049
- message: `${finding.message} - ${CAPABILITY_HINT}`,
2050
- fix: CAPABILITY_HINT,
2051
- suggestion: {
2052
- kind: "manual",
2053
- title: "Restore declared effect provenance",
2054
- steps: [
2055
- "Route effectful work through an import listed in capabilities.provenance.imports.",
2056
- "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
2057
- "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.",
2058
- "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
2059
- ],
2060
- },
2534
+ message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
2535
+ fix: seamFix ?? CAPABILITY_HINT,
2536
+ suggestion:
2537
+ seamFix === undefined
2538
+ ? {
2539
+ kind: "manual",
2540
+ title: "Restore declared effect provenance",
2541
+ steps: [
2542
+ "Route effectful work through an import listed in capabilities.provenance.imports.",
2543
+ "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
2544
+ "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.",
2545
+ "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
2546
+ ],
2547
+ }
2548
+ : {
2549
+ kind: "manual",
2550
+ title: "Point the provenance rule at a module that exists",
2551
+ steps: [
2552
+ "Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
2553
+ "Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
2554
+ "For a routeModules entry, give the project-relative path of the file that implements the route.",
2555
+ "Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
2556
+ ],
2557
+ },
2061
2558
  })
2062
2559
  }
2063
2560
  }
@@ -2076,6 +2573,10 @@ export async function collectCheckResult(
2076
2573
  : {}),
2077
2574
  },
2078
2575
  )
2576
+ evidence ??= snapshotProjectEvidence(config.source, {
2577
+ assurance,
2578
+ ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2579
+ })
2079
2580
  const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
2080
2581
  let message: string | undefined
2081
2582
  if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
@@ -2086,7 +2587,7 @@ export async function collectCheckResult(
2086
2587
  } else {
2087
2588
  try {
2088
2589
  const current = await buildNifraManifest({
2089
- source: config.source,
2590
+ evidence,
2090
2591
  assurance,
2091
2592
  ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2092
2593
  })
@@ -2136,80 +2637,221 @@ export async function collectCheckResult(
2136
2637
  }
2137
2638
  }
2138
2639
 
2640
+ try {
2641
+ const { checkContractsLock } = await import("./contracts.ts")
2642
+ if (!existsSync(join(cwd, "backend.ts")) && !existsSync(join(cwd, "contracts.lock.json"))) {
2643
+ throw new Error("contract source not configured")
2644
+ }
2645
+ const contract = await checkContractsLock(cwd)
2646
+ if (!contract.present) {
2647
+ structuredExtras.push(
2648
+ diagnostic({
2649
+ code: "NF-K001",
2650
+ severity: "info",
2651
+ message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
2652
+ verify: "nifra contracts snapshot",
2653
+ }),
2654
+ )
2655
+ } else {
2656
+ for (const finding of contract.diagnostics) {
2657
+ diagnostics.push({
2658
+ rule: "contract-drift",
2659
+ severity: "error",
2660
+ code: "NF-K001",
2661
+ ...(finding.route !== undefined ? { evidence: [finding.route] } : {}),
2662
+ message: finding.message,
2663
+ fix: "run `nifra contracts snapshot` after reviewing the contract change",
2664
+ verify: "nifra check --lints-only",
2665
+ })
2666
+ }
2667
+ }
2668
+ } catch (error) {
2669
+ if (error instanceof Error && error.message === "contract source not configured") {
2670
+ // API-only source scans have no contract surface to snapshot.
2671
+ } else {
2672
+ diagnostics.push({
2673
+ rule: "contract-drift",
2674
+ severity: "error",
2675
+ code: "NF-K001",
2676
+ message: `contract lock could not be checked: ${error instanceof Error ? error.message : String(error)}`,
2677
+ verify: "nifra contracts snapshot",
2678
+ })
2679
+ }
2680
+ }
2681
+
2682
+ const ruleContext: RuleContext = {
2683
+ root: cwd,
2684
+ sources: sourceIndex(sourceFiles),
2685
+ project: {
2686
+ legacyDiagnostics: diagnostics,
2687
+ assurance: opts.assurance,
2688
+ // Route registrations collected during the source walk, so the route-table rules
2689
+ // (rules/routes.ts) lint them without a second scan.
2690
+ staticRoutes,
2691
+ },
2692
+ }
2693
+ const structuredDiagnostics = [...structuredExtras]
2694
+ let registryDiagnostics: Diagnostic[] = []
2695
+ try {
2696
+ registryDiagnostics = await runRuleRegistry(
2697
+ ruleContext,
2698
+ [...legacyRules, ...securityRules, ...routeRules],
2699
+ applicationRulePacks,
2700
+ )
2701
+ structuredDiagnostics.push(...registryDiagnostics)
2702
+ } catch (error) {
2703
+ registryDiagnostics = [
2704
+ diagnostic({
2705
+ code: "NF-C017",
2706
+ severity: "error",
2707
+ message: `rule registry failed closed: ${error instanceof Error ? error.message : String(error)}`,
2708
+ fix: { recipe: "rule-pack.repair", command: "nifra check --lints-only" },
2709
+ verify: "nifra check --lints-only",
2710
+ }),
2711
+ ]
2712
+ structuredDiagnostics.push(...registryDiagnostics)
2713
+ }
2714
+ for (const item of registryDiagnostics) {
2715
+ // The legacy adapters publish stable structured findings; the human compatibility view already
2716
+ // contains their original rule names. Security rules and application packs still need a legacy-view
2717
+ // row because they have no pre-registry diagnostic.
2718
+ if (legacyRules.some((rule) => rule.code === item.code)) continue
2719
+ diagnostics.push({
2720
+ rule: item.code,
2721
+ severity: item.severity === "error" ? "error" : "warning",
2722
+ code: item.code,
2723
+ ...(item.file !== undefined ? { file: item.file } : {}),
2724
+ ...(item.line !== undefined ? { line: item.line } : {}),
2725
+ message: item.message,
2726
+ ...(item.evidence !== undefined ? { evidence: item.evidence } : {}),
2727
+ ...(item.verify !== undefined ? { verify: item.verify } : {}),
2728
+ })
2729
+ }
2730
+
2731
+ // Per-rule overrides from `nifra.check.json` `rules`, applied centrally to BOTH diagnostic views
2732
+ // before `ok` is computed - never inside individual rules, so no rule can dodge (or double-apply)
2733
+ // them. A key matches the legacy rule name or the stable NF- code.
2734
+ const overrideFor = (...keys: (string | undefined)[]): RuleOverride | undefined => {
2735
+ for (const key of keys) {
2736
+ if (key !== undefined && checkConfig.rules[key] !== undefined) return checkConfig.rules[key]
2737
+ }
2738
+ return undefined
2739
+ }
2740
+ const ignoreGlobs = new Map<RuleOverride, Glob[]>()
2741
+ const dropped = (override: RuleOverride, file: string | undefined): boolean => {
2742
+ if (override.severity === "off") return true
2743
+ if (override.ignore === undefined || file === undefined) return false
2744
+ let globs = ignoreGlobs.get(override)
2745
+ if (globs === undefined) {
2746
+ globs = override.ignore.map((pattern) => new Glob(pattern))
2747
+ ignoreGlobs.set(override, globs)
2748
+ }
2749
+ return globs.some((glob) => glob.match(file))
2750
+ }
2751
+ const codeToLegacy = new Map(
2752
+ Object.entries(LEGACY_RULE_CODES).map(([name, code]) => [code, name]),
2753
+ )
2754
+ const finalDiagnostics = diagnostics.flatMap<CheckDiagnostic>((d) => {
2755
+ const override = overrideFor(d.rule, d.code, LEGACY_RULE_CODES[d.rule])
2756
+ if (override === undefined) return [d]
2757
+ if (dropped(override, d.file)) return []
2758
+ // "off" is fully handled by `dropped` above - only real severities reach the retag.
2759
+ if (override.severity === undefined || override.severity === "off") return [d]
2760
+ return [{ ...d, severity: override.severity === "warn" ? "warning" : override.severity }]
2761
+ })
2762
+ const finalStructured = structuredDiagnostics.flatMap<Diagnostic>((d) => {
2763
+ const override = overrideFor(d.code, codeToLegacy.get(d.code))
2764
+ if (override === undefined) return [d]
2765
+ if (dropped(override, d.file)) return []
2766
+ if (override.severity === undefined || override.severity === "off") return [d]
2767
+ return [Object.freeze({ ...d, severity: override.severity })]
2768
+ })
2769
+
2139
2770
  // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
2140
2771
  // message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
2141
- const total = diagnostics.length
2772
+ const total = finalDiagnostics.length
2142
2773
  const max = opts.maxDiagnostics
2143
- const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics
2144
- return {
2145
- ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
2774
+ const shown = max !== undefined && total > max ? finalDiagnostics.slice(0, max) : finalDiagnostics
2775
+ const result: CheckResult = {
2776
+ ok: !finalDiagnostics.some((diagnostic) => diagnostic.severity === "error"),
2146
2777
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
2778
+ ...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
2147
2779
  diagnostics: shown,
2148
2780
  ...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
2149
2781
  ...(checkConfig.externalMounts.length > 0
2150
2782
  ? { externalMounts: checkConfig.externalMounts }
2151
2783
  : {}),
2784
+ ...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
2152
2785
  ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
2153
2786
  }
2154
- }
2155
-
2156
- /** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
2157
- export async function runCheck(
2158
- cwd: string,
2159
- opts: { readonly json?: boolean; readonly lintsOnly?: boolean } = {},
2160
- ): Promise<boolean> {
2161
- // The check view over the one project verification: the same collector `assure` and `levels` read.
2162
- const { collectProjectVerification } = await import("./verification.ts")
2163
- const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
2164
- const result = await verification.check()
2165
- if (opts.json) {
2166
- console.log(JSON.stringify(result, null, 2))
2167
- return result.ok
2168
- }
2787
+ Object.defineProperty(result, "structuredDiagnostics", {
2788
+ value: finalStructured.slice(0, shown.length + structuredExtras.length),
2789
+ enumerable: false,
2790
+ })
2791
+ return result
2792
+ }
2793
+
2794
+ /** The named rule sections of the human report, in print order. A rule absent from this list is NOT
2795
+ * dropped: {@link renderCheckReport} renders every remaining diagnostic in a generic section keyed by
2796
+ * its rule code, so a finding that can flip the exit code is never invisible in the default output. */
2797
+ const REPORT_SECTIONS = [
2798
+ ["typecheck", "typecheck"],
2799
+ ["typed-client", "hand-rolled fetch() to your own API"],
2800
+ ["untyped-client", 'client("…") missing its <typeof app> type argument'],
2801
+ ["server-only-import", "server-only import in a route module"],
2802
+ ["interpolated-sql", "SQL built by interpolating a value into the statement"],
2803
+ ["response-route", "route returns a raw Response (typed client → data: never)"],
2804
+ ["undeclared-dependency", "undeclared dependency in package.json"],
2805
+ ["duplicate-install", "duplicate identity-sensitive dependency install"],
2806
+ ["stale-workspace-dist", "workspace-linked dist older than its source"],
2807
+ ["pipeline", "bundler pipeline (Vite/Bun) config"],
2808
+ ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
2809
+ ["manifest-drift", "versioned trust manifest drift"],
2810
+ ["capability-assurance", "effect/capability assurance"],
2811
+ ["capability-config", "capability assurance config"],
2812
+ ["check-config", "nifra.check.json"],
2813
+ ] as const
2169
2814
 
2170
- console.log("nifra check\n")
2171
- console.log(
2815
+ /**
2816
+ * Render the human-readable check report as lines. Pure (no I/O, no cwd) so tests can assert
2817
+ * report/exit-code parity: every diagnostic in `result.diagnostics` appears in the output, and the
2818
+ * trailer states the error/advisory counts that produced `ok`.
2819
+ */
2820
+ export function renderCheckReport(result: CheckResult): string[] {
2821
+ const lines: string[] = []
2822
+ lines.push("nifra check", "")
2823
+ lines.push(
2172
2824
  result.typecheck === "pass"
2173
2825
  ? "✓ typecheck passed"
2174
2826
  : result.typecheck === "fail"
2175
2827
  ? "✗ typecheck failed - the frontend/backend contract is broken"
2176
- : "• typecheck skipped (no tsconfig / typescript not installed)",
2828
+ : `⚠ typecheck SKIPPED - ${result.typecheckNote ?? "no tsconfig / typescript not installed"} (the contract gate did not run)`,
2177
2829
  )
2178
2830
  // Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
2179
2831
  // live and which toolchain compiles a component, so it belongs in the report rather than only in the
2180
2832
  // dev server's banner - where CI never sees it.
2181
2833
  if (result.pipeline !== undefined) {
2182
- console.log(
2834
+ lines.push(
2183
2835
  result.pipeline.pipeline === "unknown"
2184
2836
  ? `• bundler: not readable from ${result.pipeline.configFile} - ${result.pipeline.reason}`
2185
2837
  : `• bundler: ${result.pipeline.pipeline} (${result.pipeline.reason})`,
2186
2838
  )
2187
2839
  }
2188
2840
  if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
2189
- console.log(
2841
+ lines.push(
2190
2842
  `• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
2191
2843
  )
2192
2844
  }
2193
- const counts = (rule: CheckDiagnostic["rule"]): CheckDiagnostic[] =>
2194
- result.diagnostics.filter((d) => d.rule === rule)
2195
- for (const [rule, label] of [
2196
- ["typecheck", "typecheck"],
2197
- ["typed-client", "hand-rolled fetch() to your own API"],
2198
- ["untyped-client", 'client("…") missing its <typeof app> type argument'],
2199
- ["server-only-import", "server-only import in a route module"],
2200
- ["interpolated-sql", "SQL built by interpolating a value into the statement"],
2201
- ["response-route", "route returns a raw Response (typed client → data: never)"],
2202
- ["undeclared-dependency", "undeclared dependency in package.json"],
2203
- ["duplicate-install", "duplicate identity-sensitive dependency install"],
2204
- ["stale-workspace-dist", "workspace-linked dist older than its source"],
2205
- ["pipeline", "bundler pipeline (Vite/Bun) config"],
2206
- ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
2207
- ["manifest-drift", "versioned trust manifest drift"],
2208
- ["capability-assurance", "effect/capability assurance"],
2209
- ["capability-config", "capability assurance config"],
2210
- ["check-config", "nifra.check.json"],
2211
- ] as const) {
2212
- const ds = counts(rule)
2845
+ if (result.ruleOverrides !== undefined) {
2846
+ const active = Object.entries(result.ruleOverrides).map(([rule, override]) => {
2847
+ const parts: string[] = []
2848
+ if (override.severity !== undefined) parts.push(`severity=${override.severity}`)
2849
+ if (override.ignore !== undefined) parts.push(`ignore=${override.ignore.join(",")}`)
2850
+ return `${rule} (${parts.join(", ")})`
2851
+ })
2852
+ lines.push(`• rule overrides from nifra.check.json: ${active.join("; ")}`)
2853
+ }
2854
+ const renderSection = (rule: string, label: string, ds: readonly CheckDiagnostic[]): void => {
2213
2855
  if (rule !== "typecheck") {
2214
2856
  // Marked by SEVERITY, not by rule name. `response-route` and `stale-workspace-dist` are advisory
2215
2857
  // in whole; `pipeline` is the first rule that is advisory in part (a misplaced plugin fails, a
@@ -2217,7 +2859,7 @@ export async function runCheck(
2217
2859
  // rounded up to the worse of the two.
2218
2860
  const errors = ds.filter((d) => d.severity === "error").length
2219
2861
  const advisory = ds.length - errors
2220
- console.log(
2862
+ lines.push(
2221
2863
  ds.length === 0
2222
2864
  ? `✓ ${label}: none`
2223
2865
  : errors === 0
@@ -2226,25 +2868,88 @@ export async function runCheck(
2226
2868
  )
2227
2869
  }
2228
2870
  for (const d of ds) {
2229
- console.log(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
2871
+ lines.push(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
2230
2872
  if (d.suggestion !== undefined) {
2231
- console.log(` fix: ${d.suggestion.title}`)
2873
+ lines.push(` fix: ${d.suggestion.title}`)
2232
2874
  if (d.suggestion.command !== undefined) {
2233
- console.log(` command: ${d.suggestion.command.join(" ")}`)
2875
+ lines.push(` command: ${d.suggestion.command.join(" ")}`)
2234
2876
  }
2235
2877
  if (d.suggestion.diff !== undefined) {
2236
- console.log(
2237
- d.suggestion.diff
2238
- .split("\n")
2239
- .map((line) => ` ${line}`)
2240
- .join("\n"),
2241
- )
2878
+ for (const line of d.suggestion.diff.split("\n")) lines.push(` ${line}`)
2242
2879
  }
2243
- for (const step of d.suggestion.steps ?? []) console.log(` - ${step}`)
2880
+ for (const step of d.suggestion.steps ?? []) lines.push(` - ${step}`)
2244
2881
  }
2245
2882
  }
2246
2883
  }
2247
- console.log(result.ok ? "\n✓ check passed" : "\n✗ check failed")
2884
+ for (const [rule, label] of REPORT_SECTIONS) {
2885
+ renderSection(
2886
+ rule,
2887
+ label,
2888
+ result.diagnostics.filter((d) => d.rule === rule),
2889
+ )
2890
+ }
2891
+ // Generic section: diagnostics whose rule has no named section above (registry rules publishing
2892
+ // under their NF- code, application rule packs). These count toward `ok` exactly like the named
2893
+ // ones, so they get the same severity-marked rendering - only "✓ …: none" is skipped, because
2894
+ // the set of possible unlisted rules is open-ended.
2895
+ const named = new Set<string>(REPORT_SECTIONS.map(([rule]) => rule))
2896
+ const extraRules: string[] = []
2897
+ for (const d of result.diagnostics) {
2898
+ if (!named.has(d.rule) && !extraRules.includes(d.rule)) extraRules.push(d.rule)
2899
+ }
2900
+ for (const rule of extraRules) {
2901
+ const title = (RULE_CODES as Record<string, string | undefined>)[rule]
2902
+ renderSection(
2903
+ rule,
2904
+ title !== undefined ? `${title} (${rule})` : rule,
2905
+ result.diagnostics.filter((d) => d.rule === rule),
2906
+ )
2907
+ }
2908
+ if (result.truncated !== undefined) {
2909
+ lines.push(
2910
+ `• showing ${result.truncated.shown} of ${result.truncated.total} diagnostics (truncated)`,
2911
+ )
2912
+ }
2913
+ const errors = result.diagnostics.filter((d) => d.severity === "error").length
2914
+ const advisory = result.diagnostics.length - errors
2915
+ lines.push(
2916
+ "",
2917
+ result.ok
2918
+ ? advisory > 0
2919
+ ? `✓ check passed (${advisory} advisory)`
2920
+ : "✓ check passed"
2921
+ : `✗ check failed: ${errors} error${errors === 1 ? "" : "s"}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
2922
+ )
2923
+ return lines
2924
+ }
2925
+
2926
+ /** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
2927
+ export async function runCheck(
2928
+ cwd: string,
2929
+ opts: {
2930
+ readonly json?: boolean
2931
+ readonly lintsOnly?: boolean
2932
+ readonly structured?: boolean
2933
+ } = {},
2934
+ ): Promise<boolean> {
2935
+ // The check view over the one project verification: the same collector `assure` and `levels` read.
2936
+ const { collectProjectVerification } = await import("./verification.ts")
2937
+ const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
2938
+ const result = await verification.check()
2939
+ if (opts.json) {
2940
+ console.log(
2941
+ JSON.stringify(
2942
+ opts.structured === true && result.structuredDiagnostics !== undefined
2943
+ ? { ...result, diagnostics: result.structuredDiagnostics }
2944
+ : result,
2945
+ null,
2946
+ 2,
2947
+ ),
2948
+ )
2949
+ return result.ok
2950
+ }
2951
+
2952
+ console.log(renderCheckReport(result).join("\n"))
2248
2953
  // Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
2249
2954
  // `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
2250
2955
  // one-line tip in the human report only (the `--json` path returns above, unaffected).