@nifrajs/cli 2.13.0 → 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.
- package/dist/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +234 -43
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +91 -413
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +244 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1014 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +18 -2
- package/dist/doctor.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +59 -349
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +12 -14
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/docs/types.json +27 -14
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +285 -59
- package/src/cli.ts +100 -404
- package/src/command-catalog.ts +1415 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +35 -4
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +82 -414
- package/src/project-facts.ts +86 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +13 -14
- 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
|
-
|
|
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
|
-
/**
|
|
1729
|
-
|
|
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
|
-
|
|
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;
|
|
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",
|
|
@@ -1995,7 +2161,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1995
2161
|
diagnostics.push({
|
|
1996
2162
|
rule: "duplicate-install",
|
|
1997
2163
|
severity: "error",
|
|
1998
|
-
message: `${finding.package}
|
|
2164
|
+
message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
|
|
1999
2165
|
fix: finding.remediation,
|
|
2000
2166
|
suggestion: {
|
|
2001
2167
|
kind: "manual",
|
|
@@ -2076,6 +2242,8 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
2076
2242
|
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
2077
2243
|
const provided = opts.assurance;
|
|
2078
2244
|
let applicationRulePacks = [];
|
|
2245
|
+
let loadedPolicy = provided;
|
|
2246
|
+
let loadedCapability = provided?.capability;
|
|
2079
2247
|
const assuranceConfigPath = join(cwd, "nifra.assurance.ts");
|
|
2080
2248
|
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
2081
2249
|
try {
|
|
@@ -2102,6 +2270,14 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
2102
2270
|
const { collectCapabilityProjectReport } = await import("./capabilities-tool.js");
|
|
2103
2271
|
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
|
|
2104
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;
|
|
2105
2281
|
}
|
|
2106
2282
|
applicationRulePacks = parseRulePacks(config.rulePacks);
|
|
2107
2283
|
const capabilityReport = project?.report;
|
|
@@ -2211,6 +2387,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
2211
2387
|
}
|
|
2212
2388
|
}
|
|
2213
2389
|
catch (err) {
|
|
2390
|
+
loadedPolicy = { present: true, error: err };
|
|
2214
2391
|
diagnostics.push({
|
|
2215
2392
|
rule: "capability-config",
|
|
2216
2393
|
severity: "error",
|
|
@@ -2242,6 +2419,17 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
2242
2419
|
}));
|
|
2243
2420
|
}
|
|
2244
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
|
+
}
|
|
2245
2433
|
for (const finding of contract.diagnostics) {
|
|
2246
2434
|
diagnostics.push({
|
|
2247
2435
|
rule: "contract-drift",
|
|
@@ -2269,21 +2457,24 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
2269
2457
|
});
|
|
2270
2458
|
}
|
|
2271
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);
|
|
2272
2469
|
const ruleContext = {
|
|
2273
2470
|
root: cwd,
|
|
2274
|
-
sources:
|
|
2275
|
-
project:
|
|
2276
|
-
legacyDiagnostics: diagnostics,
|
|
2277
|
-
assurance: opts.assurance,
|
|
2278
|
-
// Route registrations collected during the source walk, so the route-table rules
|
|
2279
|
-
// (rules/routes.ts) lint them without a second scan.
|
|
2280
|
-
staticRoutes,
|
|
2281
|
-
},
|
|
2471
|
+
sources: projectFacts.source,
|
|
2472
|
+
project: projectFacts,
|
|
2282
2473
|
};
|
|
2283
2474
|
const structuredDiagnostics = [...structuredExtras];
|
|
2284
2475
|
let registryDiagnostics = [];
|
|
2285
2476
|
try {
|
|
2286
|
-
registryDiagnostics = await runRuleRegistry(ruleContext, [...legacyRules, ...securityRules, ...routeRules],
|
|
2477
|
+
registryDiagnostics = await runRuleRegistry(ruleContext, [...legacyRules, ...securityRules, ...routeRules], projectFacts.policies.rulePacks);
|
|
2287
2478
|
structuredDiagnostics.push(...registryDiagnostics);
|
|
2288
2479
|
}
|
|
2289
2480
|
catch (error) {
|