@nifrajs/cli 2.14.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +6 -1
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +3 -2
- package/dist/assure.js.map +1 -1
- package/dist/check-diagnostics.d.ts +165 -0
- package/dist/check-diagnostics.d.ts.map +1 -0
- package/dist/check-diagnostics.js +1009 -0
- package/dist/check-diagnostics.js.map +1 -0
- package/dist/check-scan.d.ts +245 -0
- package/dist/check-scan.d.ts.map +1 -0
- package/dist/check-scan.js +1599 -0
- package/dist/check-scan.js.map +1 -0
- package/dist/check.d.ts +4 -324
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +8 -2346
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +285 -10
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +27 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +142 -2
- package/dist/command-catalog.js.map +1 -1
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -7
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.d.ts +57 -0
- package/dist/frontend-guidance.d.ts.map +1 -0
- package/dist/frontend-guidance.js +195 -0
- package/dist/frontend-guidance.js.map +1 -0
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +6 -0
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +4 -1
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +48 -0
- package/dist/mcp-context.d.ts.map +1 -0
- package/dist/mcp-context.js +215 -0
- package/dist/mcp-context.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +31 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +74 -0
- package/dist/mcp-exec.d.ts.map +1 -0
- package/dist/mcp-exec.js +844 -0
- package/dist/mcp-exec.js.map +1 -0
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -0
- package/dist/mcp-io.js +100 -0
- package/dist/mcp-io.js.map +1 -0
- package/dist/mcp-reflect.d.ts +16 -0
- package/dist/mcp-reflect.d.ts.map +1 -0
- package/dist/mcp-reflect.js +101 -0
- package/dist/mcp-reflect.js.map +1 -0
- package/dist/mcp-root.d.ts +7 -0
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +51 -3
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp.d.ts +10 -84
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +13 -1170
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +5 -10
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -2
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +21 -0
- package/dist/openapi-types.d.ts.map +1 -0
- package/dist/openapi-types.js +214 -0
- package/dist/openapi-types.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +2 -1
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +2 -1
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +4 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +6 -123
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/codes.d.ts +4 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +4 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/islands.d.ts +4 -0
- package/dist/rules/islands.d.ts.map +1 -0
- package/dist/rules/islands.js +155 -0
- package/dist/rules/islands.js.map +1 -0
- package/dist/rules/nano.d.ts +6 -0
- package/dist/rules/nano.d.ts.map +1 -0
- package/dist/rules/nano.js +264 -0
- package/dist/rules/nano.js.map +1 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +17 -30
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +38 -4
- package/dist/rules/security.js.map +1 -1
- package/dist/scaffold.d.ts +12 -6
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +149 -10
- package/dist/scaffold.js.map +1 -1
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -3
- package/dist/search-terms.js.map +1 -1
- package/dist/stylex-migrate.d.ts +38 -0
- package/dist/stylex-migrate.d.ts.map +1 -0
- package/dist/stylex-migrate.js +1052 -0
- package/dist/stylex-migrate.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/verification-plan.d.ts +24 -0
- package/dist/verification-plan.d.ts.map +1 -0
- package/dist/verification-plan.js +116 -0
- package/dist/verification-plan.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +6 -3
- package/dist/work-graph.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +37 -1
- package/docs/llms-full.txt +1598 -44
- package/docs/types.json +5157 -172
- package/package.json +9 -9
- package/src/assure-hydration.ts +6 -1
- package/src/assure.ts +4 -2
- package/src/check-diagnostics.ts +1219 -0
- package/src/check-scan.ts +1825 -0
- package/src/check.ts +47 -2731
- package/src/cli.ts +291 -10
- package/src/command-catalog.ts +177 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/frontend-guidance.ts +259 -0
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/levels-tool.ts +6 -0
- package/src/load.ts +4 -1
- package/src/mcp-context.ts +266 -0
- package/src/mcp-docs-tools.ts +37 -0
- package/src/mcp-exec.ts +1040 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +118 -0
- package/src/mcp-reflect.ts +140 -0
- package/src/mcp-root.ts +62 -4
- package/src/mcp.ts +61 -1436
- package/src/openapi-tool.ts +32 -2
- package/src/openapi-types.ts +261 -0
- package/src/port.ts +2 -1
- package/src/project-facts.ts +2 -4
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +22 -147
- package/src/rules/codes.ts +4 -0
- package/src/rules/islands.ts +171 -0
- package/src/rules/nano.ts +295 -0
- package/src/rules/routes.ts +17 -30
- package/src/rules/security.ts +40 -4
- package/src/scaffold.ts +169 -9
- package/src/search-terms.ts +7 -3
- package/src/stylex-migrate.ts +1139 -0
- package/src/sync-manifest.ts +2 -1
- package/src/verification-plan.ts +242 -0
- package/src/work-graph.ts +6 -3
- package/src/workspace-link.ts +80 -0
package/src/check.ts
CHANGED
|
@@ -16,1832 +16,57 @@
|
|
|
16
16
|
* anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync, readFileSync
|
|
20
|
-
import { dirname, isAbsolute, join
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
20
|
+
import { dirname, isAbsolute, join } from "node:path"
|
|
21
|
+
import {
|
|
22
|
+
type CheckAssuranceContext,
|
|
23
|
+
type CheckConfig,
|
|
24
|
+
type CheckDiagnostic,
|
|
25
|
+
type CheckResult,
|
|
26
|
+
collectCheckDiagnostics,
|
|
27
|
+
type RuleOverride,
|
|
28
|
+
} from "./check-diagnostics.ts"
|
|
29
|
+
import { createSourceFacts } from "./internal/source-facts.ts"
|
|
28
30
|
import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
|
|
29
31
|
// Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
|
|
30
32
|
// close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
|
|
31
|
-
import type {
|
|
32
|
-
import { freezeProjectFacts, type ProjectFactsSeed } from "./project-facts.ts"
|
|
33
|
+
import type { ProjectFactsSeed } from "./project-facts.ts"
|
|
33
34
|
import { RULE_CODES } from "./rules/codes.ts"
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
import { sourceIndex } from "./rules/index.ts"
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
CheckAnalysisInput,
|
|
39
|
+
CheckAssuranceContext,
|
|
40
|
+
CheckConfig,
|
|
41
|
+
CheckDiagnostic,
|
|
42
|
+
CheckDiagnosticsOptions,
|
|
43
|
+
CheckResult,
|
|
44
|
+
CheckSuggestion,
|
|
45
|
+
CheckTypecheckResult,
|
|
46
|
+
RuleOverride,
|
|
47
|
+
} from "./check-diagnostics.ts"
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
createProjectSqlImports,
|
|
51
|
+
type ModuleReader,
|
|
52
|
+
type ModuleResolver,
|
|
53
|
+
resolveServerOnlyChains,
|
|
54
|
+
type SourceFinding,
|
|
55
|
+
type StaticRouteFinding,
|
|
56
|
+
scanFetchText,
|
|
57
|
+
scanInterpolatedSql,
|
|
58
|
+
scanRemovedImports,
|
|
59
|
+
scanResponseRoutes,
|
|
60
|
+
scanServerManifestDrift,
|
|
61
|
+
scanStaticRouteText,
|
|
62
|
+
scanUntypedClient,
|
|
63
|
+
type TransitiveServerImportFinding,
|
|
64
|
+
walkSource,
|
|
65
|
+
} from "./check-scan.ts"
|
|
66
|
+
|
|
67
|
+
export * from "./check-scan.ts"
|
|
39
68
|
|
|
40
|
-
export interface SourceFinding {
|
|
41
|
-
readonly file: string
|
|
42
|
-
readonly line: number
|
|
43
|
-
readonly snippet: string
|
|
44
|
-
}
|
|
45
|
-
/** A server-only-import finding, carrying the offending module specifier on top of the base location so
|
|
46
|
-
* the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
|
|
47
|
-
export interface ServerImportFinding extends SourceFinding {
|
|
48
|
-
/** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
|
|
49
|
-
readonly specifier: string
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
53
|
-
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
54
|
-
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
55
|
-
// alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
|
|
56
|
-
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
57
|
-
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
58
|
-
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
59
|
-
const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
|
|
60
|
-
|
|
61
|
-
// Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
|
|
62
|
-
// per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
|
|
63
|
-
const IGNORED_DIR =
|
|
64
|
-
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
|
|
65
|
-
// A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
|
|
66
|
-
// legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
|
|
67
|
-
// `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
|
|
68
|
-
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
69
|
-
const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
|
|
70
|
-
|
|
71
|
-
// A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
|
|
72
69
|
const ROUTE_FILE = /(^|\/)routes\//
|
|
73
|
-
|
|
74
|
-
// Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
|
|
75
|
-
// DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
|
|
76
|
-
const SERVER_ONLY =
|
|
77
|
-
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b|^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/
|
|
78
|
-
|
|
79
|
-
// A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
|
|
80
|
-
// and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
|
|
81
|
-
// after `import`, so `import\s+` never matches it.
|
|
82
|
-
const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g
|
|
83
|
-
|
|
84
|
-
const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
|
|
85
|
-
const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g
|
|
86
|
-
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
87
|
-
|
|
88
|
-
export interface StaticRouteFinding extends SourceFinding {
|
|
89
|
-
readonly method: string
|
|
90
|
-
readonly path: string
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
interface SimpleFetchCall {
|
|
94
|
-
readonly path: string
|
|
95
|
-
readonly method: string
|
|
96
|
-
readonly start: number
|
|
97
|
-
readonly end: number
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Line number (1-based) of a match index within `content`. */
|
|
101
|
-
const lineAt = (content: string, index: number): number =>
|
|
102
|
-
content.slice(0, index).split("\n").length
|
|
103
|
-
|
|
104
|
-
function parseQuotedLiteral(raw: string): string | undefined {
|
|
105
|
-
try {
|
|
106
|
-
return JSON.parse(raw) as string
|
|
107
|
-
} catch {
|
|
108
|
-
if (!raw.startsWith("'") || !raw.endsWith("'")) return undefined
|
|
109
|
-
try {
|
|
110
|
-
// Convert the single-quoted body to a JSON string body by scan, handling backslash pairs
|
|
111
|
-
// atomically: `\'` becomes `'`, other escapes pass through untouched, and a bare `"` is
|
|
112
|
-
// escaped. A quote-only `replace` mis-handles content with backslashes (double-escaping
|
|
113
|
-
// an already-escaped quote, or letting a trailing backslash swallow the added escape).
|
|
114
|
-
const inner = raw.slice(1, -1)
|
|
115
|
-
let body = ""
|
|
116
|
-
for (let i = 0; i < inner.length; i++) {
|
|
117
|
-
const ch = inner[i] as string
|
|
118
|
-
if (ch === "\\" && i + 1 < inner.length) {
|
|
119
|
-
const next = inner[i + 1] as string
|
|
120
|
-
body += next === "'" ? "'" : ch + next
|
|
121
|
-
i++
|
|
122
|
-
} else if (ch === '"') {
|
|
123
|
-
body += '\\"'
|
|
124
|
-
} else {
|
|
125
|
-
body += ch
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return JSON.parse(`"${body}"`) as string
|
|
129
|
-
} catch {
|
|
130
|
-
return undefined
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function findMatchingParen(src: string, openIndex: number): number | undefined {
|
|
136
|
-
let depth = 0
|
|
137
|
-
let quote: '"' | "'" | "`" | undefined
|
|
138
|
-
for (let i = openIndex; i < src.length; i++) {
|
|
139
|
-
const c = src[i]
|
|
140
|
-
if (quote !== undefined) {
|
|
141
|
-
if (c === "\\") {
|
|
142
|
-
i++
|
|
143
|
-
continue
|
|
144
|
-
}
|
|
145
|
-
if (c === quote) quote = undefined
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
if (c === '"' || c === "'" || c === "`") {
|
|
149
|
-
quote = c
|
|
150
|
-
continue
|
|
151
|
-
}
|
|
152
|
-
if (c === "(") depth++
|
|
153
|
-
else if (c === ")") {
|
|
154
|
-
depth--
|
|
155
|
-
if (depth === 0) return i
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return undefined
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function splitTopLevelArgs(src: string): string[] | undefined {
|
|
162
|
-
const args: string[] = []
|
|
163
|
-
let start = 0
|
|
164
|
-
let depth = 0
|
|
165
|
-
let quote: '"' | "'" | "`" | undefined
|
|
166
|
-
for (let i = 0; i < src.length; i++) {
|
|
167
|
-
const c = src[i]
|
|
168
|
-
if (quote !== undefined) {
|
|
169
|
-
if (c === "\\") {
|
|
170
|
-
i++
|
|
171
|
-
continue
|
|
172
|
-
}
|
|
173
|
-
if (c === quote) quote = undefined
|
|
174
|
-
continue
|
|
175
|
-
}
|
|
176
|
-
if (c === '"' || c === "'" || c === "`") {
|
|
177
|
-
quote = c
|
|
178
|
-
continue
|
|
179
|
-
}
|
|
180
|
-
if (c === "(" || c === "{" || c === "[") depth++
|
|
181
|
-
else if (c === ")" || c === "}" || c === "]") depth--
|
|
182
|
-
else if (c === "," && depth === 0) {
|
|
183
|
-
args.push(src.slice(start, i).trim())
|
|
184
|
-
start = i + 1
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (quote !== undefined || depth !== 0) return undefined
|
|
188
|
-
const tail = src.slice(start).trim()
|
|
189
|
-
if (tail !== "") args.push(tail)
|
|
190
|
-
return args
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseMethodOnlyInit(raw: string | undefined): string | undefined {
|
|
194
|
-
if (raw === undefined) return "GET"
|
|
195
|
-
const init = raw.trim()
|
|
196
|
-
if (!/^\{[\s\S]*\}$/.test(init)) return undefined
|
|
197
|
-
const body = init.slice(1, -1).trim()
|
|
198
|
-
if (body === "") return "GET"
|
|
199
|
-
const m = /^(?:"method"|'method'|method)\s*:\s*(["'])([A-Za-z]+)\1\s*,?$/.exec(body)
|
|
200
|
-
if (!m) return undefined
|
|
201
|
-
const method = (m[2] ?? "").toUpperCase()
|
|
202
|
-
return HTTP_VERBS.has(method) ? method : undefined
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefined {
|
|
206
|
-
FETCH_CALL.lastIndex = 0
|
|
207
|
-
const matches = [...snippet.matchAll(FETCH_CALL)]
|
|
208
|
-
if (matches.length !== 1) return undefined
|
|
209
|
-
const match = matches[0]
|
|
210
|
-
if (match === undefined || match.index === undefined) return undefined
|
|
211
|
-
const open = match.index + match[0].lastIndexOf("(")
|
|
212
|
-
const close = findMatchingParen(snippet, open)
|
|
213
|
-
if (close === undefined) return undefined
|
|
214
|
-
|
|
215
|
-
const args = splitTopLevelArgs(snippet.slice(open + 1, close))
|
|
216
|
-
if (args === undefined || args.length === 0 || args.length > 2) return undefined
|
|
217
|
-
const first = args[0] ?? ""
|
|
218
|
-
const quote = first[0]
|
|
219
|
-
if ((quote !== '"' && quote !== "'") || first[first.length - 1] !== quote) return undefined
|
|
220
|
-
const path = parseQuotedLiteral(first)
|
|
221
|
-
if (
|
|
222
|
-
path === undefined ||
|
|
223
|
-
!path.startsWith("/") ||
|
|
224
|
-
path.startsWith("//") ||
|
|
225
|
-
path.includes("?") ||
|
|
226
|
-
path.includes("#")
|
|
227
|
-
) {
|
|
228
|
-
return undefined
|
|
229
|
-
}
|
|
230
|
-
const method = parseMethodOnlyInit(args[1])
|
|
231
|
-
if (method === undefined) return undefined
|
|
232
|
-
return { path, method, start: match.index, end: close + 1 }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function isPotentialBackendSource(code: string): boolean {
|
|
236
|
-
return (
|
|
237
|
-
code.includes("@nifrajs/core") ||
|
|
238
|
-
/(?<![.\w])server\s*\(/.test(code) ||
|
|
239
|
-
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
240
|
-
/new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
|
|
241
|
-
)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function scanRoutePattern(
|
|
245
|
-
file: string,
|
|
246
|
-
content: string,
|
|
247
|
-
code: string,
|
|
248
|
-
pattern: RegExp,
|
|
249
|
-
facts?: SourceFacts,
|
|
250
|
-
): StaticRouteFinding[] {
|
|
251
|
-
const out: StaticRouteFinding[] = []
|
|
252
|
-
const lines = content.split("\n")
|
|
253
|
-
pattern.lastIndex = 0
|
|
254
|
-
for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
|
|
255
|
-
const method = (m[1] ?? "").toUpperCase()
|
|
256
|
-
if (!HTTP_VERBS.has(method)) continue
|
|
257
|
-
const path = parseQuotedLiteral(
|
|
258
|
-
`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
|
|
259
|
-
)
|
|
260
|
-
if (path === undefined || !path.startsWith("/") || path.startsWith("//")) continue
|
|
261
|
-
if (facts !== undefined) {
|
|
262
|
-
const source = facts.parse(file, content)
|
|
263
|
-
// A parseable source model is authoritative for candidate locations. If parsing is unavailable
|
|
264
|
-
// or the source is malformed, retain the lexical finding and fail closed.
|
|
265
|
-
if (
|
|
266
|
-
source !== undefined &&
|
|
267
|
-
facts.isRouteRegistrationAt(source, m.index, method, path) !== true
|
|
268
|
-
)
|
|
269
|
-
continue
|
|
270
|
-
}
|
|
271
|
-
const line = lineAt(content, m.index)
|
|
272
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
|
|
273
|
-
}
|
|
274
|
-
return out
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
279
|
-
* - `//` line comments and block comments;
|
|
280
|
-
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
281
|
-
* generators), never a real statement to lint.
|
|
282
|
-
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
|
|
283
|
-
* char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
|
|
284
|
-
* could mis-skip - in practice the constructs these scanners look for sit before any such regex.
|
|
285
|
-
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
286
|
-
*/
|
|
287
|
-
export function stripComments(src: string): string {
|
|
288
|
-
const out = src.split("")
|
|
289
|
-
const n = src.length
|
|
290
|
-
let i = 0
|
|
291
|
-
const blank = (a: number, b: number): void => {
|
|
292
|
-
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
293
|
-
}
|
|
294
|
-
while (i < n) {
|
|
295
|
-
const c = src[i]
|
|
296
|
-
const d = src[i + 1]
|
|
297
|
-
if (c === "/" && d === "/") {
|
|
298
|
-
let j = i + 2
|
|
299
|
-
while (j < n && src[j] !== "\n") j++
|
|
300
|
-
blank(i, j)
|
|
301
|
-
i = j
|
|
302
|
-
} else if (c === "/" && d === "*") {
|
|
303
|
-
let j = i + 2
|
|
304
|
-
while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
|
|
305
|
-
j = Math.min(j + 2, n)
|
|
306
|
-
blank(i, j)
|
|
307
|
-
i = j
|
|
308
|
-
} else if (c === "`") {
|
|
309
|
-
let j = i + 1
|
|
310
|
-
while (j < n && src[j] !== "`") {
|
|
311
|
-
if (src[j] === "\\") j++
|
|
312
|
-
j++
|
|
313
|
-
}
|
|
314
|
-
blank(i + 1, j) // keep the backticks, blank the code-as-text inside
|
|
315
|
-
i = j + 1
|
|
316
|
-
} else if (c === "'" || c === '"') {
|
|
317
|
-
let j = i + 1
|
|
318
|
-
while (j < n && src[j] !== c) {
|
|
319
|
-
if (src[j] === "\\") j++ // skip the escaped char
|
|
320
|
-
j++
|
|
321
|
-
}
|
|
322
|
-
i = j + 1
|
|
323
|
-
} else {
|
|
324
|
-
i++
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return out.join("")
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** Blank comments and every quoted/template literal while preserving offsets. Unlike
|
|
331
|
-
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
332
|
-
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
333
|
-
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
334
|
-
export function codePositionMask(src: string): string {
|
|
335
|
-
const out = src.split("")
|
|
336
|
-
const n = src.length
|
|
337
|
-
let i = 0
|
|
338
|
-
const blank = (a: number, b: number): void => {
|
|
339
|
-
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
340
|
-
}
|
|
341
|
-
while (i < n) {
|
|
342
|
-
const c = src[i]
|
|
343
|
-
const d = src[i + 1]
|
|
344
|
-
if (c === "/" && d === "/") {
|
|
345
|
-
let j = i + 2
|
|
346
|
-
while (j < n && src[j] !== "\n") j++
|
|
347
|
-
blank(i, j)
|
|
348
|
-
i = j
|
|
349
|
-
} else if (c === "/" && d === "*") {
|
|
350
|
-
let j = i + 2
|
|
351
|
-
while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
|
|
352
|
-
j = Math.min(j + 2, n)
|
|
353
|
-
blank(i, j)
|
|
354
|
-
i = j
|
|
355
|
-
} else if (c === "'" || c === '"' || c === "`") {
|
|
356
|
-
const quote = c
|
|
357
|
-
let j = i + 1
|
|
358
|
-
while (j < n && src[j] !== quote) {
|
|
359
|
-
if (src[j] === "\\") j++
|
|
360
|
-
j++
|
|
361
|
-
}
|
|
362
|
-
j = Math.min(j + 1, n)
|
|
363
|
-
blank(i, j)
|
|
364
|
-
i = j
|
|
365
|
-
} else {
|
|
366
|
-
i++
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
return out.join("")
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const firstArgumentIndex = (content: string, callEnd: number): number => {
|
|
373
|
-
let index = callEnd
|
|
374
|
-
while (/\s/.test(content[index] ?? "")) index++
|
|
375
|
-
return index
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
|
|
379
|
-
// stopping at the first quote / query / hash / template-expression boundary, and test it against the
|
|
380
|
-
// allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
|
|
381
|
-
// is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
|
|
382
|
-
function matchesExternalMount(content: string, start: number, mounts: readonly string[]): boolean {
|
|
383
|
-
let end = start
|
|
384
|
-
while (end < content.length) {
|
|
385
|
-
const ch = content[end]
|
|
386
|
-
if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#") break
|
|
387
|
-
if (ch === "$" && content[end + 1] === "{") break
|
|
388
|
-
end++
|
|
389
|
-
}
|
|
390
|
-
const path = content.slice(start, end)
|
|
391
|
-
// A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
|
|
392
|
-
// `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
|
|
393
|
-
// fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
|
|
394
|
-
try {
|
|
395
|
-
if (path.split("/").some((segment) => decodeURIComponent(segment) === "..")) return false
|
|
396
|
-
} catch {
|
|
397
|
-
// A malformed escape is not a prefix we can prove stays inside the external mount.
|
|
398
|
-
return false
|
|
399
|
-
}
|
|
400
|
-
return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function findTopLevelPropertyColon(src: string): number | undefined {
|
|
404
|
-
let depth = 0
|
|
405
|
-
let quote: '"' | "'" | "`" | undefined
|
|
406
|
-
for (let i = 0; i < src.length; i++) {
|
|
407
|
-
const c = src[i]
|
|
408
|
-
if (quote !== undefined) {
|
|
409
|
-
if (c === "\\") {
|
|
410
|
-
i++
|
|
411
|
-
continue
|
|
412
|
-
}
|
|
413
|
-
if (c === quote) quote = undefined
|
|
414
|
-
continue
|
|
415
|
-
}
|
|
416
|
-
if (c === '"' || c === "'" || c === "`") {
|
|
417
|
-
quote = c
|
|
418
|
-
continue
|
|
419
|
-
}
|
|
420
|
-
if (c === "(" || c === "{" || c === "[") depth++
|
|
421
|
-
else if (c === ")" || c === "}" || c === "]") depth--
|
|
422
|
-
else if (c === ":" && depth === 0) return i
|
|
423
|
-
}
|
|
424
|
-
return undefined
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function parseStaticPropertyName(raw: string): string | undefined {
|
|
428
|
-
const name = raw.trim()
|
|
429
|
-
if (IDENT.test(name)) return name
|
|
430
|
-
const quoted = parseQuotedLiteral(name)
|
|
431
|
-
if (quoted !== undefined) return quoted
|
|
432
|
-
if (name.startsWith("[") && name.endsWith("]")) {
|
|
433
|
-
return parseQuotedLiteral(name.slice(1, -1).trim())
|
|
434
|
-
}
|
|
435
|
-
return undefined
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** Return a statically named property from a plain object literal. Deliberately does not follow
|
|
439
|
-
* spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
|
|
440
|
-
* transport rather than an arbitrary same-origin API request. */
|
|
441
|
-
function staticObjectProperty(raw: string | undefined, name: string): string | undefined {
|
|
442
|
-
if (raw === undefined) return undefined
|
|
443
|
-
const object = raw.trim()
|
|
444
|
-
if (!object.startsWith("{") || !object.endsWith("}")) return undefined
|
|
445
|
-
const properties = splitTopLevelArgs(object.slice(1, -1))
|
|
446
|
-
if (properties === undefined) return undefined
|
|
447
|
-
for (const property of properties) {
|
|
448
|
-
const colon = findTopLevelPropertyColon(property)
|
|
449
|
-
if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name) continue
|
|
450
|
-
return property.slice(colon + 1).trim()
|
|
451
|
-
}
|
|
452
|
-
return undefined
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function hasLiteralNifraDataHeader(content: string, openParen: number): boolean {
|
|
456
|
-
const closeParen = findMatchingParen(content, openParen)
|
|
457
|
-
if (closeParen === undefined) return false
|
|
458
|
-
const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen))
|
|
459
|
-
if (args === undefined || args.length !== 2) return false
|
|
460
|
-
const headers = staticObjectProperty(args[1], "headers")
|
|
461
|
-
return staticObjectProperty(headers, "x-nifra-data") !== undefined
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
|
|
465
|
-
* are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
|
|
466
|
-
* `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
|
|
467
|
-
* inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
|
|
468
|
-
* skipped too. */
|
|
469
|
-
export function scanFetchText(
|
|
470
|
-
file: string,
|
|
471
|
-
content: string,
|
|
472
|
-
externalMounts: readonly string[] = [],
|
|
473
|
-
): SourceFinding[] {
|
|
474
|
-
const out: SourceFinding[] = []
|
|
475
|
-
const lines = content.split("\n")
|
|
476
|
-
const code = codePositionMask(content)
|
|
477
|
-
GLOBAL_FETCH_CALL.lastIndex = 0
|
|
478
|
-
for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
|
|
479
|
-
const argument = firstArgumentIndex(content, m.index + m[0].length)
|
|
480
|
-
const quote = content[argument]
|
|
481
|
-
if (quote !== "'" && quote !== '"' && quote !== "`") continue
|
|
482
|
-
if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
|
|
483
|
-
const openParen = m.index + m[0].lastIndexOf("(")
|
|
484
|
-
if (hasLiteralNifraDataHeader(content, openParen)) continue
|
|
485
|
-
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
486
|
-
continue
|
|
487
|
-
const line = lineAt(content, m.index)
|
|
488
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
489
|
-
}
|
|
490
|
-
return out
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
494
|
-
export function scanStaticRouteText(
|
|
495
|
-
file: string,
|
|
496
|
-
content: string,
|
|
497
|
-
facts?: SourceFacts,
|
|
498
|
-
): StaticRouteFinding[] {
|
|
499
|
-
const code = stripComments(content)
|
|
500
|
-
if (!isPotentialBackendSource(code)) return []
|
|
501
|
-
return [
|
|
502
|
-
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
503
|
-
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
504
|
-
].sort(bySite)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* SQL built by interpolating a value into the statement text. The value is not a parameter, it is
|
|
509
|
-
* STATEMENT, so anything the caller controls can end the literal and continue as SQL.
|
|
510
|
-
*
|
|
511
|
-
* Two things this deliberately does not flag, because both are safe and flagging them would train
|
|
512
|
-
* people to ignore the rule:
|
|
513
|
-
*
|
|
514
|
-
* - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
|
|
515
|
-
* substitutions separately and binds them; that is the parameterised form, not a bypass of it.
|
|
516
|
-
* - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
|
|
517
|
-
* argued for.
|
|
518
|
-
*
|
|
519
|
-
* A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
|
|
520
|
-
* escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
|
|
521
|
-
* is to take a statement as text, and a substitution in one is the exact thing the name warns about.
|
|
522
|
-
*/
|
|
523
|
-
const SQL_METHODS = new Set([
|
|
524
|
-
"query",
|
|
525
|
-
"queryRaw",
|
|
526
|
-
"prepare",
|
|
527
|
-
"exec",
|
|
528
|
-
"execute",
|
|
529
|
-
"run",
|
|
530
|
-
"unsafe",
|
|
531
|
-
"$queryRawUnsafe",
|
|
532
|
-
"$executeRawUnsafe",
|
|
533
|
-
"raw",
|
|
534
|
-
])
|
|
535
|
-
const SQL_KEYWORD =
|
|
536
|
-
/\b(select|insert\s+into|update|delete\s+from|drop\s+table|create\s+table|alter\s+table|truncate|from|where|values|set|join|union)\b/i
|
|
537
|
-
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
|
|
538
|
-
|
|
539
|
-
interface SqlExpressionShape {
|
|
540
|
-
readonly staticText: string
|
|
541
|
-
readonly dynamic: boolean
|
|
542
|
-
readonly concatenated: boolean
|
|
543
|
-
readonly literal: boolean
|
|
544
|
-
readonly parameterizedTag: boolean
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
|
|
548
|
-
type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
|
|
549
|
-
|
|
550
|
-
/** A name some other module exports, named exactly as the edge that reaches it writes it. */
|
|
551
|
-
interface ExportEdge {
|
|
552
|
-
readonly specifier: string
|
|
553
|
-
readonly exported: string
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* One module's provable module-scope bindings: its top-level `const` initializers plus the exact
|
|
558
|
-
* import/export edges this scan is allowed to follow.
|
|
559
|
-
*
|
|
560
|
-
* Everything absent from these maps stays unresolved, and an unresolved interpolation keeps flagging.
|
|
561
|
-
* That is the whole safety argument: resolution can only ever make this rule QUIETER, so a form the
|
|
562
|
-
* scan cannot prove must fall out as "unknown" rather than be guessed at. Deliberately absent:
|
|
563
|
-
* `export default`, `export let`/`var`, destructured or computed names, default and namespace imports,
|
|
564
|
-
* `export * as ns`, `require`, and dynamic `import()`.
|
|
565
|
-
*/
|
|
566
|
-
interface SqlModuleScope {
|
|
567
|
-
/** The module's identity - an absolute path from the loader, or the entry file's path as given. Keys
|
|
568
|
-
* the parsed-scope cache and the cycle guard, and is the base its own specifiers resolve against. */
|
|
569
|
-
readonly id: string
|
|
570
|
-
readonly consts: ModuleConsts
|
|
571
|
-
/** Local name bound by `import { X as local }` → the edge that provides it. */
|
|
572
|
-
readonly imports: ReadonlyMap<string, ExportEdge>
|
|
573
|
-
/** Exported name → the local name it reads (`export { a as b }` → `b` → `a`). */
|
|
574
|
-
readonly localExports: ReadonlyMap<string, string>
|
|
575
|
-
/** Exported name → another module's export (`export { a as b } from "./m"`). */
|
|
576
|
-
readonly reExports: ReadonlyMap<string, ExportEdge>
|
|
577
|
-
/** `export * from "./m"` specifiers, in source order. */
|
|
578
|
-
readonly starExports: readonly string[]
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
|
|
582
|
-
* no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
|
|
583
|
-
* module-scope `const` is the only form that provably cannot carry request data. */
|
|
584
|
-
function sqlModuleScope(ts: TypeScriptApi, id: string, source: TSApi.SourceFile): SqlModuleScope {
|
|
585
|
-
const consts = new Map<string, TSApi.Expression>()
|
|
586
|
-
const imports = new Map<string, ExportEdge>()
|
|
587
|
-
const localExports = new Map<string, string>()
|
|
588
|
-
const reExports = new Map<string, ExportEdge>()
|
|
589
|
-
const starExports: string[] = []
|
|
590
|
-
for (const statement of source.statements) {
|
|
591
|
-
if (ts.isVariableStatement(statement)) {
|
|
592
|
-
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
|
|
593
|
-
const exported =
|
|
594
|
-
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
595
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
596
|
-
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
|
|
597
|
-
consts.set(declaration.name.text, declaration.initializer)
|
|
598
|
-
if (exported) localExports.set(declaration.name.text, declaration.name.text)
|
|
599
|
-
}
|
|
600
|
-
continue
|
|
601
|
-
}
|
|
602
|
-
if (ts.isImportDeclaration(statement)) {
|
|
603
|
-
const clause = statement.importClause
|
|
604
|
-
if (clause === undefined || clause.isTypeOnly) continue
|
|
605
|
-
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
|
|
606
|
-
const bindings = clause.namedBindings
|
|
607
|
-
// Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
|
|
608
|
-
// whose contents this scan cannot prove, so it stays unbound and every use of it flags.
|
|
609
|
-
if (bindings === undefined || !ts.isNamedImports(bindings)) continue
|
|
610
|
-
for (const element of bindings.elements) {
|
|
611
|
-
if (element.isTypeOnly) continue
|
|
612
|
-
imports.set(element.name.text, {
|
|
613
|
-
specifier: statement.moduleSpecifier.text,
|
|
614
|
-
exported: element.propertyName?.text ?? element.name.text,
|
|
615
|
-
})
|
|
616
|
-
}
|
|
617
|
-
continue
|
|
618
|
-
}
|
|
619
|
-
if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue
|
|
620
|
-
const from = statement.moduleSpecifier
|
|
621
|
-
if (from !== undefined && !ts.isStringLiteral(from)) continue
|
|
622
|
-
const clause = statement.exportClause
|
|
623
|
-
if (clause === undefined) {
|
|
624
|
-
// No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
|
|
625
|
-
// NamespaceExport clause instead and falls out below, unresolved, which is what we want.
|
|
626
|
-
if (from !== undefined) starExports.push(from.text)
|
|
627
|
-
continue
|
|
628
|
-
}
|
|
629
|
-
if (!ts.isNamedExports(clause)) continue
|
|
630
|
-
for (const element of clause.elements) {
|
|
631
|
-
if (element.isTypeOnly) continue
|
|
632
|
-
const local = element.propertyName?.text ?? element.name.text
|
|
633
|
-
if (from === undefined) localExports.set(element.name.text, local)
|
|
634
|
-
else reExports.set(element.name.text, { specifier: from.text, exported: local })
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
return { id, consts, imports, localExports, reExports, starExports }
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/** A module's identity + source, as resolved from a specifier. `id` must be stable and absolute: it
|
|
641
|
-
* keys the parsed-scope cache, seeds the cycle guard, and is what the module's own relative specifiers
|
|
642
|
-
* resolve against. */
|
|
643
|
-
export interface SqlModuleSource {
|
|
644
|
-
readonly id: string
|
|
645
|
-
readonly content: string
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Resolves a specifier written in the module `fromModule` to the target's identity + source, or
|
|
650
|
-
* `undefined` when it cannot be resolved, read, or proved to be in-project source.
|
|
651
|
-
*
|
|
652
|
-
* Injected rather than built in, for the same reason {@link ModuleResolver} is: it keeps
|
|
653
|
-
* {@link scanInterpolatedSql} pure and filesystem-free, so the resolution rules can be unit-tested
|
|
654
|
-
* against a virtual module graph. {@link createProjectSqlImports} is the on-disk implementation.
|
|
655
|
-
*/
|
|
656
|
-
export type SqlModuleLoader = (fromModule: string, specifier: string) => SqlModuleSource | undefined
|
|
657
|
-
|
|
658
|
-
/** Cross-module resolution state for one run: the loader plus a parsed-scope cache, so a fragments
|
|
659
|
-
* module imported by fifty callers is read and parsed once. A cached `undefined` is a REFUSAL (the
|
|
660
|
-
* module did not parse), cached so it is not re-attempted. Build with {@link createSqlImports}. */
|
|
661
|
-
export interface SqlImports {
|
|
662
|
-
readonly load: SqlModuleLoader
|
|
663
|
-
readonly scopes: Map<string, SqlModuleScope | undefined>
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
export const createSqlImports = (load: SqlModuleLoader): SqlImports => ({
|
|
667
|
-
load,
|
|
668
|
-
scopes: new Map(),
|
|
669
|
-
})
|
|
670
|
-
|
|
671
|
-
/** Where an expression's identifiers are read: the module the expression physically lives in, plus the
|
|
672
|
-
* run's cross-module state. `imports === undefined` is same-file-only resolution - the behavior before
|
|
673
|
-
* imported fragments were followed, and still what an unconfigured `scanInterpolatedSql` call does. */
|
|
674
|
-
interface SqlScope {
|
|
675
|
-
readonly module: SqlModuleScope
|
|
676
|
-
readonly imports: SqlImports | undefined
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
|
|
680
|
-
* a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
|
|
681
|
-
* parse contributes no findings and proves no constant. */
|
|
682
|
-
function parseSqlSource(
|
|
683
|
-
ts: TypeScriptApi,
|
|
684
|
-
file: string,
|
|
685
|
-
content: string,
|
|
686
|
-
): TSApi.SourceFile | undefined {
|
|
687
|
-
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
688
|
-
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
689
|
-
const parseDiagnostics = (
|
|
690
|
-
source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
691
|
-
).parseDiagnostics
|
|
692
|
-
return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
|
|
696
|
-
* already spends two, and the third leaves room for one more layer without letting the scan wander the
|
|
697
|
-
* module graph. Past the cap the name is unresolved, so the interpolation flags. */
|
|
698
|
-
const SQL_IMPORT_HOPS = 3
|
|
699
|
-
|
|
700
|
-
/** Load + parse the module a specifier names, through the run's cache. */
|
|
701
|
-
function loadSqlModuleScope(
|
|
702
|
-
ts: TypeScriptApi,
|
|
703
|
-
imports: SqlImports,
|
|
704
|
-
fromModule: string,
|
|
705
|
-
specifier: string,
|
|
706
|
-
): SqlModuleScope | undefined {
|
|
707
|
-
const loaded = imports.load(fromModule, specifier)
|
|
708
|
-
if (loaded === undefined) return undefined
|
|
709
|
-
if (imports.scopes.has(loaded.id)) return imports.scopes.get(loaded.id)
|
|
710
|
-
const source = parseSqlSource(ts, loaded.id, loaded.content)
|
|
711
|
-
const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source)
|
|
712
|
-
imports.scopes.set(loaded.id, scope)
|
|
713
|
-
return scope
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/** One exported name resolved to the `const` initializer behind it, together with the scope that
|
|
717
|
-
* initializer's OWN identifiers must be read in. */
|
|
718
|
-
interface ResolvedExport {
|
|
719
|
-
readonly expression: TSApi.Expression
|
|
720
|
-
readonly scope: SqlModuleScope
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* Follow an exported name to its `const` initializer, across re-export hops.
|
|
725
|
-
*
|
|
726
|
-
* Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
|
|
727
|
-
* `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
|
|
728
|
-
* barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
|
|
729
|
-
* a per-chain visited set, so a cyclic barrel terminates instead of recursing.
|
|
730
|
-
*
|
|
731
|
-
* Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
|
|
732
|
-
* picking one: guessing here would silence a real finding.
|
|
733
|
-
*/
|
|
734
|
-
function resolveExportedConst(
|
|
735
|
-
ts: TypeScriptApi,
|
|
736
|
-
imports: SqlImports,
|
|
737
|
-
scope: SqlModuleScope,
|
|
738
|
-
name: string,
|
|
739
|
-
hops: number,
|
|
740
|
-
seen: ReadonlySet<string>,
|
|
741
|
-
): ResolvedExport | undefined {
|
|
742
|
-
if (hops > SQL_IMPORT_HOPS) return undefined
|
|
743
|
-
const key = `${scope.id}#${name}`
|
|
744
|
-
if (seen.has(key)) return undefined
|
|
745
|
-
const nextSeen = new Set(seen).add(key)
|
|
746
|
-
const follow = (edge: ExportEdge): ResolvedExport | undefined => {
|
|
747
|
-
const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier)
|
|
748
|
-
return target === undefined
|
|
749
|
-
? undefined
|
|
750
|
-
: resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen)
|
|
751
|
-
}
|
|
752
|
-
const local = scope.localExports.get(name)
|
|
753
|
-
if (local !== undefined) {
|
|
754
|
-
const initializer = scope.consts.get(local)
|
|
755
|
-
if (initializer !== undefined) return { expression: initializer, scope }
|
|
756
|
-
// `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
|
|
757
|
-
// written in two statements. Anything else the name could be (a `let`, a function, a class) has no
|
|
758
|
-
// initializer in `consts` and no import edge, so it lands on `undefined` and flags.
|
|
759
|
-
const via = scope.imports.get(local)
|
|
760
|
-
return via === undefined ? undefined : follow(via)
|
|
761
|
-
}
|
|
762
|
-
const reExported = scope.reExports.get(name)
|
|
763
|
-
if (reExported !== undefined) return follow(reExported)
|
|
764
|
-
let found: ResolvedExport | undefined
|
|
765
|
-
for (const specifier of scope.starExports) {
|
|
766
|
-
const hit = follow({ specifier, exported: name })
|
|
767
|
-
if (hit === undefined) continue
|
|
768
|
-
if (found !== undefined) return undefined
|
|
769
|
-
found = hit
|
|
770
|
-
}
|
|
771
|
-
return found
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
|
|
775
|
-
* Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
|
|
776
|
-
* of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
|
|
777
|
-
* hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
|
|
778
|
-
* resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
|
|
779
|
-
* the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
|
|
780
|
-
* enclosing function or block reads module scope directly and cannot be shadowed. */
|
|
781
|
-
function isShadowedAt(ts: TypeScriptApi, at: TSApi.Node, name: string): boolean {
|
|
782
|
-
let scope: TSApi.Node | undefined
|
|
783
|
-
for (let node: TSApi.Node | undefined = at.parent; node !== undefined; node = node.parent) {
|
|
784
|
-
if (ts.isSourceFile(node)) break
|
|
785
|
-
if (ts.isFunctionLike(node) || ts.isBlock(node)) scope = node
|
|
786
|
-
}
|
|
787
|
-
if (scope === undefined) return false
|
|
788
|
-
const bindsName = (binding: TSApi.BindingName): boolean =>
|
|
789
|
-
ts.isIdentifier(binding)
|
|
790
|
-
? binding.text === name
|
|
791
|
-
: binding.elements.some(
|
|
792
|
-
(element) =>
|
|
793
|
-
!ts.isOmittedExpression(element) &&
|
|
794
|
-
(ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)),
|
|
795
|
-
)
|
|
796
|
-
let found = false
|
|
797
|
-
const scan = (node: TSApi.Node): void => {
|
|
798
|
-
if (found) return
|
|
799
|
-
if (
|
|
800
|
-
(ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
|
|
801
|
-
bindsName(node.name as TSApi.BindingName)
|
|
802
|
-
) {
|
|
803
|
-
found = true
|
|
804
|
-
return
|
|
805
|
-
}
|
|
806
|
-
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
807
|
-
found = true
|
|
808
|
-
return
|
|
809
|
-
}
|
|
810
|
-
ts.forEachChild(node, scan)
|
|
811
|
-
}
|
|
812
|
-
scan(scope)
|
|
813
|
-
return found
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const CONST_RESOLUTION_DEPTH = 5
|
|
817
|
-
|
|
818
|
-
/**
|
|
819
|
-
* Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
|
|
820
|
-
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
821
|
-
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
822
|
-
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
823
|
-
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
|
|
824
|
-
* initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
|
|
825
|
-
* `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
|
|
826
|
-
* call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
|
|
827
|
-
* interpolation is flagged exactly as before.
|
|
828
|
-
*/
|
|
829
|
-
function resolveStaticSqlText(
|
|
830
|
-
ts: TypeScriptApi,
|
|
831
|
-
node: TSApi.Expression,
|
|
832
|
-
scope: SqlScope,
|
|
833
|
-
depth: number,
|
|
834
|
-
): string | undefined {
|
|
835
|
-
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
836
|
-
if (ts.isParenthesizedExpression(node)) {
|
|
837
|
-
return resolveStaticSqlText(ts, node.expression, scope, depth)
|
|
838
|
-
}
|
|
839
|
-
if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
840
|
-
return resolveStaticSqlText(ts, node.expression, scope, depth)
|
|
841
|
-
}
|
|
842
|
-
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
|
843
|
-
if (ts.isNumericLiteral(node)) return node.text
|
|
844
|
-
if (ts.isTemplateExpression(node)) {
|
|
845
|
-
let text = node.head.text
|
|
846
|
-
for (const span of node.templateSpans) {
|
|
847
|
-
const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
|
|
848
|
-
if (part === undefined) return undefined
|
|
849
|
-
text += part + span.literal.text
|
|
850
|
-
}
|
|
851
|
-
return text
|
|
852
|
-
}
|
|
853
|
-
if (ts.isConditionalExpression(node)) {
|
|
854
|
-
const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1)
|
|
855
|
-
const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1)
|
|
856
|
-
if (whenTrue === undefined || whenFalse === undefined) return undefined
|
|
857
|
-
return `${whenTrue} ${whenFalse}`
|
|
858
|
-
}
|
|
859
|
-
if (ts.isIdentifier(node)) {
|
|
860
|
-
const initializer = scope.module.consts.get(node.text)
|
|
861
|
-
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
862
|
-
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
863
|
-
// module const nor an import binding.
|
|
864
|
-
if (initializer === undefined && edge === undefined) return undefined
|
|
865
|
-
// Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
|
|
866
|
-
// of an import hop: an initializer in the imported module sits at module scope with nothing above
|
|
867
|
-
// it to shadow it, while a use inside a function in this file is still refused.
|
|
868
|
-
if (isShadowedAt(ts, node, node.text)) return undefined
|
|
869
|
-
if (initializer !== undefined) return resolveStaticSqlText(ts, initializer, scope, depth + 1)
|
|
870
|
-
if (edge === undefined || scope.imports === undefined) return undefined
|
|
871
|
-
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
|
|
872
|
-
if (target === undefined) return undefined
|
|
873
|
-
const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
|
|
874
|
-
if (exported === undefined) return undefined
|
|
875
|
-
// The imported initializer's own identifiers read THAT module's consts, never this file's. A
|
|
876
|
-
// caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
|
|
877
|
-
// which is the one way cross-module resolution could resolve to text nobody wrote.
|
|
878
|
-
return resolveStaticSqlText(
|
|
879
|
-
ts,
|
|
880
|
-
exported.expression,
|
|
881
|
-
{ module: exported.scope, imports: scope.imports },
|
|
882
|
-
depth + 1,
|
|
883
|
-
)
|
|
884
|
-
}
|
|
885
|
-
return undefined
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
889
|
-
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
890
|
-
function templateShape(
|
|
891
|
-
ts: TypeScriptApi,
|
|
892
|
-
template: TSApi.TemplateExpression,
|
|
893
|
-
scope: SqlScope | undefined,
|
|
894
|
-
): { staticText: string; dynamic: boolean } {
|
|
895
|
-
let staticText = template.head.text
|
|
896
|
-
let dynamic = false
|
|
897
|
-
for (const span of template.templateSpans) {
|
|
898
|
-
const resolved =
|
|
899
|
-
scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
|
|
900
|
-
if (resolved === undefined) dynamic = true
|
|
901
|
-
else staticText += resolved
|
|
902
|
-
staticText += span.literal.text
|
|
903
|
-
}
|
|
904
|
-
return { staticText, dynamic }
|
|
905
|
-
}
|
|
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
|
-
|
|
1059
|
-
/** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
|
|
1060
|
-
function sqlExpressionShape(
|
|
1061
|
-
ts: TypeScriptApi,
|
|
1062
|
-
node: TSApi.Expression,
|
|
1063
|
-
scope?: SqlScope,
|
|
1064
|
-
): SqlExpressionShape {
|
|
1065
|
-
if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
|
|
1066
|
-
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
1067
|
-
return {
|
|
1068
|
-
staticText: node.text,
|
|
1069
|
-
dynamic: false,
|
|
1070
|
-
concatenated: false,
|
|
1071
|
-
literal: true,
|
|
1072
|
-
parameterizedTag: false,
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
if (ts.isTaggedTemplateExpression(node)) {
|
|
1076
|
-
const tag = node.tag
|
|
1077
|
-
const parameterizedTag =
|
|
1078
|
-
(ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
1079
|
-
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
1080
|
-
const template = node.template
|
|
1081
|
-
const templated = ts.isTemplateExpression(template)
|
|
1082
|
-
? templateShape(ts, template, parameterizedTag ? undefined : scope)
|
|
1083
|
-
: { staticText: template.text, dynamic: false }
|
|
1084
|
-
const staticText = templated.staticText
|
|
1085
|
-
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
1086
|
-
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
1087
|
-
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
1088
|
-
// deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
|
|
1089
|
-
// same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
|
|
1090
|
-
//
|
|
1091
|
-
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
1092
|
-
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
1093
|
-
// convention for, added so Nifra's source would pass its own rule. Names earn their place here by
|
|
1094
|
-
// being what drivers already call the thing, not by being convenient for the framework.
|
|
1095
|
-
//
|
|
1096
|
-
// Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
|
|
1097
|
-
// is treated as plain interpolation.
|
|
1098
|
-
return {
|
|
1099
|
-
staticText,
|
|
1100
|
-
dynamic: templated.dynamic && !parameterizedTag,
|
|
1101
|
-
concatenated: false,
|
|
1102
|
-
literal: !ts.isTemplateExpression(template),
|
|
1103
|
-
parameterizedTag,
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
if (ts.isTemplateExpression(node)) {
|
|
1107
|
-
const templated = templateShape(ts, node, scope)
|
|
1108
|
-
return {
|
|
1109
|
-
staticText: templated.staticText,
|
|
1110
|
-
dynamic: templated.dynamic,
|
|
1111
|
-
concatenated: false,
|
|
1112
|
-
literal: false,
|
|
1113
|
-
parameterizedTag: false,
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
1117
|
-
const left = sqlExpressionShape(ts, node.left, scope)
|
|
1118
|
-
const right = sqlExpressionShape(ts, node.right, scope)
|
|
1119
|
-
return {
|
|
1120
|
-
staticText: left.staticText + right.staticText,
|
|
1121
|
-
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
1122
|
-
// narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
|
|
1123
|
-
// a binding tag being concatenated into plain text (which un-parameterizes it).
|
|
1124
|
-
dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
|
|
1125
|
-
concatenated: true,
|
|
1126
|
-
literal: left.literal && right.literal,
|
|
1127
|
-
parameterizedTag: false,
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
// A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
|
|
1131
|
-
// text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
|
|
1132
|
-
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
|
|
1133
|
-
// `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
|
|
1134
|
-
// resolved const, because taking statement text from ANY variable is what their rule warns about.
|
|
1135
|
-
if (scope !== undefined) {
|
|
1136
|
-
const resolved = resolveStaticSqlText(ts, node, scope, 0)
|
|
1137
|
-
if (resolved !== undefined) {
|
|
1138
|
-
return {
|
|
1139
|
-
staticText: resolved,
|
|
1140
|
-
dynamic: false,
|
|
1141
|
-
concatenated: false,
|
|
1142
|
-
literal: false,
|
|
1143
|
-
parameterizedTag: false,
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
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
|
-
}
|
|
1158
|
-
return {
|
|
1159
|
-
staticText: "",
|
|
1160
|
-
dynamic: true,
|
|
1161
|
-
concatenated: false,
|
|
1162
|
-
literal: false,
|
|
1163
|
-
parameterizedTag: false,
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
function sqlMethod(
|
|
1168
|
-
ts: TypeScriptApi,
|
|
1169
|
-
call: TSApi.CallExpression,
|
|
1170
|
-
): { method: string; receiver: string } | undefined {
|
|
1171
|
-
if (ts.isPropertyAccessExpression(call.expression)) {
|
|
1172
|
-
const method = call.expression.name.text
|
|
1173
|
-
return SQL_METHODS.has(method)
|
|
1174
|
-
? { method, receiver: call.expression.expression.getText() }
|
|
1175
|
-
: undefined
|
|
1176
|
-
}
|
|
1177
|
-
if (
|
|
1178
|
-
ts.isElementAccessExpression(call.expression) &&
|
|
1179
|
-
call.expression.argumentExpression !== undefined &&
|
|
1180
|
-
ts.isStringLiteral(call.expression.argumentExpression)
|
|
1181
|
-
) {
|
|
1182
|
-
const method = call.expression.argumentExpression.text
|
|
1183
|
-
return SQL_METHODS.has(method)
|
|
1184
|
-
? { method, receiver: call.expression.expression.getText() }
|
|
1185
|
-
: undefined
|
|
1186
|
-
}
|
|
1187
|
-
return undefined
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
|
|
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
|
-
|
|
1197
|
-
/**
|
|
1198
|
-
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
1199
|
-
*
|
|
1200
|
-
* `imports` opts into following a fragment `const` into another module. Without it the scan is
|
|
1201
|
-
* same-file only, which is what the unit tests exercise and what any caller that has no module graph
|
|
1202
|
-
* to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
|
|
1203
|
-
*/
|
|
1204
|
-
export function scanInterpolatedSql(
|
|
1205
|
-
file: string,
|
|
1206
|
-
content: string,
|
|
1207
|
-
ts: TypeScriptApi,
|
|
1208
|
-
imports?: SqlImports,
|
|
1209
|
-
): SourceFinding[] {
|
|
1210
|
-
const out: SourceFinding[] = []
|
|
1211
|
-
const lines = content.split("\n")
|
|
1212
|
-
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
1213
|
-
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
1214
|
-
const source = parseSqlSource(ts, file, content)
|
|
1215
|
-
if (source === undefined) return out
|
|
1216
|
-
// Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
|
|
1217
|
-
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1218
|
-
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1219
|
-
const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
|
|
1220
|
-
const visit = (node: TSApi.Node): void => {
|
|
1221
|
-
if (ts.isCallExpression(node)) {
|
|
1222
|
-
const sink = sqlMethod(ts, node)
|
|
1223
|
-
const argument = node.arguments[0]
|
|
1224
|
-
if (sink !== undefined && argument !== undefined) {
|
|
1225
|
-
const shape = sqlExpressionShape(ts, argument, scope)
|
|
1226
|
-
const unsafeEscape =
|
|
1227
|
-
ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
|
|
1228
|
-
const assembledSql =
|
|
1229
|
-
shape.dynamic &&
|
|
1230
|
-
(SQL_KEYWORD.test(shape.staticText) ||
|
|
1231
|
-
(shape.concatenated && SQL_RECEIVER.test(sink.receiver)))
|
|
1232
|
-
if (unsafeEscape || assembledSql) {
|
|
1233
|
-
const index = node.getStart(source)
|
|
1234
|
-
const line = source.getLineAndCharacterOfPosition(index).line + 1
|
|
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() })
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
ts.forEachChild(node, visit)
|
|
1245
|
-
}
|
|
1246
|
-
visit(source)
|
|
1247
|
-
return out
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
/**
|
|
1251
|
-
* Collect interpolated-SQL findings across the project.
|
|
1252
|
-
*
|
|
1253
|
-
* Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
|
|
1254
|
-
* with the compiler, so without it there is nothing to report and, more to the point, no basis for
|
|
1255
|
-
* reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
|
|
1256
|
-
* answer a security rule must never give when it did not run.
|
|
1257
|
-
*/
|
|
1258
|
-
export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
|
|
1259
|
-
const ts = await importProjectTypeScript(cwd)
|
|
1260
|
-
if (ts === undefined) return undefined
|
|
1261
|
-
const out: SourceFinding[] = []
|
|
1262
|
-
const imports = createProjectSqlImports(cwd)
|
|
1263
|
-
await walkSource(cwd, (rel, content) =>
|
|
1264
|
-
out.push(...scanInterpolatedSql(rel, content, ts, imports)),
|
|
1265
|
-
)
|
|
1266
|
-
return out.sort(bySite)
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
/** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
|
|
1270
|
-
* initializer to read, so following one could only ever produce a "proved" the source never wrote. */
|
|
1271
|
-
const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/
|
|
1272
|
-
/** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
|
|
1273
|
-
const SQL_SOURCE_FILE = /\.[cm]?tsx?$/
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* The on-disk loader for cross-module `const` resolution, scoped to one project.
|
|
1277
|
-
*
|
|
1278
|
-
* Deliberately narrow, because every widening here is a way for a real finding to go quiet:
|
|
1279
|
-
*
|
|
1280
|
-
* - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
|
|
1281
|
-
* this project changing, so "provably a `const` string" is not a property the project owns.
|
|
1282
|
-
* - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
|
|
1283
|
-
* under the project root: a symlink pointing out of the tree is not source this scan reads. The
|
|
1284
|
-
* `node_modules` / `dist` / build exclusions are the same ones the walk applies.
|
|
1285
|
-
* - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
|
|
1286
|
-
*
|
|
1287
|
-
* A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
|
|
1288
|
-
* flagged - the pre-feature answer.
|
|
1289
|
-
*/
|
|
1290
|
-
export function createProjectSqlImports(cwd: string): SqlImports {
|
|
1291
|
-
const root = (() => {
|
|
1292
|
-
try {
|
|
1293
|
-
return realpathSync(resolve(cwd))
|
|
1294
|
-
} catch {
|
|
1295
|
-
return resolve(cwd)
|
|
1296
|
-
}
|
|
1297
|
-
})()
|
|
1298
|
-
return createSqlImports((fromModule, specifier) => {
|
|
1299
|
-
if (!isRelativeSpecifier(specifier)) return undefined
|
|
1300
|
-
try {
|
|
1301
|
-
// `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
|
|
1302
|
-
// every module reached through a hop (their ids are the resolved paths).
|
|
1303
|
-
const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule)
|
|
1304
|
-
const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)))
|
|
1305
|
-
if (!resolved.startsWith(root + sep)) return undefined
|
|
1306
|
-
const rel = resolved.slice(root.length + 1)
|
|
1307
|
-
if (IGNORED_DIR.test(rel)) return undefined
|
|
1308
|
-
if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved)) return undefined
|
|
1309
|
-
return { id: resolved, content: readFileSync(resolved, "utf8") }
|
|
1310
|
-
} catch {
|
|
1311
|
-
return undefined // unresolvable, unreadable, or outside the project
|
|
1312
|
-
}
|
|
1313
|
-
})
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
// `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
|
|
1317
|
-
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
1318
|
-
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
1319
|
-
// contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
|
|
1320
|
-
const CLIENT_CALL = /(?<![.\w])client\s*\(/g
|
|
1321
|
-
|
|
1322
|
-
/** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
|
|
1323
|
-
export function scanUntypedClient(file: string, content: string): SourceFinding[] {
|
|
1324
|
-
const out: SourceFinding[] = []
|
|
1325
|
-
const lines = content.split("\n")
|
|
1326
|
-
const code = codePositionMask(content)
|
|
1327
|
-
CLIENT_CALL.lastIndex = 0
|
|
1328
|
-
for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
|
|
1329
|
-
const argument = firstArgumentIndex(content, m.index + m[0].length)
|
|
1330
|
-
const quote = content[argument]
|
|
1331
|
-
if (quote !== "'" && quote !== '"' && quote !== "`") continue
|
|
1332
|
-
const line = lineAt(content, m.index)
|
|
1333
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
1334
|
-
}
|
|
1335
|
-
return out
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
/**
|
|
1339
|
-
* Specifiers that no longer exist, and what replaced them.
|
|
1340
|
-
*
|
|
1341
|
-
* These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
|
|
1342
|
-
* was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
|
|
1343
|
-
* the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
|
|
1344
|
-
* into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
|
|
1345
|
-
* that names neither the cause nor the replacement.
|
|
1346
|
-
*
|
|
1347
|
-
* A lint is the right shape for this: it runs before boot, names the file and line, and states the
|
|
1348
|
-
* replacement - which is what neither the resolver error nor the runtime failure does.
|
|
1349
|
-
*/
|
|
1350
|
-
const REMOVED_IMPORTS: ReadonlyArray<{
|
|
1351
|
-
readonly specifier: string
|
|
1352
|
-
readonly since: string
|
|
1353
|
-
readonly replacement: string
|
|
1354
|
-
/** Flag ONLY the bare side-effect form (`import "x"`). A module that still exports values is not
|
|
1355
|
-
* removed - only its install-by-import behaviour is - so flagging every import of it would be
|
|
1356
|
-
* wrong and would train people to ignore the rule. */
|
|
1357
|
-
readonly sideEffectOnly?: boolean
|
|
1358
|
-
}> = [
|
|
1359
|
-
{
|
|
1360
|
-
specifier: "@nifrajs/budget",
|
|
1361
|
-
since: "2.0",
|
|
1362
|
-
replacement:
|
|
1363
|
-
'import from "@nifrajs/core/budget" - the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
|
|
1364
|
-
},
|
|
1365
|
-
{
|
|
1366
|
-
specifier: "@nifrajs/core/ws",
|
|
1367
|
-
since: "2.0",
|
|
1368
|
-
sideEffectOnly: true,
|
|
1369
|
-
replacement:
|
|
1370
|
-
'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
|
|
1371
|
-
},
|
|
1372
|
-
]
|
|
1373
|
-
|
|
1374
|
-
/** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
|
|
1375
|
-
* one with bindings (`import { y } from "x"`). */
|
|
1376
|
-
function isSideEffectImport(content: string, index: number): boolean {
|
|
1377
|
-
// `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
|
|
1378
|
-
// a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
|
|
1379
|
-
// no `from` in between.
|
|
1380
|
-
return /^import\s*["'`]/.test(content.slice(index, index + 32))
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
/** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
|
|
1384
|
-
* of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
|
|
1385
|
-
export function scanRemovedImports(file: string, content: string): SourceFinding[] {
|
|
1386
|
-
const out: SourceFinding[] = []
|
|
1387
|
-
const lines = content.split("\n")
|
|
1388
|
-
for (const edge of staticImportEdges(content)) {
|
|
1389
|
-
const removed = REMOVED_IMPORTS.find(
|
|
1390
|
-
(entry) =>
|
|
1391
|
-
edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
|
|
1392
|
-
)
|
|
1393
|
-
if (removed === undefined) continue
|
|
1394
|
-
if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index)) continue
|
|
1395
|
-
const line = lineAt(content, edge.index)
|
|
1396
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
1397
|
-
}
|
|
1398
|
-
return out
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
1402
|
-
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
1403
|
-
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
1404
|
-
export function scanServerOnlyImports(
|
|
1405
|
-
file: string,
|
|
1406
|
-
content: string,
|
|
1407
|
-
facts?: SourceFacts,
|
|
1408
|
-
): ServerImportFinding[] {
|
|
1409
|
-
if (!ROUTE_FILE.test(file)) return []
|
|
1410
|
-
const out: ServerImportFinding[] = []
|
|
1411
|
-
const lines = content.split("\n")
|
|
1412
|
-
const code = stripComments(content)
|
|
1413
|
-
const positions = codePositionMask(content)
|
|
1414
|
-
STATIC_IMPORT.lastIndex = 0
|
|
1415
|
-
for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
|
|
1416
|
-
if (positions[m.index] === " ") continue
|
|
1417
|
-
const specifier = m[1] ?? ""
|
|
1418
|
-
if (!SERVER_ONLY.test(specifier)) continue
|
|
1419
|
-
if (facts !== undefined) {
|
|
1420
|
-
const source = facts.parse(file, content)
|
|
1421
|
-
// Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
|
|
1422
|
-
// runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
|
|
1423
|
-
if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
|
|
1424
|
-
continue
|
|
1425
|
-
}
|
|
1426
|
-
const line = lineAt(content, m.index)
|
|
1427
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
|
|
1428
|
-
}
|
|
1429
|
-
return out
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// ---------------------------------------------------------------------------------------------------
|
|
1433
|
-
// #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
|
|
1434
|
-
// pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
|
|
1435
|
-
// `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
|
|
1436
|
-
// `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
|
|
1437
|
-
// import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
|
|
1438
|
-
// local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
|
|
1439
|
-
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
1440
|
-
// ---------------------------------------------------------------------------------------------------
|
|
1441
|
-
|
|
1442
|
-
// Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
|
|
1443
|
-
// npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
|
|
1444
|
-
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
1445
|
-
const SERVER_ONLY_SINK =
|
|
1446
|
-
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
|
|
1447
|
-
// The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
|
|
1448
|
-
const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
1449
|
-
// The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
|
|
1450
|
-
// guard. A resolved file whose source carries this side-effect import is a server-only sink.
|
|
1451
|
-
const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
|
|
1452
|
-
// Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
|
|
1453
|
-
// few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
|
|
1454
|
-
const TRANSITIVE_MAX_DEPTH = 8
|
|
1455
|
-
const TRANSITIVE_MAX_VISITED = 200
|
|
1456
|
-
|
|
1457
|
-
/** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
|
|
1458
|
-
* is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
|
|
1459
|
-
const isRelativeSpecifier = (spec: string): boolean =>
|
|
1460
|
-
spec.startsWith("./") || spec.startsWith("../")
|
|
1461
|
-
|
|
1462
|
-
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
|
|
1463
|
-
// per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
|
|
1464
|
-
// `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
|
|
1465
|
-
// loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
|
|
1466
|
-
const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags)
|
|
1467
|
-
|
|
1468
|
-
/** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
|
|
1469
|
-
* each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
|
|
1470
|
-
* dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
|
|
1471
|
-
* transitive walk. Pure. */
|
|
1472
|
-
function staticImportEdges(
|
|
1473
|
-
content: string,
|
|
1474
|
-
facts?: SourceFacts,
|
|
1475
|
-
file?: string,
|
|
1476
|
-
): Array<{ specifier: string; index: number }> {
|
|
1477
|
-
const edges: Array<{ specifier: string; index: number }> = []
|
|
1478
|
-
const re = staticImportRegex()
|
|
1479
|
-
const code = stripComments(content)
|
|
1480
|
-
const positions = codePositionMask(content)
|
|
1481
|
-
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
1482
|
-
if (positions[m.index] === " ") continue
|
|
1483
|
-
if (m[1] === undefined) continue
|
|
1484
|
-
if (facts !== undefined && file !== undefined) {
|
|
1485
|
-
const source = facts.parse(file, content)
|
|
1486
|
-
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
|
|
1487
|
-
}
|
|
1488
|
-
edges.push({ specifier: m[1], index: m.index })
|
|
1489
|
-
}
|
|
1490
|
-
return edges
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
/** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
|
|
1494
|
-
* Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
|
|
1495
|
-
export function parseStaticImports(content: string, facts?: SourceFacts, file?: string): string[] {
|
|
1496
|
-
return staticImportEdges(content, facts, file).map((e) => e.specifier)
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
1500
|
-
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1501
|
-
* itself (it's already the actionable name). */
|
|
1502
|
-
export function directSinkSpecifier(spec: string): string | undefined {
|
|
1503
|
-
return SERVER_ONLY_SINK.test(spec) ? spec : undefined
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
/** One transitive server-only finding: the route module, the route's offending top-level import (the
|
|
1507
|
-
* first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
|
|
1508
|
-
export interface TransitiveServerImportFinding extends ServerImportFinding {
|
|
1509
|
-
/** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
|
|
1510
|
-
* the route imports the sink directly (same as the regex scan's direct edge). */
|
|
1511
|
-
readonly chain: readonly string[]
|
|
1512
|
-
/** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
|
|
1513
|
-
* so the chain is the honest direct edge rather than a fabricated deeper path. */
|
|
1514
|
-
readonly fallback: boolean
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
/** A resolver from `(fromFile, specifier)` to an absolute module path, or `undefined` when it can't be
|
|
1518
|
-
* resolved precisely (bare pkg, path alias, missing file). Abstracted so the BFS is unit-testable with a
|
|
1519
|
-
* fake graph (no real fs). The production resolver wraps `Bun.resolveSync`. */
|
|
1520
|
-
export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
|
|
1521
|
-
/** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
|
|
1522
|
-
export type ModuleReader = (absPath: string) => string | undefined
|
|
1523
|
-
|
|
1524
|
-
/**
|
|
1525
|
-
* BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
|
|
1526
|
-
* sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
|
|
1527
|
-
* node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
|
|
1528
|
-
* that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
|
|
1529
|
-
* unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
|
|
1530
|
-
* `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
|
|
1531
|
-
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
1532
|
-
* - pure given those, so it's unit-testable with a fake graph.
|
|
1533
|
-
*/
|
|
1534
|
-
export function walkServerOnlyChain(
|
|
1535
|
-
routeFile: string,
|
|
1536
|
-
routeContent: string,
|
|
1537
|
-
resolve: ModuleResolver,
|
|
1538
|
-
read: ModuleReader,
|
|
1539
|
-
facts?: SourceFacts,
|
|
1540
|
-
): readonly string[] | undefined {
|
|
1541
|
-
// Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
|
|
1542
|
-
// (route + the as-written specifier of each hop), and the file the imports resolve relative to.
|
|
1543
|
-
interface Node {
|
|
1544
|
-
readonly abs: string
|
|
1545
|
-
readonly content: string
|
|
1546
|
-
readonly chain: readonly string[]
|
|
1547
|
-
readonly depth: number
|
|
1548
|
-
}
|
|
1549
|
-
const seen = new Set<string>([routeFile])
|
|
1550
|
-
let frontier: Node[] = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }]
|
|
1551
|
-
let visited = 0
|
|
1552
|
-
while (frontier.length > 0) {
|
|
1553
|
-
const next: Node[] = []
|
|
1554
|
-
for (const node of frontier) {
|
|
1555
|
-
if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
|
|
1556
|
-
for (const spec of parseStaticImports(node.content, facts, node.abs)) {
|
|
1557
|
-
// (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
|
|
1558
|
-
// since BFS reaches the nearest sink before any deeper one).
|
|
1559
|
-
const sink = directSinkSpecifier(spec)
|
|
1560
|
-
if (sink !== undefined) return [...node.chain, sink]
|
|
1561
|
-
// (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
|
|
1562
|
-
// specifier that isn't a known sink is a leaf - don't follow it into node_modules.
|
|
1563
|
-
if (!isRelativeSpecifier(spec)) continue
|
|
1564
|
-
const abs = resolve(node.abs, spec)
|
|
1565
|
-
if (abs === undefined || seen.has(abs)) continue
|
|
1566
|
-
// (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
|
|
1567
|
-
// ends at it (named by the as-written specifier).
|
|
1568
|
-
if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
|
|
1569
|
-
const content = read(abs)
|
|
1570
|
-
if (content === undefined) continue // unreadable → can't walk; treat as a leaf
|
|
1571
|
-
// (d) A resolved module that opts into the `server-only` marker is a sink too.
|
|
1572
|
-
if (SERVER_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
|
|
1573
|
-
if (visited >= TRANSITIVE_MAX_VISITED) continue
|
|
1574
|
-
visited++
|
|
1575
|
-
seen.add(abs)
|
|
1576
|
-
next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 })
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
frontier = next
|
|
1580
|
-
}
|
|
1581
|
-
return undefined
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
/**
|
|
1585
|
-
* Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
|
|
1586
|
-
* reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
|
|
1587
|
-
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
1588
|
-
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
1589
|
-
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
1590
|
-
* de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
|
|
1591
|
-
*/
|
|
1592
|
-
export function resolveServerOnlyChains(
|
|
1593
|
-
file: string,
|
|
1594
|
-
content: string,
|
|
1595
|
-
resolve: ModuleResolver,
|
|
1596
|
-
read: ModuleReader,
|
|
1597
|
-
facts?: SourceFacts,
|
|
1598
|
-
): TransitiveServerImportFinding[] {
|
|
1599
|
-
if (!ROUTE_FILE.test(file)) return []
|
|
1600
|
-
const lines = content.split("\n")
|
|
1601
|
-
const out: TransitiveServerImportFinding[] = []
|
|
1602
|
-
const flaggedSpecifiers = new Set<string>()
|
|
1603
|
-
// Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
|
|
1604
|
-
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1605
|
-
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1606
|
-
for (const { specifier, index } of staticImportEdges(content, facts, file)) {
|
|
1607
|
-
if (flaggedSpecifiers.has(specifier)) continue
|
|
1608
|
-
const line = lineAt(content, index)
|
|
1609
|
-
const snippet = (lines[line - 1] ?? "").trim()
|
|
1610
|
-
// (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
|
|
1611
|
-
// direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
|
|
1612
|
-
if (directSinkSpecifier(specifier) !== undefined) {
|
|
1613
|
-
flaggedSpecifiers.add(specifier)
|
|
1614
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1615
|
-
continue
|
|
1616
|
-
}
|
|
1617
|
-
// (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
|
|
1618
|
-
// a sink, emit the full chain rooted at THIS route's import line.
|
|
1619
|
-
if (isRelativeSpecifier(specifier)) {
|
|
1620
|
-
const abs = resolve(file, specifier)
|
|
1621
|
-
if (abs === undefined) {
|
|
1622
|
-
// Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
|
|
1623
|
-
// flags directly, fall back to the direct edge (precise resolve impossible - say so via
|
|
1624
|
-
// `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
|
|
1625
|
-
if (SERVER_ONLY.test(specifier)) {
|
|
1626
|
-
flaggedSpecifiers.add(specifier)
|
|
1627
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
|
|
1628
|
-
}
|
|
1629
|
-
continue
|
|
1630
|
-
}
|
|
1631
|
-
// The `.server` / marker sink can be the first hop itself.
|
|
1632
|
-
if (SERVER_MODULE_FILE.test(abs)) {
|
|
1633
|
-
flaggedSpecifiers.add(specifier)
|
|
1634
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1635
|
-
continue
|
|
1636
|
-
}
|
|
1637
|
-
const depContent = read(abs)
|
|
1638
|
-
if (depContent === undefined) continue
|
|
1639
|
-
if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
|
|
1640
|
-
flaggedSpecifiers.add(specifier)
|
|
1641
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1642
|
-
continue
|
|
1643
|
-
}
|
|
1644
|
-
// Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
|
|
1645
|
-
const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
|
|
1646
|
-
if (subChain !== undefined) {
|
|
1647
|
-
flaggedSpecifiers.add(specifier)
|
|
1648
|
-
// subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
|
|
1649
|
-
// route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
|
|
1650
|
-
const chain = [file, specifier, ...subChain.slice(1)]
|
|
1651
|
-
out.push({ file, line, snippet, specifier, chain, fallback: false })
|
|
1652
|
-
continue
|
|
1653
|
-
}
|
|
1654
|
-
// (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
|
|
1655
|
-
// found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
|
|
1656
|
-
// direct edge so we still surface the known-server-only convention rather than going silent.
|
|
1657
|
-
if (SERVER_ONLY.test(specifier)) {
|
|
1658
|
-
flaggedSpecifiers.add(specifier)
|
|
1659
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
return out
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
// A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
|
|
1667
|
-
// typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
|
|
1668
|
-
// silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
|
|
1669
|
-
// (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
|
|
1670
|
-
const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g
|
|
1671
|
-
|
|
1672
|
-
/** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
|
|
1673
|
-
* the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
|
|
1674
|
-
export function scanResponseRoutes(
|
|
1675
|
-
file: string,
|
|
1676
|
-
content: string,
|
|
1677
|
-
facts?: SourceFacts,
|
|
1678
|
-
): SourceFinding[] {
|
|
1679
|
-
// Strip comments + template literals first: a commented-out or doc-example `return new Response(`
|
|
1680
|
-
// must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
|
|
1681
|
-
const code = stripComments(content)
|
|
1682
|
-
if (!/(?<![.\w])server\s*\(/.test(code)) return []
|
|
1683
|
-
const out: SourceFinding[] = []
|
|
1684
|
-
const lines = content.split("\n")
|
|
1685
|
-
RESPONSE_RETURN.lastIndex = 0
|
|
1686
|
-
for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
|
|
1687
|
-
if (facts !== undefined) {
|
|
1688
|
-
const source = facts.parse(file, content)
|
|
1689
|
-
// A parseable AST rejects `return new Response(` text that only lives in a normal string. On
|
|
1690
|
-
// malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
|
|
1691
|
-
if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
|
|
1692
|
-
}
|
|
1693
|
-
const line = lineAt(content, m.index)
|
|
1694
|
-
// A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
|
|
1695
|
-
// be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
|
|
1696
|
-
// ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
|
|
1697
|
-
// leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
|
|
1698
|
-
// `code` (so it never affects the match); the raw `lines` still carry it.
|
|
1699
|
-
const thisLine = lines[line - 1] ?? ""
|
|
1700
|
-
const aboveLine = lines[line - 2] ?? ""
|
|
1701
|
-
const aboveSuppresses =
|
|
1702
|
-
aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
|
|
1703
|
-
if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
|
|
1704
|
-
out.push({ file, line, snippet: thisLine.trim() })
|
|
1705
|
-
}
|
|
1706
|
-
return out
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
/** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
1710
|
-
const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
|
|
1711
|
-
|
|
1712
|
-
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
1713
|
-
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
1714
|
-
*
|
|
1715
|
-
* `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
|
|
1716
|
-
* for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
|
|
1717
|
-
* build that a clean install cannot produce - the exact false confidence doctor is for. */
|
|
1718
|
-
/**
|
|
1719
|
-
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
|
|
1720
|
-
* (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
|
|
1721
|
-
* is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
|
|
1722
|
-
* that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
|
|
1723
|
-
* findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
|
|
1724
|
-
* repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
|
|
1725
|
-
*/
|
|
1726
|
-
async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<string>> {
|
|
1727
|
-
if (rels.length === 0) return new Set()
|
|
1728
|
-
try {
|
|
1729
|
-
const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
|
|
1730
|
-
cwd,
|
|
1731
|
-
stdin: "pipe",
|
|
1732
|
-
stdout: "pipe",
|
|
1733
|
-
stderr: "ignore",
|
|
1734
|
-
})
|
|
1735
|
-
proc.stdin.write(`${rels.join("\n")}\n`)
|
|
1736
|
-
await proc.stdin.end()
|
|
1737
|
-
// git echoes back each ignored path exactly as fed on stdin (one per line); exit 1 = none matched.
|
|
1738
|
-
const out = await new Response(proc.stdout).text()
|
|
1739
|
-
await proc.exited
|
|
1740
|
-
return new Set(
|
|
1741
|
-
out
|
|
1742
|
-
.split("\n")
|
|
1743
|
-
.map((s) => s.trim())
|
|
1744
|
-
.filter(Boolean),
|
|
1745
|
-
)
|
|
1746
|
-
} catch {
|
|
1747
|
-
return new Set()
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
export async function walkSource(
|
|
1752
|
-
cwd: string,
|
|
1753
|
-
visit: (rel: string, content: string) => void,
|
|
1754
|
-
opts: { readonly includeTests?: boolean } = {},
|
|
1755
|
-
): Promise<void> {
|
|
1756
|
-
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
|
|
1757
|
-
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
1758
|
-
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
1759
|
-
const rels: string[] = []
|
|
1760
|
-
for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
|
|
1761
|
-
if (!skip.test(rel)) rels.push(rel)
|
|
1762
|
-
}
|
|
1763
|
-
const ignored = await gitIgnored(cwd, rels)
|
|
1764
|
-
for (const rel of rels) {
|
|
1765
|
-
if (ignored.has(rel)) continue
|
|
1766
|
-
visit(rel, await Bun.file(join(cwd, rel)).text())
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|
|
1771
|
-
a.file.localeCompare(b.file) || a.line - b.line
|
|
1772
|
-
|
|
1773
|
-
/** Collect own-API `fetch()` findings across the project. */
|
|
1774
|
-
export async function scanProject(cwd: string): Promise<SourceFinding[]> {
|
|
1775
|
-
const out: SourceFinding[] = []
|
|
1776
|
-
await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)))
|
|
1777
|
-
return out.sort(bySite)
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
// #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
|
|
1781
|
-
// list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
|
|
1782
|
-
// regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
|
|
1783
|
-
// We diff each committed manifest's imported route files against the live `routes/` tree.
|
|
1784
|
-
|
|
1785
|
-
// The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
|
|
1786
|
-
// manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
|
|
1787
|
-
const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
|
|
1788
|
-
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
|
|
1789
|
-
// locate the routes dir relative to the manifest, and to strip to route-relative keys.
|
|
1790
|
-
const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
|
|
1791
|
-
// Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
|
|
1792
|
-
const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
|
|
1793
|
-
|
|
1794
|
-
export interface ManifestDriftFinding {
|
|
1795
|
-
/** The committed server-manifest file (relative to cwd). */
|
|
1796
|
-
readonly file: string
|
|
1797
|
-
/** Route files in `routes/` missing from the manifest (stale manifest - these routes won't serve). */
|
|
1798
|
-
readonly missing: readonly string[]
|
|
1799
|
-
/** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
|
|
1800
|
-
readonly extra: readonly string[]
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
/**
|
|
1804
|
-
* Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
|
|
1805
|
-
* from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
|
|
1806
|
-
* prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
|
|
1807
|
-
* imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
|
|
1808
|
-
* on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
|
|
1809
|
-
* a per-manifest miss (an unreadable routes dir is simply skipped).
|
|
1810
|
-
*/
|
|
1811
|
-
export async function scanServerManifestDrift(cwd: string): Promise<ManifestDriftFinding[]> {
|
|
1812
|
-
const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import(
|
|
1813
|
-
"@nifrajs/web/build"
|
|
1814
|
-
)
|
|
1815
|
-
const findings: ManifestDriftFinding[] = []
|
|
1816
|
-
for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
|
|
1817
|
-
if (IGNORED.test(rel)) continue
|
|
1818
|
-
const source = await Bun.file(join(cwd, rel)).text()
|
|
1819
|
-
if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest - skip
|
|
1820
|
-
const prefixMatch = ROUTES_PREFIX.exec(source)
|
|
1821
|
-
if (prefixMatch?.[1] === undefined) continue // no `routes/` imports → nothing to diff (empty app)
|
|
1822
|
-
const routesPrefix = prefixMatch[1]
|
|
1823
|
-
const manifestFiles = parseManifestRouteFiles(source, routesPrefix)
|
|
1824
|
-
// The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
|
|
1825
|
-
const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
1826
|
-
const routesDir = join(cwd, manifestDir, routesPrefix)
|
|
1827
|
-
let discovered: string[]
|
|
1828
|
-
try {
|
|
1829
|
-
discovered = (
|
|
1830
|
-
await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir }))
|
|
1831
|
-
)
|
|
1832
|
-
.map((f) => f.replaceAll("\\", "/"))
|
|
1833
|
-
.filter((f) => ROUTE_FILE_EXT.test(f))
|
|
1834
|
-
} catch {
|
|
1835
|
-
continue // routes dir gone/unreadable - not a drift we can assess
|
|
1836
|
-
}
|
|
1837
|
-
const drift = diffManifestRoutes(manifestFiles, discovered)
|
|
1838
|
-
if (!isManifestInSync(drift)) {
|
|
1839
|
-
findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
return findings.sort((a, b) => a.file.localeCompare(b.file))
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
70
|
interface TypecheckResult {
|
|
1846
71
|
readonly ran: boolean
|
|
1847
72
|
readonly ok: boolean
|
|
@@ -1921,129 +146,8 @@ async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckRe
|
|
|
1921
146
|
}
|
|
1922
147
|
|
|
1923
148
|
// `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
|
|
1924
|
-
const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
|
|
1925
|
-
|
|
1926
|
-
/** A single machine-readable check failure - the unit an agent (or CI) acts on. */
|
|
1927
|
-
export interface CheckDiagnostic {
|
|
1928
|
-
readonly rule: string
|
|
1929
|
-
/** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
|
|
1930
|
-
* does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
|
|
1931
|
-
* `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
|
|
1932
|
-
readonly severity: "error" | "warning" | "info"
|
|
1933
|
-
/** Stable diagnostic protocol code. The legacy `rule` remains for compatibility. */
|
|
1934
|
-
readonly code?: string
|
|
1935
|
-
readonly file?: string
|
|
1936
|
-
readonly line?: number
|
|
1937
|
-
readonly message: string
|
|
1938
|
-
/** The canonical, rule-level fix - clean of the per-occurrence snippet, so an agent can apply it
|
|
1939
|
-
* directly. Set for the lint rules (they have one correct fix); omitted for `typecheck` (the fix is
|
|
1940
|
-
* specific to each type error). */
|
|
1941
|
-
readonly fix?: string
|
|
1942
|
-
/** A richer, agent-oriented fix hint. Diffs are only emitted when the edit is mechanical and local;
|
|
1943
|
-
* ambiguous cases give concrete steps instead of pretending the checker can safely rewrite code. */
|
|
1944
|
-
readonly suggestion?: CheckSuggestion
|
|
1945
|
-
/**
|
|
1946
|
-
* The import chain that pulls server-only code into the browser bundle, as display labels
|
|
1947
|
-
* `[routeFile, …as-written specifiers…, sink]`. Set only on `server-only-import`.
|
|
1948
|
-
*
|
|
1949
|
-
* #4.4: this is now the FULL **transitive** chain - a bounded import-resolution walk (`Bun.resolveSync`
|
|
1950
|
-
* from each file's dir, BFS the local module graph) follows `route → ../data → ../db → node:crypto`,
|
|
1951
|
-
* matching the build leak-guard's depth (`detectNodeBuiltinsInClient` in `@nifrajs/web/build`). A
|
|
1952
|
-
* length-2 chain (`[routeFile, specifier]`) means the route imports the sink directly. When a hop can't
|
|
1953
|
-
* be resolved precisely (a bare pkg, a tsconfig path alias), the walk degrades to the honest direct
|
|
1954
|
-
* edge rather than fabricating a deeper path - never a lie.
|
|
1955
|
-
*/
|
|
1956
|
-
readonly chain?: readonly string[]
|
|
1957
|
-
readonly evidence?: readonly string[]
|
|
1958
|
-
readonly verify?: string
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
export interface CheckSuggestion {
|
|
1962
|
-
readonly kind: "edit" | "command" | "manual"
|
|
1963
|
-
readonly title: string
|
|
1964
|
-
readonly diff?: string
|
|
1965
|
-
/** argv array, not a shell string, so MCP clients can run it without quoting hazards. */
|
|
1966
|
-
readonly command?: readonly string[]
|
|
1967
|
-
readonly steps?: readonly string[]
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
/** The structured result of a full check - what `--json` prints and the `nifra_check` MCP tool returns. */
|
|
1971
|
-
export interface CheckResult {
|
|
1972
|
-
readonly ok: boolean
|
|
1973
|
-
readonly typecheck: "pass" | "fail" | "skipped"
|
|
1974
|
-
/** Why `typecheck` is `"skipped"` (no tsconfig.json, typescript not installed, lints-only mode).
|
|
1975
|
-
* Absent when the typecheck ran. Echoed in the human report so a skip is never a dim mystery. */
|
|
1976
|
-
readonly typecheckNote?: string
|
|
1977
|
-
readonly diagnostics: readonly CheckDiagnostic[]
|
|
1978
|
-
/** Normalized diagnostics with stable codes for agents and external renderers. */
|
|
1979
|
-
readonly structuredDiagnostics?: readonly Diagnostic[]
|
|
1980
|
-
/**
|
|
1981
|
-
* Which bundler this app's phases run on, read statically from the config, and how nifra concluded
|
|
1982
|
-
* it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
|
|
1983
|
-
* slot is live and which toolchain compiles a component before its next edit, and every `pipeline`
|
|
1984
|
-
* diagnostic below is only interpretable against it. Absent when the directory is not a nifra app.
|
|
1985
|
-
*/
|
|
1986
|
-
readonly pipeline?: PipelineReport
|
|
1987
|
-
/** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
|
|
1988
|
-
* better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
|
|
1989
|
-
* deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
|
|
1990
|
-
readonly externalMounts?: readonly string[]
|
|
1991
|
-
/** Active per-rule overrides from `nifra.check.json` `rules`, echoed verbatim so a retagged or
|
|
1992
|
-
* suppressed finding stays auditable in `--json`, the MCP tool, and the human report - config can
|
|
1993
|
-
* lower (or raise) the gate, but never invisibly. */
|
|
1994
|
-
readonly ruleOverrides?: Readonly<Record<string, RuleOverride>>
|
|
1995
|
-
/** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
|
|
1996
|
-
* first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
|
|
1997
|
-
* message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
|
|
1998
|
-
readonly truncated?: { readonly shown: number; readonly total: number }
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
/**
|
|
2002
|
-
* Pre-resolved route-assurance inputs, so the same reflection that `nifra assure` / `nifra levels`
|
|
2003
|
-
* already ran can feed `check`'s capability + trust-manifest diagnostics instead of a second pass.
|
|
2004
|
-
* Supplied by {@link collectProjectVerification}. When omitted, `collectCheckResult` loads and computes
|
|
2005
|
-
* these itself (the standalone `nifra_check` MCP path); the two routes produce byte-identical results.
|
|
2006
|
-
*/
|
|
2007
|
-
export interface CheckAssuranceContext {
|
|
2008
|
-
/** Whether `nifra.assurance.ts` exists: the gate for running the assurance-fed diagnostics at all. */
|
|
2009
|
-
readonly present: boolean
|
|
2010
|
-
/** The loaded config, when it loaded. Absent when the file is missing or {@link error} is set. */
|
|
2011
|
-
readonly config?: AssuranceConfig
|
|
2012
|
-
/** The failure from loading/evaluating the config, surfaced as a `capability-config` diagnostic. */
|
|
2013
|
-
readonly error?: unknown
|
|
2014
|
-
/** `evaluateRouteAssurance` over the config's source + policy (drives the trust-manifest check). */
|
|
2015
|
-
readonly routeAssurance?: AssuranceReport
|
|
2016
|
-
/** Static capability provenance, when the config declares a capabilities policy. */
|
|
2017
|
-
readonly capability?: CapabilityProjectReport
|
|
2018
|
-
/** Canonical token-only evidence reused by manifest and other offline projections. */
|
|
2019
|
-
readonly evidence?: ProjectEvidenceSnapshot
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
/** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
|
|
2023
|
-
* the app is built or even importable, preserving check's pre-`loadApp` invariant. */
|
|
2024
|
-
export interface CheckConfig {
|
|
2025
|
-
readonly externalMounts: readonly string[]
|
|
2026
|
-
readonly rules: Readonly<Record<string, RuleOverride>>
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
/**
|
|
2030
|
-
* One entry of `nifra.check.json` `rules`, keyed by legacy rule name (`response-route`) or stable
|
|
2031
|
-
* NF- code (`NF-S002`) - one key retags the finding in both diagnostic views. `severity: "off"`
|
|
2032
|
-
* drops the rule's findings; `ignore` drops findings whose file matches any of the globs. Overrides
|
|
2033
|
-
* are applied centrally BEFORE `ok` is computed and echoed in the result and the human report -
|
|
2034
|
-
* configuration can lower (or raise) the gate, but never invisibly.
|
|
2035
|
-
*/
|
|
2036
|
-
export interface RuleOverride {
|
|
2037
|
-
readonly severity?: "error" | "warn" | "info" | "off"
|
|
2038
|
-
readonly ignore?: readonly string[]
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
149
|
const OVERRIDE_SEVERITIES: readonly string[] = ["error", "warn", "info", "off"]
|
|
2042
150
|
|
|
2043
|
-
/** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed JSON → empty plus a
|
|
2044
|
-
* parse `error`; an invalid entry → skipped plus a `warnings` note the caller surfaces as a `warning`
|
|
2045
|
-
* diagnostic (never throws, never blocks the gate). Mount entries are normalized to bare path prefixes
|
|
2046
|
-
* (`/auth/**` and `/auth/` both become `/auth`). */
|
|
2047
151
|
async function loadCheckConfig(
|
|
2048
152
|
cwd: string,
|
|
2049
153
|
): Promise<{ config: CheckConfig; error?: string; warnings: readonly string[] }> {
|
|
@@ -2116,160 +220,6 @@ async function loadCheckConfig(
|
|
|
2116
220
|
}
|
|
2117
221
|
}
|
|
2118
222
|
|
|
2119
|
-
const UNTYPED_CLIENT_HINT =
|
|
2120
|
-
'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
|
|
2121
|
-
const FETCH_HINT =
|
|
2122
|
-
"hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
|
|
2123
|
-
const SERVER_IMPORT_HINT =
|
|
2124
|
-
"server-only import in a route module (bundled for the browser) - reach it via c.db / ctx.api inside a loader, never a top-level import"
|
|
2125
|
-
const RESPONSE_ROUTE_HINT =
|
|
2126
|
-
"route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
|
|
2127
|
-
const PIPELINE_DOC_HINT =
|
|
2128
|
-
"nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
|
|
2129
|
-
const UNDECLARED_DEP_HINT =
|
|
2130
|
-
"imported package is not declared in package.json dependencies - run bun add to declare it"
|
|
2131
|
-
const SQL_COMPILER_MISSING_HINT =
|
|
2132
|
-
"the interpolated-SQL rule did NOT run - it parses source with the TypeScript compiler, which is an optional peer and is not installed here. This report says nothing about SQL injection either way. Install it with `bun add -d typescript`"
|
|
2133
|
-
const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}"
|
|
2134
|
-
const INTERPOLATED_SQL_HINT = `SQL built by interpolating a value into the statement text - the value becomes statement, not a parameter, so anything the caller controls can end the literal and continue as SQL. Pass it as a bound parameter (\`?\` / \`$1\` and an argument), or use your driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`), which binds the substitutions for you`
|
|
2135
|
-
const MANIFEST_DRIFT_HINT =
|
|
2136
|
-
"server-manifest.ts is out of sync with routes/ - re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it"
|
|
2137
|
-
const TRUST_MANIFEST_DRIFT_HINT =
|
|
2138
|
-
"nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
|
|
2139
|
-
const CAPABILITY_HINT =
|
|
2140
|
-
"effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam"
|
|
2141
|
-
|
|
2142
|
-
function oneLineDiff(file: string, line: number, before: string, after: string): string {
|
|
2143
|
-
return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
function untypedClientSuggestion(f: SourceFinding): CheckSuggestion {
|
|
2147
|
-
const replacement = f.snippet.replace(/(?<![.\w])client\s*\(/, "client<typeof app>(")
|
|
2148
|
-
return replacement === f.snippet
|
|
2149
|
-
? {
|
|
2150
|
-
kind: "manual",
|
|
2151
|
-
title: "Add the app type argument to the client factory",
|
|
2152
|
-
steps: [
|
|
2153
|
-
'Change `client("...")` to `client<typeof app>("...")`.',
|
|
2154
|
-
"Make sure the backend app type is imported or otherwise in scope.",
|
|
2155
|
-
],
|
|
2156
|
-
}
|
|
2157
|
-
: {
|
|
2158
|
-
kind: "edit",
|
|
2159
|
-
title: "Insert `<typeof app>` into the client factory call",
|
|
2160
|
-
diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
|
|
2161
|
-
steps: ["Make sure the backend app type is imported or otherwise in scope."],
|
|
2162
|
-
}
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
function routeKey(method: string, path: string): string {
|
|
2166
|
-
return `${method.toUpperCase()} ${path}`
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
function typedClientCall(method: string, path: string): string {
|
|
2170
|
-
const segs = path.split("/").filter((seg) => seg !== "")
|
|
2171
|
-
let chain = "api"
|
|
2172
|
-
if (segs.length === 0) chain += ".index"
|
|
2173
|
-
else {
|
|
2174
|
-
for (const seg of segs) {
|
|
2175
|
-
chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
return `${chain}.${method.toLowerCase()}()`
|
|
2179
|
-
}
|
|
2180
|
-
|
|
2181
|
-
function staticRouteMap(routes: readonly StaticRouteFinding[]): Map<string, StaticRouteFinding[]> {
|
|
2182
|
-
const out = new Map<string, StaticRouteFinding[]>()
|
|
2183
|
-
for (const route of routes) {
|
|
2184
|
-
if (route.path.includes(":") || route.path.includes("*")) continue
|
|
2185
|
-
const key = routeKey(route.method, route.path)
|
|
2186
|
-
const bucket = out.get(key)
|
|
2187
|
-
if (bucket === undefined) out.set(key, [route])
|
|
2188
|
-
else bucket.push(route)
|
|
2189
|
-
}
|
|
2190
|
-
return out
|
|
2191
|
-
}
|
|
2192
|
-
|
|
2193
|
-
function ownFetchEditSuggestion(
|
|
2194
|
-
f: SourceFinding,
|
|
2195
|
-
routes: Map<string, StaticRouteFinding[]>,
|
|
2196
|
-
): CheckSuggestion | undefined {
|
|
2197
|
-
const call = parseSimpleFetchCall(f.snippet)
|
|
2198
|
-
if (call === undefined || !SIMPLE_REWRITE_METHODS.has(call.method)) return undefined
|
|
2199
|
-
const matches = routes.get(routeKey(call.method, call.path))
|
|
2200
|
-
if (matches === undefined || matches.length !== 1) return undefined
|
|
2201
|
-
const replacementCall = typedClientCall(call.method, call.path)
|
|
2202
|
-
const replacement = `${f.snippet.slice(0, call.start)}${replacementCall}${f.snippet.slice(call.end)}`
|
|
2203
|
-
if (replacement === f.snippet) return undefined
|
|
2204
|
-
const route = matches[0]
|
|
2205
|
-
if (route === undefined) return undefined
|
|
2206
|
-
return {
|
|
2207
|
-
kind: "edit",
|
|
2208
|
-
title: "Rewrite simple own-API fetch to the typed nifra client",
|
|
2209
|
-
diff: oneLineDiff(f.file, f.line, f.snippet, replacement),
|
|
2210
|
-
steps: [
|
|
2211
|
-
`Matched ${route.method} ${route.path} at ${route.file}:${route.line}.`,
|
|
2212
|
-
"Use an in-scope typed client named `api` (`client<typeof app>(baseUrl)` or the route loader/action `api`).",
|
|
2213
|
-
"Update downstream `Response` handling to branch on `{ ok, data, error }` if this variable is used later.",
|
|
2214
|
-
],
|
|
2215
|
-
}
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
function ownFetchSuggestion(
|
|
2219
|
-
f: SourceFinding,
|
|
2220
|
-
routes: Map<string, StaticRouteFinding[]>,
|
|
2221
|
-
): CheckSuggestion {
|
|
2222
|
-
const exact = ownFetchEditSuggestion(f, routes)
|
|
2223
|
-
if (exact !== undefined) return exact
|
|
2224
|
-
return {
|
|
2225
|
-
kind: "manual",
|
|
2226
|
-
title: "Replace own-API fetch with the typed nifra client",
|
|
2227
|
-
steps: [
|
|
2228
|
-
"Call `nifra_routes` or read `nifra://routes` for the exact typed-client call form.",
|
|
2229
|
-
"Create `const api = client<typeof app>(baseUrl)` from `@nifrajs/client`.",
|
|
2230
|
-
"Replace the relative `fetch()` call with the generated `api...get/post/...` call and branch on `{ ok, data, error }`.",
|
|
2231
|
-
],
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
function serverImportSuggestion(
|
|
2236
|
-
specifier: string,
|
|
2237
|
-
chain: readonly string[],
|
|
2238
|
-
fallback: boolean,
|
|
2239
|
-
): CheckSuggestion {
|
|
2240
|
-
const sink = chain[chain.length - 1] ?? specifier
|
|
2241
|
-
// Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
|
|
2242
|
-
// ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
|
|
2243
|
-
const chainStep =
|
|
2244
|
-
chain.length > 2
|
|
2245
|
-
? fallback
|
|
2246
|
-
? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
|
|
2247
|
-
: `Server-only code reaches this route transitively: \`${chain.join(" → ")}\`. The sink is \`${sink}\`; break the chain at the first hop (\`${specifier}\`) or move the sink behind the server boundary.`
|
|
2248
|
-
: undefined
|
|
2249
|
-
return {
|
|
2250
|
-
kind: "manual",
|
|
2251
|
-
title: "Move server-only code behind the route server boundary",
|
|
2252
|
-
steps: [
|
|
2253
|
-
...(chainStep !== undefined ? [chainStep] : []),
|
|
2254
|
-
`Remove the top-level \`import … from "${specifier}"\` from this route module (it's bundled for the browser).`,
|
|
2255
|
-
"Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
|
|
2256
|
-
`If a direct module import is unavoidable, lazy-load it (\`await import("${specifier}")\`) inside the server-only loader/action path.`,
|
|
2257
|
-
],
|
|
2258
|
-
}
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
function responseRouteSuggestion(): CheckSuggestion {
|
|
2262
|
-
return {
|
|
2263
|
-
kind: "manual",
|
|
2264
|
-
title: "Preserve typed-client response inference",
|
|
2265
|
-
steps: [
|
|
2266
|
-
"Prefer returning a plain object from JSON routes; nifra serializes it for you.",
|
|
2267
|
-
"For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
|
|
2268
|
-
"If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect raw-response` comment above the return to mark it intentional and silence this advisory.",
|
|
2269
|
-
],
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
223
|
interface CheckCollectionOptions {
|
|
2274
224
|
readonly lintsOnly?: boolean
|
|
2275
225
|
readonly signal?: AbortSignal
|
|
@@ -2370,647 +320,13 @@ async function buildProjectScan(
|
|
|
2370
320
|
|
|
2371
321
|
/** Run the three checks and assemble a structured, machine-readable result. The single source the CLI
|
|
2372
322
|
* report, `--json`, and the MCP tool all render from. */
|
|
323
|
+
/** Run the scan, freeze its facts, and project them into the public diagnostic result. */
|
|
2373
324
|
export async function collectCheckResult(
|
|
2374
325
|
cwd: string,
|
|
2375
326
|
opts: CheckCollectionOptions = {},
|
|
2376
327
|
): Promise<CheckResult> {
|
|
2377
328
|
const scan = await buildProjectScan(cwd, opts)
|
|
2378
|
-
|
|
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
|
|
2389
|
-
const diagnostics: CheckDiagnostic[] = []
|
|
2390
|
-
const structuredExtras: Diagnostic[] = []
|
|
2391
|
-
if (checkConfigError !== undefined) {
|
|
2392
|
-
diagnostics.push({
|
|
2393
|
-
rule: "check-config",
|
|
2394
|
-
severity: "warning",
|
|
2395
|
-
file: "nifra.check.json",
|
|
2396
|
-
message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
|
|
2397
|
-
fix: "Fix the JSON syntax in nifra.check.json",
|
|
2398
|
-
})
|
|
2399
|
-
}
|
|
2400
|
-
for (const warning of checkConfigWarnings) {
|
|
2401
|
-
diagnostics.push({
|
|
2402
|
-
rule: "check-config",
|
|
2403
|
-
severity: "warning",
|
|
2404
|
-
file: "nifra.check.json",
|
|
2405
|
-
message: `nifra.check.json: ${warning}`,
|
|
2406
|
-
fix: "Fix the entry in nifra.check.json",
|
|
2407
|
-
})
|
|
2408
|
-
}
|
|
2409
|
-
if (tc.missingTypeScript === true) {
|
|
2410
|
-
// A tsconfig with no reachable `typescript` install is a broken gate, not a benign skip: the
|
|
2411
|
-
// contract check the project asked for (by having a tsconfig) silently didn't run. Fail closed.
|
|
2412
|
-
diagnostics.push({
|
|
2413
|
-
rule: "typecheck",
|
|
2414
|
-
severity: "error",
|
|
2415
|
-
file: "tsconfig.json",
|
|
2416
|
-
message:
|
|
2417
|
-
"tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
|
|
2418
|
-
fix: "bun add -d typescript",
|
|
2419
|
-
suggestion: {
|
|
2420
|
-
kind: "command",
|
|
2421
|
-
title: "Install TypeScript so the contract gate can run",
|
|
2422
|
-
command: ["bun", "add", "-d", "typescript"],
|
|
2423
|
-
},
|
|
2424
|
-
})
|
|
2425
|
-
}
|
|
2426
|
-
if (tc.ran && !tc.ok) {
|
|
2427
|
-
const lines = (tc.output ?? "").split("\n")
|
|
2428
|
-
let matched = false
|
|
2429
|
-
for (const l of lines) {
|
|
2430
|
-
const m = TSC_LINE.exec(l.trim())
|
|
2431
|
-
if (m) {
|
|
2432
|
-
matched = true
|
|
2433
|
-
diagnostics.push({
|
|
2434
|
-
rule: "typecheck",
|
|
2435
|
-
severity: "error",
|
|
2436
|
-
file: m[1] as string,
|
|
2437
|
-
line: Number(m[2]),
|
|
2438
|
-
message: m[3] as string,
|
|
2439
|
-
suggestion: {
|
|
2440
|
-
kind: "manual",
|
|
2441
|
-
title: "Fix the TypeScript contract error",
|
|
2442
|
-
steps: [
|
|
2443
|
-
"Open the reported file and line.",
|
|
2444
|
-
"Align the handler, route schema, or typed-client call with the compiler error.",
|
|
2445
|
-
"Run `nifra_check` again after the edit.",
|
|
2446
|
-
],
|
|
2447
|
-
},
|
|
2448
|
-
})
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
if (!matched)
|
|
2452
|
-
diagnostics.push({
|
|
2453
|
-
rule: "typecheck",
|
|
2454
|
-
severity: "error",
|
|
2455
|
-
message: tc.output || "typecheck failed",
|
|
2456
|
-
suggestion: {
|
|
2457
|
-
kind: "manual",
|
|
2458
|
-
title: "Fix the TypeScript contract error",
|
|
2459
|
-
steps: ["Run `tsc --noEmit` locally for the full compiler output."],
|
|
2460
|
-
},
|
|
2461
|
-
})
|
|
2462
|
-
}
|
|
2463
|
-
const routes = staticRouteMap(staticRoutes)
|
|
2464
|
-
for (const f of [...fetches].sort(bySite)) {
|
|
2465
|
-
diagnostics.push({
|
|
2466
|
-
rule: "typed-client",
|
|
2467
|
-
severity: "error",
|
|
2468
|
-
file: f.file,
|
|
2469
|
-
line: f.line,
|
|
2470
|
-
message: `${f.snippet} - ${FETCH_HINT}`,
|
|
2471
|
-
fix: FETCH_HINT,
|
|
2472
|
-
suggestion: ownFetchSuggestion(f, routes),
|
|
2473
|
-
})
|
|
2474
|
-
}
|
|
2475
|
-
for (const f of [...removedImports].sort(bySite)) {
|
|
2476
|
-
const entry = REMOVED_IMPORTS.find(
|
|
2477
|
-
(candidate) =>
|
|
2478
|
-
f.snippet.includes(`"${candidate.specifier}`) ||
|
|
2479
|
-
f.snippet.includes(`'${candidate.specifier}`),
|
|
2480
|
-
)
|
|
2481
|
-
diagnostics.push({
|
|
2482
|
-
rule: "removed-import",
|
|
2483
|
-
severity: "error",
|
|
2484
|
-
file: f.file,
|
|
2485
|
-
line: f.line,
|
|
2486
|
-
message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
|
|
2487
|
-
fix: entry?.replacement ?? "see the changelog",
|
|
2488
|
-
})
|
|
2489
|
-
}
|
|
2490
|
-
for (const f of [...untypedClients].sort(bySite)) {
|
|
2491
|
-
diagnostics.push({
|
|
2492
|
-
rule: "untyped-client",
|
|
2493
|
-
severity: "error",
|
|
2494
|
-
file: f.file,
|
|
2495
|
-
line: f.line,
|
|
2496
|
-
message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
|
|
2497
|
-
fix: UNTYPED_CLIENT_HINT,
|
|
2498
|
-
suggestion: untypedClientSuggestion(f),
|
|
2499
|
-
})
|
|
2500
|
-
}
|
|
2501
|
-
for (const f of [...serverImports].sort(bySite)) {
|
|
2502
|
-
// #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
|
|
2503
|
-
// node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
|
|
2504
|
-
// tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
|
|
2505
|
-
// (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
|
|
2506
|
-
const chain = f.chain
|
|
2507
|
-
const sink = chain[chain.length - 1] ?? f.specifier
|
|
2508
|
-
diagnostics.push({
|
|
2509
|
-
rule: "server-only-import",
|
|
2510
|
-
severity: "error",
|
|
2511
|
-
file: f.file,
|
|
2512
|
-
line: f.line,
|
|
2513
|
-
message: `${f.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${f.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
|
|
2514
|
-
fix: SERVER_IMPORT_HINT,
|
|
2515
|
-
chain,
|
|
2516
|
-
suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
|
|
2517
|
-
})
|
|
2518
|
-
}
|
|
2519
|
-
if (sqlCompiler === undefined) {
|
|
2520
|
-
// A security rule that could not run must say so. Silence here is indistinguishable from a clean
|
|
2521
|
-
// result, and this is the one rule whose clean result means "no SQL injection was found".
|
|
2522
|
-
diagnostics.push({
|
|
2523
|
-
rule: "interpolated-sql",
|
|
2524
|
-
severity: "warning",
|
|
2525
|
-
message: SQL_COMPILER_MISSING_HINT,
|
|
2526
|
-
fix: SQL_COMPILER_MISSING_HINT,
|
|
2527
|
-
suggestion: {
|
|
2528
|
-
kind: "command",
|
|
2529
|
-
title: "Install TypeScript so the SQL rule can run",
|
|
2530
|
-
command: ["bun add -d typescript"],
|
|
2531
|
-
},
|
|
2532
|
-
})
|
|
2533
|
-
}
|
|
2534
|
-
for (const f of [...interpolatedSql].sort(bySite)) {
|
|
2535
|
-
diagnostics.push({
|
|
2536
|
-
rule: "interpolated-sql",
|
|
2537
|
-
severity: "error",
|
|
2538
|
-
file: f.file,
|
|
2539
|
-
line: f.line,
|
|
2540
|
-
message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
|
|
2541
|
-
fix: "bind the value as a parameter instead of interpolating it into the statement",
|
|
2542
|
-
suggestion: {
|
|
2543
|
-
kind: "manual",
|
|
2544
|
-
title: "Bind the value instead of interpolating it",
|
|
2545
|
-
steps: [
|
|
2546
|
-
"Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
|
|
2547
|
-
"Pass the value as an argument alongside the statement, so the driver binds it.",
|
|
2548
|
-
`Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
|
|
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.",
|
|
2551
|
-
],
|
|
2552
|
-
},
|
|
2553
|
-
})
|
|
2554
|
-
}
|
|
2555
|
-
// Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
|
|
2556
|
-
for (const f of [...responseRoutes].sort(bySite)) {
|
|
2557
|
-
diagnostics.push({
|
|
2558
|
-
rule: "response-route",
|
|
2559
|
-
severity: "warning",
|
|
2560
|
-
file: f.file,
|
|
2561
|
-
line: f.line,
|
|
2562
|
-
message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
|
|
2563
|
-
fix: RESPONSE_ROUTE_HINT,
|
|
2564
|
-
suggestion: responseRouteSuggestion(),
|
|
2565
|
-
})
|
|
2566
|
-
}
|
|
2567
|
-
if (dr.ran) {
|
|
2568
|
-
for (const f of dr.findings) {
|
|
2569
|
-
diagnostics.push({
|
|
2570
|
-
rule: "undeclared-dependency",
|
|
2571
|
-
severity: "error",
|
|
2572
|
-
file: f.file,
|
|
2573
|
-
line: f.line,
|
|
2574
|
-
message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
|
|
2575
|
-
fix: `add ${f.package} to package.json dependencies`,
|
|
2576
|
-
suggestion: {
|
|
2577
|
-
kind: "command",
|
|
2578
|
-
title: `Declare ${f.package} in package.json`,
|
|
2579
|
-
command: ["bun", "add", f.package],
|
|
2580
|
-
},
|
|
2581
|
-
})
|
|
2582
|
-
}
|
|
2583
|
-
for (const finding of dr.duplicateInstalls) {
|
|
2584
|
-
const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
|
|
2585
|
-
diagnostics.push({
|
|
2586
|
-
rule: "duplicate-install",
|
|
2587
|
-
severity: "error",
|
|
2588
|
-
message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
|
|
2589
|
-
fix: finding.remediation,
|
|
2590
|
-
suggestion: {
|
|
2591
|
-
kind: "manual",
|
|
2592
|
-
title: `Deduplicate ${finding.package}`,
|
|
2593
|
-
steps: [
|
|
2594
|
-
"Align workspace dependency and peer ranges on one compatible version.",
|
|
2595
|
-
"Remove stale nested installs and run the package manager from the workspace root.",
|
|
2596
|
-
"Run `nifra doctor` again; do not suppress same-version duplicate paths.",
|
|
2597
|
-
],
|
|
2598
|
-
},
|
|
2599
|
-
})
|
|
2600
|
-
}
|
|
2601
|
-
// Advisory (never fails the gate): while actively editing a linked package its dist is always
|
|
2602
|
-
// momentarily behind. The finding earns its keep when a dev server is about to start against it -
|
|
2603
|
-
// Bun reads live `src` while Vite's SSR runner reads the artifact, so a stale one 500s inside
|
|
2604
|
-
// framework/shared-package code and reads exactly like an upstream regression.
|
|
2605
|
-
for (const f of dr.staleDists) {
|
|
2606
|
-
diagnostics.push({
|
|
2607
|
-
rule: "stale-workspace-dist",
|
|
2608
|
-
severity: "warning",
|
|
2609
|
-
message: f.missing
|
|
2610
|
-
? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - rebuild ${f.package}`
|
|
2611
|
-
: `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - rebuild ${f.package}`,
|
|
2612
|
-
fix: `rebuild ${f.package}`,
|
|
2613
|
-
evidence: [f.package, f.distFile, f.sourceFile],
|
|
2614
|
-
suggestion: {
|
|
2615
|
-
kind: "manual",
|
|
2616
|
-
title: `Rebuild ${f.package}`,
|
|
2617
|
-
steps: [
|
|
2618
|
-
`Run the package's build (usually \`bun run build\` in its directory) so ${f.distFile} matches its source again.`,
|
|
2619
|
-
"Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
|
|
2620
|
-
],
|
|
2621
|
-
},
|
|
2622
|
-
})
|
|
2623
|
-
}
|
|
2624
|
-
}
|
|
2625
|
-
// The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
|
|
2626
|
-
// `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
|
|
2627
|
-
// never executes project code, and CI that never starts a dev server, would otherwise meet these for
|
|
2628
|
-
// the first time as a production server that built cleanly and died at startup.
|
|
2629
|
-
for (const f of dr.pipeline?.findings ?? []) {
|
|
2630
|
-
diagnostics.push({
|
|
2631
|
-
rule: "pipeline",
|
|
2632
|
-
severity: f.severity,
|
|
2633
|
-
file: f.file,
|
|
2634
|
-
...(f.line !== undefined ? { line: f.line } : {}),
|
|
2635
|
-
message: f.message,
|
|
2636
|
-
fix: f.fix,
|
|
2637
|
-
suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
|
|
2638
|
-
})
|
|
2639
|
-
}
|
|
2640
|
-
// #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
|
|
2641
|
-
for (const f of manifestDrift) {
|
|
2642
|
-
const parts: string[] = []
|
|
2643
|
-
if (f.missing.length > 0) parts.push(`missing from manifest: ${f.missing.join(", ")}`)
|
|
2644
|
-
if (f.extra.length > 0) parts.push(`stale in manifest: ${f.extra.join(", ")}`)
|
|
2645
|
-
diagnostics.push({
|
|
2646
|
-
rule: "server-manifest-drift",
|
|
2647
|
-
severity: "error",
|
|
2648
|
-
file: f.file,
|
|
2649
|
-
message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
|
|
2650
|
-
fix: MANIFEST_DRIFT_HINT,
|
|
2651
|
-
evidence: [...f.missing, ...f.extra],
|
|
2652
|
-
suggestion: {
|
|
2653
|
-
kind: "manual",
|
|
2654
|
-
title: "Regenerate the committed server manifest",
|
|
2655
|
-
steps: [
|
|
2656
|
-
"Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
|
|
2657
|
-
"Commit the updated server-manifest.ts.",
|
|
2658
|
-
],
|
|
2659
|
-
},
|
|
2660
|
-
})
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
|
-
// G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
|
|
2664
|
-
// provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
|
|
2665
|
-
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
2666
|
-
const provided = opts.assurance
|
|
2667
|
-
let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
|
|
2668
|
-
let loadedPolicy = provided
|
|
2669
|
-
let loadedCapability: CapabilityProjectReport | undefined = provided?.capability
|
|
2670
|
-
const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
|
|
2671
|
-
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
2672
|
-
try {
|
|
2673
|
-
// Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
|
|
2674
|
-
// standalone path, loaded + reflected here. Both branches yield the same config + evidence.
|
|
2675
|
-
let config: AssuranceConfig
|
|
2676
|
-
let project: CapabilityProjectReport | undefined
|
|
2677
|
-
let routeAssurance: AssuranceReport | undefined
|
|
2678
|
-
let evidence: ProjectEvidenceSnapshot | undefined
|
|
2679
|
-
if (provided !== undefined) {
|
|
2680
|
-
// A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
|
|
2681
|
-
// capability-config diagnostic the standalone catch produces.
|
|
2682
|
-
if (provided.error !== undefined) throw provided.error
|
|
2683
|
-
config = provided.config as AssuranceConfig
|
|
2684
|
-
project = provided.capability
|
|
2685
|
-
routeAssurance = provided.routeAssurance
|
|
2686
|
-
evidence = provided.evidence
|
|
2687
|
-
} else {
|
|
2688
|
-
const { loadAssuranceConfig } = await import("./assure.ts")
|
|
2689
|
-
config = await loadAssuranceConfig(cwd)
|
|
2690
|
-
if (config.capabilities !== undefined) {
|
|
2691
|
-
const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
|
|
2692
|
-
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
|
|
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
|
|
2702
|
-
}
|
|
2703
|
-
applicationRulePacks = parseRulePacks(config.rulePacks)
|
|
2704
|
-
const capabilityReport = project?.report
|
|
2705
|
-
if (config.capabilities !== undefined && project !== undefined) {
|
|
2706
|
-
for (const finding of project.report.findings) {
|
|
2707
|
-
const violation =
|
|
2708
|
-
finding.code === "forbidden-effect-import"
|
|
2709
|
-
? project.violations.find(
|
|
2710
|
-
(candidate) =>
|
|
2711
|
-
candidate.method === finding.method && candidate.path === finding.path,
|
|
2712
|
-
)
|
|
2713
|
-
: undefined
|
|
2714
|
-
const truncation =
|
|
2715
|
-
finding.code === "provenance-truncated"
|
|
2716
|
-
? project.truncations.find(
|
|
2717
|
-
(candidate) =>
|
|
2718
|
-
candidate.method === finding.method && candidate.path === finding.path,
|
|
2719
|
-
)
|
|
2720
|
-
: undefined
|
|
2721
|
-
// An unmatched seam is a policy defect, not a route defect: the fix is in the policy file,
|
|
2722
|
-
// so it gets its own steps instead of the route-side provenance guidance.
|
|
2723
|
-
const seamFix =
|
|
2724
|
-
finding.code === "unmatched-provenance-seam"
|
|
2725
|
-
? "Write the seam exactly as the code imports it, or delete the rule."
|
|
2726
|
-
: undefined
|
|
2727
|
-
diagnostics.push({
|
|
2728
|
-
rule: "capability-assurance",
|
|
2729
|
-
severity: "error",
|
|
2730
|
-
...(violation !== undefined
|
|
2731
|
-
? { file: violation.module, chain: violation.chain }
|
|
2732
|
-
: truncation !== undefined
|
|
2733
|
-
? { chain: truncation.chain }
|
|
2734
|
-
: {}),
|
|
2735
|
-
message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
|
|
2736
|
-
fix: seamFix ?? CAPABILITY_HINT,
|
|
2737
|
-
suggestion:
|
|
2738
|
-
seamFix === undefined
|
|
2739
|
-
? {
|
|
2740
|
-
kind: "manual",
|
|
2741
|
-
title: "Restore declared effect provenance",
|
|
2742
|
-
steps: [
|
|
2743
|
-
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
2744
|
-
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
2745
|
-
"For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
|
|
2746
|
-
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
2747
|
-
],
|
|
2748
|
-
}
|
|
2749
|
-
: {
|
|
2750
|
-
kind: "manual",
|
|
2751
|
-
title: "Point the provenance rule at a module that exists",
|
|
2752
|
-
steps: [
|
|
2753
|
-
"Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
|
|
2754
|
-
"Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
|
|
2755
|
-
"For a routeModules entry, give the project-relative path of the file that implements the route.",
|
|
2756
|
-
"Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
|
|
2757
|
-
],
|
|
2758
|
-
},
|
|
2759
|
-
})
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
if (config.manifest !== undefined) {
|
|
2763
|
-
const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
|
|
2764
|
-
"@nifrajs/core/manifest"
|
|
2765
|
-
)
|
|
2766
|
-
const assurance =
|
|
2767
|
-
routeAssurance ??
|
|
2768
|
-
(await import("@nifrajs/core/assurance")).evaluateRouteAssurance(
|
|
2769
|
-
config.source,
|
|
2770
|
-
config.policy,
|
|
2771
|
-
{
|
|
2772
|
-
...(config.capabilities !== undefined
|
|
2773
|
-
? { definitions: config.capabilities.definitions }
|
|
2774
|
-
: {}),
|
|
2775
|
-
},
|
|
2776
|
-
)
|
|
2777
|
-
evidence ??= snapshotProjectEvidence(config.source, {
|
|
2778
|
-
assurance,
|
|
2779
|
-
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
2780
|
-
})
|
|
2781
|
-
const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
|
|
2782
|
-
let message: string | undefined
|
|
2783
|
-
if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
|
|
2784
|
-
message =
|
|
2785
|
-
"the configured assurance policy is failing, so a trusted manifest cannot be built"
|
|
2786
|
-
} else if (!existsSync(path)) {
|
|
2787
|
-
message = "the configured trust manifest is missing"
|
|
2788
|
-
} else {
|
|
2789
|
-
try {
|
|
2790
|
-
const current = await buildNifraManifest({
|
|
2791
|
-
evidence,
|
|
2792
|
-
assurance,
|
|
2793
|
-
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
2794
|
-
})
|
|
2795
|
-
const storedText = await Bun.file(path).text()
|
|
2796
|
-
const stored = await parseNifraManifest(storedText, path)
|
|
2797
|
-
const expectedText = `${serializeNifraManifest(current)}\n`
|
|
2798
|
-
if (storedText !== expectedText || stored.contentHash !== current.contentHash) {
|
|
2799
|
-
message = "the configured trust manifest does not match live route reflection"
|
|
2800
|
-
}
|
|
2801
|
-
} catch (error) {
|
|
2802
|
-
message = `the configured trust manifest is invalid: ${error instanceof Error ? error.message : String(error)}`
|
|
2803
|
-
}
|
|
2804
|
-
}
|
|
2805
|
-
if (message !== undefined) {
|
|
2806
|
-
diagnostics.push({
|
|
2807
|
-
rule: "manifest-drift",
|
|
2808
|
-
severity: "error",
|
|
2809
|
-
file: path,
|
|
2810
|
-
message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
|
|
2811
|
-
fix: TRUST_MANIFEST_DRIFT_HINT,
|
|
2812
|
-
suggestion: {
|
|
2813
|
-
kind: "command",
|
|
2814
|
-
title: "Regenerate the signed-manifest input artifact",
|
|
2815
|
-
command: ["nifra", "manifest", "emit"],
|
|
2816
|
-
steps: [
|
|
2817
|
-
"Review the route, assurance, capability, and classification delta before committing it.",
|
|
2818
|
-
],
|
|
2819
|
-
},
|
|
2820
|
-
})
|
|
2821
|
-
}
|
|
2822
|
-
}
|
|
2823
|
-
} catch (err) {
|
|
2824
|
-
loadedPolicy = { present: true, error: err }
|
|
2825
|
-
diagnostics.push({
|
|
2826
|
-
rule: "capability-config",
|
|
2827
|
-
severity: "error",
|
|
2828
|
-
file: "nifra.assurance.ts",
|
|
2829
|
-
message: `capability assurance config could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
|
|
2830
|
-
suggestion: {
|
|
2831
|
-
kind: "manual",
|
|
2832
|
-
title: "Repair the assurance config",
|
|
2833
|
-
steps: [
|
|
2834
|
-
"Ensure nifra.assurance.ts default-exports defineAssuranceConfig({ source, policy, capabilities }).",
|
|
2835
|
-
"Fix configuration/import errors; the provenance firewall fails closed when its policy cannot load.",
|
|
2836
|
-
],
|
|
2837
|
-
},
|
|
2838
|
-
})
|
|
2839
|
-
}
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
try {
|
|
2843
|
-
const { checkContractsLock } = await import("./contracts.ts")
|
|
2844
|
-
if (!existsSync(join(cwd, "backend.ts")) && !existsSync(join(cwd, "contracts.lock.json"))) {
|
|
2845
|
-
throw new Error("contract source not configured")
|
|
2846
|
-
}
|
|
2847
|
-
const contract = await checkContractsLock(cwd)
|
|
2848
|
-
if (!contract.present) {
|
|
2849
|
-
structuredExtras.push(
|
|
2850
|
-
diagnostic({
|
|
2851
|
-
code: "NF-K001",
|
|
2852
|
-
severity: "info",
|
|
2853
|
-
message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
|
|
2854
|
-
verify: "nifra contracts snapshot",
|
|
2855
|
-
}),
|
|
2856
|
-
)
|
|
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
|
-
}
|
|
2872
|
-
for (const finding of contract.diagnostics) {
|
|
2873
|
-
diagnostics.push({
|
|
2874
|
-
rule: "contract-drift",
|
|
2875
|
-
severity: "error",
|
|
2876
|
-
code: "NF-K001",
|
|
2877
|
-
...(finding.route !== undefined ? { evidence: [finding.route] } : {}),
|
|
2878
|
-
message: finding.message,
|
|
2879
|
-
fix: "run `nifra contracts snapshot` after reviewing the contract change",
|
|
2880
|
-
verify: "nifra check --lints-only",
|
|
2881
|
-
})
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2884
|
-
} catch (error) {
|
|
2885
|
-
if (error instanceof Error && error.message === "contract source not configured") {
|
|
2886
|
-
// API-only source scans have no contract surface to snapshot.
|
|
2887
|
-
} else {
|
|
2888
|
-
diagnostics.push({
|
|
2889
|
-
rule: "contract-drift",
|
|
2890
|
-
severity: "error",
|
|
2891
|
-
code: "NF-K001",
|
|
2892
|
-
message: `contract lock could not be checked: ${error instanceof Error ? error.message : String(error)}`,
|
|
2893
|
-
verify: "nifra contracts snapshot",
|
|
2894
|
-
})
|
|
2895
|
-
}
|
|
2896
|
-
}
|
|
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
|
-
)
|
|
2910
|
-
const ruleContext: RuleContext = {
|
|
2911
|
-
root: cwd,
|
|
2912
|
-
sources: projectFacts.source,
|
|
2913
|
-
project: projectFacts,
|
|
2914
|
-
}
|
|
2915
|
-
const structuredDiagnostics = [...structuredExtras]
|
|
2916
|
-
let registryDiagnostics: Diagnostic[] = []
|
|
2917
|
-
try {
|
|
2918
|
-
registryDiagnostics = await runRuleRegistry(
|
|
2919
|
-
ruleContext,
|
|
2920
|
-
[...legacyRules, ...securityRules, ...routeRules],
|
|
2921
|
-
projectFacts.policies.rulePacks,
|
|
2922
|
-
)
|
|
2923
|
-
structuredDiagnostics.push(...registryDiagnostics)
|
|
2924
|
-
} catch (error) {
|
|
2925
|
-
registryDiagnostics = [
|
|
2926
|
-
diagnostic({
|
|
2927
|
-
code: "NF-C017",
|
|
2928
|
-
severity: "error",
|
|
2929
|
-
message: `rule registry failed closed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2930
|
-
fix: { recipe: "rule-pack.repair", command: "nifra check --lints-only" },
|
|
2931
|
-
verify: "nifra check --lints-only",
|
|
2932
|
-
}),
|
|
2933
|
-
]
|
|
2934
|
-
structuredDiagnostics.push(...registryDiagnostics)
|
|
2935
|
-
}
|
|
2936
|
-
for (const item of registryDiagnostics) {
|
|
2937
|
-
// The legacy adapters publish stable structured findings; the human compatibility view already
|
|
2938
|
-
// contains their original rule names. Security rules and application packs still need a legacy-view
|
|
2939
|
-
// row because they have no pre-registry diagnostic.
|
|
2940
|
-
if (legacyRules.some((rule) => rule.code === item.code)) continue
|
|
2941
|
-
diagnostics.push({
|
|
2942
|
-
rule: item.code,
|
|
2943
|
-
severity: item.severity === "error" ? "error" : "warning",
|
|
2944
|
-
code: item.code,
|
|
2945
|
-
...(item.file !== undefined ? { file: item.file } : {}),
|
|
2946
|
-
...(item.line !== undefined ? { line: item.line } : {}),
|
|
2947
|
-
message: item.message,
|
|
2948
|
-
...(item.evidence !== undefined ? { evidence: item.evidence } : {}),
|
|
2949
|
-
...(item.verify !== undefined ? { verify: item.verify } : {}),
|
|
2950
|
-
})
|
|
2951
|
-
}
|
|
2952
|
-
|
|
2953
|
-
// Per-rule overrides from `nifra.check.json` `rules`, applied centrally to BOTH diagnostic views
|
|
2954
|
-
// before `ok` is computed - never inside individual rules, so no rule can dodge (or double-apply)
|
|
2955
|
-
// them. A key matches the legacy rule name or the stable NF- code.
|
|
2956
|
-
const overrideFor = (...keys: (string | undefined)[]): RuleOverride | undefined => {
|
|
2957
|
-
for (const key of keys) {
|
|
2958
|
-
if (key !== undefined && checkConfig.rules[key] !== undefined) return checkConfig.rules[key]
|
|
2959
|
-
}
|
|
2960
|
-
return undefined
|
|
2961
|
-
}
|
|
2962
|
-
const ignoreGlobs = new Map<RuleOverride, Glob[]>()
|
|
2963
|
-
const dropped = (override: RuleOverride, file: string | undefined): boolean => {
|
|
2964
|
-
if (override.severity === "off") return true
|
|
2965
|
-
if (override.ignore === undefined || file === undefined) return false
|
|
2966
|
-
let globs = ignoreGlobs.get(override)
|
|
2967
|
-
if (globs === undefined) {
|
|
2968
|
-
globs = override.ignore.map((pattern) => new Glob(pattern))
|
|
2969
|
-
ignoreGlobs.set(override, globs)
|
|
2970
|
-
}
|
|
2971
|
-
return globs.some((glob) => glob.match(file))
|
|
2972
|
-
}
|
|
2973
|
-
const codeToLegacy = new Map(
|
|
2974
|
-
Object.entries(LEGACY_RULE_CODES).map(([name, code]) => [code, name]),
|
|
2975
|
-
)
|
|
2976
|
-
const finalDiagnostics = diagnostics.flatMap<CheckDiagnostic>((d) => {
|
|
2977
|
-
const override = overrideFor(d.rule, d.code, LEGACY_RULE_CODES[d.rule])
|
|
2978
|
-
if (override === undefined) return [d]
|
|
2979
|
-
if (dropped(override, d.file)) return []
|
|
2980
|
-
// "off" is fully handled by `dropped` above - only real severities reach the retag.
|
|
2981
|
-
if (override.severity === undefined || override.severity === "off") return [d]
|
|
2982
|
-
return [{ ...d, severity: override.severity === "warn" ? "warning" : override.severity }]
|
|
2983
|
-
})
|
|
2984
|
-
const finalStructured = structuredDiagnostics.flatMap<Diagnostic>((d) => {
|
|
2985
|
-
const override = overrideFor(d.code, codeToLegacy.get(d.code))
|
|
2986
|
-
if (override === undefined) return [d]
|
|
2987
|
-
if (dropped(override, d.file)) return []
|
|
2988
|
-
if (override.severity === undefined || override.severity === "off") return [d]
|
|
2989
|
-
return [Object.freeze({ ...d, severity: override.severity })]
|
|
2990
|
-
})
|
|
2991
|
-
|
|
2992
|
-
// Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
|
|
2993
|
-
// message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
|
|
2994
|
-
const total = finalDiagnostics.length
|
|
2995
|
-
const max = opts.maxDiagnostics
|
|
2996
|
-
const shown = max !== undefined && total > max ? finalDiagnostics.slice(0, max) : finalDiagnostics
|
|
2997
|
-
const result: CheckResult = {
|
|
2998
|
-
ok: !finalDiagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
2999
|
-
typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
|
|
3000
|
-
...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
|
|
3001
|
-
diagnostics: shown,
|
|
3002
|
-
...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
|
|
3003
|
-
...(checkConfig.externalMounts.length > 0
|
|
3004
|
-
? { externalMounts: checkConfig.externalMounts }
|
|
3005
|
-
: {}),
|
|
3006
|
-
...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
|
|
3007
|
-
...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
|
|
3008
|
-
}
|
|
3009
|
-
Object.defineProperty(result, "structuredDiagnostics", {
|
|
3010
|
-
value: finalStructured.slice(0, shown.length + structuredExtras.length),
|
|
3011
|
-
enumerable: false,
|
|
3012
|
-
})
|
|
3013
|
-
return result
|
|
329
|
+
return collectCheckDiagnostics(cwd, scan, opts)
|
|
3014
330
|
}
|
|
3015
331
|
|
|
3016
332
|
/** The named rule sections of the human report, in print order. A rule absent from this list is NOT
|