@nifrajs/cli 2.12.1 → 2.14.1

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 (74) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +235 -47
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +96 -406
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +244 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1014 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +18 -11
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +30 -230
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/load.d.ts.map +1 -1
  26. package/dist/load.js +22 -5
  27. package/dist/load.js.map +1 -1
  28. package/dist/manifest-tool.d.ts +15 -1
  29. package/dist/manifest-tool.d.ts.map +1 -1
  30. package/dist/manifest-tool.js +23 -9
  31. package/dist/manifest-tool.js.map +1 -1
  32. package/dist/mcp-http.js +1 -1
  33. package/dist/mcp.d.ts +8 -0
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +59 -349
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/project-facts.d.ts +40 -0
  38. package/dist/project-facts.d.ts.map +1 -0
  39. package/dist/project-facts.js +36 -0
  40. package/dist/project-facts.js.map +1 -0
  41. package/dist/release-verification.d.ts +45 -0
  42. package/dist/release-verification.d.ts.map +1 -0
  43. package/dist/release-verification.js +303 -0
  44. package/dist/release-verification.js.map +1 -0
  45. package/dist/rules/comment-markers.d.ts +8 -0
  46. package/dist/rules/comment-markers.d.ts.map +1 -1
  47. package/dist/rules/comment-markers.js +36 -0
  48. package/dist/rules/comment-markers.js.map +1 -1
  49. package/dist/rules/index.d.ts +2 -3
  50. package/dist/rules/index.d.ts.map +1 -1
  51. package/dist/rules/index.js.map +1 -1
  52. package/dist/rules/routes.d.ts.map +1 -1
  53. package/dist/rules/routes.js +12 -14
  54. package/dist/rules/routes.js.map +1 -1
  55. package/dist/rules/security.js +14 -14
  56. package/dist/rules/security.js.map +1 -1
  57. package/docs/types.json +28 -15
  58. package/package.json +9 -9
  59. package/src/capabilities-tool.ts +229 -21
  60. package/src/check.ts +286 -64
  61. package/src/cli.ts +106 -399
  62. package/src/command-catalog.ts +1415 -0
  63. package/src/contracts.ts +28 -7
  64. package/src/doctor.ts +56 -264
  65. package/src/load.ts +25 -6
  66. package/src/manifest-tool.ts +43 -12
  67. package/src/mcp-http.ts +1 -1
  68. package/src/mcp.ts +82 -414
  69. package/src/project-facts.ts +86 -0
  70. package/src/release-verification.ts +377 -0
  71. package/src/rules/comment-markers.ts +36 -0
  72. package/src/rules/index.ts +3 -4
  73. package/src/rules/routes.ts +13 -14
  74. package/src/rules/security.ts +14 -14
package/src/check.ts CHANGED
@@ -29,7 +29,9 @@ import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescri
29
29
  // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
30
30
  // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
31
31
  import type { PipelineReport } from "./pipeline-report.ts"
32
+ import { freezeProjectFacts, type ProjectFactsSeed } from "./project-facts.ts"
32
33
  import { RULE_CODES } from "./rules/codes.ts"
34
+ import { commentBlockMarkerReason } from "./rules/comment-markers.ts"
33
35
  import { parseRulePacks, type RuleContext, runRuleRegistry, sourceIndex } from "./rules/index.ts"
34
36
  import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.ts"
35
37
  import { routeRules } from "./rules/routes.ts"
@@ -902,6 +904,158 @@ function templateShape(
902
904
  return { staticText, dynamic }
903
905
  }
904
906
 
