@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
|
@@ -0,0 +1,1825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure source scanners for the nifra verification pipeline.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately has no CLI, policy, or diagnostic-view dependencies. Its interface is
|
|
5
|
+
* source text plus injected module/type resolvers, which keeps the scanner seam independently testable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, realpathSync } from "node:fs"
|
|
9
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path"
|
|
10
|
+
import { Glob } from "bun"
|
|
11
|
+
import type * as TSApi from "typescript"
|
|
12
|
+
import type { SourceFacts } from "./internal/source-facts.ts"
|
|
13
|
+
import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
|
|
14
|
+
import { commentBlockMarkerReason } from "./rules/comment-markers.ts"
|
|
15
|
+
|
|
16
|
+
export interface SourceFinding {
|
|
17
|
+
readonly file: string
|
|
18
|
+
readonly line: number
|
|
19
|
+
readonly snippet: string
|
|
20
|
+
}
|
|
21
|
+
/** A server-only-import finding, carrying the offending module specifier on top of the base location so
|
|
22
|
+
* the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
|
|
23
|
+
export interface ServerImportFinding extends SourceFinding {
|
|
24
|
+
/** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
|
|
25
|
+
readonly specifier: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
29
|
+
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
30
|
+
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
31
|
+
// alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
|
|
32
|
+
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
33
|
+
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
34
|
+
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
35
|
+
export const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
|
|
36
|
+
|
|
37
|
+
// Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
|
|
38
|
+
// per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
|
|
39
|
+
const IGNORED_DIR =
|
|
40
|
+
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
|
|
41
|
+
// A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
|
|
42
|
+
// legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
|
|
43
|
+
// `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
|
|
44
|
+
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
45
|
+
const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
|
|
46
|
+
|
|
47
|
+
/** Paths crossing the scanner boundary are project-relative identifiers, not filesystem paths. Keep
|
|
48
|
+
* them stable across runtimes so rules, diagnostics, ignore globs, and import chains have one shape. */
|
|
49
|
+
const normalizeProjectPath = (path: string): string => path.replaceAll("\\", "/")
|
|
50
|
+
|
|
51
|
+
// A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
|
|
52
|
+
const ROUTE_FILE = /(^|\/)routes\//
|
|
53
|
+
|
|
54
|
+
// Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
|
|
55
|
+
// DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
|
|
56
|
+
const SERVER_ONLY =
|
|
57
|
+
/^(?: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?)?$/
|
|
58
|
+
|
|
59
|
+
// A static, non-type import with a string specifier. `import type …` is erased at build, so it's safe
|
|
60
|
+
// and skipped. Dynamic `import(…)` (the correct way to lazy-load server code in a loader) has `(` right
|
|
61
|
+
// after `import`, so `import\s+` never matches it.
|
|
62
|
+
const STATIC_IMPORT = /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?['"]([^'"]+)['"]/g
|
|
63
|
+
|
|
64
|
+
const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
|
|
65
|
+
const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g
|
|
66
|
+
export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
67
|
+
|
|
68
|
+
export interface StaticRouteFinding extends SourceFinding {
|
|
69
|
+
readonly method: string
|
|
70
|
+
readonly path: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface SimpleFetchCall {
|
|
74
|
+
readonly path: string
|
|
75
|
+
readonly method: string
|
|
76
|
+
readonly start: number
|
|
77
|
+
readonly end: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Line number (1-based) of a match index within `content`. */
|
|
81
|
+
const lineAt = (content: string, index: number): number =>
|
|
82
|
+
content.slice(0, index).split("\n").length
|
|
83
|
+
|
|
84
|
+
function parseQuotedLiteral(raw: string): string | undefined {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(raw) as string
|
|
87
|
+
} catch {
|
|
88
|
+
if (!raw.startsWith("'") || !raw.endsWith("'")) return undefined
|
|
89
|
+
try {
|
|
90
|
+
// Convert the single-quoted body to a JSON string body by scan, handling backslash pairs
|
|
91
|
+
// atomically: `\'` becomes `'`, other escapes pass through untouched, and a bare `"` is
|
|
92
|
+
// escaped. A quote-only `replace` mis-handles content with backslashes (double-escaping
|
|
93
|
+
// an already-escaped quote, or letting a trailing backslash swallow the added escape).
|
|
94
|
+
const inner = raw.slice(1, -1)
|
|
95
|
+
let body = ""
|
|
96
|
+
for (let i = 0; i < inner.length; i++) {
|
|
97
|
+
const ch = inner[i] as string
|
|
98
|
+
if (ch === "\\" && i + 1 < inner.length) {
|
|
99
|
+
const next = inner[i + 1] as string
|
|
100
|
+
body += next === "'" ? "'" : ch + next
|
|
101
|
+
i++
|
|
102
|
+
} else if (ch === '"') {
|
|
103
|
+
body += '\\"'
|
|
104
|
+
} else {
|
|
105
|
+
body += ch
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return JSON.parse(`"${body}"`) as string
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findMatchingParen(src: string, openIndex: number): number | undefined {
|
|
116
|
+
let depth = 0
|
|
117
|
+
let quote: '"' | "'" | "`" | undefined
|
|
118
|
+
for (let i = openIndex; i < src.length; i++) {
|
|
119
|
+
const c = src[i]
|
|
120
|
+
if (quote !== undefined) {
|
|
121
|
+
if (c === "\\") {
|
|
122
|
+
i++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (c === quote) quote = undefined
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
129
|
+
quote = c
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (c === "(") depth++
|
|
133
|
+
else if (c === ")") {
|
|
134
|
+
depth--
|
|
135
|
+
if (depth === 0) return i
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function splitTopLevelArgs(src: string): string[] | undefined {
|
|
142
|
+
const args: string[] = []
|
|
143
|
+
let start = 0
|
|
144
|
+
let depth = 0
|
|
145
|
+
let quote: '"' | "'" | "`" | undefined
|
|
146
|
+
for (let i = 0; i < src.length; i++) {
|
|
147
|
+
const c = src[i]
|
|
148
|
+
if (quote !== undefined) {
|
|
149
|
+
if (c === "\\") {
|
|
150
|
+
i++
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (c === quote) quote = undefined
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
157
|
+
quote = c
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
if (c === "(" || c === "{" || c === "[") depth++
|
|
161
|
+
else if (c === ")" || c === "}" || c === "]") depth--
|
|
162
|
+
else if (c === "," && depth === 0) {
|
|
163
|
+
args.push(src.slice(start, i).trim())
|
|
164
|
+
start = i + 1
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (quote !== undefined || depth !== 0) return undefined
|
|
168
|
+
const tail = src.slice(start).trim()
|
|
169
|
+
if (tail !== "") args.push(tail)
|
|
170
|
+
return args
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseMethodOnlyInit(raw: string | undefined): string | undefined {
|
|
174
|
+
if (raw === undefined) return "GET"
|
|
175
|
+
const init = raw.trim()
|
|
176
|
+
if (!/^\{[\s\S]*\}$/.test(init)) return undefined
|
|
177
|
+
const body = init.slice(1, -1).trim()
|
|
178
|
+
if (body === "") return "GET"
|
|
179
|
+
const m = /^(?:"method"|'method'|method)\s*:\s*(["'])([A-Za-z]+)\1\s*,?$/.exec(body)
|
|
180
|
+
if (!m) return undefined
|
|
181
|
+
const method = (m[2] ?? "").toUpperCase()
|
|
182
|
+
return HTTP_VERBS.has(method) ? method : undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefined {
|
|
186
|
+
FETCH_CALL.lastIndex = 0
|
|
187
|
+
const matches = [...snippet.matchAll(FETCH_CALL)]
|
|
188
|
+
if (matches.length !== 1) return undefined
|
|
189
|
+
const match = matches[0]
|
|
190
|
+
if (match === undefined || match.index === undefined) return undefined
|
|
191
|
+
const open = match.index + match[0].lastIndexOf("(")
|
|
192
|
+
const close = findMatchingParen(snippet, open)
|
|
193
|
+
if (close === undefined) return undefined
|
|
194
|
+
|
|
195
|
+
const args = splitTopLevelArgs(snippet.slice(open + 1, close))
|
|
196
|
+
if (args === undefined || args.length === 0 || args.length > 2) return undefined
|
|
197
|
+
const first = args[0] ?? ""
|
|
198
|
+
const quote = first[0]
|
|
199
|
+
if ((quote !== '"' && quote !== "'") || first[first.length - 1] !== quote) return undefined
|
|
200
|
+
const path = parseQuotedLiteral(first)
|
|
201
|
+
if (
|
|
202
|
+
path === undefined ||
|
|
203
|
+
!path.startsWith("/") ||
|
|
204
|
+
path.startsWith("//") ||
|
|
205
|
+
path.includes("?") ||
|
|
206
|
+
path.includes("#")
|
|
207
|
+
) {
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
const method = parseMethodOnlyInit(args[1])
|
|
211
|
+
if (method === undefined) return undefined
|
|
212
|
+
return { path, method, start: match.index, end: close + 1 }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isPotentialBackendSource(code: string): boolean {
|
|
216
|
+
return (
|
|
217
|
+
code.includes("@nifrajs/core") ||
|
|
218
|
+
/(?<![.\w])server\s*\(/.test(code) ||
|
|
219
|
+
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
220
|
+
/new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function scanRoutePattern(
|
|
225
|
+
file: string,
|
|
226
|
+
content: string,
|
|
227
|
+
code: string,
|
|
228
|
+
pattern: RegExp,
|
|
229
|
+
facts?: SourceFacts,
|
|
230
|
+
): StaticRouteFinding[] {
|
|
231
|
+
const out: StaticRouteFinding[] = []
|
|
232
|
+
const lines = content.split("\n")
|
|
233
|
+
pattern.lastIndex = 0
|
|
234
|
+
for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
|
|
235
|
+
const method = (m[1] ?? "").toUpperCase()
|
|
236
|
+
if (!HTTP_VERBS.has(method)) continue
|
|
237
|
+
const path = parseQuotedLiteral(
|
|
238
|
+
`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
|
|
239
|
+
)
|
|
240
|
+
if (path === undefined || !path.startsWith("/") || path.startsWith("//")) continue
|
|
241
|
+
if (facts !== undefined) {
|
|
242
|
+
const source = facts.parse(file, content)
|
|
243
|
+
// A parseable source model is authoritative for candidate locations. If parsing is unavailable
|
|
244
|
+
// or the source is malformed, retain the lexical finding and fail closed.
|
|
245
|
+
if (
|
|
246
|
+
source !== undefined &&
|
|
247
|
+
facts.isRouteRegistrationAt(source, m.index, method, path) !== true
|
|
248
|
+
)
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
const line = lineAt(content, m.index)
|
|
252
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
259
|
+
* - `//` line comments and block comments;
|
|
260
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
261
|
+
* generators), never a real statement to lint.
|
|
262
|
+
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
|
|
263
|
+
* char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
|
|
264
|
+
* could mis-skip - in practice the constructs these scanners look for sit before any such regex.
|
|
265
|
+
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
266
|
+
*/
|
|
267
|
+
export function stripComments(src: string): string {
|
|
268
|
+
const out = src.split("")
|
|
269
|
+
const n = src.length
|
|
270
|
+
let i = 0
|
|
271
|
+
const blank = (a: number, b: number): void => {
|
|
272
|
+
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
273
|
+
}
|
|
274
|
+
while (i < n) {
|
|
275
|
+
const c = src[i]
|
|
276
|
+
const d = src[i + 1]
|
|
277
|
+
if (c === "/" && d === "/") {
|
|
278
|
+
let j = i + 2
|
|
279
|
+
while (j < n && src[j] !== "\n") j++
|
|
280
|
+
blank(i, j)
|
|
281
|
+
i = j
|
|
282
|
+
} else if (c === "/" && d === "*") {
|
|
283
|
+
let j = i + 2
|
|
284
|
+
while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
|
|
285
|
+
j = Math.min(j + 2, n)
|
|
286
|
+
blank(i, j)
|
|
287
|
+
i = j
|
|
288
|
+
} else if (c === "`") {
|
|
289
|
+
let j = i + 1
|
|
290
|
+
while (j < n && src[j] !== "`") {
|
|
291
|
+
if (src[j] === "\\") j++
|
|
292
|
+
j++
|
|
293
|
+
}
|
|
294
|
+
blank(i + 1, j) // keep the backticks, blank the code-as-text inside
|
|
295
|
+
i = j + 1
|
|
296
|
+
} else if (c === "'" || c === '"') {
|
|
297
|
+
let j = i + 1
|
|
298
|
+
while (j < n && src[j] !== c) {
|
|
299
|
+
if (src[j] === "\\") j++ // skip the escaped char
|
|
300
|
+
j++
|
|
301
|
+
}
|
|
302
|
+
i = j + 1
|
|
303
|
+
} else {
|
|
304
|
+
i++
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out.join("")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Blank comments and every quoted/template literal while preserving offsets. Unlike
|
|
311
|
+
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
312
|
+
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
313
|
+
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
314
|
+
export function codePositionMask(src: string): string {
|
|
315
|
+
const out = src.split("")
|
|
316
|
+
const n = src.length
|
|
317
|
+
let i = 0
|
|
318
|
+
const blank = (a: number, b: number): void => {
|
|
319
|
+
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
320
|
+
}
|
|
321
|
+
while (i < n) {
|
|
322
|
+
const c = src[i]
|
|
323
|
+
const d = src[i + 1]
|
|
324
|
+
if (c === "/" && d === "/") {
|
|
325
|
+
let j = i + 2
|
|
326
|
+
while (j < n && src[j] !== "\n") j++
|
|
327
|
+
blank(i, j)
|
|
328
|
+
i = j
|
|
329
|
+
} else if (c === "/" && d === "*") {
|
|
330
|
+
let j = i + 2
|
|
331
|
+
while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
|
|
332
|
+
j = Math.min(j + 2, n)
|
|
333
|
+
blank(i, j)
|
|
334
|
+
i = j
|
|
335
|
+
} else if (c === "'" || c === '"' || c === "`") {
|
|
336
|
+
const quote = c
|
|
337
|
+
let j = i + 1
|
|
338
|
+
while (j < n && src[j] !== quote) {
|
|
339
|
+
if (src[j] === "\\") j++
|
|
340
|
+
j++
|
|
341
|
+
}
|
|
342
|
+
j = Math.min(j + 1, n)
|
|
343
|
+
blank(i, j)
|
|
344
|
+
i = j
|
|
345
|
+
} else {
|
|
346
|
+
i++
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out.join("")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const firstArgumentIndex = (content: string, callEnd: number): number => {
|
|
353
|
+
let index = callEnd
|
|
354
|
+
while (/\s/.test(content[index] ?? "")) index++
|
|
355
|
+
return index
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
|
|
359
|
+
// stopping at the first quote / query / hash / template-expression boundary, and test it against the
|
|
360
|
+
// allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
|
|
361
|
+
// is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
|
|
362
|
+
function matchesExternalMount(content: string, start: number, mounts: readonly string[]): boolean {
|
|
363
|
+
let end = start
|
|
364
|
+
while (end < content.length) {
|
|
365
|
+
const ch = content[end]
|
|
366
|
+
if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#") break
|
|
367
|
+
if (ch === "$" && content[end + 1] === "{") break
|
|
368
|
+
end++
|
|
369
|
+
}
|
|
370
|
+
const path = content.slice(start, end)
|
|
371
|
+
// A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
|
|
372
|
+
// `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
|
|
373
|
+
// fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
|
|
374
|
+
try {
|
|
375
|
+
if (path.split("/").some((segment) => decodeURIComponent(segment) === "..")) return false
|
|
376
|
+
} catch {
|
|
377
|
+
// A malformed escape is not a prefix we can prove stays inside the external mount.
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function findTopLevelPropertyColon(src: string): number | undefined {
|
|
384
|
+
let depth = 0
|
|
385
|
+
let quote: '"' | "'" | "`" | undefined
|
|
386
|
+
for (let i = 0; i < src.length; i++) {
|
|
387
|
+
const c = src[i]
|
|
388
|
+
if (quote !== undefined) {
|
|
389
|
+
if (c === "\\") {
|
|
390
|
+
i++
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
if (c === quote) quote = undefined
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
397
|
+
quote = c
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (c === "(" || c === "{" || c === "[") depth++
|
|
401
|
+
else if (c === ")" || c === "}" || c === "]") depth--
|
|
402
|
+
else if (c === ":" && depth === 0) return i
|
|
403
|
+
}
|
|
404
|
+
return undefined
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseStaticPropertyName(raw: string): string | undefined {
|
|
408
|
+
const name = raw.trim()
|
|
409
|
+
if (IDENT.test(name)) return name
|
|
410
|
+
const quoted = parseQuotedLiteral(name)
|
|
411
|
+
if (quoted !== undefined) return quoted
|
|
412
|
+
if (name.startsWith("[") && name.endsWith("]")) {
|
|
413
|
+
return parseQuotedLiteral(name.slice(1, -1).trim())
|
|
414
|
+
}
|
|
415
|
+
return undefined
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Return a statically named property from a plain object literal. Deliberately does not follow
|
|
419
|
+
* spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
|
|
420
|
+
* transport rather than an arbitrary same-origin API request. */
|
|
421
|
+
function staticObjectProperty(raw: string | undefined, name: string): string | undefined {
|
|
422
|
+
if (raw === undefined) return undefined
|
|
423
|
+
const object = raw.trim()
|
|
424
|
+
if (!object.startsWith("{") || !object.endsWith("}")) return undefined
|
|
425
|
+
const properties = splitTopLevelArgs(object.slice(1, -1))
|
|
426
|
+
if (properties === undefined) return undefined
|
|
427
|
+
for (const property of properties) {
|
|
428
|
+
const colon = findTopLevelPropertyColon(property)
|
|
429
|
+
if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name) continue
|
|
430
|
+
return property.slice(colon + 1).trim()
|
|
431
|
+
}
|
|
432
|
+
return undefined
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function hasLiteralNifraDataHeader(content: string, openParen: number): boolean {
|
|
436
|
+
const closeParen = findMatchingParen(content, openParen)
|
|
437
|
+
if (closeParen === undefined) return false
|
|
438
|
+
const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen))
|
|
439
|
+
if (args === undefined || args.length !== 2) return false
|
|
440
|
+
const headers = staticObjectProperty(args[1], "headers")
|
|
441
|
+
return staticObjectProperty(headers, "x-nifra-data") !== undefined
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
|
|
445
|
+
* are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
|
|
446
|
+
* `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
|
|
447
|
+
* inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
|
|
448
|
+
* skipped too. */
|
|
449
|
+
export function scanFetchText(
|
|
450
|
+
file: string,
|
|
451
|
+
content: string,
|
|
452
|
+
externalMounts: readonly string[] = [],
|
|
453
|
+
): SourceFinding[] {
|
|
454
|
+
const out: SourceFinding[] = []
|
|
455
|
+
const lines = content.split("\n")
|
|
456
|
+
const code = codePositionMask(content)
|
|
457
|
+
GLOBAL_FETCH_CALL.lastIndex = 0
|
|
458
|
+
for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
|
|
459
|
+
const argument = firstArgumentIndex(content, m.index + m[0].length)
|
|
460
|
+
const quote = content[argument]
|
|
461
|
+
if (quote !== "'" && quote !== '"' && quote !== "`") continue
|
|
462
|
+
if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
|
|
463
|
+
const openParen = m.index + m[0].lastIndexOf("(")
|
|
464
|
+
if (hasLiteralNifraDataHeader(content, openParen)) continue
|
|
465
|
+
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
466
|
+
continue
|
|
467
|
+
const line = lineAt(content, m.index)
|
|
468
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
469
|
+
}
|
|
470
|
+
return out
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
474
|
+
export function scanStaticRouteText(
|
|
475
|
+
file: string,
|
|
476
|
+
content: string,
|
|
477
|
+
facts?: SourceFacts,
|
|
478
|
+
): StaticRouteFinding[] {
|
|
479
|
+
const code = stripComments(content)
|
|
480
|
+
if (!isPotentialBackendSource(code)) return []
|
|
481
|
+
return [
|
|
482
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
483
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
484
|
+
].sort(bySite)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* SQL built by interpolating a value into the statement text. The value is not a parameter, it is
|
|
489
|
+
* STATEMENT, so anything the caller controls can end the literal and continue as SQL.
|
|
490
|
+
*
|
|
491
|
+
* Two things this deliberately does not flag, because both are safe and flagging them would train
|
|
492
|
+
* people to ignore the rule:
|
|
493
|
+
*
|
|
494
|
+
* - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
|
|
495
|
+
* substitutions separately and binds them; that is the parameterised form, not a bypass of it.
|
|
496
|
+
* - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
|
|
497
|
+
* argued for.
|
|
498
|
+
*
|
|
499
|
+
* A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
|
|
500
|
+
* escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
|
|
501
|
+
* is to take a statement as text, and a substitution in one is the exact thing the name warns about.
|
|
502
|
+
*/
|
|
503
|
+
const SQL_METHODS = new Set([
|
|
504
|
+
"query",
|
|
505
|
+
"queryRaw",
|
|
506
|
+
"prepare",
|
|
507
|
+
"exec",
|
|
508
|
+
"execute",
|
|
509
|
+
"run",
|
|
510
|
+
"unsafe",
|
|
511
|
+
"$queryRawUnsafe",
|
|
512
|
+
"$executeRawUnsafe",
|
|
513
|
+
"raw",
|
|
514
|
+
])
|
|
515
|
+
const SQL_KEYWORD =
|
|
516
|
+
/\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
|
|
517
|
+
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
|
|
518
|
+
|
|
519
|
+
interface SqlExpressionShape {
|
|
520
|
+
readonly staticText: string
|
|
521
|
+
readonly dynamic: boolean
|
|
522
|
+
readonly concatenated: boolean
|
|
523
|
+
readonly literal: boolean
|
|
524
|
+
readonly parameterizedTag: boolean
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
|
|
528
|
+
type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
|
|
529
|
+
|
|
530
|
+
/** A name some other module exports, named exactly as the edge that reaches it writes it. */
|
|
531
|
+
interface ExportEdge {
|
|
532
|
+
readonly specifier: string
|
|
533
|
+
readonly exported: string
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* One module's provable module-scope bindings: its top-level `const` initializers plus the exact
|
|
538
|
+
* import/export edges this scan is allowed to follow.
|
|
539
|
+
*
|
|
540
|
+
* Everything absent from these maps stays unresolved, and an unresolved interpolation keeps flagging.
|
|
541
|
+
* That is the whole safety argument: resolution can only ever make this rule QUIETER, so a form the
|
|
542
|
+
* scan cannot prove must fall out as "unknown" rather than be guessed at. Deliberately absent:
|
|
543
|
+
* `export default`, `export let`/`var`, destructured or computed names, default and namespace imports,
|
|
544
|
+
* `export * as ns`, `require`, and dynamic `import()`.
|
|
545
|
+
*/
|
|
546
|
+
interface SqlModuleScope {
|
|
547
|
+
/** The module's identity - an absolute path from the loader, or the entry file's path as given. Keys
|
|
548
|
+
* the parsed-scope cache and the cycle guard, and is the base its own specifiers resolve against. */
|
|
549
|
+
readonly id: string
|
|
550
|
+
readonly consts: ModuleConsts
|
|
551
|
+
/** Local name bound by `import { X as local }` → the edge that provides it. */
|
|
552
|
+
readonly imports: ReadonlyMap<string, ExportEdge>
|
|
553
|
+
/** Exported name → the local name it reads (`export { a as b }` → `b` → `a`). */
|
|
554
|
+
readonly localExports: ReadonlyMap<string, string>
|
|
555
|
+
/** Exported name → another module's export (`export { a as b } from "./m"`). */
|
|
556
|
+
readonly reExports: ReadonlyMap<string, ExportEdge>
|
|
557
|
+
/** `export * from "./m"` specifiers, in source order. */
|
|
558
|
+
readonly starExports: readonly string[]
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
|
|
562
|
+
* no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
|
|
563
|
+
* module-scope `const` is the only form that provably cannot carry request data. */
|
|
564
|
+
function sqlModuleScope(ts: TypeScriptApi, id: string, source: TSApi.SourceFile): SqlModuleScope {
|
|
565
|
+
const consts = new Map<string, TSApi.Expression>()
|
|
566
|
+
const imports = new Map<string, ExportEdge>()
|
|
567
|
+
const localExports = new Map<string, string>()
|
|
568
|
+
const reExports = new Map<string, ExportEdge>()
|
|
569
|
+
const starExports: string[] = []
|
|
570
|
+
for (const statement of source.statements) {
|
|
571
|
+
if (ts.isVariableStatement(statement)) {
|
|
572
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
|
|
573
|
+
const exported =
|
|
574
|
+
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
575
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
576
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
|
|
577
|
+
consts.set(declaration.name.text, declaration.initializer)
|
|
578
|
+
if (exported) localExports.set(declaration.name.text, declaration.name.text)
|
|
579
|
+
}
|
|
580
|
+
continue
|
|
581
|
+
}
|
|
582
|
+
if (ts.isImportDeclaration(statement)) {
|
|
583
|
+
const clause = statement.importClause
|
|
584
|
+
if (clause === undefined || clause.isTypeOnly) continue
|
|
585
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
|
|
586
|
+
const bindings = clause.namedBindings
|
|
587
|
+
// Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
|
|
588
|
+
// whose contents this scan cannot prove, so it stays unbound and every use of it flags.
|
|
589
|
+
if (bindings === undefined || !ts.isNamedImports(bindings)) continue
|
|
590
|
+
for (const element of bindings.elements) {
|
|
591
|
+
if (element.isTypeOnly) continue
|
|
592
|
+
imports.set(element.name.text, {
|
|
593
|
+
specifier: statement.moduleSpecifier.text,
|
|
594
|
+
exported: element.propertyName?.text ?? element.name.text,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
continue
|
|
598
|
+
}
|
|
599
|
+
if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue
|
|
600
|
+
const from = statement.moduleSpecifier
|
|
601
|
+
if (from !== undefined && !ts.isStringLiteral(from)) continue
|
|
602
|
+
const clause = statement.exportClause
|
|
603
|
+
if (clause === undefined) {
|
|
604
|
+
// No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
|
|
605
|
+
// NamespaceExport clause instead and falls out below, unresolved, which is what we want.
|
|
606
|
+
if (from !== undefined) starExports.push(from.text)
|
|
607
|
+
continue
|
|
608
|
+
}
|
|
609
|
+
if (!ts.isNamedExports(clause)) continue
|
|
610
|
+
for (const element of clause.elements) {
|
|
611
|
+
if (element.isTypeOnly) continue
|
|
612
|
+
const local = element.propertyName?.text ?? element.name.text
|
|
613
|
+
if (from === undefined) localExports.set(element.name.text, local)
|
|
614
|
+
else reExports.set(element.name.text, { specifier: from.text, exported: local })
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { id, consts, imports, localExports, reExports, starExports }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** A module's identity + source, as resolved from a specifier. `id` must be stable and absolute: it
|
|
621
|
+
* keys the parsed-scope cache, seeds the cycle guard, and is what the module's own relative specifiers
|
|
622
|
+
* resolve against. */
|
|
623
|
+
export interface SqlModuleSource {
|
|
624
|
+
readonly id: string
|
|
625
|
+
readonly content: string
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Resolves a specifier written in the module `fromModule` to the target's identity + source, or
|
|
630
|
+
* `undefined` when it cannot be resolved, read, or proved to be in-project source.
|
|
631
|
+
*
|
|
632
|
+
* Injected rather than built in, for the same reason {@link ModuleResolver} is: it keeps
|
|
633
|
+
* {@link scanInterpolatedSql} pure and filesystem-free, so the resolution rules can be unit-tested
|
|
634
|
+
* against a virtual module graph. {@link createProjectSqlImports} is the on-disk implementation.
|
|
635
|
+
*/
|
|
636
|
+
export type SqlModuleLoader = (fromModule: string, specifier: string) => SqlModuleSource | undefined
|
|
637
|
+
|
|
638
|
+
/** Cross-module resolution state for one run: the loader plus a parsed-scope cache, so a fragments
|
|
639
|
+
* module imported by fifty callers is read and parsed once. A cached `undefined` is a REFUSAL (the
|
|
640
|
+
* module did not parse), cached so it is not re-attempted. Build with {@link createSqlImports}. */
|
|
641
|
+
export interface SqlImports {
|
|
642
|
+
readonly load: SqlModuleLoader
|
|
643
|
+
readonly scopes: Map<string, SqlModuleScope | undefined>
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export const createSqlImports = (load: SqlModuleLoader): SqlImports => ({
|
|
647
|
+
load,
|
|
648
|
+
scopes: new Map(),
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
/** Where an expression's identifiers are read: the module the expression physically lives in, plus the
|
|
652
|
+
* run's cross-module state. `imports === undefined` is same-file-only resolution - the behavior before
|
|
653
|
+
* imported fragments were followed, and still what an unconfigured `scanInterpolatedSql` call does. */
|
|
654
|
+
interface SqlScope {
|
|
655
|
+
readonly module: SqlModuleScope
|
|
656
|
+
readonly imports: SqlImports | undefined
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
|
|
660
|
+
* a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
|
|
661
|
+
* parse contributes no findings and proves no constant. */
|
|
662
|
+
function parseSqlSource(
|
|
663
|
+
ts: TypeScriptApi,
|
|
664
|
+
file: string,
|
|
665
|
+
content: string,
|
|
666
|
+
): TSApi.SourceFile | undefined {
|
|
667
|
+
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
668
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
669
|
+
const parseDiagnostics = (
|
|
670
|
+
source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
671
|
+
).parseDiagnostics
|
|
672
|
+
return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
|
|
676
|
+
* already spends two, and the third leaves room for one more layer without letting the scan wander the
|
|
677
|
+
* module graph. Past the cap the name is unresolved, so the interpolation flags. */
|
|
678
|
+
const SQL_IMPORT_HOPS = 3
|
|
679
|
+
|
|
680
|
+
/** Load + parse the module a specifier names, through the run's cache. */
|
|
681
|
+
function loadSqlModuleScope(
|
|
682
|
+
ts: TypeScriptApi,
|
|
683
|
+
imports: SqlImports,
|
|
684
|
+
fromModule: string,
|
|
685
|
+
specifier: string,
|
|
686
|
+
): SqlModuleScope | undefined {
|
|
687
|
+
const loaded = imports.load(fromModule, specifier)
|
|
688
|
+
if (loaded === undefined) return undefined
|
|
689
|
+
if (imports.scopes.has(loaded.id)) return imports.scopes.get(loaded.id)
|
|
690
|
+
const source = parseSqlSource(ts, loaded.id, loaded.content)
|
|
691
|
+
const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source)
|
|
692
|
+
imports.scopes.set(loaded.id, scope)
|
|
693
|
+
return scope
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** One exported name resolved to the `const` initializer behind it, together with the scope that
|
|
697
|
+
* initializer's OWN identifiers must be read in. */
|
|
698
|
+
interface ResolvedExport {
|
|
699
|
+
readonly expression: TSApi.Expression
|
|
700
|
+
readonly scope: SqlModuleScope
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Follow an exported name to its `const` initializer, across re-export hops.
|
|
705
|
+
*
|
|
706
|
+
* Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
|
|
707
|
+
* `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
|
|
708
|
+
* barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
|
|
709
|
+
* a per-chain visited set, so a cyclic barrel terminates instead of recursing.
|
|
710
|
+
*
|
|
711
|
+
* Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
|
|
712
|
+
* picking one: guessing here would silence a real finding.
|
|
713
|
+
*/
|
|
714
|
+
function resolveExportedConst(
|
|
715
|
+
ts: TypeScriptApi,
|
|
716
|
+
imports: SqlImports,
|
|
717
|
+
scope: SqlModuleScope,
|
|
718
|
+
name: string,
|
|
719
|
+
hops: number,
|
|
720
|
+
seen: ReadonlySet<string>,
|
|
721
|
+
): ResolvedExport | undefined {
|
|
722
|
+
if (hops > SQL_IMPORT_HOPS) return undefined
|
|
723
|
+
const key = `${scope.id}#${name}`
|
|
724
|
+
if (seen.has(key)) return undefined
|
|
725
|
+
const nextSeen = new Set(seen).add(key)
|
|
726
|
+
const follow = (edge: ExportEdge): ResolvedExport | undefined => {
|
|
727
|
+
const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier)
|
|
728
|
+
return target === undefined
|
|
729
|
+
? undefined
|
|
730
|
+
: resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen)
|
|
731
|
+
}
|
|
732
|
+
const local = scope.localExports.get(name)
|
|
733
|
+
if (local !== undefined) {
|
|
734
|
+
const initializer = scope.consts.get(local)
|
|
735
|
+
if (initializer !== undefined) return { expression: initializer, scope }
|
|
736
|
+
// `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
|
|
737
|
+
// written in two statements. Anything else the name could be (a `let`, a function, a class) has no
|
|
738
|
+
// initializer in `consts` and no import edge, so it lands on `undefined` and flags.
|
|
739
|
+
const via = scope.imports.get(local)
|
|
740
|
+
return via === undefined ? undefined : follow(via)
|
|
741
|
+
}
|
|
742
|
+
const reExported = scope.reExports.get(name)
|
|
743
|
+
if (reExported !== undefined) return follow(reExported)
|
|
744
|
+
let found: ResolvedExport | undefined
|
|
745
|
+
for (const specifier of scope.starExports) {
|
|
746
|
+
const hit = follow({ specifier, exported: name })
|
|
747
|
+
if (hit === undefined) continue
|
|
748
|
+
if (found !== undefined) return undefined
|
|
749
|
+
found = hit
|
|
750
|
+
}
|
|
751
|
+
return found
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
|
|
755
|
+
* Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
|
|
756
|
+
* of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
|
|
757
|
+
* hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
|
|
758
|
+
* resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
|
|
759
|
+
* the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
|
|
760
|
+
* enclosing function or block reads module scope directly and cannot be shadowed. */
|
|
761
|
+
function isShadowedAt(ts: TypeScriptApi, at: TSApi.Node, name: string): boolean {
|
|
762
|
+
let scope: TSApi.Node | undefined
|
|
763
|
+
for (let node: TSApi.Node | undefined = at.parent; node !== undefined; node = node.parent) {
|
|
764
|
+
if (ts.isSourceFile(node)) break
|
|
765
|
+
if (ts.isFunctionLike(node) || ts.isBlock(node)) scope = node
|
|
766
|
+
}
|
|
767
|
+
if (scope === undefined) return false
|
|
768
|
+
const bindsName = (binding: TSApi.BindingName): boolean =>
|
|
769
|
+
ts.isIdentifier(binding)
|
|
770
|
+
? binding.text === name
|
|
771
|
+
: binding.elements.some(
|
|
772
|
+
(element) =>
|
|
773
|
+
!ts.isOmittedExpression(element) &&
|
|
774
|
+
(ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)),
|
|
775
|
+
)
|
|
776
|
+
let found = false
|
|
777
|
+
const scan = (node: TSApi.Node): void => {
|
|
778
|
+
if (found) return
|
|
779
|
+
if (
|
|
780
|
+
(ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
|
|
781
|
+
bindsName(node.name as TSApi.BindingName)
|
|
782
|
+
) {
|
|
783
|
+
found = true
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
787
|
+
found = true
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
ts.forEachChild(node, scan)
|
|
791
|
+
}
|
|
792
|
+
scan(scope)
|
|
793
|
+
return found
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const CONST_RESOLUTION_DEPTH = 5
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
|
|
800
|
+
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
801
|
+
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
802
|
+
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
803
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
|
|
804
|
+
* initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
|
|
805
|
+
* `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
|
|
806
|
+
* call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
|
|
807
|
+
* interpolation is flagged exactly as before.
|
|
808
|
+
*/
|
|
809
|
+
function resolveStaticSqlText(
|
|
810
|
+
ts: TypeScriptApi,
|
|
811
|
+
node: TSApi.Expression,
|
|
812
|
+
scope: SqlScope,
|
|
813
|
+
depth: number,
|
|
814
|
+
): string | undefined {
|
|
815
|
+
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
816
|
+
if (ts.isParenthesizedExpression(node)) {
|
|
817
|
+
return resolveStaticSqlText(ts, node.expression, scope, depth)
|
|
818
|
+
}
|
|
819
|
+
if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
820
|
+
return resolveStaticSqlText(ts, node.expression, scope, depth)
|
|
821
|
+
}
|
|
822
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
|
823
|
+
if (ts.isNumericLiteral(node)) return node.text
|
|
824
|
+
if (ts.isTemplateExpression(node)) {
|
|
825
|
+
let text = node.head.text
|
|
826
|
+
for (const span of node.templateSpans) {
|
|
827
|
+
const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
|
|
828
|
+
if (part === undefined) return undefined
|
|
829
|
+
text += part + span.literal.text
|
|
830
|
+
}
|
|
831
|
+
return text
|
|
832
|
+
}
|
|
833
|
+
if (ts.isConditionalExpression(node)) {
|
|
834
|
+
const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1)
|
|
835
|
+
const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1)
|
|
836
|
+
if (whenTrue === undefined || whenFalse === undefined) return undefined
|
|
837
|
+
return `${whenTrue} ${whenFalse}`
|
|
838
|
+
}
|
|
839
|
+
if (ts.isIdentifier(node)) {
|
|
840
|
+
const initializer = scope.module.consts.get(node.text)
|
|
841
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
842
|
+
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
843
|
+
// module const nor an import binding.
|
|
844
|
+
if (initializer === undefined && edge === undefined) return undefined
|
|
845
|
+
// Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
|
|
846
|
+
// of an import hop: an initializer in the imported module sits at module scope with nothing above
|
|
847
|
+
// it to shadow it, while a use inside a function in this file is still refused.
|
|
848
|
+
if (isShadowedAt(ts, node, node.text)) return undefined
|
|
849
|
+
if (initializer !== undefined) return resolveStaticSqlText(ts, initializer, scope, depth + 1)
|
|
850
|
+
if (edge === undefined || scope.imports === undefined) return undefined
|
|
851
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
|
|
852
|
+
if (target === undefined) return undefined
|
|
853
|
+
const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
|
|
854
|
+
if (exported === undefined) return undefined
|
|
855
|
+
// The imported initializer's own identifiers read THAT module's consts, never this file's. A
|
|
856
|
+
// caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
|
|
857
|
+
// which is the one way cross-module resolution could resolve to text nobody wrote.
|
|
858
|
+
return resolveStaticSqlText(
|
|
859
|
+
ts,
|
|
860
|
+
exported.expression,
|
|
861
|
+
{ module: exported.scope, imports: scope.imports },
|
|
862
|
+
depth + 1,
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
return undefined
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
869
|
+
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
870
|
+
function templateShape(
|
|
871
|
+
ts: TypeScriptApi,
|
|
872
|
+
template: TSApi.TemplateExpression,
|
|
873
|
+
scope: SqlScope | undefined,
|
|
874
|
+
): { staticText: string; dynamic: boolean } {
|
|
875
|
+
let staticText = template.head.text
|
|
876
|
+
let dynamic = false
|
|
877
|
+
for (const span of template.templateSpans) {
|
|
878
|
+
const resolved =
|
|
879
|
+
scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
|
|
880
|
+
if (resolved === undefined) dynamic = true
|
|
881
|
+
else staticText += resolved
|
|
882
|
+
staticText += span.literal.text
|
|
883
|
+
}
|
|
884
|
+
return { staticText, dynamic }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
|
|
888
|
+
* whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
|
|
889
|
+
* parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
|
|
890
|
+
* farther, resolvable const of the same name is never read in its place. */
|
|
891
|
+
type ResolvableBinding = { readonly initializer: TSApi.Expression } | "shadow"
|
|
892
|
+
|
|
893
|
+
/** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
|
|
894
|
+
* bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
|
|
895
|
+
* request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
|
|
896
|
+
* before, never the reverse. */
|
|
897
|
+
function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
|
|
898
|
+
let found = false
|
|
899
|
+
const visit = (node: TSApi.Node): void => {
|
|
900
|
+
if (found) return
|
|
901
|
+
if (
|
|
902
|
+
ts.isBinaryExpression(node) &&
|
|
903
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
904
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
|
|
905
|
+
ts.isIdentifier(node.left) &&
|
|
906
|
+
node.left.text === name
|
|
907
|
+
) {
|
|
908
|
+
found = true
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
if (
|
|
912
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
913
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
914
|
+
node.operator === ts.SyntaxKind.MinusMinusToken) &&
|
|
915
|
+
ts.isIdentifier(node.operand) &&
|
|
916
|
+
node.operand.text === name
|
|
917
|
+
) {
|
|
918
|
+
found = true
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
ts.forEachChild(node, visit)
|
|
922
|
+
}
|
|
923
|
+
visit(source)
|
|
924
|
+
return found
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
|
|
928
|
+
* the pattern walk in {@link isShadowedAt}. */
|
|
929
|
+
function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
|
|
930
|
+
return ts.isIdentifier(binding)
|
|
931
|
+
? binding.text === name
|
|
932
|
+
: binding.elements.some(
|
|
933
|
+
(element) =>
|
|
934
|
+
!ts.isOmittedExpression(element) &&
|
|
935
|
+
(ts.isIdentifier(element.name)
|
|
936
|
+
? element.name.text === name
|
|
937
|
+
: bindingNameCovers(ts, element.name, name)),
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
|
|
942
|
+
* catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
|
|
943
|
+
* statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
|
|
944
|
+
* `undefined` when this scope does not bind the name at all. */
|
|
945
|
+
function directBinding(
|
|
946
|
+
ts: TypeScriptApi,
|
|
947
|
+
source: TSApi.SourceFile,
|
|
948
|
+
scope: TSApi.Node,
|
|
949
|
+
name: string,
|
|
950
|
+
): ResolvableBinding | undefined {
|
|
951
|
+
if (ts.isFunctionLike(scope)) {
|
|
952
|
+
for (const parameter of scope.parameters) {
|
|
953
|
+
if (bindingNameCovers(ts, parameter.name, name)) return "shadow"
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (ts.isCatchClause(scope)) {
|
|
957
|
+
const declared = scope.variableDeclaration
|
|
958
|
+
if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
|
|
959
|
+
}
|
|
960
|
+
const statements: readonly TSApi.Statement[] | undefined = ts.isSourceFile(scope)
|
|
961
|
+
? scope.statements
|
|
962
|
+
: ts.isBlock(scope) || ts.isModuleBlock(scope)
|
|
963
|
+
? scope.statements
|
|
964
|
+
: ts.isCaseClause(scope) || ts.isDefaultClause(scope)
|
|
965
|
+
? scope.statements
|
|
966
|
+
: undefined
|
|
967
|
+
const lists: TSApi.VariableDeclarationList[] = []
|
|
968
|
+
if (statements !== undefined) {
|
|
969
|
+
for (const statement of statements) {
|
|
970
|
+
if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
|
|
971
|
+
if (
|
|
972
|
+
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
973
|
+
statement.name?.text === name
|
|
974
|
+
) {
|
|
975
|
+
return "shadow"
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (
|
|
980
|
+
(ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
|
|
981
|
+
scope.initializer !== undefined &&
|
|
982
|
+
ts.isVariableDeclarationList(scope.initializer)
|
|
983
|
+
) {
|
|
984
|
+
lists.push(scope.initializer)
|
|
985
|
+
}
|
|
986
|
+
for (const list of lists) {
|
|
987
|
+
for (const declaration of list.declarations) {
|
|
988
|
+
if (!bindingNameCovers(ts, declaration.name, name)) continue
|
|
989
|
+
// A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
|
|
990
|
+
// value we can read one expression out of.
|
|
991
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
|
|
992
|
+
return "shadow"
|
|
993
|
+
const isConst = (list.flags & ts.NodeFlags.Const) !== 0
|
|
994
|
+
const isLet = (list.flags & ts.NodeFlags.Let) !== 0
|
|
995
|
+
if (isConst) return { initializer: declaration.initializer }
|
|
996
|
+
if (isLet && !isReassignedInFile(ts, source, name))
|
|
997
|
+
return { initializer: declaration.initializer }
|
|
998
|
+
return "shadow"
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return undefined
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Resolve a query-call ARGUMENT identifier to the expression it was initialized from, so a statement
|
|
1006
|
+
* parked in a variable is scanned as if it had been written inline at the call. This is the counterpart
|
|
1007
|
+
* to {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
|
|
1008
|
+
* whole statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
|
|
1009
|
+
* could be silenced, invisibly, by hoisting the statement to `const q = …` first.
|
|
1010
|
+
*
|
|
1011
|
+
* Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
|
|
1012
|
+
* same-named module const. Reads through a `const`, or a `let` the file never reassigns, at function or
|
|
1013
|
+
* module scope; transitive through an identifier-valued initializer; depth-capped. A parameter, an
|
|
1014
|
+
* imported or destructured binding, a reassigned `let`, a `var`, or a name with no same-file
|
|
1015
|
+
* declaration returns `undefined` - which leaves the argument scanned as itself (unflagged unless it is
|
|
1016
|
+
* a module const that resolves to static text: exactly the pre-feature behavior).
|
|
1017
|
+
*/
|
|
1018
|
+
function resolveArgumentInitializer(
|
|
1019
|
+
ts: TypeScriptApi,
|
|
1020
|
+
node: TSApi.Identifier,
|
|
1021
|
+
depth: number,
|
|
1022
|
+
): TSApi.Expression | undefined {
|
|
1023
|
+
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
1024
|
+
const source = node.getSourceFile()
|
|
1025
|
+
const name = node.text
|
|
1026
|
+
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1027
|
+
const binding = directBinding(ts, source, scope, name)
|
|
1028
|
+
if (binding === "shadow") return undefined
|
|
1029
|
+
if (binding !== undefined) {
|
|
1030
|
+
let init = binding.initializer
|
|
1031
|
+
while (ts.isParenthesizedExpression(init)) init = init.expression
|
|
1032
|
+
return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init
|
|
1033
|
+
}
|
|
1034
|
+
if (ts.isSourceFile(scope)) break
|
|
1035
|
+
}
|
|
1036
|
+
return undefined
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
|
|
1040
|
+
function sqlExpressionShape(
|
|
1041
|
+
ts: TypeScriptApi,
|
|
1042
|
+
node: TSApi.Expression,
|
|
1043
|
+
scope?: SqlScope,
|
|
1044
|
+
): SqlExpressionShape {
|
|
1045
|
+
if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
|
|
1046
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
1047
|
+
return {
|
|
1048
|
+
staticText: node.text,
|
|
1049
|
+
dynamic: false,
|
|
1050
|
+
concatenated: false,
|
|
1051
|
+
literal: true,
|
|
1052
|
+
parameterizedTag: false,
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
1056
|
+
const tag = node.tag
|
|
1057
|
+
const parameterizedTag =
|
|
1058
|
+
(ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
1059
|
+
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
1060
|
+
const template = node.template
|
|
1061
|
+
const templated = ts.isTemplateExpression(template)
|
|
1062
|
+
? templateShape(ts, template, parameterizedTag ? undefined : scope)
|
|
1063
|
+
: { staticText: template.text, dynamic: false }
|
|
1064
|
+
const staticText = templated.staticText
|
|
1065
|
+
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
1066
|
+
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
1067
|
+
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
1068
|
+
// deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
|
|
1069
|
+
// same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
|
|
1070
|
+
//
|
|
1071
|
+
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
1072
|
+
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
1073
|
+
// convention for, added so Nifra's source would pass its own rule. Names earn their place here by
|
|
1074
|
+
// being what drivers already call the thing, not by being convenient for the framework.
|
|
1075
|
+
//
|
|
1076
|
+
// Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
|
|
1077
|
+
// is treated as plain interpolation.
|
|
1078
|
+
return {
|
|
1079
|
+
staticText,
|
|
1080
|
+
dynamic: templated.dynamic && !parameterizedTag,
|
|
1081
|
+
concatenated: false,
|
|
1082
|
+
literal: !ts.isTemplateExpression(template),
|
|
1083
|
+
parameterizedTag,
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (ts.isTemplateExpression(node)) {
|
|
1087
|
+
const templated = templateShape(ts, node, scope)
|
|
1088
|
+
return {
|
|
1089
|
+
staticText: templated.staticText,
|
|
1090
|
+
dynamic: templated.dynamic,
|
|
1091
|
+
concatenated: false,
|
|
1092
|
+
literal: false,
|
|
1093
|
+
parameterizedTag: false,
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
1097
|
+
const left = sqlExpressionShape(ts, node.left, scope)
|
|
1098
|
+
const right = sqlExpressionShape(ts, node.right, scope)
|
|
1099
|
+
return {
|
|
1100
|
+
staticText: left.staticText + right.staticText,
|
|
1101
|
+
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
1102
|
+
// narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
|
|
1103
|
+
// a binding tag being concatenated into plain text (which un-parameterizes it).
|
|
1104
|
+
dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
|
|
1105
|
+
concatenated: true,
|
|
1106
|
+
literal: left.literal && right.literal,
|
|
1107
|
+
parameterizedTag: false,
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
|
|
1111
|
+
// text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
|
|
1112
|
+
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
|
|
1113
|
+
// `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
|
|
1114
|
+
// resolved const, because taking statement text from ANY variable is what their rule warns about.
|
|
1115
|
+
if (scope !== undefined) {
|
|
1116
|
+
const resolved = resolveStaticSqlText(ts, node, scope, 0)
|
|
1117
|
+
if (resolved !== undefined) {
|
|
1118
|
+
return {
|
|
1119
|
+
staticText: resolved,
|
|
1120
|
+
dynamic: false,
|
|
1121
|
+
concatenated: false,
|
|
1122
|
+
literal: false,
|
|
1123
|
+
parameterizedTag: false,
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// A bare name that did not resolve to compile-time text. If it is a same-file binding we can prove
|
|
1128
|
+
// holds one expression, scan THAT expression's shape - so a statement hoisted into `const q = …` is
|
|
1129
|
+
// read exactly as if it were written inline at the call, closing the "extract a variable to launder
|
|
1130
|
+
// the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
|
|
1131
|
+
// property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
|
|
1132
|
+
if (ts.isIdentifier(node)) {
|
|
1133
|
+
const initializer = resolveArgumentInitializer(ts, node, 0)
|
|
1134
|
+
if (initializer !== undefined) {
|
|
1135
|
+
return { ...sqlExpressionShape(ts, initializer, scope), literal: false }
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return {
|
|
1139
|
+
staticText: "",
|
|
1140
|
+
dynamic: true,
|
|
1141
|
+
concatenated: false,
|
|
1142
|
+
literal: false,
|
|
1143
|
+
parameterizedTag: false,
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function sqlMethod(
|
|
1148
|
+
ts: TypeScriptApi,
|
|
1149
|
+
call: TSApi.CallExpression,
|
|
1150
|
+
): { method: string; receiver: string } | undefined {
|
|
1151
|
+
if (ts.isPropertyAccessExpression(call.expression)) {
|
|
1152
|
+
const method = call.expression.name.text
|
|
1153
|
+
return SQL_METHODS.has(method)
|
|
1154
|
+
? { method, receiver: call.expression.expression.getText() }
|
|
1155
|
+
: undefined
|
|
1156
|
+
}
|
|
1157
|
+
if (
|
|
1158
|
+
ts.isElementAccessExpression(call.expression) &&
|
|
1159
|
+
call.expression.argumentExpression !== undefined &&
|
|
1160
|
+
ts.isStringLiteral(call.expression.argumentExpression)
|
|
1161
|
+
) {
|
|
1162
|
+
const method = call.expression.argumentExpression.text
|
|
1163
|
+
return SQL_METHODS.has(method)
|
|
1164
|
+
? { method, receiver: call.expression.expression.getText() }
|
|
1165
|
+
: undefined
|
|
1166
|
+
}
|
|
1167
|
+
return undefined
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
|
|
1171
|
+
|
|
1172
|
+
/** The opt-out pragma that silences one interpolated-SQL finding. Written `// nifra-expect sql-dynamic:
|
|
1173
|
+
* <reason>` on the flagged line or in the comment block directly above it; the reason after the colon
|
|
1174
|
+
* is mandatory (see {@link commentBlockMarkerReason}). Scoped to the single statement it sits on. */
|
|
1175
|
+
const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic"
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
1179
|
+
*
|
|
1180
|
+
* `imports` opts into following a fragment `const` into another module. Without it the scan is
|
|
1181
|
+
* same-file only, which is what the unit tests exercise and what any caller that has no module graph
|
|
1182
|
+
* to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
|
|
1183
|
+
*/
|
|
1184
|
+
export function scanInterpolatedSql(
|
|
1185
|
+
file: string,
|
|
1186
|
+
content: string,
|
|
1187
|
+
ts: TypeScriptApi,
|
|
1188
|
+
imports?: SqlImports,
|
|
1189
|
+
): SourceFinding[] {
|
|
1190
|
+
const out: SourceFinding[] = []
|
|
1191
|
+
const lines = content.split("\n")
|
|
1192
|
+
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
1193
|
+
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
1194
|
+
const source = parseSqlSource(ts, file, content)
|
|
1195
|
+
if (source === undefined) return out
|
|
1196
|
+
// Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
|
|
1197
|
+
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1198
|
+
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1199
|
+
const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
|
|
1200
|
+
const visit = (node: TSApi.Node): void => {
|
|
1201
|
+
if (ts.isCallExpression(node)) {
|
|
1202
|
+
const sink = sqlMethod(ts, node)
|
|
1203
|
+
const argument = node.arguments[0]
|
|
1204
|
+
if (sink !== undefined && argument !== undefined) {
|
|
1205
|
+
const shape = sqlExpressionShape(ts, argument, scope)
|
|
1206
|
+
const unsafeEscape =
|
|
1207
|
+
ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
|
|
1208
|
+
const assembledSql =
|
|
1209
|
+
shape.dynamic &&
|
|
1210
|
+
(SQL_KEYWORD.test(shape.staticText) ||
|
|
1211
|
+
(shape.concatenated && SQL_RECEIVER.test(sink.receiver)))
|
|
1212
|
+
if (unsafeEscape || assembledSql) {
|
|
1213
|
+
const index = node.getStart(source)
|
|
1214
|
+
const line = source.getLineAndCharacterOfPosition(index).line + 1
|
|
1215
|
+
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (batch VALUES placeholder
|
|
1216
|
+
// generation, an allowlisted identifier). The reason is mandatory: a bare `// nifra-expect
|
|
1217
|
+
// sql-dynamic` with no reason after the colon does NOT silence, so the hatch always leaves a
|
|
1218
|
+
// greppable audit trail instead of becoming a second laundering trick.
|
|
1219
|
+
const silenced = commentBlockMarkerReason(lines, line, SQL_DYNAMIC_PRAGMA) !== undefined
|
|
1220
|
+
if (!silenced) out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
ts.forEachChild(node, visit)
|
|
1225
|
+
}
|
|
1226
|
+
visit(source)
|
|
1227
|
+
return out
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Collect interpolated-SQL findings across the project.
|
|
1232
|
+
*
|
|
1233
|
+
* Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
|
|
1234
|
+
* with the compiler, so without it there is nothing to report and, more to the point, no basis for
|
|
1235
|
+
* reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
|
|
1236
|
+
* answer a security rule must never give when it did not run.
|
|
1237
|
+
*/
|
|
1238
|
+
export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
|
|
1239
|
+
const ts = await importProjectTypeScript(cwd)
|
|
1240
|
+
if (ts === undefined) return undefined
|
|
1241
|
+
const out: SourceFinding[] = []
|
|
1242
|
+
const imports = createProjectSqlImports(cwd)
|
|
1243
|
+
await walkSource(cwd, (rel, content) =>
|
|
1244
|
+
out.push(...scanInterpolatedSql(rel, content, ts, imports)),
|
|
1245
|
+
)
|
|
1246
|
+
return out.sort(bySite)
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
|
|
1250
|
+
* initializer to read, so following one could only ever produce a "proved" the source never wrote. */
|
|
1251
|
+
const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/
|
|
1252
|
+
/** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
|
|
1253
|
+
const SQL_SOURCE_FILE = /\.[cm]?tsx?$/
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* The on-disk loader for cross-module `const` resolution, scoped to one project.
|
|
1257
|
+
*
|
|
1258
|
+
* Deliberately narrow, because every widening here is a way for a real finding to go quiet:
|
|
1259
|
+
*
|
|
1260
|
+
* - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
|
|
1261
|
+
* this project changing, so "provably a `const` string" is not a property the project owns.
|
|
1262
|
+
* - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
|
|
1263
|
+
* under the project root: a symlink pointing out of the tree is not source this scan reads. The
|
|
1264
|
+
* `node_modules` / `dist` / build exclusions are the same ones the walk applies.
|
|
1265
|
+
* - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
|
|
1266
|
+
*
|
|
1267
|
+
* A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
|
|
1268
|
+
* flagged - the pre-feature answer.
|
|
1269
|
+
*/
|
|
1270
|
+
export function createProjectSqlImports(cwd: string): SqlImports {
|
|
1271
|
+
const root = (() => {
|
|
1272
|
+
try {
|
|
1273
|
+
return realpathSync(resolve(cwd))
|
|
1274
|
+
} catch {
|
|
1275
|
+
return resolve(cwd)
|
|
1276
|
+
}
|
|
1277
|
+
})()
|
|
1278
|
+
return createSqlImports((fromModule, specifier) => {
|
|
1279
|
+
if (!isRelativeSpecifier(specifier)) return undefined
|
|
1280
|
+
try {
|
|
1281
|
+
// `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
|
|
1282
|
+
// every module reached through a hop (their ids are the resolved paths).
|
|
1283
|
+
const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule)
|
|
1284
|
+
const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)))
|
|
1285
|
+
if (!resolved.startsWith(root + sep)) return undefined
|
|
1286
|
+
const rel = resolved.slice(root.length + 1)
|
|
1287
|
+
if (IGNORED_DIR.test(rel)) return undefined
|
|
1288
|
+
if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved)) return undefined
|
|
1289
|
+
return { id: resolved, content: readFileSync(resolved, "utf8") }
|
|
1290
|
+
} catch {
|
|
1291
|
+
return undefined // unresolvable, unreadable, or outside the project
|
|
1292
|
+
}
|
|
1293
|
+
})
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
|
|
1297
|
+
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
1298
|
+
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
1299
|
+
// contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
|
|
1300
|
+
const CLIENT_CALL = /(?<![.\w])client\s*\(/g
|
|
1301
|
+
|
|
1302
|
+
/** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
|
|
1303
|
+
export function scanUntypedClient(file: string, content: string): SourceFinding[] {
|
|
1304
|
+
const out: SourceFinding[] = []
|
|
1305
|
+
const lines = content.split("\n")
|
|
1306
|
+
const code = codePositionMask(content)
|
|
1307
|
+
CLIENT_CALL.lastIndex = 0
|
|
1308
|
+
for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
|
|
1309
|
+
const argument = firstArgumentIndex(content, m.index + m[0].length)
|
|
1310
|
+
const quote = content[argument]
|
|
1311
|
+
if (quote !== "'" && quote !== '"' && quote !== "`") continue
|
|
1312
|
+
const line = lineAt(content, m.index)
|
|
1313
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
1314
|
+
}
|
|
1315
|
+
return out
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Specifiers that no longer exist, and what replaced them.
|
|
1320
|
+
*
|
|
1321
|
+
* These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
|
|
1322
|
+
* was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
|
|
1323
|
+
* the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
|
|
1324
|
+
* into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
|
|
1325
|
+
* that names neither the cause nor the replacement.
|
|
1326
|
+
*
|
|
1327
|
+
* A lint is the right shape for this: it runs before boot, names the file and line, and states the
|
|
1328
|
+
* replacement - which is what neither the resolver error nor the runtime failure does.
|
|
1329
|
+
*/
|
|
1330
|
+
export const REMOVED_IMPORTS: ReadonlyArray<{
|
|
1331
|
+
readonly specifier: string
|
|
1332
|
+
readonly since: string
|
|
1333
|
+
readonly replacement: string
|
|
1334
|
+
/** Flag ONLY the bare side-effect form (`import "x"`). A module that still exports values is not
|
|
1335
|
+
* removed - only its install-by-import behaviour is - so flagging every import of it would be
|
|
1336
|
+
* wrong and would train people to ignore the rule. */
|
|
1337
|
+
readonly sideEffectOnly?: boolean
|
|
1338
|
+
}> = [
|
|
1339
|
+
{
|
|
1340
|
+
specifier: "@nifrajs/budget",
|
|
1341
|
+
since: "2.0",
|
|
1342
|
+
replacement:
|
|
1343
|
+
'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',
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
specifier: "@nifrajs/core/ws",
|
|
1347
|
+
since: "2.0",
|
|
1348
|
+
sideEffectOnly: true,
|
|
1349
|
+
replacement:
|
|
1350
|
+
'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
|
|
1351
|
+
},
|
|
1352
|
+
]
|
|
1353
|
+
|
|
1354
|
+
/** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
|
|
1355
|
+
* one with bindings (`import { y } from "x"`). */
|
|
1356
|
+
function isSideEffectImport(content: string, index: number): boolean {
|
|
1357
|
+
// `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
|
|
1358
|
+
// a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
|
|
1359
|
+
// no `from` in between.
|
|
1360
|
+
return /^import\s*["'`]/.test(content.slice(index, index + 32))
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
|
|
1364
|
+
* of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
|
|
1365
|
+
export function scanRemovedImports(file: string, content: string): SourceFinding[] {
|
|
1366
|
+
const out: SourceFinding[] = []
|
|
1367
|
+
const lines = content.split("\n")
|
|
1368
|
+
for (const edge of staticImportEdges(content)) {
|
|
1369
|
+
const removed = REMOVED_IMPORTS.find(
|
|
1370
|
+
(entry) =>
|
|
1371
|
+
edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
|
|
1372
|
+
)
|
|
1373
|
+
if (removed === undefined) continue
|
|
1374
|
+
if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index)) continue
|
|
1375
|
+
const line = lineAt(content, edge.index)
|
|
1376
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
1377
|
+
}
|
|
1378
|
+
return out
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
1382
|
+
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
1383
|
+
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
1384
|
+
export function scanServerOnlyImports(
|
|
1385
|
+
file: string,
|
|
1386
|
+
content: string,
|
|
1387
|
+
facts?: SourceFacts,
|
|
1388
|
+
): ServerImportFinding[] {
|
|
1389
|
+
if (!ROUTE_FILE.test(file)) return []
|
|
1390
|
+
const out: ServerImportFinding[] = []
|
|
1391
|
+
const lines = content.split("\n")
|
|
1392
|
+
const code = stripComments(content)
|
|
1393
|
+
const positions = codePositionMask(content)
|
|
1394
|
+
STATIC_IMPORT.lastIndex = 0
|
|
1395
|
+
for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
|
|
1396
|
+
if (positions[m.index] === " ") continue
|
|
1397
|
+
const specifier = m[1] ?? ""
|
|
1398
|
+
if (!SERVER_ONLY.test(specifier)) continue
|
|
1399
|
+
if (facts !== undefined) {
|
|
1400
|
+
const source = facts.parse(file, content)
|
|
1401
|
+
// Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
|
|
1402
|
+
// runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
|
|
1403
|
+
if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
|
|
1404
|
+
continue
|
|
1405
|
+
}
|
|
1406
|
+
const line = lineAt(content, m.index)
|
|
1407
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
|
|
1408
|
+
}
|
|
1409
|
+
return out
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1413
|
+
// #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
|
|
1414
|
+
// pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
|
|
1415
|
+
// `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
|
|
1416
|
+
// `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
|
|
1417
|
+
// import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
|
|
1418
|
+
// local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
|
|
1419
|
+
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
1420
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1421
|
+
|
|
1422
|
+
// Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
|
|
1423
|
+
// npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
|
|
1424
|
+
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
1425
|
+
const SERVER_ONLY_SINK =
|
|
1426
|
+
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
|
|
1427
|
+
// The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
|
|
1428
|
+
const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
1429
|
+
// The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
|
|
1430
|
+
// guard. A resolved file whose source carries this side-effect import is a server-only sink.
|
|
1431
|
+
const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
|
|
1432
|
+
// Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
|
|
1433
|
+
// few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
|
|
1434
|
+
const TRANSITIVE_MAX_DEPTH = 8
|
|
1435
|
+
const TRANSITIVE_MAX_VISITED = 200
|
|
1436
|
+
|
|
1437
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
|
|
1438
|
+
* is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
|
|
1439
|
+
const isRelativeSpecifier = (spec: string): boolean =>
|
|
1440
|
+
spec.startsWith("./") || spec.startsWith("../")
|
|
1441
|
+
|
|
1442
|
+
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
|
|
1443
|
+
// per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
|
|
1444
|
+
// `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
|
|
1445
|
+
// loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
|
|
1446
|
+
const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags)
|
|
1447
|
+
|
|
1448
|
+
/** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
|
|
1449
|
+
* each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
|
|
1450
|
+
* dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
|
|
1451
|
+
* transitive walk. Pure. */
|
|
1452
|
+
function staticImportEdges(
|
|
1453
|
+
content: string,
|
|
1454
|
+
facts?: SourceFacts,
|
|
1455
|
+
file?: string,
|
|
1456
|
+
): Array<{ specifier: string; index: number }> {
|
|
1457
|
+
const edges: Array<{ specifier: string; index: number }> = []
|
|
1458
|
+
const re = staticImportRegex()
|
|
1459
|
+
const code = stripComments(content)
|
|
1460
|
+
const positions = codePositionMask(content)
|
|
1461
|
+
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
1462
|
+
if (positions[m.index] === " ") continue
|
|
1463
|
+
if (m[1] === undefined) continue
|
|
1464
|
+
if (facts !== undefined && file !== undefined) {
|
|
1465
|
+
const source = facts.parse(file, content)
|
|
1466
|
+
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
|
|
1467
|
+
}
|
|
1468
|
+
edges.push({ specifier: m[1], index: m.index })
|
|
1469
|
+
}
|
|
1470
|
+
return edges
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
|
|
1474
|
+
* Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
|
|
1475
|
+
export function parseStaticImports(content: string, facts?: SourceFacts, file?: string): string[] {
|
|
1476
|
+
return staticImportEdges(content, facts, file).map((e) => e.specifier)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
1480
|
+
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1481
|
+
* itself (it's already the actionable name). */
|
|
1482
|
+
export function directSinkSpecifier(spec: string): string | undefined {
|
|
1483
|
+
return SERVER_ONLY_SINK.test(spec) ? spec : undefined
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** One transitive server-only finding: the route module, the route's offending top-level import (the
|
|
1487
|
+
* first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
|
|
1488
|
+
export interface TransitiveServerImportFinding extends ServerImportFinding {
|
|
1489
|
+
/** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
|
|
1490
|
+
* the route imports the sink directly (same as the regex scan's direct edge). */
|
|
1491
|
+
readonly chain: readonly string[]
|
|
1492
|
+
/** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
|
|
1493
|
+
* so the chain is the honest direct edge rather than a fabricated deeper path. */
|
|
1494
|
+
readonly fallback: boolean
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** A resolver from `(fromFile, specifier)` to an absolute module path, or `undefined` when it can't be
|
|
1498
|
+
* resolved precisely (bare pkg, path alias, missing file). Abstracted so the BFS is unit-testable with a
|
|
1499
|
+
* fake graph (no real fs). The production resolver wraps `Bun.resolveSync`. */
|
|
1500
|
+
export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
|
|
1501
|
+
/** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
|
|
1502
|
+
export type ModuleReader = (absPath: string) => string | undefined
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
|
|
1506
|
+
* sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
|
|
1507
|
+
* node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
|
|
1508
|
+
* that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
|
|
1509
|
+
* unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
|
|
1510
|
+
* `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
|
|
1511
|
+
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
1512
|
+
* - pure given those, so it's unit-testable with a fake graph.
|
|
1513
|
+
*/
|
|
1514
|
+
export function walkServerOnlyChain(
|
|
1515
|
+
routeFile: string,
|
|
1516
|
+
routeContent: string,
|
|
1517
|
+
resolve: ModuleResolver,
|
|
1518
|
+
read: ModuleReader,
|
|
1519
|
+
facts?: SourceFacts,
|
|
1520
|
+
): readonly string[] | undefined {
|
|
1521
|
+
// Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
|
|
1522
|
+
// (route + the as-written specifier of each hop), and the file the imports resolve relative to.
|
|
1523
|
+
interface Node {
|
|
1524
|
+
readonly abs: string
|
|
1525
|
+
readonly content: string
|
|
1526
|
+
readonly chain: readonly string[]
|
|
1527
|
+
readonly depth: number
|
|
1528
|
+
}
|
|
1529
|
+
const seen = new Set<string>([routeFile])
|
|
1530
|
+
let frontier: Node[] = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }]
|
|
1531
|
+
let visited = 0
|
|
1532
|
+
while (frontier.length > 0) {
|
|
1533
|
+
const next: Node[] = []
|
|
1534
|
+
for (const node of frontier) {
|
|
1535
|
+
if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
|
|
1536
|
+
for (const spec of parseStaticImports(node.content, facts, node.abs)) {
|
|
1537
|
+
// (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
|
|
1538
|
+
// since BFS reaches the nearest sink before any deeper one).
|
|
1539
|
+
const sink = directSinkSpecifier(spec)
|
|
1540
|
+
if (sink !== undefined) return [...node.chain, sink]
|
|
1541
|
+
// (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
|
|
1542
|
+
// specifier that isn't a known sink is a leaf - don't follow it into node_modules.
|
|
1543
|
+
if (!isRelativeSpecifier(spec)) continue
|
|
1544
|
+
const abs = resolve(node.abs, spec)
|
|
1545
|
+
if (abs === undefined || seen.has(abs)) continue
|
|
1546
|
+
// (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
|
|
1547
|
+
// ends at it (named by the as-written specifier).
|
|
1548
|
+
if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
|
|
1549
|
+
const content = read(abs)
|
|
1550
|
+
if (content === undefined) continue // unreadable → can't walk; treat as a leaf
|
|
1551
|
+
// (d) A resolved module that opts into the `server-only` marker is a sink too.
|
|
1552
|
+
if (SERVER_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
|
|
1553
|
+
if (visited >= TRANSITIVE_MAX_VISITED) continue
|
|
1554
|
+
visited++
|
|
1555
|
+
seen.add(abs)
|
|
1556
|
+
next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 })
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
frontier = next
|
|
1560
|
+
}
|
|
1561
|
+
return undefined
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
|
|
1566
|
+
* reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
|
|
1567
|
+
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
1568
|
+
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
1569
|
+
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
1570
|
+
* de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
|
|
1571
|
+
*/
|
|
1572
|
+
export function resolveServerOnlyChains(
|
|
1573
|
+
file: string,
|
|
1574
|
+
content: string,
|
|
1575
|
+
resolve: ModuleResolver,
|
|
1576
|
+
read: ModuleReader,
|
|
1577
|
+
facts?: SourceFacts,
|
|
1578
|
+
): TransitiveServerImportFinding[] {
|
|
1579
|
+
if (!ROUTE_FILE.test(file)) return []
|
|
1580
|
+
const lines = content.split("\n")
|
|
1581
|
+
const out: TransitiveServerImportFinding[] = []
|
|
1582
|
+
const flaggedSpecifiers = new Set<string>()
|
|
1583
|
+
// Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
|
|
1584
|
+
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1585
|
+
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1586
|
+
for (const { specifier, index } of staticImportEdges(content, facts, file)) {
|
|
1587
|
+
if (flaggedSpecifiers.has(specifier)) continue
|
|
1588
|
+
const line = lineAt(content, index)
|
|
1589
|
+
const snippet = (lines[line - 1] ?? "").trim()
|
|
1590
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
|
|
1591
|
+
// direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
|
|
1592
|
+
if (directSinkSpecifier(specifier) !== undefined) {
|
|
1593
|
+
flaggedSpecifiers.add(specifier)
|
|
1594
|
+
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1595
|
+
continue
|
|
1596
|
+
}
|
|
1597
|
+
// (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
|
|
1598
|
+
// a sink, emit the full chain rooted at THIS route's import line.
|
|
1599
|
+
if (isRelativeSpecifier(specifier)) {
|
|
1600
|
+
const abs = resolve(file, specifier)
|
|
1601
|
+
if (abs === undefined) {
|
|
1602
|
+
// Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
|
|
1603
|
+
// flags directly, fall back to the direct edge (precise resolve impossible - say so via
|
|
1604
|
+
// `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
|
|
1605
|
+
if (SERVER_ONLY.test(specifier)) {
|
|
1606
|
+
flaggedSpecifiers.add(specifier)
|
|
1607
|
+
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
|
|
1608
|
+
}
|
|
1609
|
+
continue
|
|
1610
|
+
}
|
|
1611
|
+
// The `.server` / marker sink can be the first hop itself.
|
|
1612
|
+
if (SERVER_MODULE_FILE.test(abs)) {
|
|
1613
|
+
flaggedSpecifiers.add(specifier)
|
|
1614
|
+
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1615
|
+
continue
|
|
1616
|
+
}
|
|
1617
|
+
const depContent = read(abs)
|
|
1618
|
+
if (depContent === undefined) continue
|
|
1619
|
+
if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
|
|
1620
|
+
flaggedSpecifiers.add(specifier)
|
|
1621
|
+
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1622
|
+
continue
|
|
1623
|
+
}
|
|
1624
|
+
// Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
|
|
1625
|
+
const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
|
|
1626
|
+
if (subChain !== undefined) {
|
|
1627
|
+
flaggedSpecifiers.add(specifier)
|
|
1628
|
+
// subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
|
|
1629
|
+
// route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
|
|
1630
|
+
const chain = [file, specifier, ...subChain.slice(1)]
|
|
1631
|
+
out.push({ file, line, snippet, specifier, chain, fallback: false })
|
|
1632
|
+
continue
|
|
1633
|
+
}
|
|
1634
|
+
// (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
|
|
1635
|
+
// found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
|
|
1636
|
+
// direct edge so we still surface the known-server-only convention rather than going silent.
|
|
1637
|
+
if (SERVER_ONLY.test(specifier)) {
|
|
1638
|
+
flaggedSpecifiers.add(specifier)
|
|
1639
|
+
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
return out
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
|
|
1647
|
+
// typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
|
|
1648
|
+
// silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
|
|
1649
|
+
// (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
|
|
1650
|
+
const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g
|
|
1651
|
+
|
|
1652
|
+
/** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
|
|
1653
|
+
* the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
|
|
1654
|
+
export function scanResponseRoutes(
|
|
1655
|
+
file: string,
|
|
1656
|
+
content: string,
|
|
1657
|
+
facts?: SourceFacts,
|
|
1658
|
+
): SourceFinding[] {
|
|
1659
|
+
// Strip comments + template literals first: a commented-out or doc-example `return new Response(`
|
|
1660
|
+
// must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
|
|
1661
|
+
const code = stripComments(content)
|
|
1662
|
+
if (!/(?<![.\w])server\s*\(/.test(code)) return []
|
|
1663
|
+
const out: SourceFinding[] = []
|
|
1664
|
+
const lines = content.split("\n")
|
|
1665
|
+
RESPONSE_RETURN.lastIndex = 0
|
|
1666
|
+
for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
|
|
1667
|
+
if (facts !== undefined) {
|
|
1668
|
+
const source = facts.parse(file, content)
|
|
1669
|
+
// A parseable AST rejects `return new Response(` text that only lives in a normal string. On
|
|
1670
|
+
// malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
|
|
1671
|
+
if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
|
|
1672
|
+
}
|
|
1673
|
+
const line = lineAt(content, m.index)
|
|
1674
|
+
// A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
|
|
1675
|
+
// be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
|
|
1676
|
+
// ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
|
|
1677
|
+
// leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
|
|
1678
|
+
// `code` (so it never affects the match); the raw `lines` still carry it.
|
|
1679
|
+
const thisLine = lines[line - 1] ?? ""
|
|
1680
|
+
const aboveLine = lines[line - 2] ?? ""
|
|
1681
|
+
const aboveSuppresses =
|
|
1682
|
+
aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
|
|
1683
|
+
if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
|
|
1684
|
+
out.push({ file, line, snippet: thisLine.trim() })
|
|
1685
|
+
}
|
|
1686
|
+
return out
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
1690
|
+
const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
|
|
1691
|
+
|
|
1692
|
+
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
1693
|
+
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
1694
|
+
*
|
|
1695
|
+
* `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
|
|
1696
|
+
* for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
|
|
1697
|
+
* build that a clean install cannot produce - the exact false confidence doctor is for. */
|
|
1698
|
+
/**
|
|
1699
|
+
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
|
|
1700
|
+
* (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
|
|
1701
|
+
* is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
|
|
1702
|
+
* that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
|
|
1703
|
+
* findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
|
|
1704
|
+
* repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
|
|
1705
|
+
*/
|
|
1706
|
+
async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<string>> {
|
|
1707
|
+
if (rels.length === 0) return new Set()
|
|
1708
|
+
try {
|
|
1709
|
+
const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
|
|
1710
|
+
cwd,
|
|
1711
|
+
stdin: "pipe",
|
|
1712
|
+
stdout: "pipe",
|
|
1713
|
+
stderr: "ignore",
|
|
1714
|
+
})
|
|
1715
|
+
proc.stdin.write(`${rels.join("\n")}\n`)
|
|
1716
|
+
await proc.stdin.end()
|
|
1717
|
+
// git echoes back each ignored path exactly as fed on stdin (one per line); exit 1 = none matched.
|
|
1718
|
+
const out = await new Response(proc.stdout).text()
|
|
1719
|
+
await proc.exited
|
|
1720
|
+
return new Set(
|
|
1721
|
+
out
|
|
1722
|
+
.split("\n")
|
|
1723
|
+
.map((s) => s.trim())
|
|
1724
|
+
.filter(Boolean),
|
|
1725
|
+
)
|
|
1726
|
+
} catch {
|
|
1727
|
+
return new Set()
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
export async function walkSource(
|
|
1732
|
+
cwd: string,
|
|
1733
|
+
visit: (rel: string, content: string) => void,
|
|
1734
|
+
opts: { readonly includeTests?: boolean } = {},
|
|
1735
|
+
): Promise<void> {
|
|
1736
|
+
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
|
|
1737
|
+
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
1738
|
+
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
1739
|
+
const rels: string[] = []
|
|
1740
|
+
for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
|
|
1741
|
+
const rel = normalizeProjectPath(rawRel)
|
|
1742
|
+
if (!skip.test(rel)) rels.push(rel)
|
|
1743
|
+
}
|
|
1744
|
+
const ignored = await gitIgnored(cwd, rels)
|
|
1745
|
+
for (const rel of rels) {
|
|
1746
|
+
if (ignored.has(rel)) continue
|
|
1747
|
+
visit(rel, await Bun.file(join(cwd, rel)).text())
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|
|
1752
|
+
a.file.localeCompare(b.file) || a.line - b.line
|
|
1753
|
+
|
|
1754
|
+
/** Collect own-API `fetch()` findings across the project. */
|
|
1755
|
+
export async function scanProject(cwd: string): Promise<SourceFinding[]> {
|
|
1756
|
+
const out: SourceFinding[] = []
|
|
1757
|
+
await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)))
|
|
1758
|
+
return out.sort(bySite)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
|
|
1762
|
+
// list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
|
|
1763
|
+
// regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
|
|
1764
|
+
// We diff each committed manifest's imported route files against the live `routes/` tree.
|
|
1765
|
+
|
|
1766
|
+
// The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
|
|
1767
|
+
// manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
|
|
1768
|
+
const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
|
|
1769
|
+
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
|
|
1770
|
+
// locate the routes dir relative to the manifest, and to strip to route-relative keys.
|
|
1771
|
+
const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
|
|
1772
|
+
// Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
|
|
1773
|
+
const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
|
|
1774
|
+
|
|
1775
|
+
export interface ManifestDriftFinding {
|
|
1776
|
+
/** The committed server-manifest file (relative to cwd). */
|
|
1777
|
+
readonly file: string
|
|
1778
|
+
/** Route files in `routes/` missing from the manifest (stale manifest - these routes won't serve). */
|
|
1779
|
+
readonly missing: readonly string[]
|
|
1780
|
+
/** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
|
|
1781
|
+
readonly extra: readonly string[]
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
|
|
1786
|
+
* from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
|
|
1787
|
+
* prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
|
|
1788
|
+
* imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
|
|
1789
|
+
* on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
|
|
1790
|
+
* a per-manifest miss (an unreadable routes dir is simply skipped).
|
|
1791
|
+
*/
|
|
1792
|
+
export async function scanServerManifestDrift(cwd: string): Promise<ManifestDriftFinding[]> {
|
|
1793
|
+
const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import(
|
|
1794
|
+
"@nifrajs/web/build"
|
|
1795
|
+
)
|
|
1796
|
+
const findings: ManifestDriftFinding[] = []
|
|
1797
|
+
for await (const rawRel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
|
|
1798
|
+
const rel = normalizeProjectPath(rawRel)
|
|
1799
|
+
if (IGNORED.test(rel)) continue
|
|
1800
|
+
const source = await Bun.file(join(cwd, rel)).text()
|
|
1801
|
+
if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest - skip
|
|
1802
|
+
const prefixMatch = ROUTES_PREFIX.exec(source)
|
|
1803
|
+
if (prefixMatch?.[1] === undefined) continue // no `routes/` imports → nothing to diff (empty app)
|
|
1804
|
+
const routesPrefix = prefixMatch[1]
|
|
1805
|
+
const manifestFiles = parseManifestRouteFiles(source, routesPrefix)
|
|
1806
|
+
// The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
|
|
1807
|
+
const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
1808
|
+
const routesDir = join(cwd, manifestDir, routesPrefix)
|
|
1809
|
+
let discovered: string[]
|
|
1810
|
+
try {
|
|
1811
|
+
discovered = (
|
|
1812
|
+
await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir }))
|
|
1813
|
+
)
|
|
1814
|
+
.map((f) => f.replaceAll("\\", "/"))
|
|
1815
|
+
.filter((f) => ROUTE_FILE_EXT.test(f))
|
|
1816
|
+
} catch {
|
|
1817
|
+
continue // routes dir gone/unreadable - not a drift we can assess
|
|
1818
|
+
}
|
|
1819
|
+
const drift = diffManifestRoutes(manifestFiles, discovered)
|
|
1820
|
+
if (!isManifestInSync(drift)) {
|
|
1821
|
+
findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file))
|
|
1825
|
+
}
|