@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/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
|
-
|
|
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
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2147
|
-
|
|
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
|
|
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",
|
|
@@ -2390,7 +2585,7 @@ export async function collectCheckResult(
|
|
|
2390
2585
|
diagnostics.push({
|
|
2391
2586
|
rule: "duplicate-install",
|
|
2392
2587
|
severity: "error",
|
|
2393
|
-
message: `${finding.package}
|
|
2588
|
+
message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
|
|
2394
2589
|
fix: finding.remediation,
|
|
2395
2590
|
suggestion: {
|
|
2396
2591
|
kind: "manual",
|
|
@@ -2470,6 +2665,8 @@ export async function collectCheckResult(
|
|
|
2470
2665
|
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
2471
2666
|
const provided = opts.assurance
|
|
2472
2667
|
let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
|
|
2668
|
+
let loadedPolicy = provided
|
|
2669
|
+
let loadedCapability: CapabilityProjectReport | undefined = provided?.capability
|
|
2473
2670
|
const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
|
|
2474
2671
|
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
2475
2672
|
try {
|
|
@@ -2494,6 +2691,14 @@ export async function collectCheckResult(
|
|
|
2494
2691
|
const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
|
|
2495
2692
|
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
|
|
2496
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
|
|
2497
2702
|
}
|
|
2498
2703
|
applicationRulePacks = parseRulePacks(config.rulePacks)
|
|
2499
2704
|
const capabilityReport = project?.report
|
|
@@ -2616,6 +2821,7 @@ export async function collectCheckResult(
|
|
|
2616
2821
|
}
|
|
2617
2822
|
}
|
|
2618
2823
|
} catch (err) {
|
|
2824
|
+
loadedPolicy = { present: true, error: err }
|
|
2619
2825
|
diagnostics.push({
|
|
2620
2826
|
rule: "capability-config",
|
|
2621
2827
|
severity: "error",
|
|
@@ -2649,6 +2855,20 @@ export async function collectCheckResult(
|
|
|
2649
2855
|
}),
|
|
2650
2856
|
)
|
|
2651
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
|
+
}
|
|
2652
2872
|
for (const finding of contract.diagnostics) {
|
|
2653
2873
|
diagnostics.push({
|
|
2654
2874
|
rule: "contract-drift",
|
|
@@ -2675,16 +2895,22 @@ export async function collectCheckResult(
|
|
|
2675
2895
|
}
|
|
2676
2896
|
}
|
|
2677
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
|
+
)
|
|
2678
2910
|
const ruleContext: RuleContext = {
|
|
2679
2911
|
root: cwd,
|
|
2680
|
-
sources:
|
|
2681
|
-
project:
|
|
2682
|
-
legacyDiagnostics: diagnostics,
|
|
2683
|
-
assurance: opts.assurance,
|
|
2684
|
-
// Route registrations collected during the source walk, so the route-table rules
|
|
2685
|
-
// (rules/routes.ts) lint them without a second scan.
|
|
2686
|
-
staticRoutes,
|
|
2687
|
-
},
|
|
2912
|
+
sources: projectFacts.source,
|
|
2913
|
+
project: projectFacts,
|
|
2688
2914
|
}
|
|
2689
2915
|
const structuredDiagnostics = [...structuredExtras]
|
|
2690
2916
|
let registryDiagnostics: Diagnostic[] = []
|
|
@@ -2692,7 +2918,7 @@ export async function collectCheckResult(
|
|
|
2692
2918
|
registryDiagnostics = await runRuleRegistry(
|
|
2693
2919
|
ruleContext,
|
|
2694
2920
|
[...legacyRules, ...securityRules, ...routeRules],
|
|
2695
|
-
|
|
2921
|
+
projectFacts.policies.rulePacks,
|
|
2696
2922
|
)
|
|
2697
2923
|
structuredDiagnostics.push(...registryDiagnostics)
|
|
2698
2924
|
} catch (error) {
|