907
+ /** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
908
+ * whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
909
+ * parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
910
+ * farther, resolvable const of the same name is never read in its place. */
911
+ type ResolvableBinding = { readonly initializer: TSApi.Expression } | "shadow"
912
+
913
+ /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
914
+ * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
915
+ * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
916
+ * before, never the reverse. */
917
+ function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
918
+ let found = false
919
+ const visit = (node: TSApi.Node): void => {
920
+ if (found) return
921
+ if (
922
+ ts.isBinaryExpression(node) &&
923
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
924
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
925
+ ts.isIdentifier(node.left) &&
926
+ node.left.text === name
927
+ ) {
928
+ found = true
929
+ return
930
+ }
931
+ if (
932
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
933
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
934
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
935
+ ts.isIdentifier(node.operand) &&
936
+ node.operand.text === name
937
+ ) {
938
+ found = true
939
+ return
940
+ }
941
+ ts.forEachChild(node, visit)
942
+ }
943
+ visit(source)
944
+ return found
945
+ }
946
+
947
+ /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
948
+ * the pattern walk in {@link isShadowedAt}. */
949
+ function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
950
+ return ts.isIdentifier(binding)
951
+ ? binding.text === name
952
+ : binding.elements.some(
953
+ (element) =>
954
+ !ts.isOmittedExpression(element) &&
955
+ (ts.isIdentifier(element.name)
956
+ ? element.name.text === name
957
+ : bindingNameCovers(ts, element.name, name)),
958
+ )
959
+ }
960
+
961
+ /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
962
+ * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
963
+ * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
964
+ * `undefined` when this scope does not bind the name at all. */
965
+ function directBinding(
966
+ ts: TypeScriptApi,
967
+ source: TSApi.SourceFile,
968
+ scope: TSApi.Node,
969
+ name: string,
970
+ ): ResolvableBinding | undefined {
971
+ if (ts.isFunctionLike(scope)) {
972
+ for (const parameter of scope.parameters) {
973
+ if (bindingNameCovers(ts, parameter.name, name)) return "shadow"
974
+ }
975
+ }
976
+ if (ts.isCatchClause(scope)) {
977
+ const declared = scope.variableDeclaration
978
+ if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
979
+ }
980
+ const statements: readonly TSApi.Statement[] | undefined = ts.isSourceFile(scope)
981
+ ? scope.statements
982
+ : ts.isBlock(scope) || ts.isModuleBlock(scope)
983
+ ? scope.statements
984
+ : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
985
+ ? scope.statements
986
+ : undefined
987
+ const lists: TSApi.VariableDeclarationList[] = []
988
+ if (statements !== undefined) {
989
+ for (const statement of statements) {
990
+ if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
991
+ if (
992
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
993
+ statement.name?.text === name
994
+ ) {
995
+ return "shadow"
996
+ }
997
+ }
998
+ }
999
+ if (
1000
+ (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
1001
+ scope.initializer !== undefined &&
1002
+ ts.isVariableDeclarationList(scope.initializer)
1003
+ ) {
1004
+ lists.push(scope.initializer)
1005
+ }
1006
+ for (const list of lists) {
1007
+ for (const declaration of list.declarations) {
1008
+ if (!bindingNameCovers(ts, declaration.name, name)) continue
1009
+ // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
1010
+ // value we can read one expression out of.
1011
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
1012
+ return "shadow"
1013
+ const isConst = (list.flags & ts.NodeFlags.Const) !== 0
1014
+ const isLet = (list.flags & ts.NodeFlags.Let) !== 0
1015
+ if (isConst) return { initializer: declaration.initializer }
1016
+ if (isLet && !isReassignedInFile(ts, source, name))
1017
+ return { initializer: declaration.initializer }
1018
+ return "shadow"
1019
+ }
1020
+ }
1021
+ return undefined
1022
+ }
1023
+
1024
+ /**
1025
+ * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
1026
+ * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
1027
+ * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
1028
+ * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
1029
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
1030
+ *
1031
+ * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
1032
+ * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
1033
+ * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
1034
+ * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
1035
+ * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
1036
+ * a module const that resolves to static text: exactly the pre-feature behavior).
1037
+ */
1038
+ function resolveArgumentInitializer(
1039
+ ts: TypeScriptApi,
1040
+ node: TSApi.Identifier,
1041
+ depth: number,
1042
+ ): TSApi.Expression | undefined {
1043
+ if (depth > CONST_RESOLUTION_DEPTH) return undefined
1044
+ const source = node.getSourceFile()
1045
+ const name = node.text
1046
+ for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
1047
+ const binding = directBinding(ts, source, scope, name)
1048
+ if (binding === "shadow") return undefined
1049
+ if (binding !== undefined) {
1050
+ let init = binding.initializer
1051
+ while (ts.isParenthesizedExpression(init)) init = init.expression
1052
+ return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init
1053
+ }
1054
+ if (ts.isSourceFile(scope)) break
1055
+ }
1056
+ return undefined
1057
+ }
1058
+
905
1059
  /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
