@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/dist/check.js CHANGED
@@ -22,7 +22,9 @@ import { Glob } from "bun";
22
22
  import { diagnostic } from "./diagnostics.js";
23
23
  import { createSourceFacts } from "./internal/source-facts.js";
24
24
  import { importProjectTypeScript } from "./internal/typescript-import.js";
25
+ import { freezeProjectFacts } from "./project-facts.js";
25
26
  import { RULE_CODES } from "./rules/codes.js";
27
+ import { commentBlockMarkerReason } from "./rules/comment-markers.js";
26
28
  import { parseRulePacks, runRuleRegistry, sourceIndex } from "./rules/index.js";
27
29
  import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.js";
28
30
  import { routeRules } from "./rules/routes.js";
@@ -788,6 +790,138 @@ function templateShape(ts, template, scope) {
788
790
  }
789
791
  return { staticText, dynamic };
790
792
  }
793
+ /** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
794
+ * bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
795
+ * request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
796
+ * before, never the reverse. */
797
+ function isReassignedInFile(ts, source, name) {
798
+ let found = false;
799
+ const visit = (node) => {
800
+ if (found)
801
+ return;
802
+ if (ts.isBinaryExpression(node) &&
803
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
804
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
805
+ ts.isIdentifier(node.left) &&
806
+ node.left.text === name) {
807
+ found = true;
808
+ return;
809
+ }
810
+ if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
811
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
812
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
813
+ ts.isIdentifier(node.operand) &&
814
+ node.operand.text === name) {
815
+ found = true;
816
+ return;
817
+ }
818
+ ts.forEachChild(node, visit);
819
+ };
820
+ visit(source);
821
+ return found;
822
+ }
823
+ /** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
824
+ * the pattern walk in {@link isShadowedAt}. */
825
+ function bindingNameCovers(ts, binding, name) {
826
+ return ts.isIdentifier(binding)
827
+ ? binding.text === name
828
+ : binding.elements.some((element) => !ts.isOmittedExpression(element) &&
829
+ (ts.isIdentifier(element.name)
830
+ ? element.name.text === name
831
+ : bindingNameCovers(ts, element.name, name)));
832
+ }
833
+ /** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
834
+ * catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
835
+ * statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
836
+ * `undefined` when this scope does not bind the name at all. */
837
+ function directBinding(ts, source, scope, name) {
838
+ if (ts.isFunctionLike(scope)) {
839
+ for (const parameter of scope.parameters) {
840
+ if (bindingNameCovers(ts, parameter.name, name))
841
+ return "shadow";
842
+ }
843
+ }
844
+ if (ts.isCatchClause(scope)) {
845
+ const declared = scope.variableDeclaration;
846
+ if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
847
+ return "shadow";
848
+ }
849
+ const statements = ts.isSourceFile(scope)
850
+ ? scope.statements
851
+ : ts.isBlock(scope) || ts.isModuleBlock(scope)
852
+ ? scope.statements
853
+ : ts.isCaseClause(scope) || ts.isDefaultClause(scope)
854
+ ? scope.statements
855
+ : undefined;
856
+ const lists = [];
857
+ if (statements !== undefined) {
858
+ for (const statement of statements) {
859
+ if (ts.isVariableStatement(statement))
860
+ lists.push(statement.declarationList);
861
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
862
+ statement.name?.text === name) {
863
+ return "shadow";
864
+ }
865
+ }
866
+ }
867
+ if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
868
+ scope.initializer !== undefined &&
869
+ ts.isVariableDeclarationList(scope.initializer)) {
870
+ lists.push(scope.initializer);
871
+ }
872
+ for (const list of lists) {
873
+ for (const declaration of list.declarations) {
874
+ if (!bindingNameCovers(ts, declaration.name, name))
875
+ continue;
876
+ // A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
877
+ // value we can read one expression out of.
878
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
879
+ return "shadow";
880
+ const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
881
+ const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
882
+ if (isConst)
883
+ return { initializer: declaration.initializer };
884
+ if (isLet && !isReassignedInFile(ts, source, name))
885
+ return { initializer: declaration.initializer };
886
+ return "shadow";
887
+ }
888
+ }
889
+ return undefined;
890
+ }
891
+ /**
892
+ * Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
893
+ * parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
894
+ * to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
895
+ * whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
896
+ * could be silenced, invisibly, by hoisting the statement to `const q = …` first.
897
+ *
898
+ * Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
899
+ * same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
900
+ * module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
901
+ * imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
902
+ * declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
903
+ * a module const that resolves to static text: exactly the pre-feature behavior).
904
+ */
905
+ function resolveArgumentInitializer(ts, node, depth) {
906
+ if (depth > CONST_RESOLUTION_DEPTH)
907
+ return undefined;
908
+ const source = node.getSourceFile();
909
+ const name = node.text;
910
+ for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
911
+ const binding = directBinding(ts, source, scope, name);
912
+ if (binding === "shadow")
913
+ return undefined;
914
+ if (binding !== undefined) {
915
+ let init = binding.initializer;
916
+ while (ts.isParenthesizedExpression(init))
917
+ init = init.expression;
918
+ return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init;
919
+ }
920
+ if (ts.isSourceFile(scope))
921
+ break;
922
+ }
923
+ return undefined;
924
+ }
791
925
  /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
