@nifrajs/cli 2.10.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 (193) hide show
  1. package/README.md +7 -1
  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 +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  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/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
package/src/check.ts CHANGED
@@ -16,13 +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"
29
+ // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
30
+ // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
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"
26
37
 
27
38
  export interface SourceFinding {
28
39
  readonly file: string
@@ -233,6 +244,7 @@ function scanRoutePattern(
233
244
  content: string,
234
245
  code: string,
235
246
  pattern: RegExp,
247
+ facts?: SourceFacts,
236
248
  ): StaticRouteFinding[] {
237
249
  const out: StaticRouteFinding[] = []
238
250
  const lines = content.split("\n")
@@ -244,6 +256,16 @@ function scanRoutePattern(
244
256
  `${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
245
257
  )
246
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
+ }
247
269
  const line = lineAt(content, m.index)
248
270
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
249
271
  }
@@ -467,12 +489,16 @@ export function scanFetchText(
467
489
  }
468
490
 
469
491
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
470
- export function scanStaticRouteText(file: string, content: string): StaticRouteFinding[] {
492
+ export function scanStaticRouteText(
493
+ file: string,
494
+ content: string,
495
+ facts?: SourceFacts,
496
+ ): StaticRouteFinding[] {
471
497
  const code = stripComments(content)
472
498
  if (!isPotentialBackendSource(code)) return []
473
499
  return [
474
- ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
475
- ...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),
476
502
  ].sort(bySite)
477
503
  }
478
504
 
@@ -519,21 +545,228 @@ interface SqlExpressionShape {
519
545
  /** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
520
546
  type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
521
547
 
522
- /** Collect the file's top-level `const NAME = <initializer>` declarations. Destructured names, `let`,
523
- * `var`, and anything nested stay out on purpose: a module-scope `const` is the only form that
524
- * provably cannot carry request data. */
525
- function moduleConstsOf(ts: TypeScriptApi, source: TSApi.SourceFile): ModuleConsts {
526
- 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[] = []
527
588
  for (const statement of source.statements) {
528
- if (!ts.isVariableStatement(statement)) continue
529
- if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
530
- for (const declaration of statement.declarationList.declarations) {
531
- if (ts.isIdentifier(declaration.name) && declaration.initializer !== undefined) {
532
- 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)
533
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
+ })
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 })
534
633
  }
535
634
  }
536
- 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
537
770
  }
538
771
 
539
772
  /** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
@@ -585,46 +818,67 @@ const CONST_RESOLUTION_DEPTH = 5
585
818
  * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
586
819
  * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
587
820
  * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
588
- * either still feeds the keyword scan), `as`/`satisfies` wrappers, and a same-file module-scope
589
- * `const` whose initializer resolves (recursively, depth-capped). Everything else - an imported
590
- * 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
591
825
  * interpolation is flagged exactly as before.
592
826
  */
593
827
  function resolveStaticSqlText(
594
828
  ts: TypeScriptApi,
595
829
  node: TSApi.Expression,
596
- consts: ModuleConsts,
830
+ scope: SqlScope,
597
831
  depth: number,
598
832
  ): string | undefined {
599
833
  if (depth > CONST_RESOLUTION_DEPTH) return undefined
600
834
  if (ts.isParenthesizedExpression(node)) {
601
- return resolveStaticSqlText(ts, node.expression, consts, depth)
835
+ return resolveStaticSqlText(ts, node.expression, scope, depth)
602
836
  }
603
837
  if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
604
- return resolveStaticSqlText(ts, node.expression, consts, depth)
838
+ return resolveStaticSqlText(ts, node.expression, scope, depth)
605
839
  }
606
840
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
607
841
  if (ts.isNumericLiteral(node)) return node.text
608
842
  if (ts.isTemplateExpression(node)) {
609
843
  let text = node.head.text
610
844
  for (const span of node.templateSpans) {
611
- const part = resolveStaticSqlText(ts, span.expression, consts, depth + 1)
845
+ const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
612
846
  if (part === undefined) return undefined
613
847
  text += part + span.literal.text
614
848
  }
615
849
  return text
616
850
  }
617
851
  if (ts.isConditionalExpression(node)) {
618
- const whenTrue = resolveStaticSqlText(ts, node.whenTrue, consts, depth + 1)
619
- 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)
620
854
  if (whenTrue === undefined || whenFalse === undefined) return undefined
621
855
  return `${whenTrue} ${whenFalse}`
622
856
  }
623
857
  if (ts.isIdentifier(node)) {
624
- const initializer = consts.get(node.text)
625
- 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.
626
866
  if (isShadowedAt(ts, node, node.text)) return undefined
627
- 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
+ )
628
882
  }
629
883
  return undefined
630
884
  }
@@ -634,13 +888,13 @@ function resolveStaticSqlText(
634
888
  function templateShape(
635
889
  ts: TypeScriptApi,
636
890
  template: TSApi.TemplateExpression,
637
- consts: ModuleConsts | undefined,
891
+ scope: SqlScope | undefined,
638
892
  ): { staticText: string; dynamic: boolean } {
639
893
  let staticText = template.head.text
640
894
  let dynamic = false
641
895
  for (const span of template.templateSpans) {
642
896
  const resolved =
643
- consts === undefined ? undefined : resolveStaticSqlText(ts, span.expression, consts, 0)
897
+ scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
644
898
  if (resolved === undefined) dynamic = true
645
899
  else staticText += resolved
646
900
  staticText += span.literal.text
@@ -652,9 +906,9 @@ function templateShape(
652
906
  function sqlExpressionShape(
653
907
  ts: TypeScriptApi,
654
908
  node: TSApi.Expression,
655
- consts?: ModuleConsts,
909
+ scope?: SqlScope,
656
910
  ): SqlExpressionShape {
657
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, consts)
911
+ if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
658
912
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
659
913
  return {
660
914
  staticText: node.text,
@@ -671,7 +925,7 @@ function sqlExpressionShape(
671
925
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
672
926
  const template = node.template
673
927
  const templated = ts.isTemplateExpression(template)
674
- ? templateShape(ts, template, parameterizedTag ? undefined : consts)
928
+ ? templateShape(ts, template, parameterizedTag ? undefined : scope)
675
929
  : { staticText: template.text, dynamic: false }
676
930
  const staticText = templated.staticText
677
931
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
@@ -696,7 +950,7 @@ function sqlExpressionShape(
696
950
  }
697
951
  }
698
952
  if (ts.isTemplateExpression(node)) {
699
- const templated = templateShape(ts, node, consts)
953
+ const templated = templateShape(ts, node, scope)
700
954
  return {
701
955
  staticText: templated.staticText,
702
956
  dynamic: templated.dynamic,
@@ -706,8 +960,8 @@ function sqlExpressionShape(
706
960
  }
707
961
  }
708
962
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
709
- const left = sqlExpressionShape(ts, node.left, consts)
710
- const right = sqlExpressionShape(ts, node.right, consts)
963
+ const left = sqlExpressionShape(ts, node.left, scope)
964
+ const right = sqlExpressionShape(ts, node.right, scope)
711
965
  return {
712
966
  staticText: left.staticText + right.staticText,
713
967
  // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
@@ -721,11 +975,11 @@ function sqlExpressionShape(
721
975
  }
722
976
  // A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
723
977
  // text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
724
- // `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.
725
979
  // `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
726
980
  // resolved const, because taking statement text from ANY variable is what their rule warns about.
727
- if (consts !== undefined) {
728
- const resolved = resolveStaticSqlText(ts, node, consts, 0)
981
+ if (scope !== undefined) {
982
+ const resolved = resolveStaticSqlText(ts, node, scope, 0)
729
983
  if (resolved !== undefined) {
730
984
  return {
731
985
  staticText: resolved,
@@ -770,32 +1024,35 @@ function sqlMethod(
770
1024
 
771
1025
  const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
772
1026
 
773
- /** 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
+ */
774
1034
  export function scanInterpolatedSql(
775
1035
  file: string,
776
1036
  content: string,
777
1037
  ts: TypeScriptApi,
1038
+ imports?: SqlImports,
778
1039
  ): SourceFinding[] {
779
1040
  const out: SourceFinding[] = []
780
1041
  const lines = content.split("\n")
781
- const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
782
- const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
783
1042
  // Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
784
1043
  // misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
785
- const parseDiagnostics = (
786
- source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
787
- ).parseDiagnostics
788
- if (parseDiagnostics !== undefined && parseDiagnostics.length > 0) return out
789
- // Module-scope consts, resolved once per file: an interpolated same-file `const` cannot carry
790
- // request data, so the shared-projection idiom (`const COLS = "id, name"` … `${COLS}`) stays quiet
791
- // while imported, mutable, parameter, and shadowed names flag exactly as before.
792
- 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 }
793
1050
  const visit = (node: TSApi.Node): void => {
794
1051
  if (ts.isCallExpression(node)) {
795
1052
  const sink = sqlMethod(ts, node)
796
1053
  const argument = node.arguments[0]
797
1054
  if (sink !== undefined && argument !== undefined) {
798
- const shape = sqlExpressionShape(ts, argument, consts)
1055
+ const shape = sqlExpressionShape(ts, argument, scope)
799
1056
  const unsafeEscape =
800
1057
  ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
801
1058
  const assembledSql =
@@ -824,13 +1081,63 @@ export function scanInterpolatedSql(
824
1081
  * answer a security rule must never give when it did not run.
825
1082
  */
826
1083
  export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
827
- const ts = await importTypeScript()
1084
+ const ts = await importProjectTypeScript(cwd)
828
1085
  if (ts === undefined) return undefined
829
1086
  const out: SourceFinding[] = []
830
- 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
+ )
831
1091
  return out.sort(bySite)
832
1092
  }
833
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
+
834
1141
  // `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
835
1142
  // the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
836
1143
  // `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
@@ -919,7 +1226,11 @@ export function scanRemovedImports(file: string, content: string): SourceFinding
919
1226
  /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
920
1227
  * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
921
1228
  * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
922
- export function scanServerOnlyImports(file: string, content: string): ServerImportFinding[] {
1229
+ export function scanServerOnlyImports(
1230
+ file: string,
1231
+ content: string,
1232
+ facts?: SourceFacts,
1233
+ ): ServerImportFinding[] {
923
1234
  if (!ROUTE_FILE.test(file)) return []
924
1235
  const out: ServerImportFinding[] = []
925
1236
  const lines = content.split("\n")
@@ -930,6 +1241,13 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
930
1241
  if (positions[m.index] === " ") continue
931
1242
  const specifier = m[1] ?? ""
932
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
+ }
933
1251
  const line = lineAt(content, m.index)
934
1252
  out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
935
1253
  }
@@ -976,22 +1294,31 @@ const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_
976
1294
  * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
977
1295
  * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
978
1296
  * transitive walk. Pure. */
979
- 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 }> {
980
1302
  const edges: Array<{ specifier: string; index: number }> = []
981
1303
  const re = staticImportRegex()
982
1304
  const code = stripComments(content)
983
1305
  const positions = codePositionMask(content)
984
1306
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
985
1307
  if (positions[m.index] === " ") continue
986
- 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 })
987
1314
  }
988
1315
  return edges
989
1316
  }
990
1317
 
991
1318
  /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
992
1319
  * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
993
- export function parseStaticImports(content: string): string[] {
994
- 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)
995
1322
  }
996
1323
 
997
1324
  /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
@@ -1034,6 +1361,7 @@ export function walkServerOnlyChain(
1034
1361
  routeContent: string,
1035
1362
  resolve: ModuleResolver,
1036
1363
  read: ModuleReader,
1364
+ facts?: SourceFacts,
1037
1365
  ): readonly string[] | undefined {
1038
1366
  // Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
1039
1367
  // (route + the as-written specifier of each hop), and the file the imports resolve relative to.
@@ -1050,7 +1378,7 @@ export function walkServerOnlyChain(
1050
1378
  const next: Node[] = []
1051
1379
  for (const node of frontier) {
1052
1380
  if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
1053
- for (const spec of parseStaticImports(node.content)) {
1381
+ for (const spec of parseStaticImports(node.content, facts, node.abs)) {
1054
1382
  // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
1055
1383
  // since BFS reaches the nearest sink before any deeper one).
1056
1384
  const sink = directSinkSpecifier(spec)
@@ -1091,6 +1419,7 @@ export function resolveServerOnlyChains(
1091
1419
  content: string,
1092
1420
  resolve: ModuleResolver,
1093
1421
  read: ModuleReader,
1422
+ facts?: SourceFacts,
1094
1423
  ): TransitiveServerImportFinding[] {
1095
1424
  if (!ROUTE_FILE.test(file)) return []
1096
1425
  const lines = content.split("\n")
@@ -1099,7 +1428,7 @@ export function resolveServerOnlyChains(
1099
1428
  // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
1100
1429
  // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
1101
1430
  // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
1102
- for (const { specifier, index } of staticImportEdges(content)) {
1431
+ for (const { specifier, index } of staticImportEdges(content, facts, file)) {
1103
1432
  if (flaggedSpecifiers.has(specifier)) continue
1104
1433
  const line = lineAt(content, index)
1105
1434
  const snippet = (lines[line - 1] ?? "").trim()
@@ -1138,7 +1467,7 @@ export function resolveServerOnlyChains(
1138
1467
  continue
1139
1468
  }
1140
1469
  // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
1141
- const subChain = walkServerOnlyChain(abs, depContent, resolve, read)
1470
+ const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
1142
1471
  if (subChain !== undefined) {
1143
1472
  flaggedSpecifiers.add(specifier)
1144
1473
  // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
@@ -1167,7 +1496,11 @@ const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*js
1167
1496
 
1168
1497
  /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
1169
1498
  * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
1170
- export function scanResponseRoutes(file: string, content: string): SourceFinding[] {
1499
+ export function scanResponseRoutes(
1500
+ file: string,
1501
+ content: string,
1502
+ facts?: SourceFacts,
1503
+ ): SourceFinding[] {
1171
1504
  // Strip comments + template literals first: a commented-out or doc-example `return new Response(`
1172
1505
  // must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
1173
1506
  const code = stripComments(content)
@@ -1176,6 +1509,12 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
1176
1509
  const lines = content.split("\n")
1177
1510
  RESPONSE_RETURN.lastIndex = 0
1178
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
+ }
1179
1518
  const line = lineAt(content, m.index)
1180
1519
  // A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
1181
1520
  // be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
@@ -1334,16 +1673,42 @@ interface TypecheckResult {
1334
1673
  readonly note?: string
1335
1674
  readonly output?: string
1336
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
+ }
1337
1697
  }
1338
1698
 
1339
1699
  /** Run the project's own `tsc --noEmit`, if TypeScript + a tsconfig are present. Never auto-installs. */
1340
1700
  async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckResult> {
1341
1701
  const tsconfig = join(cwd, "tsconfig.json")
1342
- const tscBin = join(cwd, "node_modules", "typescript", "bin", "tsc")
1343
1702
  if (!(await Bun.file(tsconfig).exists()))
1344
1703
  return { ran: false, ok: true, note: "no tsconfig.json" }
1345
- if (!(await Bun.file(tscBin).exists())) {
1346
- 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
+ }
1347
1712
  }
1348
1713
  if (signal?.aborted) return { ran: true, ok: false, cancelled: true, output: "cancelled" }
1349
1714
  const proc = Bun.spawn(["bun", tscBin, "--noEmit", "-p", tsconfig], {
@@ -1385,26 +1750,13 @@ const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
1385
1750
 
1386
1751
  /** A single machine-readable check failure - the unit an agent (or CI) acts on. */
1387
1752
  export interface CheckDiagnostic {
1388
- readonly rule:
1389
- | "typecheck"
1390
- | "typed-client"
1391
- | "untyped-client"
1392
- | "server-only-import"
1393
- | "removed-import"
1394
- | "interpolated-sql"
1395
- | "response-route"
1396
- | "undeclared-dependency"
1397
- | "duplicate-install"
1398
- | "stale-workspace-dist"
1399
- | "server-manifest-drift"
1400
- | "manifest-drift"
1401
- | "capability-assurance"
1402
- | "capability-config"
1403
- | "check-config"
1753
+ readonly rule: string
1404
1754
  /** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
1405
1755
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
1406
1756
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
1407
- 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
1408
1760
  readonly file?: string
1409
1761
  readonly line?: number
1410
1762
  readonly message: string
@@ -1427,6 +1779,8 @@ export interface CheckDiagnostic {
1427
1779
  * edge rather than fabricating a deeper path - never a lie.
1428
1780
  */
1429
1781
  readonly chain?: readonly string[]
1782
+ readonly evidence?: readonly string[]
1783
+ readonly verify?: string
1430
1784
  }
1431
1785
 
1432
1786
  export interface CheckSuggestion {
@@ -1442,11 +1796,27 @@ export interface CheckSuggestion {
1442
1796
  export interface CheckResult {
1443
1797
  readonly ok: boolean
1444
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
1445
1802
  readonly diagnostics: readonly CheckDiagnostic[]
1803
+ /** Normalized diagnostics with stable codes for agents and external renderers. */
1804
+ readonly structuredDiagnostics?: readonly Diagnostic[]
1805
+ /**
1806
+ * Which bundler this app's phases run on, read statically from the config, and how nifra concluded
1807
+ * it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
1808
+ * slot is live and which toolchain compiles a component before its next edit, and every `pipeline`
1809
+ * diagnostic below is only interpretable against it. Absent when the directory is not a nifra app.
1810
+ */
1811
+ readonly pipeline?: PipelineReport
1446
1812
  /** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
1447
1813
  * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
1448
1814
  * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
1449
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>>
1450
1820
  /** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
1451
1821
  * first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
1452
1822
  * message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
@@ -1470,31 +1840,103 @@ export interface CheckAssuranceContext {
1470
1840
  readonly routeAssurance?: AssuranceReport
1471
1841
  /** Static capability provenance, when the config declares a capabilities policy. */
1472
1842
  readonly capability?: CapabilityProjectReport
1843
+ /** Canonical token-only evidence reused by manifest and other offline projections. */
1844
+ readonly evidence?: ProjectEvidenceSnapshot
1473
1845
  }
1474
1846
 
1475
1847
  /** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
1476
1848
  * the app is built or even importable, preserving check's pre-`loadApp` invariant. */
1477
1849
  interface CheckConfig {
1478
1850
  readonly externalMounts: readonly string[]
1851
+ readonly rules: Readonly<Record<string, RuleOverride>>
1479
1852
  }
1480
1853
 
1481
- /** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed → empty plus a parse
1482
- * `error` the caller surfaces as a `warning` diagnostic (never throws, never blocks the gate). Mount
1483
- * entries are normalized to bare path prefixes (`/auth/**` and `/auth/` both become `/auth`). */
1484
- async function loadCheckConfig(cwd: string): Promise<{ config: CheckConfig; error?: string }> {
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[]
1864
+ }
1865
+
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[] }> {
1485
1875
  const path = join(cwd, "nifra.check.json")
1486
- if (!existsSync(path)) return { config: { externalMounts: [] } }
1876
+ const empty: CheckConfig = { externalMounts: [], rules: {} }
1877
+ if (!existsSync(path)) return { config: empty, warnings: [] }
1487
1878
  try {
1488
- 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
+ }
1489
1883
  const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : []
1490
1884
  const externalMounts = raw
1491
1885
  .filter((m): m is string => typeof m === "string" && m.startsWith("/"))
1492
1886
  .map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/")
1493
- 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 }
1494
1935
  } catch (error) {
1495
1936
  return {
1496
- config: { externalMounts: [] },
1937
+ config: empty,
1497
1938
  error: error instanceof Error ? error.message : String(error),
1939
+ warnings: [],
1498
1940
  }
1499
1941
  }
1500
1942
  }
@@ -1507,6 +1949,8 @@ const SERVER_IMPORT_HINT =
1507
1949
  "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"
1508
1950
  const RESPONSE_ROUTE_HINT =
1509
1951
  "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"
1952
+ const PIPELINE_DOC_HINT =
1953
+ "nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
1510
1954
  const UNDECLARED_DEP_HINT =
1511
1955
  "imported package is not declared in package.json dependencies - run bun add to declare it"
1512
1956
  const SQL_COMPILER_MISSING_HINT =
@@ -1680,28 +2124,41 @@ export async function collectCheckResult(
1680
2124
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
1681
2125
  // (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
1682
2126
  const routeModules: Array<{ rel: string; content: string }> = []
2127
+ const sourceFiles: Array<{ file: string; content: string }> = []
1683
2128
  // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1684
2129
  // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1685
- const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd)
2130
+ const {
2131
+ config: checkConfig,
2132
+ error: checkConfigError,
2133
+ warnings: checkConfigWarnings,
2134
+ } = await loadCheckConfig(cwd)
1686
2135
  // The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
1687
2136
  // once, before the walk, so a project without it is told the rule did not run rather than being
1688
2137
  // handed a clean report the rule never produced.
1689
- 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)
1690
2146
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
1691
2147
  // lints - the agent inner-loop mode; the full gate stays the definition of done.
1692
2148
  const [tc, _, dr, manifestDrift] = await Promise.all([
1693
2149
  opts.lintsOnly
1694
- ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
2150
+ ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "lints-only mode" })
1695
2151
  : typecheck(cwd, opts.signal),
1696
2152
  walkSource(cwd, (rel, content) => {
2153
+ sourceFiles.push({ file: rel, content })
1697
2154
  fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
1698
- staticRoutes.push(...scanStaticRouteText(rel, content))
2155
+ staticRoutes.push(...scanStaticRouteText(rel, content, sourceFacts))
1699
2156
  untypedClients.push(...scanUntypedClient(rel, content))
1700
2157
  removedImports.push(...scanRemovedImports(rel, content))
1701
2158
  if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
1702
- responseRoutes.push(...scanResponseRoutes(rel, content))
2159
+ responseRoutes.push(...scanResponseRoutes(rel, content, sourceFacts))
1703
2160
  if (sqlCompiler !== undefined)
1704
- interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler))
2161
+ interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler, sqlImports))
1705
2162
  }),
1706
2163
  import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
1707
2164
  scanServerManifestDrift(cwd),
@@ -1730,10 +2187,13 @@ export async function collectCheckResult(
1730
2187
  }
1731
2188
  }
1732
2189
  for (const { rel, content } of routeModules) {
1733
- serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule))
2190
+ serverImports.push(
2191
+ ...resolveServerOnlyChains(rel, content, resolveModule, readModule, sourceFacts),
2192
+ )
1734
2193
  }
1735
2194
 
1736
2195
  const diagnostics: CheckDiagnostic[] = []
2196
+ const structuredExtras: Diagnostic[] = []
1737
2197
  if (checkConfigError !== undefined) {
1738
2198
  diagnostics.push({
1739
2199
  rule: "check-config",
@@ -1743,6 +2203,32 @@ export async function collectCheckResult(
1743
2203
  fix: "Fix the JSON syntax in nifra.check.json",
1744
2204
  })
1745
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
+ }
1746
2232
  if (tc.ran && !tc.ok) {
1747
2233
  const lines = (tc.output ?? "").split("\n")
1748
2234
  let matched = false
@@ -1933,6 +2419,7 @@ export async function collectCheckResult(
1933
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}`
1934
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}`,
1935
2421
  fix: `rebuild ${f.package}`,
2422
+ evidence: [f.package, f.distFile, f.sourceFile],
1936
2423
  suggestion: {
1937
2424
  kind: "manual",
1938
2425
  title: `Rebuild ${f.package}`,
@@ -1944,6 +2431,21 @@ export async function collectCheckResult(
1944
2431
  })
1945
2432
  }
1946
2433
  }
2434
+ // The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
2435
+ // `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
2436
+ // never executes project code, and CI that never starts a dev server, would otherwise meet these for
2437
+ // the first time as a production server that built cleanly and died at startup.
2438
+ for (const f of dr.pipeline?.findings ?? []) {
2439
+ diagnostics.push({
2440
+ rule: "pipeline",
2441
+ severity: f.severity,
2442
+ file: f.file,
2443
+ ...(f.line !== undefined ? { line: f.line } : {}),
2444
+ message: f.message,
2445
+ fix: f.fix,
2446
+ suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
2447
+ })
2448
+ }
1947
2449
  // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
1948
2450
  for (const f of manifestDrift) {
1949
2451
  const parts: string[] = []
@@ -1955,6 +2457,7 @@ export async function collectCheckResult(
1955
2457
  file: f.file,
1956
2458
  message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
1957
2459
  fix: MANIFEST_DRIFT_HINT,
2460
+ evidence: [...f.missing, ...f.extra],
1958
2461
  suggestion: {
1959
2462
  kind: "manual",
1960
2463
  title: "Regenerate the committed server manifest",
@@ -1970,6 +2473,7 @@ export async function collectCheckResult(
1970
2473
  // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
1971
2474
  // without nifra.assurance.ts retain the historical scan and hot path unchanged.
1972
2475
  const provided = opts.assurance
2476
+ let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
1973
2477
  const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
1974
2478
  if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
1975
2479
  try {
@@ -1978,6 +2482,7 @@ export async function collectCheckResult(
1978
2482
  let config: AssuranceConfig
1979
2483
  let project: CapabilityProjectReport | undefined
1980
2484
  let routeAssurance: AssuranceReport | undefined
2485
+ let evidence: ProjectEvidenceSnapshot | undefined
1981
2486
  if (provided !== undefined) {
1982
2487
  // A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
1983
2488
  // capability-config diagnostic the standalone catch produces.
@@ -1985,6 +2490,7 @@ export async function collectCheckResult(
1985
2490
  config = provided.config as AssuranceConfig
1986
2491
  project = provided.capability
1987
2492
  routeAssurance = provided.routeAssurance
2493
+ evidence = provided.evidence
1988
2494
  } else {
1989
2495
  const { loadAssuranceConfig } = await import("./assure.ts")
1990
2496
  config = await loadAssuranceConfig(cwd)
@@ -1993,6 +2499,7 @@ export async function collectCheckResult(
1993
2499
  project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
1994
2500
  }
1995
2501
  }
2502
+ applicationRulePacks = parseRulePacks(config.rulePacks)
1996
2503
  const capabilityReport = project?.report
1997
2504
  if (config.capabilities !== undefined && project !== undefined) {
1998
2505
  for (const finding of project.report.findings) {
@@ -2010,6 +2517,12 @@ export async function collectCheckResult(
2010
2517
  candidate.method === finding.method && candidate.path === finding.path,
2011
2518
  )
2012
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
2013
2526
  diagnostics.push({
2014
2527
  rule: "capability-assurance",
2015
2528
  severity: "error",
@@ -2018,18 +2531,30 @@ export async function collectCheckResult(
2018
2531
  : truncation !== undefined
2019
2532
  ? { chain: truncation.chain }
2020
2533
  : {}),
2021
- message: `${finding.message} - ${CAPABILITY_HINT}`,
2022
- fix: CAPABILITY_HINT,
2023
- suggestion: {
2024
- kind: "manual",
2025
- title: "Restore declared effect provenance",
2026
- steps: [
2027
- "Route effectful work through an import listed in capabilities.provenance.imports.",
2028
- "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
2029
- "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.",
2030
- "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
2031
- ],
2032
- },
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
+ },
2033
2558
  })
2034
2559
  }
2035
2560
  }
@@ -2048,6 +2573,10 @@ export async function collectCheckResult(
2048
2573
  : {}),
2049
2574
  },
2050
2575
  )
2576
+ evidence ??= snapshotProjectEvidence(config.source, {
2577
+ assurance,
2578
+ ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2579
+ })
2051
2580
  const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
2052
2581
  let message: string | undefined
2053
2582
  if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
@@ -2058,7 +2587,7 @@ export async function collectCheckResult(
2058
2587
  } else {
2059
2588
  try {
2060
2589
  const current = await buildNifraManifest({
2061
- source: config.source,
2590
+ evidence,
2062
2591
  assurance,
2063
2592
  ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
2064
2593
  })
@@ -2108,94 +2637,319 @@ export async function collectCheckResult(
2108
2637
  }
2109
2638
  }
2110
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
+
2111
2770
  // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
2112
2771
  // message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
2113
- const total = diagnostics.length
2772
+ const total = finalDiagnostics.length
2114
2773
  const max = opts.maxDiagnostics
2115
- const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics
2116
- return {
2117
- 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"),
2118
2777
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
2778
+ ...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
2119
2779
  diagnostics: shown,
2780
+ ...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
2120
2781
  ...(checkConfig.externalMounts.length > 0
2121
2782
  ? { externalMounts: checkConfig.externalMounts }
2122
2783
  : {}),
2784
+ ...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
2123
2785
  ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
2124
2786
  }
2125
- }
2126
-
2127
- /** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
2128
- export async function runCheck(
2129
- cwd: string,
2130
- opts: { readonly json?: boolean; readonly lintsOnly?: boolean } = {},
2131
- ): Promise<boolean> {
2132
- // The check view over the one project verification: the same collector `assure` and `levels` read.
2133
- const { collectProjectVerification } = await import("./verification.ts")
2134
- const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
2135
- const result = await verification.check()
2136
- if (opts.json) {
2137
- console.log(JSON.stringify(result, null, 2))
2138
- return result.ok
2139
- }
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
2140
2814
 
2141
- console.log("nifra check\n")
2142
- 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(
2143
2824
  result.typecheck === "pass"
2144
2825
  ? "✓ typecheck passed"
2145
2826
  : result.typecheck === "fail"
2146
2827
  ? "✗ typecheck failed - the frontend/backend contract is broken"
2147
- : "• typecheck skipped (no tsconfig / typescript not installed)",
2828
+ : `⚠ typecheck SKIPPED - ${result.typecheckNote ?? "no tsconfig / typescript not installed"} (the contract gate did not run)`,
2148
2829
  )
2830
+ // Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
2831
+ // live and which toolchain compiles a component, so it belongs in the report rather than only in the
2832
+ // dev server's banner - where CI never sees it.
2833
+ if (result.pipeline !== undefined) {
2834
+ lines.push(
2835
+ result.pipeline.pipeline === "unknown"
2836
+ ? `• bundler: not readable from ${result.pipeline.configFile} - ${result.pipeline.reason}`
2837
+ : `• bundler: ${result.pipeline.pipeline} (${result.pipeline.reason})`,
2838
+ )
2839
+ }
2149
2840
  if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
2150
- console.log(
2841
+ lines.push(
2151
2842
  `• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
2152
2843
  )
2153
2844
  }
2154
- const counts = (rule: CheckDiagnostic["rule"]): CheckDiagnostic[] =>
2155
- result.diagnostics.filter((d) => d.rule === rule)
2156
- for (const [rule, label] of [
2157
- ["typecheck", "typecheck"],
2158
- ["typed-client", "hand-rolled fetch() to your own API"],
2159
- ["untyped-client", 'client("…") missing its <typeof app> type argument'],
2160
- ["server-only-import", "server-only import in a route module"],
2161
- ["interpolated-sql", "SQL built by interpolating a value into the statement"],
2162
- ["response-route", "route returns a raw Response (typed client → data: never)"],
2163
- ["undeclared-dependency", "undeclared dependency in package.json"],
2164
- ["duplicate-install", "duplicate identity-sensitive dependency install"],
2165
- ["stale-workspace-dist", "workspace-linked dist older than its source"],
2166
- ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
2167
- ["manifest-drift", "versioned trust manifest drift"],
2168
- ["capability-assurance", "effect/capability assurance"],
2169
- ["capability-config", "capability assurance config"],
2170
- ["check-config", "nifra.check.json"],
2171
- ] as const) {
2172
- const ds = counts(rule)
2173
- if (rule === "response-route" || rule === "stale-workspace-dist") {
2174
- // Advisory: surfaced with ⚠, never folded into pass/fail.
2175
- console.log(ds.length === 0 ? `✓ ${label}: none` : `⚠ ${label}: ${ds.length} (advisory)`)
2176
- } else if (rule !== "typecheck") {
2177
- console.log(ds.length === 0 ? `✓ ${label}: none` : `✗ ${label}: ${ds.length}`)
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 => {
2855
+ if (rule !== "typecheck") {
2856
+ // Marked by SEVERITY, not by rule name. `response-route` and `stale-workspace-dist` are advisory
2857
+ // in whole; `pipeline` is the first rule that is advisory in part (a misplaced plugin fails, a
2858
+ // resolve condition the Bun dev bundler can't take does not), so the counts are split rather than
2859
+ // rounded up to the worse of the two.
2860
+ const errors = ds.filter((d) => d.severity === "error").length
2861
+ const advisory = ds.length - errors
2862
+ lines.push(
2863
+ ds.length === 0
2864
+ ? `✓ ${label}: none`
2865
+ : errors === 0
2866
+ ? `⚠ ${label}: ${advisory} (advisory)`
2867
+ : `✗ ${label}: ${errors}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
2868
+ )
2178
2869
  }
2179
2870
  for (const d of ds) {
2180
- console.log(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
2871
+ lines.push(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
2181
2872
  if (d.suggestion !== undefined) {
2182
- console.log(` fix: ${d.suggestion.title}`)
2873
+ lines.push(` fix: ${d.suggestion.title}`)
2183
2874
  if (d.suggestion.command !== undefined) {
2184
- console.log(` command: ${d.suggestion.command.join(" ")}`)
2875
+ lines.push(` command: ${d.suggestion.command.join(" ")}`)
2185
2876
  }
2186
2877
  if (d.suggestion.diff !== undefined) {
2187
- console.log(
2188
- d.suggestion.diff
2189
- .split("\n")
2190
- .map((line) => ` ${line}`)
2191
- .join("\n"),
2192
- )
2878
+ for (const line of d.suggestion.diff.split("\n")) lines.push(` ${line}`)
2193
2879
  }
2194
- for (const step of d.suggestion.steps ?? []) console.log(` - ${step}`)
2880
+ for (const step of d.suggestion.steps ?? []) lines.push(` - ${step}`)
2195
2881
  }
2196
2882
  }
2197
2883
  }
2198
- 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"))
2199
2953
  // Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
2200
2954
  // `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
2201
2955
  // one-line tip in the human report only (the `--json` path returns above, unaffected).