906
1060
  function sqlExpressionShape(
907
1061
  ts: TypeScriptApi,
@@ -990,6 +1144,17 @@ function sqlExpressionShape(
990
1144
  }
991
1145
  }
992
1146
  }
1147
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
1148
+ // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
1149
+ // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1150
+ // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1151
+ // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1152
+ if (ts.isIdentifier(node)) {
1153
+ const initializer = resolveArgumentInitializer(ts, node, 0)
1154
+ if (initializer !== undefined) {
1155
+ return { ...sqlExpressionShape(ts, initializer, scope), literal: false }
1156
+ }
1157
+ }
993
1158
  return {
994
1159
  staticText: "",
995
1160
  dynamic: true,
@@ -1024,6 +1189,11 @@ function sqlMethod(
1024
1189
 
1025
1190
  const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
1026
1191
 
1192
+ /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1193
+ * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1194
+ * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1195
+ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic"
1196
+
1027
1197
  /**
1028
1198
  * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
1029
1199
  *
@@ -1062,7 +1232,12 @@ export function scanInterpolatedSql(
1062
1232
  if (unsafeEscape || assembledSql) {
1063
1233
  const index = node.getStart(source)
1064
1234
  const line = source.getLineAndCharacterOfPosition(index).line + 1
1065
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1235
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1236
+ // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1237
+ // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
1238
+ // greppable audit trail instead of becoming a second laundering trick.
1239
+ const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined
1240
+ if (!silenced) out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
1066
1241
  }
1067
1242
  }
1068
1243
  }
@@ -1846,7 +2021,7 @@ export interface CheckAssuranceContext {
1846
2021
 
1847
2022
  /** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
1848
2023
  * the app is built or even importable, preserving check's pre-`loadApp` invariant. */
1849
- interface CheckConfig {
2024
+ export interface CheckConfig {
1850
2025
  readonly externalMounts: readonly string[]
1851
2026
  readonly rules: Readonly<Record<string, RuleOverride>>
1852
2027
  }
@@ -2095,25 +2270,27 @@ function responseRouteSuggestion(): CheckSuggestion {
2095
2270
  }
2096
2271
  }
2097
2272
 
2098
- /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
2099
- * report, `--json`, and the MCP tool all render from. */
2100
- export async function collectCheckResult(
2273
+ interface CheckCollectionOptions {
2274
+ readonly lintsOnly?: boolean
2275
+ readonly signal?: AbortSignal
2276
+ readonly maxDiagnostics?: number
2277
+ readonly loadTypeScript?: () => Promise<TypeScriptApi | undefined>
2278
+ readonly assurance?: CheckAssuranceContext
2279
+ }
2280
+
2281
+ interface ProjectScan {
2282
+ readonly facts: ProjectFactsSeed
2283
+ readonly typecheck: TypecheckResult
2284
+ readonly sqlCompiler: TypeScriptApi | undefined
2285
+ readonly checkConfigError?: string
2286
+ readonly checkConfigWarnings: readonly string[]
2287
+ }
2288
+
2289
+ /** Build the rule input from one source walk and the other check-wide scans. */
2290
+ async function buildProjectScan(
2101
2291
  cwd: string,
2102
- opts: {
2103
- readonly lintsOnly?: boolean
2104
- readonly signal?: AbortSignal
2105
- /** Cap the returned `diagnostics` to this many (adds {@link CheckResult.truncated}). The MCP tool sets
2106
- * it so a huge project can't produce a transport-breaking result; the CLI leaves it unset (all shown). */
2107
- readonly maxDiagnostics?: number
2108
- /** How the interpolated-SQL rule gets its compiler. Injectable so the "not installed" path - the
2109
- * one where a security rule reports that it did not run - is testable on a machine that has it. */
2110
- readonly loadTypeScript?: () => Promise<TypeScriptApi | undefined>
2111
- /** Route-assurance inputs already computed by {@link collectProjectVerification}. When present,
2112
- * the capability + trust-manifest diagnostics reuse them instead of loading/reflecting a second
2113
- * time; when absent, they are computed here (unchanged standalone behavior). */
2114
- readonly assurance?: CheckAssuranceContext
2115
- } = {},
2116
- ): Promise<CheckResult> {
2292
+ opts: Pick<CheckCollectionOptions, "lintsOnly" | "signal" | "loadTypeScript">,
2293
+ ): Promise<ProjectScan> {
2117
2294
  const fetches: SourceFinding[] = []
2118
2295
  const staticRoutes: StaticRouteFinding[] = []
2119
2296
  const untypedClients: SourceFinding[] = []
@@ -2121,31 +2298,18 @@ export async function collectCheckResult(
2121
2298
  const serverImports: TransitiveServerImportFinding[] = []
2122
2299
  const responseRoutes: SourceFinding[] = []
2123
2300
  const interpolatedSql: SourceFinding[] = []
2124
- // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
2125
- // (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
2126
2301
  const routeModules: Array<{ rel: string; content: string }> = []
2127
2302
  const sourceFiles: Array<{ file: string; content: string }> = []
2128
- // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
2129
- // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
2130
2303
  const {
2131
2304
  config: checkConfig,
2132
2305
  error: checkConfigError,
2133
2306
  warnings: checkConfigWarnings,
2134
2307
  } = await loadCheckConfig(cwd)
2135
- // The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
2136
- // once, before the walk, so a project without it is told the rule did not run rather than being
2137
- // handed a clean report the rule never produced.
2138
2308
  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
2309
  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
2310
  const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
2146
- // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
2147
- // lints - the agent inner-loop mode; the full gate stays the definition of done.
2148
- const [tc, _, dr, manifestDrift] = await Promise.all([
2311
+
2312
+ const [typecheckResult, _, doctor, manifestDrift] = await Promise.all([
2149
2313
  opts.lintsOnly
2150
2314
  ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "lints-only mode" })
2151
2315
  : typecheck(cwd, opts.signal),
@@ -2164,19 +2328,12 @@ export async function collectCheckResult(
2164
2328
  scanServerManifestDrift(cwd),
2165
2329
  ])
2166
2330
 
2167
- // #4.4: resolve the FULL transitive server-only chain per route. The resolver/reader are fs-backed
2168
- // (`Bun.resolveSync` from the importing file's dir + a sync read), so the walk follows the real local
2169
- // module graph (`route → ../data → ../db → node:crypto`). Both are best-effort + total: a resolve/read
2170
- // miss returns `undefined`, and the walk falls back to the direct edge. The whole walk is bounded
2171
- // (depth + visited caps), so a deep/cyclic graph can't blow up the check.
2172
2331
  const resolveModule: ModuleResolver = (fromFile, specifier) => {
2173
2332
  try {
2174
- // `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
2175
- // deeper hops the walk takes (it carries resolved absolute paths) - resolve the dir for each.
2176
2333
  const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile)
2177
2334
  return Bun.resolveSync(specifier, dirname(fromAbs))
2178
2335
  } catch {
2179
- return undefined // unresolvable (bare pkg without install, tsconfig path alias, missing file)
2336
+ return undefined
2180
2337
  }
2181
2338
  }
2182
2339
  const readModule: ModuleReader = (absPath) => {
@@ -2192,6 +2349,43 @@ export async function collectCheckResult(
2192
2349
  )
2193
2350
  }
2194
2351
 
2352
+ const source = sourceIndex(sourceFiles)
2353
+ const facts: ProjectFactsSeed = {
2354
+ source,
2355
+ routes: staticRoutes,
2356
+ importGraph: serverImports,
2357
+ packages: { doctor, manifestDrift },
2358
+ ...(doctor.pipeline === undefined ? {} : { pipeline: doctor.pipeline }),
2359
+ policies: { checkConfig, rulePacks: [] },
2360
+ sourceFindings: { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql },
2361
+ }
2362
+ return {
2363
+ facts,
2364
+ typecheck: typecheckResult,
2365
+ sqlCompiler,
2366
+ ...(checkConfigError === undefined ? {} : { checkConfigError }),
2367
+ checkConfigWarnings,
2368
+ }
2369
+ }
2370
+
2371
+ /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
2372
+ * report, `--json`, and the MCP tool all render from. */
2373
+ export async function collectCheckResult(
2374
+ cwd: string,
2375
+ opts: CheckCollectionOptions = {},
2376
+ ): Promise<CheckResult> {
2377
+ const scan = await buildProjectScan(cwd, opts)
2378
+ const factsSeed = scan.facts
2379
+ const { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql } =
2380
+ factsSeed.sourceFindings
2381
+ const staticRoutes = factsSeed.routes
2382
+ const serverImports = factsSeed.importGraph
2383
+ const { doctor: dr, manifestDrift } = factsSeed.packages
2384
+ const { checkConfig } = factsSeed.policies
2385
+ const tc = scan.typecheck
2386
+ const sqlCompiler = scan.sqlCompiler
2387
+ const checkConfigError = scan.checkConfigError
2388
+ const checkConfigWarnings = scan.checkConfigWarnings
2195
2389
  const diagnostics: CheckDiagnostic[] = []
2196
2390
  const structuredExtras: Diagnostic[] = []
2197
2391
  if (checkConfigError !== undefined) {
@@ -2267,7 +2461,7 @@ export async function collectCheckResult(
2267
2461
  })
2268
2462
  }
2269
2463
  const routes = staticRouteMap(staticRoutes)
2270
- for (const f of fetches.sort(bySite)) {
2464
+ for (const f of [...fetches].sort(bySite)) {
2271
2465
  diagnostics.push({
2272
2466
  rule: "typed-client",
2273
2467
  severity: "error",
@@ -2278,7 +2472,7 @@ export async function collectCheckResult(
2278
2472
  suggestion: ownFetchSuggestion(f, routes),
2279
2473
  })
2280
2474
  }
2281
- for (const f of removedImports.sort(bySite)) {
2475
+ for (const f of [...removedImports].sort(bySite)) {
2282
2476
  const entry = REMOVED_IMPORTS.find(
2283
2477
  (candidate) =>
2284
2478
  f.snippet.includes(`"${candidate.specifier}`) ||
@@ -2293,7 +2487,7 @@ export async function collectCheckResult(
2293
2487
  fix: entry?.replacement ?? "see the changelog",
2294
2488
  })
2295
2489
  }
2296
- for (const f of untypedClients.sort(bySite)) {
2490
+ for (const f of [...untypedClients].sort(bySite)) {
2297
2491
  diagnostics.push({
2298
2492
  rule: "untyped-client",
2299
2493
  severity: "error",
@@ -2304,7 +2498,7 @@ export async function collectCheckResult(
2304
2498
  suggestion: untypedClientSuggestion(f),
2305
2499
  })
2306
2500
  }
2307
- for (const f of serverImports.sort(bySite)) {
2501
+ for (const f of [...serverImports].sort(bySite)) {
2308
2502
  // #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
2309
2503
  // node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
2310
2504
  // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
@@ -2337,7 +2531,7 @@ export async function collectCheckResult(
2337
2531
  },
2338
2532
  })
2339
2533
  }
2340
- for (const f of interpolatedSql.sort(bySite)) {
2534
+ for (const f of [...interpolatedSql].sort(bySite)) {
2341
2535
  diagnostics.push({
2342
2536
  rule: "interpolated-sql",
2343
2537
  severity: "error",
@@ -2353,12 +2547,13 @@ export async function collectCheckResult(
2353
2547
  "Pass the value as an argument alongside the statement, so the driver binds it.",
2354
2548
  `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
2355
2549
  "An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
2550
+ "If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect sql-dynamic: <reason>` comment on the line above - the reason is required.",
2356
2551
  ],
2357
2552
  },
2358
2553
  })
2359
2554
  }
2360
2555
  // Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
2361
- for (const f of responseRoutes.sort(bySite)) {
2556
+ for (const f of [...responseRoutes].sort(bySite)) {
2362
2557
  diagnostics.push({
2363
2558
  rule: "response-route",
2364
2559
  severity: "warning",
@@ -2387,15 +2582,11 @@ export async function collectCheckResult(
2387
2582
  }
2388
2583
  for (const finding of dr.duplicateInstalls) {
2389
2584
  const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
2390
- const identityHint =
2391
- finding.package === "@nifrajs/core"
2392
- ? " (a second @nifrajs/core collapses `typeof backend` to `any` at `.merge()` - usually that error's root cause)"
2393
- : ""
2394
2585
  diagnostics.push({
2395
2586
  rule: "duplicate-install",
2396
2587
  severity: "error",
2397
- message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe${identityHint}`,
2398
- fix: "align dependency ranges and reinstall from the workspace root",
2588
+ message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
2589
+ fix: finding.remediation,
2399
2590
  suggestion: {
2400
2591
  kind: "manual",
2401
2592
  title: `Deduplicate ${finding.package}`,
@@ -2474,6 +2665,8 @@ export async function collectCheckResult(
2474
2665
  // without nifra.assurance.ts retain the historical scan and hot path unchanged.
2475
2666
  const provided = opts.assurance
2476
2667
  let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
2668
+ let loadedPolicy = provided
2669
+ let loadedCapability: CapabilityProjectReport | undefined = provided?.capability
2477
2670
  const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
2478
2671
  if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
2479
2672
  try {
@@ -2498,6 +2691,14 @@ export async function collectCheckResult(
2498
2691
  const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
2499
2692
  project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
2500
2693
  }
2694
+ loadedPolicy = {
2695
+ present: true,
2696
+ config,
2697
+ ...(routeAssurance === undefined ? {} : { routeAssurance }),
2698
+ ...(project === undefined ? {} : { capability: project }),
2699
+ ...(evidence === undefined ? {} : { evidence }),
2700
+ }
2701
+ loadedCapability = project
2501
2702
  }
2502
2703
  applicationRulePacks = parseRulePacks(config.rulePacks)
2503
2704
  const capabilityReport = project?.report
@@ -2620,6 +2821,7 @@ export async function collectCheckResult(
2620
2821
  }
2621
2822
  }
2622
2823
  } catch (err) {
2824
+ loadedPolicy = { present: true, error: err }
2623
2825
  diagnostics.push({
2624
2826
  rule: "capability-config",
2625
2827
  severity: "error",
@@ -2653,6 +2855,20 @@ export async function collectCheckResult(
2653
2855
  }),
2654
2856
  )
2655
2857
  } else {
2858
+ if (contract.vacuous) {
2859
+ // Every route hashes to the empty-schema digest, so the lock exists but the drift rule guards
2860
+ // nothing: it can only ever compare "no schema" to "no schema". Say so, or a green contract check
2861
+ // reads as proof of a stable contract when there is no contract to be stable.
2862
+ structuredExtras.push(
2863
+ diagnostic({
2864
+ code: "NF-K001",
2865
+ severity: "info",
2866
+ message:
2867
+ "no route schemas declared - the trust manifest is vacuous: every contract hash is the empty-schema digest, so drift detection guards nothing. Declare route schemas (body/query/params/response) so the lock has a contract to protect.",
2868
+ verify: "nifra check --lints-only",
2869
+ }),
2870
+ )
2871
+ }
2656
2872
  for (const finding of contract.diagnostics) {
2657
2873
  diagnostics.push({
2658
2874
  rule: "contract-drift",
@@ -2679,16 +2895,22 @@ export async function collectCheckResult(
2679
2895
  }
2680
2896
  }
2681
2897
 
2898
+ const projectFacts = freezeProjectFacts(
2899
+ {
2900
+ ...factsSeed,
2901
+ policies: {
2902
+ ...factsSeed.policies,
2903
+ ...(loadedPolicy === undefined ? {} : { assurance: loadedPolicy }),
2904
+ ...(loadedCapability === undefined ? {} : { capability: loadedCapability }),
2905
+ rulePacks: applicationRulePacks,
2906
+ },
2907
+ },
2908
+ diagnostics,
2909
+ )
2682
2910
  const ruleContext: RuleContext = {
2683
2911
  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
- },
2912
+ sources: projectFacts.source,
2913
+ project: projectFacts,
2692
2914
  }
2693
2915
  const structuredDiagnostics = [...structuredExtras]
2694
2916
  let registryDiagnostics: Diagnostic[] = []
@@ -2696,7 +2918,7 @@ export async function collectCheckResult(
2696
2918
  registryDiagnostics = await runRuleRegistry(
2697
2919
  ruleContext,
2698
2920
  [...legacyRules, ...securityRules, ...routeRules],
2699
- applicationRulePacks,
2921
+ projectFacts.policies.rulePacks,
2700
2922
  )
2701
2923
  structuredDiagnostics.push(...registryDiagnostics)
2702
2924
  } catch (error) {