792
926
  function sqlExpressionShape(ts, node, scope) {
793
927
  if (ts.isParenthesizedExpression(node))
@@ -872,6 +1006,17 @@ function sqlExpressionShape(ts, node, scope) {
872
1006
  };
873
1007
  }
874
1008
  }
1009
+ // A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
1010
+ // holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
1011
+ // read exactly as if it were written inline at the call, closing the "extract a variable to launder
1012
+ // the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
1013
+ // property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
1014
+ if (ts.isIdentifier(node)) {
1015
+ const initializer = resolveArgumentInitializer(ts, node, 0);
1016
+ if (initializer !== undefined) {
1017
+ return { ...sqlExpressionShape(ts, initializer, scope), literal: false };
1018
+ }
1019
+ }
875
1020
  return {
876
1021
  staticText: "",
877
1022
  dynamic: true,
@@ -898,6 +1043,10 @@ function sqlMethod(ts, call) {
898
1043
  return undefined;
899
1044
  }
900
1045
  const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
1046
+ /** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
1047
+ * <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
1048
+ * is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
1049
+ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
901
1050
  /**
902
1051
  * Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
903
1052
  *
@@ -930,7 +1079,13 @@ export function scanInterpolatedSql(file, content, ts, imports) {
930
1079
  if (unsafeEscape || assembledSql) {
931
1080
  const index = node.getStart(source);
932
1081
  const line = source.getLineAndCharacterOfPosition(index).line + 1;
933
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
1082
+ // Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
1083
+ // generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
1084
+ // sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
1085
+ // greppable audit trail instead of becoming a second laundering trick.
1086
+ const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined;
1087
+ if (!silenced)
1088
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
934
1089
  }
935
1090
  }
936
1091
  }
@@ -1725,9 +1880,8 @@ function responseRouteSuggestion() {
1725
1880
  ],
1726
1881
  };
1727
1882
  }
1728
- /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
1729
- * report, `--json`, and the MCP tool all render from. */
1730
- export async function collectCheckResult(cwd, opts = {}) {
1883
+ /** Build the rule input from one source walk and the other check-wide scans. */
1884
+ async function buildProjectScan(cwd, opts) {
1731
1885
  const fetches = [];
1732
1886
  const staticRoutes = [];
1733
1887
  const untypedClients = [];
@@ -1735,27 +1889,13 @@ export async function collectCheckResult(cwd, opts = {}) {
1735
1889
  const serverImports = [];
1736
1890
  const responseRoutes = [];
1737
1891
  const interpolatedSql = [];
1738
- // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
1739
- // (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
1740
1892
  const routeModules = [];
1741
1893
  const sourceFiles = [];
1742
- // Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
1743
- // walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
1744
1894
  const { config: checkConfig, error: checkConfigError, warnings: checkConfigWarnings, } = await loadCheckConfig(cwd);
1745
- // The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
1746
- // once, before the walk, so a project without it is told the rule did not run rather than being
1747
- // handed a clean report the rule never produced.
1748
1895
  const sqlCompiler = await (opts.loadTypeScript ?? (() => importProjectTypeScript(cwd)))();
1749
- // AST facts are a lazy refinement of lexical candidates. The parser is not invoked for a file until
1750
- // one of the route/import/response scanners finds something worth disambiguating, and the cache is
1751
- // shared across those rules for this check run.
1752
1896
  const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler);
1753
- // One loader + parse cache for the whole walk, so a shared `sql-fragments.ts` is read once no matter
1754
- // how many route modules import a fragment out of it.
1755
1897
  const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd);
1756
- // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
1757
- // lints - the agent inner-loop mode; the full gate stays the definition of done.
1758
- const [tc, _, dr, manifestDrift] = await Promise.all([
1898
+ const [typecheckResult, _, doctor, manifestDrift] = await Promise.all([
1759
1899
  opts.lintsOnly
1760
1900
  ? Promise.resolve({ ran: false, ok: true, note: "lints-only mode" })
1761
1901
  : typecheck(cwd, opts.signal),
@@ -1774,20 +1914,13 @@ export async function collectCheckResult(cwd, opts = {}) {
1774
1914
  import("./doctor.js").then((m) => m.collectDoctorResult(cwd)),
1775
1915
  scanServerManifestDrift(cwd),
1776
1916
  ]);
1777
- // #4.4: resolve the FULL transitive server-only chain per route. The resolver/reader are fs-backed
1778
- // (`Bun.resolveSync` from the importing file's dir + a sync read), so the walk follows the real local
1779
- // module graph (`route → ../data → ../db → node:crypto`). Both are best-effort + total: a resolve/read
1780
- // miss returns `undefined`, and the walk falls back to the direct edge. The whole walk is bounded
1781
- // (depth + visited caps), so a deep/cyclic graph can't blow up the check.
1782
1917
  const resolveModule = (fromFile, specifier) => {
1783
1918
  try {
1784
- // `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
1785
- // deeper hops the walk takes (it carries resolved absolute paths) - resolve the dir for each.
1786
1919
  const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile);
1787
1920
  return Bun.resolveSync(specifier, dirname(fromAbs));
1788
1921
  }
1789
1922
  catch {
1790
- return undefined; // unresolvable (bare pkg without install, tsconfig path alias, missing file)
1923
+ return undefined;
1791
1924
  }
1792
1925
  };
1793
1926
  const readModule = (absPath) => {
@@ -1801,6 +1934,38 @@ export async function collectCheckResult(cwd, opts = {}) {
1801
1934
  for (const { rel, content } of routeModules) {
1802
1935
  serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule, sourceFacts));
1803
1936
  }
1937
+ const source = sourceIndex(sourceFiles);
1938
+ const facts = {
1939
+ source,
1940
+ routes: staticRoutes,
1941
+ importGraph: serverImports,
1942
+ packages: { doctor, manifestDrift },
1943
+ ...(doctor.pipeline === undefined ? {} : { pipeline: doctor.pipeline }),
1944
+ policies: { checkConfig, rulePacks: [] },
1945
+ sourceFindings: { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql },
1946
+ };
1947
+ return {
1948
+ facts,
1949
+ typecheck: typecheckResult,
1950
+ sqlCompiler,
1951
+ ...(checkConfigError === undefined ? {} : { checkConfigError }),
1952
+ checkConfigWarnings,
1953
+ };
1954
+ }
1955
+ /** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
1956
+ * report, `--json`, and the MCP tool all render from. */
1957
+ export async function collectCheckResult(cwd, opts = {}) {
1958
+ const scan = await buildProjectScan(cwd, opts);
1959
+ const factsSeed = scan.facts;
1960
+ const { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql } = factsSeed.sourceFindings;
1961
+ const staticRoutes = factsSeed.routes;
1962
+ const serverImports = factsSeed.importGraph;
1963
+ const { doctor: dr, manifestDrift } = factsSeed.packages;
1964
+ const { checkConfig } = factsSeed.policies;
1965
+ const tc = scan.typecheck;
1966
+ const sqlCompiler = scan.sqlCompiler;
1967
+ const checkConfigError = scan.checkConfigError;
1968
+ const checkConfigWarnings = scan.checkConfigWarnings;
1804
1969
  const diagnostics = [];
1805
1970
  const structuredExtras = [];
1806
1971
  if (checkConfigError !== undefined) {
@@ -1875,7 +2040,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1875
2040
  });
1876
2041
  }
1877
2042
  const routes = staticRouteMap(staticRoutes);
1878
- for (const f of fetches.sort(bySite)) {
2043
+ for (const f of [...fetches].sort(bySite)) {
1879
2044
  diagnostics.push({
1880
2045
  rule: "typed-client",
1881
2046
  severity: "error",
@@ -1886,7 +2051,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1886
2051
  suggestion: ownFetchSuggestion(f, routes),
1887
2052
  });
1888
2053
  }
1889
- for (const f of removedImports.sort(bySite)) {
2054
+ for (const f of [...removedImports].sort(bySite)) {
1890
2055
  const entry = REMOVED_IMPORTS.find((candidate) => f.snippet.includes(`"${candidate.specifier}`) ||
1891
2056
  f.snippet.includes(`'${candidate.specifier}`));
1892
2057
  diagnostics.push({
@@ -1898,7 +2063,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1898
2063
  fix: entry?.replacement ?? "see the changelog",
1899
2064
  });
1900
2065
  }
1901
- for (const f of untypedClients.sort(bySite)) {
2066
+ for (const f of [...untypedClients].sort(bySite)) {
1902
2067
  diagnostics.push({
1903
2068
  rule: "untyped-client",
1904
2069
  severity: "error",
@@ -1909,7 +2074,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1909
2074
  suggestion: untypedClientSuggestion(f),
1910
2075
  });
1911
2076
  }
1912
- for (const f of serverImports.sort(bySite)) {
2077
+ for (const f of [...serverImports].sort(bySite)) {
1913
2078
  // #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
1914
2079
  // node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
1915
2080
  // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
@@ -1942,7 +2107,7 @@ export async function collectCheckResult(cwd, opts = {}) {
1942
2107
  },
1943
2108
  });
1944
2109
  }
1945
- for (const f of interpolatedSql.sort(bySite)) {
2110
+ for (const f of [...interpolatedSql].sort(bySite)) {
1946
2111
  diagnostics.push({
1947
2112
  rule: "interpolated-sql",
1948
2113
  severity: "error",
@@ -1958,12 +2123,13 @@ export async function collectCheckResult(cwd, opts = {}) {
1958
2123
  "Pass the value as an argument alongside the statement, so the driver binds it.",
1959
2124
  `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
1960
2125
  "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.",
2126
+ "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.",
1961
2127
  ],
1962
2128
  },
1963
2129
  });
1964
2130
  }
1965
2131
  // Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
1966
- for (const f of responseRoutes.sort(bySite)) {
2132
+ for (const f of [...responseRoutes].sort(bySite)) {
1967
2133
  diagnostics.push({
1968
2134
  rule: "response-route",
1969
2135
  severity: "warning",
@@ -1992,14 +2158,11 @@ export async function collectCheckResult(cwd, opts = {}) {
1992
2158
  }
1993
2159
  for (const finding of dr.duplicateInstalls) {
1994
2160
  const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ");
1995
- const identityHint = finding.package === "@nifrajs/core"
1996
- ? " (a second @nifrajs/core collapses `typeof backend` to `any` at `.merge()` - usually that error's root cause)"
1997
- : "";
1998
2161
  diagnostics.push({
1999
2162
  rule: "duplicate-install",
2000
2163
  severity: "error",
2001
- message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe${identityHint}`,
2002
- fix: "align dependency ranges and reinstall from the workspace root",
2164
+ message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
2165
+ fix: finding.remediation,
2003
2166
  suggestion: {
2004
2167
  kind: "manual",
2005
2168
  title: `Deduplicate ${finding.package}`,
@@ -2079,6 +2242,8 @@ export async function collectCheckResult(cwd, opts = {}) {
2079
2242
  // without nifra.assurance.ts retain the historical scan and hot path unchanged.
2080
2243
  const provided = opts.assurance;
2081
2244
  let applicationRulePacks = [];
2245
+ let loadedPolicy = provided;
2246
+ let loadedCapability = provided?.capability;
2082
2247
  const assuranceConfigPath = join(cwd, "nifra.assurance.ts");
2083
2248
  if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
2084
2249
  try {
@@ -2105,6 +2270,14 @@ export async function collectCheckResult(cwd, opts = {}) {
2105
2270
  const { collectCapabilityProjectReport } = await import("./capabilities-tool.js");
2106
2271
  project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
2107
2272
  }
2273
+ loadedPolicy = {
2274
+ present: true,
2275
+ config,
2276
+ ...(routeAssurance === undefined ? {} : { routeAssurance }),
2277
+ ...(project === undefined ? {} : { capability: project }),
2278
+ ...(evidence === undefined ? {} : { evidence }),
2279
+ };
2280
+ loadedCapability = project;
2108
2281
  }
2109
2282
  applicationRulePacks = parseRulePacks(config.rulePacks);
2110
2283
  const capabilityReport = project?.report;
@@ -2214,6 +2387,7 @@ export async function collectCheckResult(cwd, opts = {}) {
2214
2387
  }
2215
2388
  }
2216
2389
  catch (err) {
2390
+ loadedPolicy = { present: true, error: err };
2217
2391
  diagnostics.push({
2218
2392
  rule: "capability-config",
2219
2393
  severity: "error",
@@ -2245,6 +2419,17 @@ export async function collectCheckResult(cwd, opts = {}) {
2245
2419
  }));
2246
2420
  }
2247
2421
  else {
2422
+ if (contract.vacuous) {
2423
+ // Every route hashes to the empty-schema digest, so the lock exists but the drift rule guards
2424
+ // nothing: it can only ever compare "no schema" to "no schema". Say so, or a green contract check
2425
+ // reads as proof of a stable contract when there is no contract to be stable.
2426
+ structuredExtras.push(diagnostic({
2427
+ code: "NF-K001",
2428
+ severity: "info",
2429
+ message: "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.",
2430
+ verify: "nifra check --lints-only",
2431
+ }));
2432
+ }
2248
2433
  for (const finding of contract.diagnostics) {
2249
2434
  diagnostics.push({
2250
2435
  rule: "contract-drift",
@@ -2272,21 +2457,24 @@ export async function collectCheckResult(cwd, opts = {}) {
2272
2457
  });
2273
2458
  }
2274
2459
  }
2460
+ const projectFacts = freezeProjectFacts({
2461
+ ...factsSeed,
2462
+ policies: {
2463
+ ...factsSeed.policies,
2464
+ ...(loadedPolicy === undefined ? {} : { assurance: loadedPolicy }),
2465
+ ...(loadedCapability === undefined ? {} : { capability: loadedCapability }),
2466
+ rulePacks: applicationRulePacks,
2467
+ },
2468
+ }, diagnostics);
2275
2469
  const ruleContext = {
2276
2470
  root: cwd,
2277
- sources: sourceIndex(sourceFiles),
2278
- project: {
2279
- legacyDiagnostics: diagnostics,
2280
- assurance: opts.assurance,
2281
- // Route registrations collected during the source walk, so the route-table rules
2282
- // (rules/routes.ts) lint them without a second scan.
2283
- staticRoutes,
2284
- },
2471
+ sources: projectFacts.source,
2472
+ project: projectFacts,
2285
2473
  };
2286
2474
  const structuredDiagnostics = [...structuredExtras];
2287
2475
  let registryDiagnostics = [];
2288
2476
  try {
2289
- registryDiagnostics = await runRuleRegistry(ruleContext, [...legacyRules, ...securityRules, ...routeRules], applicationRulePacks);
2477
+ registryDiagnostics = await runRuleRegistry(ruleContext, [...legacyRules, ...securityRules, ...routeRules], projectFacts.policies.rulePacks);
2290
2478
  structuredDiagnostics.push(...registryDiagnostics);
2291
2479
  }
2292
2480
  catch (error) {