@nifrajs/cli 4.0.0 → 5.0.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 +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +28 -3
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- 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 +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +958 -71
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -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/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +181 -47
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +34 -3
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/sync-manifest.ts +48 -18
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/check-scan.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync, realpathSync } from "node:fs"
|
|
9
|
-
import { dirname, isAbsolute, join, resolve, sep } from "node:path"
|
|
9
|
+
import { dirname, isAbsolute, join, posix, resolve, sep } from "node:path"
|
|
10
|
+
import { isServerPackageSpecifier } from "@nifrajs/web/zones"
|
|
10
11
|
import { Glob } from "bun"
|
|
11
12
|
import type * as TSApi from "typescript"
|
|
12
13
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
@@ -17,19 +18,43 @@ import {
|
|
|
17
18
|
type TypeScriptApi,
|
|
18
19
|
type TypeScriptSourceIndex,
|
|
19
20
|
} from "./internal/typescript-import.ts"
|
|
20
|
-
import {
|
|
21
|
+
import { commentBlockExpectation } from "./rules/comment-markers.ts"
|
|
21
22
|
|
|
22
23
|
export interface SourceFinding {
|
|
23
24
|
readonly file: string
|
|
24
25
|
readonly line: number
|
|
25
26
|
readonly snippet: string
|
|
26
27
|
}
|
|
28
|
+
/** An interpolated-SQL site, with the reason a `nifra-expect NF-C006: <reason>` comment gives for it. */
|
|
29
|
+
export interface SqlFinding extends SourceFinding {
|
|
30
|
+
readonly expected?: string
|
|
31
|
+
}
|
|
27
32
|
/** A server-only-import finding, carrying the offending module specifier on top of the base location so
|
|
28
33
|
* the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
|
|
29
34
|
export interface ServerImportFinding extends SourceFinding {
|
|
30
35
|
/** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
|
|
31
36
|
readonly specifier: string
|
|
32
37
|
}
|
|
38
|
+
/** A hand-rolled call to the app's own API, with the `// nifra-expect NF-C002` comment covering it. */
|
|
39
|
+
export interface TypedClientFinding extends SourceFinding {
|
|
40
|
+
/** Present when such a comment covers the call. `reason` is `undefined` when it gives none after
|
|
41
|
+
* its colon, and then it silences nothing. */
|
|
42
|
+
readonly expected?: { readonly reason: string | undefined }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What marks a hand-rolled own-API call as intended: a streamed POST, a foreign handler's endpoint,
|
|
46
|
+
* a static file. Only the code form; a reason after the colon is required. */
|
|
47
|
+
const TYPED_CLIENT_EXPECTATION = ["NF-C002"]
|
|
48
|
+
|
|
49
|
+
function typedClientFinding(
|
|
50
|
+
file: string,
|
|
51
|
+
lines: readonly string[],
|
|
52
|
+
line: number,
|
|
53
|
+
): TypedClientFinding {
|
|
54
|
+
const finding = { file, line, snippet: (lines[line - 1] ?? "").trim() }
|
|
55
|
+
const expected = commentBlockExpectation(lines, line, TYPED_CLIENT_EXPECTATION)
|
|
56
|
+
return expected === undefined ? finding : { ...finding, expected }
|
|
57
|
+
}
|
|
33
58
|
|
|
34
59
|
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
35
60
|
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
@@ -38,6 +63,7 @@ export interface ServerImportFinding extends SourceFinding {
|
|
|
38
63
|
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
39
64
|
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
40
65
|
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
66
|
+
const SOCKET_VERB: ReadonlySet<string> = new Set(["WS"])
|
|
41
67
|
export const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
|
|
42
68
|
|
|
43
69
|
// Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
|
|
@@ -82,10 +108,57 @@ function isBackendModule(file: string): boolean {
|
|
|
82
108
|
)
|
|
83
109
|
}
|
|
84
110
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
111
|
+
// Node builtins that have no browser counterpart, imported bare. Bare `events`, `buffer`, `path`, `util`
|
|
112
|
+
// and the like stay out: each also names an npm polyfill a browser bundle may legitimately carry.
|
|
113
|
+
const SERVER_BUILTINS: ReadonlySet<string> = new Set([
|
|
114
|
+
"async_hooks",
|
|
115
|
+
"child_process",
|
|
116
|
+
"cluster",
|
|
117
|
+
"crypto",
|
|
118
|
+
"dgram",
|
|
119
|
+
"diagnostics_channel",
|
|
120
|
+
"dns",
|
|
121
|
+
"dns/promises",
|
|
122
|
+
"fs",
|
|
123
|
+
"fs/promises",
|
|
124
|
+
"http",
|
|
125
|
+
"http2",
|
|
126
|
+
"https",
|
|
127
|
+
"inspector",
|
|
128
|
+
"module",
|
|
129
|
+
"net",
|
|
130
|
+
"os",
|
|
131
|
+
"perf_hooks",
|
|
132
|
+
"readline",
|
|
133
|
+
"readline/promises",
|
|
134
|
+
"repl",
|
|
135
|
+
"tls",
|
|
136
|
+
"trace_events",
|
|
137
|
+
"tty",
|
|
138
|
+
"v8",
|
|
139
|
+
"vm",
|
|
140
|
+
"wasi",
|
|
141
|
+
"worker_threads",
|
|
142
|
+
])
|
|
143
|
+
// The conventional `./db` module the scaffold generates.
|
|
144
|
+
const DB_MODULE = /^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a specifier names server-only code by name, so the chain ends there: a `node:`/`bun:`
|
|
148
|
+
* module, a bare builtin with no browser counterpart, or a package the browser build refuses
|
|
149
|
+
* (`SERVER_PACKAGES` and the driver entries of `@nifrajs/web/zones`).
|
|
150
|
+
*/
|
|
151
|
+
function isServerOnlySink(specifier: string): boolean {
|
|
152
|
+
return (
|
|
153
|
+
/^(?:node:|bun:)/.test(specifier) ||
|
|
154
|
+
SERVER_BUILTINS.has(specifier) ||
|
|
155
|
+
isServerPackageSpecifier(specifier)
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A specifier no route module may value-import: a {@link isServerOnlySink} name, or the `./db` module. */
|
|
160
|
+
const isServerOnly = (specifier: string): boolean =>
|
|
161
|
+
isServerOnlySink(specifier) || DB_MODULE.test(specifier)
|
|
89
162
|
|
|
90
163
|
// A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
|
|
91
164
|
// pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
|
|
@@ -103,6 +176,10 @@ const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)
|
|
|
103
176
|
// Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
|
|
104
177
|
// A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
|
|
105
178
|
const TYPE_QUERY_BEFORE = /\btypeof\s*$/
|
|
179
|
+
// `require("x")` and `import x = require("x")` pull a module into a bundle as `import` does; the
|
|
180
|
+
// `import type x = require("x")` form is erased.
|
|
181
|
+
const REQUIRE_CALL = /(?<![\w$.])require\s*\(\s*(["'])([^"'\\\r\n]+)\1\s*\)/g
|
|
182
|
+
const TYPE_ALIAS_BEFORE = /\bimport\s+type\s+[\w$]+\s*=\s*$/
|
|
106
183
|
const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/
|
|
107
184
|
|
|
108
185
|
const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
|
|
@@ -122,6 +199,17 @@ export interface StaticRouteFinding extends SourceFinding {
|
|
|
122
199
|
readonly path: string
|
|
123
200
|
}
|
|
124
201
|
|
|
202
|
+
/** A route registration whose path argument the static scan cannot read, with that argument's text. */
|
|
203
|
+
export interface UnresolvedRoutePathFinding extends SourceFinding {
|
|
204
|
+
readonly method: string
|
|
205
|
+
readonly expression: string
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// `.get(NAME, …)` or `` .get(`…`, …) ``: a path that is not a quoted literal. The comma is required:
|
|
209
|
+
// a registration always has a handler after its path, while `map.get(key)` has nothing.
|
|
210
|
+
const ROUTE_ARGUMENT = /\.([A-Za-z]+)\s*\(\s*([A-Za-z_$][\w$]*|`(?:\\.|[^`\\])*`)\s*,/g
|
|
211
|
+
const NAMED_IMPORTS = /(?<![\w$.])import\s*\{([^}]*)\}\s*from\s*(["'])([^"'\r\n]+)\2/g
|
|
212
|
+
|
|
125
213
|
interface SimpleFetchCall {
|
|
126
214
|
readonly path: string
|
|
127
215
|
readonly method: string
|
|
@@ -279,13 +367,14 @@ function scanRoutePattern(
|
|
|
279
367
|
code: string,
|
|
280
368
|
pattern: RegExp,
|
|
281
369
|
facts?: SourceFacts,
|
|
370
|
+
verbs: ReadonlySet<string> = HTTP_VERBS,
|
|
282
371
|
): StaticRouteFinding[] {
|
|
283
372
|
const out: StaticRouteFinding[] = []
|
|
284
373
|
const lines = content.split("\n")
|
|
285
374
|
pattern.lastIndex = 0
|
|
286
375
|
for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
|
|
287
376
|
const method = (m[1] ?? "").toUpperCase()
|
|
288
|
-
if (!
|
|
377
|
+
if (!verbs.has(method)) continue
|
|
289
378
|
const path = parseQuotedLiteral(
|
|
290
379
|
`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
|
|
291
380
|
)
|
|
@@ -306,25 +395,94 @@ function scanRoutePattern(
|
|
|
306
395
|
return out
|
|
307
396
|
}
|
|
308
397
|
|
|
398
|
+
// Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
|
|
399
|
+
// as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
|
|
400
|
+
// is the cost of not tracking parser context.
|
|
401
|
+
const REGEX_AFTER = new Set([
|
|
402
|
+
"(",
|
|
403
|
+
",",
|
|
404
|
+
"=",
|
|
405
|
+
":",
|
|
406
|
+
"[",
|
|
407
|
+
"!",
|
|
408
|
+
"&",
|
|
409
|
+
"|",
|
|
410
|
+
"?",
|
|
411
|
+
"{",
|
|
412
|
+
";",
|
|
413
|
+
"+",
|
|
414
|
+
"-",
|
|
415
|
+
"*",
|
|
416
|
+
"%",
|
|
417
|
+
"<",
|
|
418
|
+
">",
|
|
419
|
+
"~",
|
|
420
|
+
"^",
|
|
421
|
+
])
|
|
422
|
+
const REGEX_AFTER_WORD = new Set([
|
|
423
|
+
"return",
|
|
424
|
+
"typeof",
|
|
425
|
+
"case",
|
|
426
|
+
"do",
|
|
427
|
+
"else",
|
|
428
|
+
"in",
|
|
429
|
+
"of",
|
|
430
|
+
"new",
|
|
431
|
+
"delete",
|
|
432
|
+
"void",
|
|
433
|
+
"throw",
|
|
434
|
+
"yield",
|
|
435
|
+
"await",
|
|
436
|
+
"instanceof",
|
|
437
|
+
])
|
|
438
|
+
const isWordChar = (c: string | undefined): boolean => c !== undefined && /[\w$]/.test(c)
|
|
439
|
+
|
|
440
|
+
/** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
|
|
441
|
+
* literal does not close on its line - then the slash was division after all. */
|
|
442
|
+
function regexLiteralEnd(src: string, i: number): number | undefined {
|
|
443
|
+
const n = src.length
|
|
444
|
+
let j = i + 1
|
|
445
|
+
let inClass = false
|
|
446
|
+
while (j < n) {
|
|
447
|
+
const c = src[j]
|
|
448
|
+
if (c === "\n" || c === "\r") return undefined
|
|
449
|
+
if (c === "\\") {
|
|
450
|
+
j += 2
|
|
451
|
+
continue
|
|
452
|
+
}
|
|
453
|
+
if (inClass) {
|
|
454
|
+
if (c === "]") inClass = false
|
|
455
|
+
} else if (c === "[") {
|
|
456
|
+
inClass = true
|
|
457
|
+
} else if (c === "/") {
|
|
458
|
+
break
|
|
459
|
+
}
|
|
460
|
+
j++
|
|
461
|
+
}
|
|
462
|
+
if (j >= n) return undefined
|
|
463
|
+
j++
|
|
464
|
+
while (j < n && isWordChar(src[j])) j++
|
|
465
|
+
return j
|
|
466
|
+
}
|
|
467
|
+
|
|
309
468
|
/**
|
|
310
|
-
* Blank
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
|
|
316
|
-
* could mis-skip - in practice the constructs these scanners look for sit before any such regex.
|
|
317
|
-
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
469
|
+
* Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
|
|
470
|
+
* single/double-quoted strings and every delimiter) while preserving every offset and newline.
|
|
471
|
+
* A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
|
|
472
|
+
* an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
|
|
473
|
+
* the string state that follows.
|
|
318
474
|
*/
|
|
319
|
-
|
|
475
|
+
function maskSource(src: string, quoted: "keep" | "blank"): string {
|
|
320
476
|
const out = src.split("")
|
|
321
477
|
const n = src.length
|
|
322
|
-
let i = 0
|
|
323
478
|
const blank = (a: number, b: number): void => {
|
|
324
479
|
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
325
480
|
}
|
|
481
|
+
// The last significant code token: a punctuator character, or a whole identifier/keyword.
|
|
482
|
+
let previous = ""
|
|
483
|
+
let i = 0
|
|
326
484
|
while (i < n) {
|
|
327
|
-
const c = src
|
|
485
|
+
const c = src.charAt(i)
|
|
328
486
|
const d = src[i + 1]
|
|
329
487
|
if (c === "/" && d === "/") {
|
|
330
488
|
let j = i + 2
|
|
@@ -343,62 +501,66 @@ export function stripComments(src: string): string {
|
|
|
343
501
|
if (src[j] === "\\") j++
|
|
344
502
|
j++
|
|
345
503
|
}
|
|
346
|
-
|
|
504
|
+
if (quoted === "keep")
|
|
505
|
+
blank(i + 1, j) // keep the backticks, blank the code-as-text inside
|
|
506
|
+
else blank(i, Math.min(j + 1, n))
|
|
347
507
|
i = j + 1
|
|
508
|
+
previous = "`"
|
|
348
509
|
} else if (c === "'" || c === '"') {
|
|
349
510
|
let j = i + 1
|
|
350
511
|
while (j < n && src[j] !== c) {
|
|
351
512
|
if (src[j] === "\\") j++ // skip the escaped char
|
|
352
513
|
j++
|
|
353
514
|
}
|
|
515
|
+
if (quoted === "blank") blank(i, Math.min(j + 1, n))
|
|
354
516
|
i = j + 1
|
|
517
|
+
previous = c
|
|
518
|
+
} else if (
|
|
519
|
+
c === "/" &&
|
|
520
|
+
(previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))
|
|
521
|
+
) {
|
|
522
|
+
const end = regexLiteralEnd(src, i)
|
|
523
|
+
if (end === undefined) {
|
|
524
|
+
previous = c
|
|
525
|
+
i++
|
|
526
|
+
} else {
|
|
527
|
+
if (quoted === "keep") blank(i + 1, end)
|
|
528
|
+
else blank(i, end)
|
|
529
|
+
previous = "/regex"
|
|
530
|
+
i = end
|
|
531
|
+
}
|
|
532
|
+
} else if (isWordChar(c)) {
|
|
533
|
+
let j = i + 1
|
|
534
|
+
while (j < n && isWordChar(src[j])) j++
|
|
535
|
+
previous = src.slice(i, j)
|
|
536
|
+
i = j
|
|
355
537
|
} else {
|
|
538
|
+
if (!/\s/.test(c)) previous = c
|
|
356
539
|
i++
|
|
357
540
|
}
|
|
358
541
|
}
|
|
359
542
|
return out.join("")
|
|
360
543
|
}
|
|
361
544
|
|
|
362
|
-
/**
|
|
545
|
+
/**
|
|
546
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
547
|
+
* - `//` line comments and block comments;
|
|
548
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
549
|
+
* generators), never a real statement to lint.
|
|
550
|
+
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
|
|
551
|
+
* literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
|
|
552
|
+
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
553
|
+
*/
|
|
554
|
+
export function stripComments(src: string): string {
|
|
555
|
+
return maskSource(src, "keep")
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
|
|
363
559
|
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
364
560
|
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
365
561
|
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
366
562
|
export function codePositionMask(src: string): string {
|
|
367
|
-
|
|
368
|
-
const n = src.length
|
|
369
|
-
let i = 0
|
|
370
|
-
const blank = (a: number, b: number): void => {
|
|
371
|
-
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
372
|
-
}
|
|
373
|
-
while (i < n) {
|
|
374
|
-
const c = src[i]
|
|
375
|
-
const d = src[i + 1]
|
|
376
|
-
if (c === "/" && d === "/") {
|
|
377
|
-
let j = i + 2
|
|
378
|
-
while (j < n && src[j] !== "\n") j++
|
|
379
|
-
blank(i, j)
|
|
380
|
-
i = j
|
|
381
|
-
} else if (c === "/" && d === "*") {
|
|
382
|
-
let j = i + 2
|
|
383
|
-
while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
|
|
384
|
-
j = Math.min(j + 2, n)
|
|
385
|
-
blank(i, j)
|
|
386
|
-
i = j
|
|
387
|
-
} else if (c === "'" || c === '"' || c === "`") {
|
|
388
|
-
const quote = c
|
|
389
|
-
let j = i + 1
|
|
390
|
-
while (j < n && src[j] !== quote) {
|
|
391
|
-
if (src[j] === "\\") j++
|
|
392
|
-
j++
|
|
393
|
-
}
|
|
394
|
-
j = Math.min(j + 1, n)
|
|
395
|
-
blank(i, j)
|
|
396
|
-
i = j
|
|
397
|
-
} else {
|
|
398
|
-
i++
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return out.join("")
|
|
563
|
+
return maskSource(src, "blank")
|
|
402
564
|
}
|
|
403
565
|
|
|
404
566
|
const firstArgumentIndex = (content: string, callEnd: number): number => {
|
|
@@ -502,8 +664,8 @@ export function scanFetchText(
|
|
|
502
664
|
file: string,
|
|
503
665
|
content: string,
|
|
504
666
|
externalMounts: readonly string[] = [],
|
|
505
|
-
):
|
|
506
|
-
const out:
|
|
667
|
+
): TypedClientFinding[] {
|
|
668
|
+
const out: TypedClientFinding[] = []
|
|
507
669
|
const lines = content.split("\n")
|
|
508
670
|
const code = codePositionMask(content)
|
|
509
671
|
GLOBAL_FETCH_CALL.lastIndex = 0
|
|
@@ -516,8 +678,7 @@ export function scanFetchText(
|
|
|
516
678
|
if (hasLiteralNifraDataHeader(content, openParen)) continue
|
|
517
679
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
518
680
|
continue
|
|
519
|
-
|
|
520
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
681
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
|
|
521
682
|
}
|
|
522
683
|
return out
|
|
523
684
|
}
|
|
@@ -540,8 +701,8 @@ export function scanStreamText(
|
|
|
540
701
|
file: string,
|
|
541
702
|
content: string,
|
|
542
703
|
externalMounts: readonly string[] = [],
|
|
543
|
-
):
|
|
544
|
-
const out:
|
|
704
|
+
): TypedClientFinding[] {
|
|
705
|
+
const out: TypedClientFinding[] = []
|
|
545
706
|
const lines = content.split("\n")
|
|
546
707
|
const code = codePositionMask(content)
|
|
547
708
|
STREAM_CONSTRUCT.lastIndex = 0
|
|
@@ -552,8 +713,7 @@ export function scanStreamText(
|
|
|
552
713
|
if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
|
|
553
714
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
554
715
|
continue
|
|
555
|
-
|
|
556
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
716
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
|
|
557
717
|
}
|
|
558
718
|
return out
|
|
559
719
|
}
|
|
@@ -563,6 +723,8 @@ export function scanStaticRouteText(
|
|
|
563
723
|
file: string,
|
|
564
724
|
content: string,
|
|
565
725
|
facts?: SourceFacts,
|
|
726
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
727
|
+
project?: ReadonlyMap<string, string>,
|
|
566
728
|
): StaticRouteFinding[] {
|
|
567
729
|
const code = stripComments(content)
|
|
568
730
|
if (!isPotentialBackendSource(code)) return []
|
|
@@ -570,6 +732,201 @@ export function scanStaticRouteText(
|
|
|
570
732
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
571
733
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
572
734
|
...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
|
|
735
|
+
...scanRouteArguments(file, content, facts, project).routes,
|
|
736
|
+
].sort(bySite)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** A module-level `const`: its value when the initializer is one string literal, else `undefined`. */
|
|
740
|
+
interface ModuleConstant {
|
|
741
|
+
readonly exported: boolean
|
|
742
|
+
readonly value: string | undefined
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
|
|
746
|
+
function literalEnd(text: string): number {
|
|
747
|
+
const quote = text[0]
|
|
748
|
+
for (let i = 1; i < text.length; i++) {
|
|
749
|
+
if (text[i] === "\\") i++
|
|
750
|
+
else if (text[i] === quote) return i + 1
|
|
751
|
+
}
|
|
752
|
+
return -1
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** The index after the whitespace run at `from`. */
|
|
756
|
+
function skipSpace(text: string, from: number): number {
|
|
757
|
+
let i = from
|
|
758
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++
|
|
759
|
+
return i
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
|
|
764
|
+
* indented, never stands in for a module-level one. The shape is read from the comment-stripped
|
|
765
|
+
* line; the literal itself from the source line, since stripping blanks template contents.
|
|
766
|
+
*/
|
|
767
|
+
function moduleConstantOn(code: string, source: string): [string, ModuleConstant] | undefined {
|
|
768
|
+
const exported = code.startsWith("export ")
|
|
769
|
+
let i = exported ? skipSpace(code, 7) : 0
|
|
770
|
+
if (!code.startsWith("const ", i)) return undefined
|
|
771
|
+
i = skipSpace(code, i + 6)
|
|
772
|
+
const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0]
|
|
773
|
+
if (name === undefined) return undefined
|
|
774
|
+
i = skipSpace(code, i + name.length)
|
|
775
|
+
if (code[i] === ":") i = code.indexOf("=", i)
|
|
776
|
+
if (i === -1 || code[i] !== "=" || code[i + 1] === "=") return undefined
|
|
777
|
+
i = skipSpace(code, i + 1)
|
|
778
|
+
const opener = code[i]
|
|
779
|
+
const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1
|
|
780
|
+
const tail = end === -1 ? undefined : code.slice(i + end).trim()
|
|
781
|
+
const literal = source.slice(i, i + end)
|
|
782
|
+
const value =
|
|
783
|
+
tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
|
|
784
|
+
? undefined
|
|
785
|
+
: opener === "`"
|
|
786
|
+
? /[$\\]/.test(literal)
|
|
787
|
+
? undefined
|
|
788
|
+
: literal.slice(1, -1)
|
|
789
|
+
: parseQuotedLiteral(literal)
|
|
790
|
+
return [name, { exported, value }]
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function moduleConstants(code: string, source: string): ReadonlyMap<string, ModuleConstant> {
|
|
794
|
+
const constants = new Map<string, ModuleConstant>()
|
|
795
|
+
const sourceLines = source.split("\n")
|
|
796
|
+
code.split("\n").forEach((line, index) => {
|
|
797
|
+
const constant = moduleConstantOn(line, sourceLines[index] ?? "")
|
|
798
|
+
if (constant !== undefined) constants.set(constant[0], constant[1])
|
|
799
|
+
})
|
|
800
|
+
return constants
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Local name → the export it imports and from where, for value imports only. */
|
|
804
|
+
function namedImports(
|
|
805
|
+
code: string,
|
|
806
|
+
): ReadonlyMap<string, { readonly specifier: string; readonly name: string }> {
|
|
807
|
+
const imports = new Map<string, { readonly specifier: string; readonly name: string }>()
|
|
808
|
+
NAMED_IMPORTS.lastIndex = 0
|
|
809
|
+
for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
|
|
810
|
+
if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6))) continue
|
|
811
|
+
for (const element of (m[1] ?? "").split(",")) {
|
|
812
|
+
const parts = element.trim().split(/\s+/)
|
|
813
|
+
if (parts[0] === "type" || parts[0] === undefined || parts[0] === "") continue
|
|
814
|
+
const local = parts[1] === "as" ? parts[2] : parts[0]
|
|
815
|
+
if (local !== undefined) imports.set(local, { specifier: m[3] ?? "", name: parts[0] })
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return imports
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
|
|
822
|
+
function projectModule(
|
|
823
|
+
project: ReadonlyMap<string, string>,
|
|
824
|
+
fromFile: string,
|
|
825
|
+
specifier: string,
|
|
826
|
+
): string | undefined {
|
|
827
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
|
|
828
|
+
const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier))
|
|
829
|
+
const stem = base.replace(/\.(?:m?js|jsx)$/, "")
|
|
830
|
+
return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) =>
|
|
831
|
+
project.has(candidate),
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Route registrations whose path is a name or a template rather than a quoted literal. A name bound
|
|
837
|
+
* to a module-level string `const`, here or imported from a project file, and a template with no
|
|
838
|
+
* substitution resolve to routes; any other template, and a module-level or imported name nifra
|
|
839
|
+
* cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
|
|
840
|
+
* nothing marks such a call as a route rather than a `map.get(key, fallback)`.
|
|
841
|
+
*/
|
|
842
|
+
function scanRouteArguments(
|
|
843
|
+
file: string,
|
|
844
|
+
content: string,
|
|
845
|
+
facts: SourceFacts | undefined,
|
|
846
|
+
project: ReadonlyMap<string, string> | undefined,
|
|
847
|
+
verbs: ReadonlySet<string> = HTTP_VERBS,
|
|
848
|
+
): { routes: StaticRouteFinding[]; unresolved: UnresolvedRoutePathFinding[] } {
|
|
849
|
+
const routes: StaticRouteFinding[] = []
|
|
850
|
+
const unresolved: UnresolvedRoutePathFinding[] = []
|
|
851
|
+
const code = stripComments(content)
|
|
852
|
+
if (!isPotentialBackendSource(code)) return { routes, unresolved }
|
|
853
|
+
const lines = content.split("\n")
|
|
854
|
+
const constants = moduleConstants(code, content)
|
|
855
|
+
const imports = namedImports(code)
|
|
856
|
+
const importedFiles = new Map<string, ReadonlyMap<string, ModuleConstant>>()
|
|
857
|
+
const imported = (specifier: string, name: string): ModuleConstant | undefined => {
|
|
858
|
+
const target = project === undefined ? undefined : projectModule(project, file, specifier)
|
|
859
|
+
if (target === undefined) return undefined
|
|
860
|
+
let found = importedFiles.get(target)
|
|
861
|
+
if (found === undefined) {
|
|
862
|
+
const text = project?.get(target) ?? ""
|
|
863
|
+
found = moduleConstants(stripComments(text), text)
|
|
864
|
+
importedFiles.set(target, found)
|
|
865
|
+
}
|
|
866
|
+
const constant = found.get(name)
|
|
867
|
+
return constant?.exported === true ? constant : undefined
|
|
868
|
+
}
|
|
869
|
+
ROUTE_ARGUMENT.lastIndex = 0
|
|
870
|
+
for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
|
|
871
|
+
const method = (m[1] ?? "").toUpperCase()
|
|
872
|
+
if (!verbs.has(method)) continue
|
|
873
|
+
const at = m.index + m[0].lastIndexOf(m[2] ?? "")
|
|
874
|
+
const argument = content.slice(at, at + (m[2] ?? "").length)
|
|
875
|
+
let path: string | undefined
|
|
876
|
+
if (argument.startsWith("`")) {
|
|
877
|
+
path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1)
|
|
878
|
+
} else {
|
|
879
|
+
const binding = imports.get(argument)
|
|
880
|
+
const constant =
|
|
881
|
+
binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name)
|
|
882
|
+
if (binding === undefined && constant === undefined) continue
|
|
883
|
+
path = constant?.value
|
|
884
|
+
}
|
|
885
|
+
// A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
|
|
886
|
+
if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) continue
|
|
887
|
+
if (facts !== undefined) {
|
|
888
|
+
const source = facts.parse(file, content)
|
|
889
|
+
if (
|
|
890
|
+
source !== undefined &&
|
|
891
|
+
facts.isRouteArgumentAt(source, m.index, method, argument) !== true
|
|
892
|
+
)
|
|
893
|
+
continue
|
|
894
|
+
}
|
|
895
|
+
const line = lineAt(content, m.index)
|
|
896
|
+
const snippet = (lines[line - 1] ?? "").trim()
|
|
897
|
+
if (path !== undefined) routes.push({ file, line, snippet, method, path })
|
|
898
|
+
else unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) })
|
|
899
|
+
}
|
|
900
|
+
return { routes, unresolved }
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
|
|
904
|
+
export function scanUnresolvedRoutePaths(
|
|
905
|
+
file: string,
|
|
906
|
+
content: string,
|
|
907
|
+
project?: ReadonlyMap<string, string>,
|
|
908
|
+
facts?: SourceFacts,
|
|
909
|
+
): UnresolvedRoutePathFinding[] {
|
|
910
|
+
return [
|
|
911
|
+
...scanRouteArguments(file, content, facts, project).unresolved,
|
|
912
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
|
|
913
|
+
].sort(bySite)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
|
|
917
|
+
export function scanStaticSocketText(
|
|
918
|
+
file: string,
|
|
919
|
+
content: string,
|
|
920
|
+
facts?: SourceFacts,
|
|
921
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
922
|
+
project?: ReadonlyMap<string, string>,
|
|
923
|
+
): StaticRouteFinding[] {
|
|
924
|
+
const code = stripComments(content)
|
|
925
|
+
if (!isPotentialBackendSource(code)) return []
|
|
926
|
+
return [
|
|
927
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
|
|
928
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
|
|
929
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
|
|
573
930
|
].sort(bySite)
|
|
574
931
|
}
|
|
575
932
|
|
|
@@ -660,6 +1017,25 @@ const SQL_METHODS = new Set([
|
|
|
660
1017
|
const SQL_KEYWORD =
|
|
661
1018
|
/\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
|
|
662
1019
|
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
|
|
1020
|
+
/** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
|
|
1021
|
+
* `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
|
|
1022
|
+
* calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
|
|
1023
|
+
const DRIVER_READ_METHODS = new Set([
|
|
1024
|
+
"all",
|
|
1025
|
+
"get",
|
|
1026
|
+
"each",
|
|
1027
|
+
"any",
|
|
1028
|
+
"one",
|
|
1029
|
+
"none",
|
|
1030
|
+
"many",
|
|
1031
|
+
"oneOrNone",
|
|
1032
|
+
"manyOrNone",
|
|
1033
|
+
"result",
|
|
1034
|
+
"multi",
|
|
1035
|
+
"multiResult",
|
|
1036
|
+
])
|
|
1037
|
+
const STATEMENT_START =
|
|
1038
|
+
/^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i
|
|
663
1039
|
|
|
664
1040
|
interface SqlExpressionShape {
|
|
665
1041
|
readonly staticText: string
|
|
@@ -952,11 +1328,12 @@ const CONST_RESOLUTION_DEPTH = 5
|
|
|
952
1328
|
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
953
1329
|
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
954
1330
|
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
955
|
-
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a
|
|
956
|
-
*
|
|
957
|
-
* `
|
|
958
|
-
*
|
|
959
|
-
*
|
|
1331
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
|
|
1332
|
+
* resolves (the nearest binding: function or block scope, then module scope; recursively,
|
|
1333
|
+
* depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
|
|
1334
|
+
* `scope.imports` is configured - a module `const` imported by name from another in-project module.
|
|
1335
|
+
* Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
|
|
1336
|
+
* bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
|
|
960
1337
|
*/
|
|
961
1338
|
function resolveStaticSqlText(
|
|
962
1339
|
ts: TypeScriptApi,
|
|
@@ -976,7 +1353,9 @@ function resolveStaticSqlText(
|
|
|
976
1353
|
if (ts.isTemplateExpression(node)) {
|
|
977
1354
|
let text = node.head.text
|
|
978
1355
|
for (const span of node.templateSpans) {
|
|
979
|
-
const part =
|
|
1356
|
+
const part =
|
|
1357
|
+
placeholderNumber(ts, text, span.expression, depth) ??
|
|
1358
|
+
resolveStaticSqlText(ts, span.expression, scope, depth + 1)
|
|
980
1359
|
if (part === undefined) return undefined
|
|
981
1360
|
text += part + span.literal.text
|
|
982
1361
|
}
|
|
@@ -988,7 +1367,11 @@ function resolveStaticSqlText(
|
|
|
988
1367
|
if (whenTrue === undefined || whenFalse === undefined) return undefined
|
|
989
1368
|
return `${whenTrue} ${whenFalse}`
|
|
990
1369
|
}
|
|
1370
|
+
if (ts.isCallExpression(node)) return resolveStaticJoin(ts, node, scope, depth)
|
|
991
1371
|
if (ts.isIdentifier(node)) {
|
|
1372
|
+
const local = localConstInitializer(ts, node)
|
|
1373
|
+
if (local === "shadow") return undefined
|
|
1374
|
+
if (local !== undefined) return resolveStaticSqlText(ts, local, scope, depth + 1)
|
|
992
1375
|
const initializer = scope.module.consts.get(node.text)
|
|
993
1376
|
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
994
1377
|
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
@@ -1017,90 +1400,567 @@ function resolveStaticSqlText(
|
|
|
1017
1400
|
return undefined
|
|
1018
1401
|
}
|
|
1019
1402
|
|
|
1020
|
-
/**
|
|
1021
|
-
*
|
|
1022
|
-
|
|
1403
|
+
/**
|
|
1404
|
+
* The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
|
|
1405
|
+
* nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
|
|
1406
|
+
* when no scope below the module binds it - the module-scope lookup applies then.
|
|
1407
|
+
*/
|
|
1408
|
+
function localConstInitializer(
|
|
1023
1409
|
ts: TypeScriptApi,
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
let
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if (resolved === undefined) dynamic = true
|
|
1033
|
-
else staticText += resolved
|
|
1034
|
-
staticText += span.literal.text
|
|
1410
|
+
node: TSApi.Identifier,
|
|
1411
|
+
): TSApi.Expression | "shadow" | undefined {
|
|
1412
|
+
const source = node.getSourceFile()
|
|
1413
|
+
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1414
|
+
if (ts.isSourceFile(scope)) return undefined
|
|
1415
|
+
const binding = directBinding(ts, source, scope, node.text)
|
|
1416
|
+
if (binding === "shadow") return "shadow"
|
|
1417
|
+
if (binding !== undefined) return binding.constant ? binding.initializer : "shadow"
|
|
1035
1418
|
}
|
|
1036
|
-
return
|
|
1419
|
+
return undefined
|
|
1037
1420
|
}
|
|
1038
1421
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1422
|
+
const unwrapExpression = (ts: TypeScriptApi, node: TSApi.Expression): TSApi.Expression => {
|
|
1423
|
+
let current = node
|
|
1424
|
+
while (
|
|
1425
|
+
ts.isParenthesizedExpression(current) ||
|
|
1426
|
+
ts.isAsExpression(current) ||
|
|
1427
|
+
ts.isSatisfiesExpression(current)
|
|
1428
|
+
) {
|
|
1429
|
+
current = current.expression
|
|
1430
|
+
}
|
|
1431
|
+
return current
|
|
1432
|
+
}
|
|
1044
1433
|
|
|
1045
|
-
/**
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1434
|
+
/** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
|
|
1435
|
+
function frozenArrayLiteral(
|
|
1436
|
+
ts: TypeScriptApi,
|
|
1437
|
+
node: TSApi.Expression,
|
|
1438
|
+
): TSApi.ArrayLiteralExpression | undefined {
|
|
1439
|
+
const call = unwrapExpression(ts, node)
|
|
1440
|
+
if (!ts.isCallExpression(call) || call.arguments.length !== 1) return undefined
|
|
1441
|
+
const callee = call.expression
|
|
1442
|
+
if (
|
|
1443
|
+
!ts.isPropertyAccessExpression(callee) ||
|
|
1444
|
+
callee.name.text !== "freeze" ||
|
|
1445
|
+
!ts.isIdentifier(callee.expression) ||
|
|
1446
|
+
callee.expression.text !== "Object" ||
|
|
1447
|
+
isShadowedAt(ts, callee.expression, "Object")
|
|
1448
|
+
) {
|
|
1449
|
+
return undefined
|
|
1450
|
+
}
|
|
1451
|
+
const array = unwrapExpression(ts, call.arguments[0]!)
|
|
1452
|
+
return ts.isArrayLiteralExpression(array) ? array : undefined
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const ARRAY_READS: ReadonlySet<string> = new Set(["join", "includes", "indexOf"])
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
|
|
1459
|
+
* `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
|
|
1460
|
+
* away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
|
|
1461
|
+
* too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
|
|
1462
|
+
* same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
|
|
1463
|
+
*/
|
|
1464
|
+
function onlyReadsArray(
|
|
1465
|
+
ts: TypeScriptApi,
|
|
1466
|
+
source: TSApi.SourceFile,
|
|
1467
|
+
name: string,
|
|
1468
|
+
declaration: TSApi.Node,
|
|
1469
|
+
pushed?: TSApi.Expression[],
|
|
1470
|
+
): boolean {
|
|
1471
|
+
let ok = true
|
|
1051
1472
|
const visit = (node: TSApi.Node): void => {
|
|
1052
|
-
if (
|
|
1053
|
-
if (
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1473
|
+
if (!ok) return
|
|
1474
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
|
|
1475
|
+
const access = node.parent
|
|
1476
|
+
const call =
|
|
1477
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1478
|
+
access.expression === node &&
|
|
1479
|
+
ts.isCallExpression(access.parent) &&
|
|
1480
|
+
access.parent.expression === access
|
|
1481
|
+
? access.parent
|
|
1482
|
+
: undefined
|
|
1483
|
+
if (
|
|
1484
|
+
pushed !== undefined &&
|
|
1485
|
+
call !== undefined &&
|
|
1486
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1487
|
+
access.name.text === "push"
|
|
1488
|
+
) {
|
|
1489
|
+
if (call.arguments.some((argument) => ts.isSpreadElement(argument))) ok = false
|
|
1490
|
+
else pushed.push(...call.arguments)
|
|
1491
|
+
// An argument can mention the array again; those uses are checked as well.
|
|
1492
|
+
for (const argument of call.arguments) visit(argument)
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
const read =
|
|
1496
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1497
|
+
access.expression === node &&
|
|
1498
|
+
(access.name.text === "length"
|
|
1499
|
+
? !isWrittenTo(ts, access)
|
|
1500
|
+
: ARRAY_READS.has(access.name.text) && call !== undefined)
|
|
1501
|
+
if (!read) ok = false
|
|
1071
1502
|
return
|
|
1072
1503
|
}
|
|
1073
1504
|
ts.forEachChild(node, visit)
|
|
1074
1505
|
}
|
|
1075
1506
|
visit(source)
|
|
1076
|
-
return
|
|
1507
|
+
return ok
|
|
1077
1508
|
}
|
|
1078
1509
|
|
|
1079
|
-
/**
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1510
|
+
/** `node` as the target of an assignment, `++`/`--`, or `delete`. */
|
|
1511
|
+
function isWrittenTo(ts: TypeScriptApi, node: TSApi.Node): boolean {
|
|
1512
|
+
const parent = node.parent
|
|
1513
|
+
if (
|
|
1514
|
+
ts.isBinaryExpression(parent) &&
|
|
1515
|
+
parent.left === node &&
|
|
1516
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1517
|
+
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
1518
|
+
) {
|
|
1519
|
+
return true
|
|
1520
|
+
}
|
|
1521
|
+
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
|
|
1522
|
+
return (
|
|
1523
|
+
parent.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1524
|
+
parent.operator === ts.SyntaxKind.MinusMinusToken
|
|
1525
|
+
)
|
|
1526
|
+
}
|
|
1527
|
+
return ts.isDeleteExpression(parent)
|
|
1091
1528
|
}
|
|
1092
1529
|
|
|
1093
|
-
/**
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
|
|
1530
|
+
/**
|
|
1531
|
+
* The element texts of an array `receiver` names, when the array provably still holds them: an
|
|
1532
|
+
* inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
|
|
1533
|
+
* literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
|
|
1534
|
+
* is type-only and proves nothing.
|
|
1535
|
+
*/
|
|
1536
|
+
function staticArrayItems(
|
|
1098
1537
|
ts: TypeScriptApi,
|
|
1099
|
-
|
|
1100
|
-
scope:
|
|
1101
|
-
|
|
1102
|
-
):
|
|
1103
|
-
|
|
1538
|
+
receiver: TSApi.Expression,
|
|
1539
|
+
scope: SqlScope,
|
|
1540
|
+
depth: number,
|
|
1541
|
+
): readonly string[] | undefined {
|
|
1542
|
+
const node = unwrapExpression(ts, receiver)
|
|
1543
|
+
let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node)
|
|
1544
|
+
let itemScope = scope
|
|
1545
|
+
const pushed: TSApi.Expression[] = []
|
|
1546
|
+
if (array === undefined && ts.isIdentifier(node)) {
|
|
1547
|
+
const local = localConstInitializer(ts, node)
|
|
1548
|
+
if (local === "shadow") return undefined
|
|
1549
|
+
let initializer = local
|
|
1550
|
+
let exported = false
|
|
1551
|
+
if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
|
|
1552
|
+
initializer = scope.module.consts.get(node.text)
|
|
1553
|
+
exported = [...scope.module.localExports.values()].includes(node.text)
|
|
1554
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
1555
|
+
if (edge !== undefined && scope.imports !== undefined) {
|
|
1556
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
|
|
1557
|
+
const found =
|
|
1558
|
+
target === undefined
|
|
1559
|
+
? undefined
|
|
1560
|
+
: resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
|
|
1561
|
+
if (found === undefined) return undefined
|
|
1562
|
+
// Another module's array is accepted only frozen: its own file may mutate it.
|
|
1563
|
+
array = frozenArrayLiteral(ts, found.expression)
|
|
1564
|
+
itemScope = { module: found.scope, imports: scope.imports }
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (array === undefined && initializer !== undefined) {
|
|
1568
|
+
array = frozenArrayLiteral(ts, initializer)
|
|
1569
|
+
const literal = unwrapExpression(ts, initializer)
|
|
1570
|
+
if (
|
|
1571
|
+
array === undefined &&
|
|
1572
|
+
!exported &&
|
|
1573
|
+
ts.isArrayLiteralExpression(literal) &&
|
|
1574
|
+
ts.isVariableDeclaration(initializer.parent) &&
|
|
1575
|
+
onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)
|
|
1576
|
+
) {
|
|
1577
|
+
array = literal
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (array === undefined) return undefined
|
|
1582
|
+
const items: string[] = []
|
|
1583
|
+
// What the array may hold: its literal elements, and anything pushed onto it in any order.
|
|
1584
|
+
for (const element of [...array.elements, ...pushed]) {
|
|
1585
|
+
if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return undefined
|
|
1586
|
+
const text = resolveStaticSqlText(ts, element, itemScope, depth + 1)
|
|
1587
|
+
if (text === undefined) return undefined
|
|
1588
|
+
items.push(text)
|
|
1589
|
+
}
|
|
1590
|
+
return items
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
|
|
1594
|
+
function resolveStaticJoin(
|
|
1595
|
+
ts: TypeScriptApi,
|
|
1596
|
+
call: TSApi.CallExpression,
|
|
1597
|
+
scope: SqlScope,
|
|
1598
|
+
depth: number,
|
|
1599
|
+
): string | undefined {
|
|
1600
|
+
const callee = call.expression
|
|
1601
|
+
if (
|
|
1602
|
+
!ts.isPropertyAccessExpression(callee) ||
|
|
1603
|
+
callee.questionDotToken !== undefined ||
|
|
1604
|
+
callee.name.text !== "join" ||
|
|
1605
|
+
call.arguments.length > 1
|
|
1606
|
+
) {
|
|
1607
|
+
return undefined
|
|
1608
|
+
}
|
|
1609
|
+
const separatorArg = call.arguments[0]
|
|
1610
|
+
const separator =
|
|
1611
|
+
separatorArg === undefined
|
|
1612
|
+
? ","
|
|
1613
|
+
: ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
|
|
1614
|
+
? separatorArg.text
|
|
1615
|
+
: undefined
|
|
1616
|
+
if (separator === undefined) return undefined
|
|
1617
|
+
const items =
|
|
1618
|
+
staticArrayItems(ts, callee.expression, scope, depth + 1) ??
|
|
1619
|
+
mappedItem(ts, callee.expression, scope, depth + 1)
|
|
1620
|
+
return items?.join(separator)
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
|
|
1625
|
+
* for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
|
|
1626
|
+
* many items there are is runtime data, what each one says is not, so one item stands for all.
|
|
1627
|
+
*/
|
|
1628
|
+
function mappedItem(
|
|
1629
|
+
ts: TypeScriptApi,
|
|
1630
|
+
receiver: TSApi.Expression,
|
|
1631
|
+
scope: SqlScope,
|
|
1632
|
+
depth: number,
|
|
1633
|
+
): readonly string[] | undefined {
|
|
1634
|
+
const call = unwrapExpression(ts, receiver)
|
|
1635
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
|
|
1636
|
+
return undefined
|
|
1637
|
+
// Only these return what the callback returns; `filter` and the rest hand back the elements.
|
|
1638
|
+
const method = call.expression.name.text
|
|
1639
|
+
const argument = indexCallbackArgument(ts, call)
|
|
1640
|
+
if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
|
|
1641
|
+
return undefined
|
|
1642
|
+
const fn = call.arguments[argument]
|
|
1643
|
+
if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return undefined
|
|
1644
|
+
const only =
|
|
1645
|
+
ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined
|
|
1646
|
+
const body = ts.isBlock(fn.body)
|
|
1647
|
+
? only !== undefined && ts.isReturnStatement(only)
|
|
1648
|
+
? only.expression
|
|
1649
|
+
: undefined
|
|
1650
|
+
: fn.body
|
|
1651
|
+
const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1)
|
|
1652
|
+
return text === undefined ? undefined : [text]
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** Array methods whose callback receives the element's index, and where in the callback it is. */
|
|
1656
|
+
const INDEX_PARAMETER: ReadonlyMap<string, number> = new Map([
|
|
1657
|
+
["map", 1],
|
|
1658
|
+
["flatMap", 1],
|
|
1659
|
+
["forEach", 1],
|
|
1660
|
+
["filter", 1],
|
|
1661
|
+
["some", 1],
|
|
1662
|
+
["every", 1],
|
|
1663
|
+
["find", 1],
|
|
1664
|
+
["findIndex", 1],
|
|
1665
|
+
["reduce", 2],
|
|
1666
|
+
["reduceRight", 2],
|
|
1667
|
+
])
|
|
1668
|
+
|
|
1669
|
+
/** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
|
|
1670
|
+
* kin, the second for an unshadowed `Array.from(src, fn)`. */
|
|
1671
|
+
function indexCallbackArgument(ts: TypeScriptApi, call: TSApi.CallExpression): number | undefined {
|
|
1672
|
+
const callee = call.expression
|
|
1673
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1674
|
+
return undefined
|
|
1675
|
+
if (INDEX_PARAMETER.has(callee.name.text)) return 0
|
|
1676
|
+
return callee.name.text === "from" &&
|
|
1677
|
+
ts.isIdentifier(callee.expression) &&
|
|
1678
|
+
callee.expression.text === "Array" &&
|
|
1679
|
+
!isShadowedAt(ts, callee.expression, "Array")
|
|
1680
|
+
? 1
|
|
1681
|
+
: undefined
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
|
|
1686
|
+
* Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
|
|
1687
|
+
* there could, so anything not proved numeric stays unresolved.
|
|
1688
|
+
*/
|
|
1689
|
+
function placeholderNumber(
|
|
1690
|
+
ts: TypeScriptApi,
|
|
1691
|
+
before: string,
|
|
1692
|
+
node: TSApi.Expression,
|
|
1693
|
+
depth: number,
|
|
1694
|
+
): string | undefined {
|
|
1695
|
+
return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const NUMERIC_OPERATORS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
|
|
1699
|
+
new Set([
|
|
1700
|
+
ts.SyntaxKind.MinusToken,
|
|
1701
|
+
ts.SyntaxKind.AsteriskToken,
|
|
1702
|
+
ts.SyntaxKind.AsteriskAsteriskToken,
|
|
1703
|
+
ts.SyntaxKind.SlashToken,
|
|
1704
|
+
ts.SyntaxKind.PercentToken,
|
|
1705
|
+
ts.SyntaxKind.AmpersandToken,
|
|
1706
|
+
ts.SyntaxKind.BarToken,
|
|
1707
|
+
ts.SyntaxKind.CaretToken,
|
|
1708
|
+
ts.SyntaxKind.LessThanLessThanToken,
|
|
1709
|
+
ts.SyntaxKind.GreaterThanGreaterThanToken,
|
|
1710
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
|
|
1711
|
+
])
|
|
1712
|
+
const NUMBER_FUNCTIONS: ReadonlySet<string> = new Set(["Number", "parseInt", "parseFloat"])
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
|
|
1716
|
+
* `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
|
|
1717
|
+
* both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
|
|
1718
|
+
* `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
|
|
1719
|
+
* (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
|
|
1720
|
+
* nearby binding builds from a literal. A type annotation proves nothing: it does not run.
|
|
1721
|
+
*/
|
|
1722
|
+
function isNumericExpression(ts: TypeScriptApi, node: TSApi.Expression, depth: number): boolean {
|
|
1723
|
+
if (depth > CONST_RESOLUTION_DEPTH) return false
|
|
1724
|
+
const expression = unwrapExpression(ts, node)
|
|
1725
|
+
if (ts.isNumericLiteral(expression)) return true
|
|
1726
|
+
if (ts.isPrefixUnaryExpression(expression))
|
|
1727
|
+
return expression.operator !== ts.SyntaxKind.ExclamationToken
|
|
1728
|
+
if (ts.isPostfixUnaryExpression(expression)) return true
|
|
1729
|
+
if (ts.isBinaryExpression(expression)) {
|
|
1730
|
+
const operator = expression.operatorToken.kind
|
|
1731
|
+
if (operator === ts.SyntaxKind.PlusToken) {
|
|
1732
|
+
return (
|
|
1733
|
+
isNumericExpression(ts, expression.left, depth + 1) &&
|
|
1734
|
+
isNumericExpression(ts, expression.right, depth + 1)
|
|
1735
|
+
)
|
|
1736
|
+
}
|
|
1737
|
+
return NUMERIC_OPERATORS(ts).has(operator)
|
|
1738
|
+
}
|
|
1739
|
+
if (ts.isIdentifier(expression)) return isNumericName(ts, expression, depth)
|
|
1740
|
+
if (ts.isCallExpression(expression)) {
|
|
1741
|
+
const callee = expression.expression
|
|
1742
|
+
if (ts.isIdentifier(callee))
|
|
1743
|
+
return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text)
|
|
1744
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1745
|
+
return false
|
|
1746
|
+
if (callee.name.text === "push") return true
|
|
1747
|
+
return (
|
|
1748
|
+
ts.isIdentifier(callee.expression) &&
|
|
1749
|
+
callee.expression.text === "Math" &&
|
|
1750
|
+
!isShadowedAt(ts, callee.expression, "Math")
|
|
1751
|
+
)
|
|
1752
|
+
}
|
|
1753
|
+
if (
|
|
1754
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
1755
|
+
expression.name.text === "length" &&
|
|
1756
|
+
ts.isIdentifier(expression.expression)
|
|
1757
|
+
) {
|
|
1758
|
+
const binding = nameBinding(ts, expression.expression)
|
|
1759
|
+
return (
|
|
1760
|
+
binding.kind === "value" &&
|
|
1761
|
+
ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer))
|
|
1762
|
+
)
|
|
1763
|
+
}
|
|
1764
|
+
return false
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/** How the nearest scope binds a name: a readable value ({@link directBinding}), a parameter, a `let`
|
|
1768
|
+
* counter a `for` statement declares and updates, or nothing this walk can read. */
|
|
1769
|
+
type NameBinding =
|
|
1770
|
+
| { readonly kind: "value"; readonly initializer: TSApi.Expression }
|
|
1771
|
+
| {
|
|
1772
|
+
readonly kind: "parameter"
|
|
1773
|
+
readonly fn: TSApi.SignatureDeclaration
|
|
1774
|
+
readonly position: number
|
|
1775
|
+
}
|
|
1776
|
+
| { readonly kind: "counter"; readonly initializer: TSApi.Expression }
|
|
1777
|
+
| { readonly kind: "unknown" }
|
|
1778
|
+
|
|
1779
|
+
function nameBinding(ts: TypeScriptApi, node: TSApi.Identifier): NameBinding {
|
|
1780
|
+
const source = node.getSourceFile()
|
|
1781
|
+
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1782
|
+
if (ts.isFunctionLike(scope)) {
|
|
1783
|
+
const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text))
|
|
1784
|
+
if (position !== -1) {
|
|
1785
|
+
return ts.isIdentifier(scope.parameters[position]?.name ?? node)
|
|
1786
|
+
? { kind: "parameter", fn: scope, position }
|
|
1787
|
+
: { kind: "unknown" }
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
const binding = directBinding(ts, source, scope, node.text)
|
|
1791
|
+
if (binding === "shadow") {
|
|
1792
|
+
// `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
|
|
1793
|
+
const list = ts.isForStatement(scope) ? scope.initializer : undefined
|
|
1794
|
+
const declaration =
|
|
1795
|
+
list !== undefined && ts.isVariableDeclarationList(list)
|
|
1796
|
+
? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
|
|
1797
|
+
: undefined
|
|
1798
|
+
return declaration?.initializer === undefined
|
|
1799
|
+
? { kind: "unknown" }
|
|
1800
|
+
: { kind: "counter", initializer: declaration.initializer }
|
|
1801
|
+
}
|
|
1802
|
+
if (binding !== undefined) return { kind: "value", initializer: binding.initializer }
|
|
1803
|
+
if (ts.isSourceFile(scope)) break
|
|
1804
|
+
}
|
|
1805
|
+
return { kind: "unknown" }
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
function isNumericName(ts: TypeScriptApi, node: TSApi.Identifier, depth: number): boolean {
|
|
1809
|
+
const source = node.getSourceFile()
|
|
1810
|
+
const binding = nameBinding(ts, node)
|
|
1811
|
+
if (binding.kind === "value") return isNumericExpression(ts, binding.initializer, depth + 1)
|
|
1812
|
+
if (binding.kind === "counter") {
|
|
1813
|
+
return (
|
|
1814
|
+
isNumericExpression(ts, binding.initializer, depth + 1) &&
|
|
1815
|
+
onlyNumericWrites(ts, source, node.text, depth)
|
|
1816
|
+
)
|
|
1817
|
+
}
|
|
1818
|
+
if (binding.kind !== "parameter") return false
|
|
1819
|
+
// An array callback's index parameter, never reassigned.
|
|
1820
|
+
const call = binding.fn.parent
|
|
1821
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return false
|
|
1822
|
+
const argument = indexCallbackArgument(ts, call)
|
|
1823
|
+
const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text)
|
|
1824
|
+
return (
|
|
1825
|
+
argument !== undefined &&
|
|
1826
|
+
call.arguments[argument] === binding.fn &&
|
|
1827
|
+
binding.position === position &&
|
|
1828
|
+
!isReassignedInFile(ts, source, node.text)
|
|
1829
|
+
)
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** Compound assignments that always store a number. */
|
|
1833
|
+
const NUMERIC_ASSIGNMENTS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
|
|
1834
|
+
new Set([
|
|
1835
|
+
ts.SyntaxKind.MinusEqualsToken,
|
|
1836
|
+
ts.SyntaxKind.AsteriskEqualsToken,
|
|
1837
|
+
ts.SyntaxKind.AsteriskAsteriskEqualsToken,
|
|
1838
|
+
ts.SyntaxKind.SlashEqualsToken,
|
|
1839
|
+
ts.SyntaxKind.PercentEqualsToken,
|
|
1840
|
+
ts.SyntaxKind.AmpersandEqualsToken,
|
|
1841
|
+
ts.SyntaxKind.BarEqualsToken,
|
|
1842
|
+
ts.SyntaxKind.CaretEqualsToken,
|
|
1843
|
+
ts.SyntaxKind.LessThanLessThanEqualsToken,
|
|
1844
|
+
ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
|
|
1845
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
|
1846
|
+
])
|
|
1847
|
+
|
|
1848
|
+
/** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
|
|
1849
|
+
function onlyNumericWrites(
|
|
1850
|
+
ts: TypeScriptApi,
|
|
1851
|
+
source: TSApi.SourceFile,
|
|
1852
|
+
name: string,
|
|
1853
|
+
depth: number,
|
|
1854
|
+
): boolean {
|
|
1855
|
+
let ok = true
|
|
1856
|
+
const visit = (node: TSApi.Node): void => {
|
|
1857
|
+
if (!ok) return
|
|
1858
|
+
if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
|
|
1859
|
+
const parent = node.parent
|
|
1860
|
+
if (ts.isBinaryExpression(parent)) {
|
|
1861
|
+
const operator = parent.operatorToken.kind
|
|
1862
|
+
ok =
|
|
1863
|
+
operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
|
|
1864
|
+
? isNumericExpression(ts, parent.right, depth + 1)
|
|
1865
|
+
: NUMERIC_ASSIGNMENTS(ts).has(operator)
|
|
1866
|
+
} else ok = !ts.isDeleteExpression(parent)
|
|
1867
|
+
return
|
|
1868
|
+
}
|
|
1869
|
+
ts.forEachChild(node, visit)
|
|
1870
|
+
}
|
|
1871
|
+
visit(source)
|
|
1872
|
+
return ok
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
1876
|
+
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
1877
|
+
function templateShape(
|
|
1878
|
+
ts: TypeScriptApi,
|
|
1879
|
+
template: TSApi.TemplateExpression,
|
|
1880
|
+
scope: SqlScope | undefined,
|
|
1881
|
+
): { staticText: string; dynamic: boolean } {
|
|
1882
|
+
let staticText = template.head.text
|
|
1883
|
+
let dynamic = false
|
|
1884
|
+
for (const span of template.templateSpans) {
|
|
1885
|
+
const resolved =
|
|
1886
|
+
scope === undefined
|
|
1887
|
+
? undefined
|
|
1888
|
+
: (placeholderNumber(ts, staticText, span.expression, 0) ??
|
|
1889
|
+
resolveStaticSqlText(ts, span.expression, scope, 0))
|
|
1890
|
+
if (resolved === undefined) dynamic = true
|
|
1891
|
+
else staticText += resolved
|
|
1892
|
+
staticText += span.literal.text
|
|
1893
|
+
}
|
|
1894
|
+
return { staticText, dynamic }
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
|
|
1898
|
+
* whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
|
|
1899
|
+
* parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
|
|
1900
|
+
* farther, resolvable const of the same name is never read in its place. */
|
|
1901
|
+
type ResolvableBinding =
|
|
1902
|
+
| { readonly initializer: TSApi.Expression; readonly constant: boolean }
|
|
1903
|
+
| "shadow"
|
|
1904
|
+
|
|
1905
|
+
/** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
|
|
1906
|
+
* bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
|
|
1907
|
+
* request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
|
|
1908
|
+
* before, never the reverse. */
|
|
1909
|
+
function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
|
|
1910
|
+
let found = false
|
|
1911
|
+
const visit = (node: TSApi.Node): void => {
|
|
1912
|
+
if (found) return
|
|
1913
|
+
if (
|
|
1914
|
+
ts.isBinaryExpression(node) &&
|
|
1915
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1916
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
|
|
1917
|
+
ts.isIdentifier(node.left) &&
|
|
1918
|
+
node.left.text === name
|
|
1919
|
+
) {
|
|
1920
|
+
found = true
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
if (
|
|
1924
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
1925
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1926
|
+
node.operator === ts.SyntaxKind.MinusMinusToken) &&
|
|
1927
|
+
ts.isIdentifier(node.operand) &&
|
|
1928
|
+
node.operand.text === name
|
|
1929
|
+
) {
|
|
1930
|
+
found = true
|
|
1931
|
+
return
|
|
1932
|
+
}
|
|
1933
|
+
ts.forEachChild(node, visit)
|
|
1934
|
+
}
|
|
1935
|
+
visit(source)
|
|
1936
|
+
return found
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
|
|
1940
|
+
* the pattern walk in {@link isShadowedAt}. */
|
|
1941
|
+
function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
|
|
1942
|
+
return ts.isIdentifier(binding)
|
|
1943
|
+
? binding.text === name
|
|
1944
|
+
: binding.elements.some(
|
|
1945
|
+
(element) =>
|
|
1946
|
+
!ts.isOmittedExpression(element) &&
|
|
1947
|
+
(ts.isIdentifier(element.name)
|
|
1948
|
+
? element.name.text === name
|
|
1949
|
+
: bindingNameCovers(ts, element.name, name)),
|
|
1950
|
+
)
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/** Inspect the bindings a single scope node introduces DIRECTLY (not nested) for `name`: parameters and
|
|
1954
|
+
* catch variables of a function/catch, `var`/`let`/`const` and function/class declarations of a
|
|
1955
|
+
* statement list. Returns the readable initializer, `"shadow"` for a form we cannot read through, or
|
|
1956
|
+
* `undefined` when this scope does not bind the name at all. */
|
|
1957
|
+
function directBinding(
|
|
1958
|
+
ts: TypeScriptApi,
|
|
1959
|
+
source: TSApi.SourceFile,
|
|
1960
|
+
scope: TSApi.Node,
|
|
1961
|
+
name: string,
|
|
1962
|
+
): ResolvableBinding | undefined {
|
|
1963
|
+
if (ts.isFunctionLike(scope)) {
|
|
1104
1964
|
for (const parameter of scope.parameters) {
|
|
1105
1965
|
if (bindingNameCovers(ts, parameter.name, name)) return "shadow"
|
|
1106
1966
|
}
|
|
@@ -1109,33 +1969,15 @@ function directBinding(
|
|
|
1109
1969
|
const declared = scope.variableDeclaration
|
|
1110
1970
|
if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
|
|
1111
1971
|
}
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
: undefined
|
|
1119
|
-
const lists: TSApi.VariableDeclarationList[] = []
|
|
1120
|
-
if (statements !== undefined) {
|
|
1121
|
-
for (const statement of statements) {
|
|
1122
|
-
if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
|
|
1123
|
-
if (
|
|
1124
|
-
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
1125
|
-
statement.name?.text === name
|
|
1126
|
-
) {
|
|
1127
|
-
return "shadow"
|
|
1128
|
-
}
|
|
1972
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1973
|
+
if (
|
|
1974
|
+
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
1975
|
+
statement.name?.text === name
|
|
1976
|
+
) {
|
|
1977
|
+
return "shadow"
|
|
1129
1978
|
}
|
|
1130
1979
|
}
|
|
1131
|
-
|
|
1132
|
-
(ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
|
|
1133
|
-
scope.initializer !== undefined &&
|
|
1134
|
-
ts.isVariableDeclarationList(scope.initializer)
|
|
1135
|
-
) {
|
|
1136
|
-
lists.push(scope.initializer)
|
|
1137
|
-
}
|
|
1138
|
-
for (const list of lists) {
|
|
1980
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
1139
1981
|
for (const declaration of list.declarations) {
|
|
1140
1982
|
if (!bindingNameCovers(ts, declaration.name, name)) continue
|
|
1141
1983
|
// A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
|
|
@@ -1144,57 +1986,306 @@ function directBinding(
|
|
|
1144
1986
|
return "shadow"
|
|
1145
1987
|
const isConst = (list.flags & ts.NodeFlags.Const) !== 0
|
|
1146
1988
|
const isLet = (list.flags & ts.NodeFlags.Let) !== 0
|
|
1147
|
-
if (isConst) return { initializer: declaration.initializer }
|
|
1989
|
+
if (isConst) return { initializer: declaration.initializer, constant: true }
|
|
1148
1990
|
if (isLet && !isReassignedInFile(ts, source, name))
|
|
1149
|
-
return { initializer: declaration.initializer }
|
|
1991
|
+
return { initializer: declaration.initializer, constant: false }
|
|
1150
1992
|
return "shadow"
|
|
1151
1993
|
}
|
|
1152
1994
|
}
|
|
1153
1995
|
return undefined
|
|
1154
1996
|
}
|
|
1155
1997
|
|
|
1998
|
+
function scopeStatements(
|
|
1999
|
+
ts: TypeScriptApi,
|
|
2000
|
+
scope: TSApi.Node,
|
|
2001
|
+
): readonly TSApi.Statement[] | undefined {
|
|
2002
|
+
return ts.isSourceFile(scope) ||
|
|
2003
|
+
ts.isBlock(scope) ||
|
|
2004
|
+
ts.isModuleBlock(scope) ||
|
|
2005
|
+
ts.isCaseClause(scope) ||
|
|
2006
|
+
ts.isDefaultClause(scope)
|
|
2007
|
+
? scope.statements
|
|
2008
|
+
: undefined
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
|
|
2012
|
+
function scopeDeclarationLists(
|
|
2013
|
+
ts: TypeScriptApi,
|
|
2014
|
+
scope: TSApi.Node,
|
|
2015
|
+
): TSApi.VariableDeclarationList[] {
|
|
2016
|
+
const lists: TSApi.VariableDeclarationList[] = []
|
|
2017
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
2018
|
+
if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
|
|
2019
|
+
}
|
|
2020
|
+
if (
|
|
2021
|
+
(ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
|
|
2022
|
+
scope.initializer !== undefined &&
|
|
2023
|
+
ts.isVariableDeclarationList(scope.initializer)
|
|
2024
|
+
) {
|
|
2025
|
+
lists.push(scope.initializer)
|
|
2026
|
+
}
|
|
2027
|
+
return lists
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
|
|
2031
|
+
function mutableDeclaration(
|
|
2032
|
+
ts: TypeScriptApi,
|
|
2033
|
+
scope: TSApi.Node,
|
|
2034
|
+
name: string,
|
|
2035
|
+
): TSApi.VariableDeclaration | undefined {
|
|
2036
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
2037
|
+
if ((list.flags & ts.NodeFlags.Const) !== 0) continue
|
|
2038
|
+
for (const declaration of list.declarations) {
|
|
2039
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) return declaration
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
return undefined
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
interface SqlWrite {
|
|
2046
|
+
readonly value: TSApi.Expression
|
|
2047
|
+
/** `+=`: the value is glued onto what the variable already holds. */
|
|
2048
|
+
readonly appends: boolean
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1156
2051
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
* could be silenced, invisibly, by hoisting the statement to `const q = …` first
|
|
2052
|
+
* Every value a statement variable may hold where the query reads it, so a statement parked in a
|
|
2053
|
+
* variable is scanned as if it had been written inline at the call. This is the counterpart to
|
|
2054
|
+
* {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
|
|
2055
|
+
* statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
|
|
2056
|
+
* could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
|
|
2057
|
+
* in steps (`let q = "SELECT …"; q += …`).
|
|
1162
2058
|
*
|
|
1163
2059
|
* Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
|
|
1164
|
-
* same-named module const.
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
2060
|
+
* same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
|
|
2061
|
+
* `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
|
|
2062
|
+
* the function (or module) that declares it: over-collected rather than scope-simulated, which only
|
|
2063
|
+
* ever adds a value to check. `key` names the binding, so a value that reads the variable it is
|
|
2064
|
+
* assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
|
|
2065
|
+
* same-file declaration returns `undefined` - which leaves the argument scanned as itself.
|
|
1169
2066
|
*/
|
|
1170
|
-
function
|
|
2067
|
+
function variableWrites(
|
|
1171
2068
|
ts: TypeScriptApi,
|
|
1172
2069
|
node: TSApi.Identifier,
|
|
1173
|
-
|
|
1174
|
-
): TSApi.Expression | undefined {
|
|
1175
|
-
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
2070
|
+
): { readonly key: TSApi.Node; readonly writes: readonly SqlWrite[] } | undefined {
|
|
1176
2071
|
const source = node.getSourceFile()
|
|
1177
2072
|
const name = node.text
|
|
1178
2073
|
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1179
2074
|
const binding = directBinding(ts, source, scope, name)
|
|
1180
|
-
if (binding === "shadow")
|
|
2075
|
+
if (binding === "shadow") {
|
|
2076
|
+
const declaration = mutableDeclaration(ts, scope, name)
|
|
2077
|
+
if (declaration === undefined) return undefined
|
|
2078
|
+
let owner: TSApi.Node = declaration
|
|
2079
|
+
while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent
|
|
2080
|
+
const writes = writesTo(ts, owner, name)
|
|
2081
|
+
return {
|
|
2082
|
+
key: declaration,
|
|
2083
|
+
writes:
|
|
2084
|
+
declaration.initializer === undefined
|
|
2085
|
+
? writes
|
|
2086
|
+
: [{ value: declaration.initializer, appends: false }, ...writes],
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
1181
2089
|
if (binding !== undefined) {
|
|
1182
|
-
|
|
1183
|
-
while (ts.isParenthesizedExpression(init)) init = init.expression
|
|
1184
|
-
return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init
|
|
2090
|
+
return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] }
|
|
1185
2091
|
}
|
|
1186
2092
|
if (ts.isSourceFile(scope)) break
|
|
1187
2093
|
}
|
|
1188
2094
|
return undefined
|
|
1189
2095
|
}
|
|
1190
2096
|
|
|
1191
|
-
/**
|
|
2097
|
+
/** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
|
|
2098
|
+
function writesTo(ts: TypeScriptApi, root: TSApi.Node, name: string): SqlWrite[] {
|
|
2099
|
+
const writes: SqlWrite[] = []
|
|
2100
|
+
const visit = (node: TSApi.Node): void => {
|
|
2101
|
+
if (ts.isIdentifier(node) && node.text === name) {
|
|
2102
|
+
const parent = node.parent
|
|
2103
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
2104
|
+
const operator = parent.operatorToken.kind
|
|
2105
|
+
if (operator === ts.SyntaxKind.PlusEqualsToken) {
|
|
2106
|
+
writes.push({ value: parent.right, appends: true })
|
|
2107
|
+
} else if (
|
|
2108
|
+
operator === ts.SyntaxKind.EqualsToken ||
|
|
2109
|
+
operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
|
|
2110
|
+
operator === ts.SyntaxKind.BarBarEqualsToken ||
|
|
2111
|
+
operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken
|
|
2112
|
+
) {
|
|
2113
|
+
writes.push({ value: parent.right, appends: false })
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
ts.forEachChild(node, visit)
|
|
2118
|
+
}
|
|
2119
|
+
visit(root)
|
|
2120
|
+
return writes
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function mentionsName(ts: TypeScriptApi, node: TSApi.Node, name: string): boolean {
|
|
2124
|
+
if (ts.isIdentifier(node)) return node.text === name
|
|
2125
|
+
return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const UNKNOWN_SQL: SqlExpressionShape = {
|
|
2129
|
+
staticText: "",
|
|
2130
|
+
dynamic: true,
|
|
2131
|
+
concatenated: false,
|
|
2132
|
+
literal: false,
|
|
2133
|
+
parameterizedTag: false,
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function textShape(text: string): SqlExpressionShape {
|
|
2137
|
+
return {
|
|
2138
|
+
staticText: text,
|
|
2139
|
+
dynamic: false,
|
|
2140
|
+
concatenated: false,
|
|
2141
|
+
literal: true,
|
|
2142
|
+
parameterizedTag: false,
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function joinShapes(shapes: readonly SqlExpressionShape[]): SqlExpressionShape {
|
|
2147
|
+
return {
|
|
2148
|
+
staticText: shapes.map((shape) => shape.staticText).join(""),
|
|
2149
|
+
// As with `+`: text glued onto a binding tag's output un-parameterizes it.
|
|
2150
|
+
dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
|
|
2151
|
+
concatenated: true,
|
|
2152
|
+
literal: false,
|
|
2153
|
+
parameterizedTag: false,
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
|
|
2158
|
+
* them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
|
|
2159
|
+
* before inside one. */
|
|
2160
|
+
function sqlRisk(shape: SqlExpressionShape): number {
|
|
2161
|
+
const dynamic = !shape.dynamic
|
|
2162
|
+
? 0
|
|
2163
|
+
: SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
|
|
2164
|
+
? 3
|
|
2165
|
+
: shape.concatenated
|
|
2166
|
+
? 2
|
|
2167
|
+
: 1
|
|
2168
|
+
return dynamic * 2 + (shape.parameterizedTag ? 0 : 1)
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
|
|
2173
|
+
* The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
|
|
2174
|
+
* once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
|
|
2175
|
+
* interpolated `AND …` reads as the one statement it becomes.
|
|
2176
|
+
*/
|
|
2177
|
+
function writtenShape(
|
|
2178
|
+
ts: TypeScriptApi,
|
|
2179
|
+
name: string,
|
|
2180
|
+
writes: readonly SqlWrite[],
|
|
2181
|
+
scope: SqlScope | undefined,
|
|
2182
|
+
trail: readonly TSApi.Node[],
|
|
2183
|
+
): SqlExpressionShape {
|
|
2184
|
+
const replacing: SqlExpressionShape[] = []
|
|
2185
|
+
const building: SqlExpressionShape[] = []
|
|
2186
|
+
for (const write of writes) {
|
|
2187
|
+
const shape = sqlExpressionShape(ts, write.value, scope, trail)
|
|
2188
|
+
if (write.appends || mentionsName(ts, write.value, name)) building.push(shape)
|
|
2189
|
+
else replacing.push(shape)
|
|
2190
|
+
}
|
|
2191
|
+
const candidates =
|
|
2192
|
+
building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])]
|
|
2193
|
+
let worst: SqlExpressionShape | undefined
|
|
2194
|
+
for (const candidate of candidates) {
|
|
2195
|
+
if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst)) worst = candidate
|
|
2196
|
+
}
|
|
2197
|
+
return worst ?? UNKNOWN_SQL
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
const TEXT_METHODS = new Set([
|
|
2201
|
+
"toString",
|
|
2202
|
+
"valueOf",
|
|
2203
|
+
"trim",
|
|
2204
|
+
"trimStart",
|
|
2205
|
+
"trimEnd",
|
|
2206
|
+
"toLowerCase",
|
|
2207
|
+
"toUpperCase",
|
|
2208
|
+
])
|
|
2209
|
+
|
|
2210
|
+
/** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
|
|
2211
|
+
function passedThrough(
|
|
2212
|
+
ts: TypeScriptApi,
|
|
2213
|
+
call: TSApi.CallExpression,
|
|
2214
|
+
): TSApi.Expression | undefined {
|
|
2215
|
+
const callee = call.expression
|
|
2216
|
+
if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
|
|
2217
|
+
return call.arguments[0]
|
|
2218
|
+
}
|
|
2219
|
+
if (
|
|
2220
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
2221
|
+
TEXT_METHODS.has(callee.name.text) &&
|
|
2222
|
+
call.arguments.length === 0
|
|
2223
|
+
) {
|
|
2224
|
+
return callee.expression
|
|
2225
|
+
}
|
|
2226
|
+
return undefined
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
|
|
2230
|
+
function joinedParts(
|
|
2231
|
+
ts: TypeScriptApi,
|
|
2232
|
+
call: TSApi.CallExpression,
|
|
2233
|
+
): readonly (TSApi.Expression | string)[] | undefined {
|
|
2234
|
+
const callee = call.expression
|
|
2235
|
+
if (!ts.isPropertyAccessExpression(callee)) return undefined
|
|
2236
|
+
if (callee.name.text === "concat") return [callee.expression, ...call.arguments]
|
|
2237
|
+
const items = unwrapExpression(ts, callee.expression)
|
|
2238
|
+
if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items)) return undefined
|
|
2239
|
+
const separator = call.arguments[0]
|
|
2240
|
+
const glue: TSApi.Expression | string =
|
|
2241
|
+
separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator
|
|
2242
|
+
const parts: (TSApi.Expression | string)[] = []
|
|
2243
|
+
items.elements.forEach((element, index) => {
|
|
2244
|
+
if (index > 0) parts.push(glue)
|
|
2245
|
+
if (ts.isSpreadElement(element)) parts.push(element.expression)
|
|
2246
|
+
else if (!ts.isOmittedExpression(element)) parts.push(element)
|
|
2247
|
+
})
|
|
2248
|
+
return parts
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
|
|
2252
|
+
* which is plain interpolation whatever its name. A parameter, an import, or a driver instance
|
|
2253
|
+
* (`const sql = postgres(url)`) keeps the name's trust. */
|
|
2254
|
+
function definesSqlFunction(ts: TypeScriptApi, tag: TSApi.Identifier): boolean {
|
|
2255
|
+
for (let scope: TSApi.Node | undefined = tag.parent; scope !== undefined; scope = scope.parent) {
|
|
2256
|
+
if (
|
|
2257
|
+
ts.isFunctionLike(scope) &&
|
|
2258
|
+
scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))
|
|
2259
|
+
) {
|
|
2260
|
+
return false
|
|
2261
|
+
}
|
|
2262
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
2263
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text) return true
|
|
2264
|
+
}
|
|
2265
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
2266
|
+
for (const declaration of list.declarations) {
|
|
2267
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text) continue
|
|
2268
|
+
const value =
|
|
2269
|
+
declaration.initializer === undefined
|
|
2270
|
+
? undefined
|
|
2271
|
+
: unwrapExpression(ts, declaration.initializer)
|
|
2272
|
+
return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
return false
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
|
|
2280
|
+
* `trail` holds the variables being read through, innermost last. */
|
|
1192
2281
|
function sqlExpressionShape(
|
|
1193
2282
|
ts: TypeScriptApi,
|
|
1194
|
-
|
|
2283
|
+
expression: TSApi.Expression,
|
|
1195
2284
|
scope?: SqlScope,
|
|
2285
|
+
trail: readonly TSApi.Node[] = [],
|
|
1196
2286
|
): SqlExpressionShape {
|
|
1197
|
-
|
|
2287
|
+
let node = unwrapExpression(ts, expression)
|
|
2288
|
+
while (ts.isNonNullExpression(node)) node = unwrapExpression(ts, node.expression)
|
|
1198
2289
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
1199
2290
|
return {
|
|
1200
2291
|
staticText: node.text,
|
|
@@ -1207,7 +2298,7 @@ function sqlExpressionShape(
|
|
|
1207
2298
|
if (ts.isTaggedTemplateExpression(node)) {
|
|
1208
2299
|
const tag = node.tag
|
|
1209
2300
|
const parameterizedTag =
|
|
1210
|
-
(ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
2301
|
+
(ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
|
|
1211
2302
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
1212
2303
|
const template = node.template
|
|
1213
2304
|
const templated = ts.isTemplateExpression(template)
|
|
@@ -1217,8 +2308,9 @@ function sqlExpressionShape(
|
|
|
1217
2308
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
1218
2309
|
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
1219
2310
|
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
1220
|
-
// deliberate trade, and it is worth stating what it costs: a
|
|
1221
|
-
//
|
|
2311
|
+
// deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
|
|
2312
|
+
// function is read as the interpolation it is, but one imported from anywhere is trusted, so
|
|
2313
|
+
// this rule finds mistakes, not an adversary who reads it first.
|
|
1222
2314
|
//
|
|
1223
2315
|
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
1224
2316
|
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
@@ -1240,14 +2332,17 @@ function sqlExpressionShape(
|
|
|
1240
2332
|
return {
|
|
1241
2333
|
staticText: templated.staticText,
|
|
1242
2334
|
dynamic: templated.dynamic,
|
|
1243
|
-
|
|
2335
|
+
// `${a} ${b}` writes no text of its own; it only glues values together.
|
|
2336
|
+
concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every(
|
|
2337
|
+
(part) => part.text.trim() === "",
|
|
2338
|
+
),
|
|
1244
2339
|
literal: false,
|
|
1245
2340
|
parameterizedTag: false,
|
|
1246
2341
|
}
|
|
1247
2342
|
}
|
|
1248
2343
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
1249
|
-
const left = sqlExpressionShape(ts, node.left, scope)
|
|
1250
|
-
const right = sqlExpressionShape(ts, node.right, scope)
|
|
2344
|
+
const left = sqlExpressionShape(ts, node.left, scope, trail)
|
|
2345
|
+
const right = sqlExpressionShape(ts, node.right, scope, trail)
|
|
1251
2346
|
return {
|
|
1252
2347
|
staticText: left.staticText + right.staticText,
|
|
1253
2348
|
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
@@ -1276,55 +2371,163 @@ function sqlExpressionShape(
|
|
|
1276
2371
|
}
|
|
1277
2372
|
}
|
|
1278
2373
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
2374
|
+
if (ts.isCallExpression(node)) {
|
|
2375
|
+
const inner = passedThrough(ts, node)
|
|
2376
|
+
if (inner !== undefined)
|
|
2377
|
+
return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false }
|
|
2378
|
+
const parts = joinedParts(ts, node)
|
|
2379
|
+
if (parts !== undefined) {
|
|
2380
|
+
return joinShapes(
|
|
2381
|
+
parts.map((part) =>
|
|
2382
|
+
typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail),
|
|
2383
|
+
),
|
|
2384
|
+
)
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
// A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
|
|
2388
|
+
// shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
|
|
1281
2389
|
// read exactly as if it were written inline at the call, closing the "extract a variable to launder
|
|
1282
2390
|
// the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
|
|
1283
2391
|
// property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
|
|
1284
2392
|
if (ts.isIdentifier(node)) {
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
2393
|
+
const read = variableWrites(ts, node)
|
|
2394
|
+
// Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
|
|
2395
|
+
// already account for.
|
|
2396
|
+
if (read !== undefined && trail.includes(read.key)) return { ...textShape(""), literal: false }
|
|
2397
|
+
if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
|
|
2398
|
+
const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key])
|
|
2399
|
+
return { ...written, literal: false }
|
|
1288
2400
|
}
|
|
1289
2401
|
}
|
|
1290
|
-
return
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
2402
|
+
return UNKNOWN_SQL
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
interface SqlSink {
|
|
2406
|
+
readonly method: string
|
|
2407
|
+
/** `undefined` for a bare call: `const { query } = db` and then `query(…)`. */
|
|
2408
|
+
readonly receiver: string | undefined
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function sqlMethod(ts: TypeScriptApi, call: TSApi.CallExpression): SqlSink | undefined {
|
|
2412
|
+
const sink = (method: string, receiver: TSApi.Expression | undefined): SqlSink | undefined =>
|
|
2413
|
+
SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
|
|
2414
|
+
? { method, receiver: receiver?.getText() }
|
|
2415
|
+
: undefined
|
|
2416
|
+
const callee = call.expression
|
|
2417
|
+
if (ts.isIdentifier(callee)) return sink(callee.text, undefined)
|
|
2418
|
+
if (ts.isPropertyAccessExpression(callee)) return sink(callee.name.text, callee.expression)
|
|
2419
|
+
if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
|
|
2420
|
+
return sink(callee.argumentExpression.text, callee.expression)
|
|
2421
|
+
}
|
|
2422
|
+
return undefined
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
|
|
2426
|
+
|
|
2427
|
+
/** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
|
|
2428
|
+
function isAssembledSql(sink: SqlSink, shape: SqlExpressionShape): boolean {
|
|
2429
|
+
if (!shape.dynamic) return false
|
|
2430
|
+
if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
|
|
2431
|
+
return STATEMENT_START.test(shape.staticText)
|
|
1296
2432
|
}
|
|
2433
|
+
return (
|
|
2434
|
+
SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver))
|
|
2435
|
+
)
|
|
1297
2436
|
}
|
|
1298
2437
|
|
|
1299
|
-
|
|
2438
|
+
/** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
|
|
2439
|
+
* (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
|
|
2440
|
+
function statementArgument(
|
|
1300
2441
|
ts: TypeScriptApi,
|
|
1301
|
-
|
|
1302
|
-
):
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
2442
|
+
argument: TSApi.Expression,
|
|
2443
|
+
): TSApi.Expression | undefined {
|
|
2444
|
+
const node = unwrapExpression(ts, argument)
|
|
2445
|
+
if (!ts.isObjectLiteralExpression(node)) return argument
|
|
2446
|
+
for (const property of node.properties) {
|
|
2447
|
+
if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
|
|
2448
|
+
return property.name
|
|
2449
|
+
}
|
|
2450
|
+
if (
|
|
2451
|
+
ts.isPropertyAssignment(property) &&
|
|
2452
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
2453
|
+
STATEMENT_KEYS.has(property.name.text)
|
|
2454
|
+
) {
|
|
2455
|
+
return property.initializer
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
return undefined
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
const STATEMENT_KEYS = new Set(["text", "sql"])
|
|
2462
|
+
|
|
2463
|
+
interface StatementHelper {
|
|
2464
|
+
readonly position: number
|
|
2465
|
+
readonly sink: SqlSink
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
|
|
2469
|
+
* `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
|
|
2470
|
+
* scanned as a call to that sink. One level deep. */
|
|
2471
|
+
function statementHelpers(
|
|
2472
|
+
ts: TypeScriptApi,
|
|
2473
|
+
source: TSApi.SourceFile,
|
|
2474
|
+
): ReadonlyMap<string, StatementHelper> {
|
|
2475
|
+
const helpers = new Map<string, StatementHelper>()
|
|
2476
|
+
const visit = (node: TSApi.Node): void => {
|
|
2477
|
+
const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined
|
|
2478
|
+
const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined
|
|
2479
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed)
|
|
2480
|
+
const statement = argument === undefined ? undefined : unwrapExpression(ts, argument)
|
|
2481
|
+
if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
|
|
2482
|
+
const binding = nameBinding(ts, statement)
|
|
2483
|
+
const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined
|
|
2484
|
+
if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
|
|
2485
|
+
helpers.set(name, { position: binding.position, sink })
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
ts.forEachChild(node, visit)
|
|
1308
2489
|
}
|
|
2490
|
+
visit(source)
|
|
2491
|
+
return helpers
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function functionName(ts: TypeScriptApi, fn: TSApi.SignatureDeclaration): string | undefined {
|
|
2495
|
+
if (ts.isFunctionDeclaration(fn)) return fn.name?.text
|
|
1309
2496
|
if (
|
|
1310
|
-
ts.
|
|
1311
|
-
|
|
1312
|
-
ts.
|
|
2497
|
+
(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
2498
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
2499
|
+
ts.isIdentifier(fn.parent.name)
|
|
1313
2500
|
) {
|
|
1314
|
-
|
|
1315
|
-
return SQL_METHODS.has(method)
|
|
1316
|
-
? { method, receiver: call.expression.expression.getText() }
|
|
1317
|
-
: undefined
|
|
2501
|
+
return fn.parent.name.text
|
|
1318
2502
|
}
|
|
1319
2503
|
return undefined
|
|
1320
2504
|
}
|
|
1321
2505
|
|
|
1322
|
-
|
|
2506
|
+
/** `content` with every string, template and regex literal blanked, line breaks kept. */
|
|
2507
|
+
function literalsBlanked(ts: TypeScriptApi, source: TSApi.SourceFile, content: string): string {
|
|
2508
|
+
const out = content.split("")
|
|
2509
|
+
const visit = (node: TSApi.Node): void => {
|
|
2510
|
+
if (
|
|
2511
|
+
ts.isStringLiteralLike(node) ||
|
|
2512
|
+
ts.isTemplateHead(node) ||
|
|
2513
|
+
ts.isTemplateMiddle(node) ||
|
|
2514
|
+
ts.isTemplateTail(node) ||
|
|
2515
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
2516
|
+
ts.isJsxText(node)
|
|
2517
|
+
) {
|
|
2518
|
+
for (let i = node.getStart(source); i < node.end; i++) if (out[i] !== "\n") out[i] = " "
|
|
2519
|
+
return
|
|
2520
|
+
}
|
|
2521
|
+
ts.forEachChild(node, visit)
|
|
2522
|
+
}
|
|
2523
|
+
visit(source)
|
|
2524
|
+
return out.join("")
|
|
2525
|
+
}
|
|
1323
2526
|
|
|
1324
|
-
/**
|
|
1325
|
-
*
|
|
1326
|
-
* is mandatory (see {@link
|
|
1327
|
-
const
|
|
2527
|
+
/** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
|
|
2528
|
+
* `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
|
|
2529
|
+
* the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
|
|
2530
|
+
const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"]
|
|
1328
2531
|
|
|
1329
2532
|
/**
|
|
1330
2533
|
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
@@ -1339,7 +2542,17 @@ export function scanInterpolatedSql(
|
|
|
1339
2542
|
ts: TypeScriptApi,
|
|
1340
2543
|
imports?: SqlImports,
|
|
1341
2544
|
): SourceFinding[] {
|
|
1342
|
-
|
|
2545
|
+
return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined)
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
|
|
2549
|
+
export function scanSqlSites(
|
|
2550
|
+
file: string,
|
|
2551
|
+
content: string,
|
|
2552
|
+
ts: TypeScriptApi,
|
|
2553
|
+
imports?: SqlImports,
|
|
2554
|
+
): SqlFinding[] {
|
|
2555
|
+
const out: SqlFinding[] = []
|
|
1343
2556
|
const lines = content.split("\n")
|
|
1344
2557
|
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
1345
2558
|
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
@@ -1349,27 +2562,37 @@ export function scanInterpolatedSql(
|
|
|
1349
2562
|
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1350
2563
|
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1351
2564
|
const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
|
|
2565
|
+
const helpers = statementHelpers(ts, source)
|
|
2566
|
+
// A marker counts only in a comment: one inside a string the call passes is data, not a reason.
|
|
2567
|
+
let markerLines: readonly string[] | undefined
|
|
1352
2568
|
const visit = (node: TSApi.Node): void => {
|
|
1353
2569
|
if (ts.isCallExpression(node)) {
|
|
1354
|
-
const
|
|
1355
|
-
|
|
2570
|
+
const helper = ts.isIdentifier(node.expression)
|
|
2571
|
+
? helpers.get(node.expression.text)
|
|
2572
|
+
: undefined
|
|
2573
|
+
const sink = helper?.sink ?? sqlMethod(ts, node)
|
|
2574
|
+
const passed = node.arguments[helper?.position ?? 0]
|
|
2575
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed)
|
|
1356
2576
|
if (sink !== undefined && argument !== undefined) {
|
|
1357
2577
|
const shape = sqlExpressionShape(ts, argument, scope)
|
|
2578
|
+
// A helper's own hatch call already answers for passing it a variable.
|
|
1358
2579
|
const unsafeEscape =
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
if (unsafeEscape ||
|
|
2580
|
+
helper === undefined &&
|
|
2581
|
+
sink.receiver !== undefined &&
|
|
2582
|
+
ALWAYS_UNSAFE.has(sink.method) &&
|
|
2583
|
+
!shape.literal &&
|
|
2584
|
+
!shape.parameterizedTag
|
|
2585
|
+
if (unsafeEscape || isAssembledSql(sink, shape)) {
|
|
1365
2586
|
const index = node.getStart(source)
|
|
1366
2587
|
const line = source.getLineAndCharacterOfPosition(index).line + 1
|
|
1367
|
-
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
2588
|
+
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
|
|
2589
|
+
// placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
|
|
2590
|
+
// NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
|
|
1370
2591
|
// greppable audit trail instead of becoming a second laundering trick.
|
|
1371
|
-
|
|
1372
|
-
|
|
2592
|
+
markerLines ??= literalsBlanked(ts, source, content).split("\n")
|
|
2593
|
+
const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason
|
|
2594
|
+
const site = { file, line, snippet: (lines[line - 1] ?? "").trim() }
|
|
2595
|
+
out.push(reason === undefined ? site : { ...site, expected: reason })
|
|
1373
2596
|
}
|
|
1374
2597
|
}
|
|
1375
2598
|
}
|
|
@@ -1656,7 +2879,7 @@ export function scanServerOnlyImports(
|
|
|
1656
2879
|
// Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
|
|
1657
2880
|
// failure keeps the lexical edge so the security rule fails closed.
|
|
1658
2881
|
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
1659
|
-
if (!
|
|
2882
|
+
if (!isServerOnly(specifier)) continue
|
|
1660
2883
|
const line = lineAt(content, index)
|
|
1661
2884
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
|
|
1662
2885
|
}
|
|
@@ -1673,23 +2896,24 @@ export function scanServerOnlyImports(
|
|
|
1673
2896
|
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
1674
2897
|
// ---------------------------------------------------------------------------------------------------
|
|
1675
2898
|
|
|
1676
|
-
// Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
|
|
1677
|
-
// npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
|
|
1678
|
-
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
1679
|
-
const SERVER_ONLY_SINK =
|
|
1680
|
-
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
|
|
1681
2899
|
// The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
|
|
1682
2900
|
const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
1683
2901
|
// The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
|
|
1684
2902
|
// module opting into the client-leak guard. A resolved file carrying this side-effect import is a sink.
|
|
1685
|
-
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
2903
|
+
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g
|
|
2904
|
+
|
|
2905
|
+
/** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
|
|
2906
|
+
* such as a note on how to opt in, marks nothing. */
|
|
2907
|
+
function hasBackendOnlyMarker(content: string): boolean {
|
|
2908
|
+
if (!content.includes("-only")) return false
|
|
2909
|
+
const positions = codePositionMask(content)
|
|
2910
|
+
for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
|
|
2911
|
+
if (positions[match.index] !== " ") return true
|
|
2912
|
+
}
|
|
2913
|
+
return false
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
|
|
1693
2917
|
const isRelativeSpecifier = (spec: string): boolean =>
|
|
1694
2918
|
spec.startsWith("./") || spec.startsWith("../")
|
|
1695
2919
|
|
|
@@ -1761,19 +2985,39 @@ function dynamicImportEdges(
|
|
|
1761
2985
|
return edges
|
|
1762
2986
|
}
|
|
1763
2987
|
|
|
1764
|
-
/**
|
|
2988
|
+
/** The literal `require("x")` calls in executable code. Pure. */
|
|
2989
|
+
function requireEdges(
|
|
2990
|
+
content: string,
|
|
2991
|
+
positions: string,
|
|
2992
|
+
): Array<{ specifier: string; index: number }> {
|
|
2993
|
+
const edges: Array<{ specifier: string; index: number }> = []
|
|
2994
|
+
const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags)
|
|
2995
|
+
for (let m = re.exec(content); m !== null; m = re.exec(content)) {
|
|
2996
|
+
if (positions[m.index] === " ") continue // inside a comment or a string
|
|
2997
|
+
const specifier = m[2]
|
|
2998
|
+
if (specifier === undefined) continue
|
|
2999
|
+
if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index))) continue
|
|
3000
|
+
edges.push({ specifier, index: m.index })
|
|
3001
|
+
}
|
|
3002
|
+
return edges
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
|
|
3006
|
+
* order. Pure. */
|
|
1765
3007
|
function importEdges(
|
|
1766
3008
|
content: string,
|
|
1767
3009
|
facts?: SourceFacts,
|
|
1768
3010
|
file?: string,
|
|
1769
3011
|
): Array<{ specifier: string; index: number }> {
|
|
1770
|
-
// Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
|
|
1771
3012
|
const positions = codePositionMask(content)
|
|
1772
3013
|
const edges = staticImportEdges(content, facts, file, positions)
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
)
|
|
3014
|
+
// Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
|
|
3015
|
+
const dynamic = content.includes("import(") || /import\s+\(/.test(content)
|
|
3016
|
+
const required = content.includes("require")
|
|
3017
|
+
if (!dynamic && !required) return edges
|
|
3018
|
+
if (dynamic) edges.push(...dynamicImportEdges(content, facts, file, positions))
|
|
3019
|
+
if (required) edges.push(...requireEdges(content, positions))
|
|
3020
|
+
return edges.sort((a, b) => a.index - b.index)
|
|
1777
3021
|
}
|
|
1778
3022
|
|
|
1779
3023
|
/** The runtime imports of a module with the line each sits on, in source order. Pure. */
|
|
@@ -1796,7 +3040,7 @@ export function parseImports(content: string, facts?: SourceFacts, file?: string
|
|
|
1796
3040
|
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1797
3041
|
* itself (it's already the actionable name). */
|
|
1798
3042
|
export function directSinkSpecifier(spec: string): string | undefined {
|
|
1799
|
-
return
|
|
3043
|
+
return isServerOnlySink(spec) ? spec : undefined
|
|
1800
3044
|
}
|
|
1801
3045
|
|
|
1802
3046
|
/** One transitive server-only finding: the route module, the route's offending top-level import (the
|
|
@@ -1805,28 +3049,150 @@ export interface TransitiveServerImportFinding extends ServerImportFinding {
|
|
|
1805
3049
|
/** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
|
|
1806
3050
|
* the route imports the sink directly (same as the regex scan's direct edge). */
|
|
1807
3051
|
readonly chain: readonly string[]
|
|
3052
|
+
/** The project files the chain runs through, as `resolve` named them: the route, each module the
|
|
3053
|
+
* walk entered, and the sink when it is a project file. A change to any of them can make the chain. */
|
|
3054
|
+
readonly modules: readonly string[]
|
|
1808
3055
|
/** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
|
|
1809
3056
|
* so the chain is the honest direct edge rather than a fabricated deeper path. */
|
|
1810
3057
|
readonly fallback: boolean
|
|
1811
3058
|
}
|
|
1812
3059
|
|
|
1813
|
-
/** A resolver from `(fromFile, specifier)` to
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
3060
|
+
/** A resolver from `(fromFile, specifier)` to a project module path, or `undefined` when it names no
|
|
3061
|
+
* project file (a package, a missing file). Abstracted so the BFS is unit-testable with a fake graph (no
|
|
3062
|
+
* real fs). The production resolver follows relative paths, tsconfig `paths` and package.json `imports`. */
|
|
1816
3063
|
export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
|
|
1817
3064
|
/** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
|
|
1818
3065
|
export type ModuleReader = (absPath: string) => string | undefined
|
|
1819
3066
|
|
|
3067
|
+
/** What a server-only chain search reads the project through. */
|
|
3068
|
+
export interface ServerOnlyChainSource {
|
|
3069
|
+
readonly resolve: ModuleResolver
|
|
3070
|
+
readonly read: ModuleReader
|
|
3071
|
+
readonly facts?: SourceFacts | undefined
|
|
3072
|
+
readonly componentReceipts?: ReadonlySet<string>
|
|
3073
|
+
/** Whether a bare specifier `resolve` leaves unanswered names an installed package that declares
|
|
3074
|
+
* `"nifra": { "environment": "backend" }`, which the browser build refuses. */
|
|
3075
|
+
readonly backendPackage?: (fromFile: string, specifier: string) => boolean
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/** A chain from a module to a sink: `[start, ...as-written specifiers, sink]`, and the project files
|
|
3079
|
+
* it runs through (see {@link TransitiveServerImportFinding.modules}). */
|
|
3080
|
+
interface ChainWalk {
|
|
3081
|
+
readonly chain: readonly string[]
|
|
3082
|
+
readonly modules: readonly string[]
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
/** The chain searches of one check, sharing what they learn about each module. */
|
|
3086
|
+
interface ChainGraph {
|
|
3087
|
+
/** The shortest chain from `start` to a sink. */
|
|
3088
|
+
walk(start: string, content: string): ChainWalk | undefined
|
|
3089
|
+
read(abs: string): string | undefined
|
|
3090
|
+
resolve(from: string, specifier: string): string | undefined
|
|
3091
|
+
backendPackage(from: string, specifier: string): boolean
|
|
3092
|
+
readonly componentReceipts: ReadonlySet<string>
|
|
3093
|
+
readonly facts: SourceFacts | undefined
|
|
3094
|
+
}
|
|
3095
|
+
|
|
1820
3096
|
/**
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
3097
|
+
* Every module is read, parsed and resolved at most once, and the chain from a module is searched at
|
|
3098
|
+
* most once, however many routes reach it: per-route searches repeated all of that for every route
|
|
3099
|
+
* that shared a module, which made a check quadratic in the size of a shared graph.
|
|
3100
|
+
*/
|
|
3101
|
+
function chainGraph(source: ServerOnlyChainSource): ChainGraph {
|
|
3102
|
+
const { facts } = source
|
|
3103
|
+
const componentReceipts = source.componentReceipts ?? new Set<string>()
|
|
3104
|
+
const contents = new Map<string, string | undefined>()
|
|
3105
|
+
const imports = new Map<string, readonly string[]>()
|
|
3106
|
+
const resolutions = new Map<string, string | undefined>()
|
|
3107
|
+
const walks = new Map<string, ChainWalk | undefined>()
|
|
3108
|
+
const packages = new Map<string, boolean>()
|
|
3109
|
+
const read = (abs: string): string | undefined => {
|
|
3110
|
+
if (!contents.has(abs)) contents.set(abs, source.read(abs))
|
|
3111
|
+
return contents.get(abs)
|
|
3112
|
+
}
|
|
3113
|
+
const resolve = (from: string, specifier: string): string | undefined => {
|
|
3114
|
+
const key = `${from}\0${specifier}`
|
|
3115
|
+
if (!resolutions.has(key)) resolutions.set(key, source.resolve(from, specifier))
|
|
3116
|
+
return resolutions.get(key)
|
|
3117
|
+
}
|
|
3118
|
+
const backendPackage = (from: string, specifier: string): boolean => {
|
|
3119
|
+
if (source.backendPackage === undefined || /^[./#]/.test(specifier)) return false
|
|
3120
|
+
const key = `${from}\0${specifier}`
|
|
3121
|
+
let found = packages.get(key)
|
|
3122
|
+
if (found === undefined) {
|
|
3123
|
+
found = source.backendPackage(from, specifier)
|
|
3124
|
+
packages.set(key, found)
|
|
3125
|
+
}
|
|
3126
|
+
return found
|
|
3127
|
+
}
|
|
3128
|
+
const importsOf = (abs: string, content: string): readonly string[] => {
|
|
3129
|
+
let found = imports.get(abs)
|
|
3130
|
+
if (found === undefined) {
|
|
3131
|
+
found = parseImports(content, facts, abs)
|
|
3132
|
+
imports.set(abs, found)
|
|
3133
|
+
}
|
|
3134
|
+
return found
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
|
|
3138
|
+
* dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
|
|
3139
|
+
* never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
|
|
3140
|
+
* importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
|
|
3141
|
+
* entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
|
|
3142
|
+
*/
|
|
3143
|
+
const search = (start: string, startContent: string): ChainWalk | undefined => {
|
|
3144
|
+
interface Node extends ChainWalk {
|
|
3145
|
+
readonly abs: string
|
|
3146
|
+
readonly content: string
|
|
3147
|
+
}
|
|
3148
|
+
const seen = new Set<string>([start])
|
|
3149
|
+
let frontier: Node[] = [{ abs: start, content: startContent, chain: [start], modules: [start] }]
|
|
3150
|
+
while (frontier.length > 0) {
|
|
3151
|
+
const next: Node[] = []
|
|
3152
|
+
for (const node of frontier) {
|
|
3153
|
+
const { chain, modules } = node
|
|
3154
|
+
for (const spec of importsOf(node.abs, node.content)) {
|
|
3155
|
+
const sink = directSinkSpecifier(spec)
|
|
3156
|
+
if (sink !== undefined) return { chain: [...chain, sink], modules }
|
|
3157
|
+
const abs = resolve(node.abs, spec)
|
|
3158
|
+
if (abs === undefined) {
|
|
3159
|
+
if (backendPackage(node.abs, spec)) return { chain: [...chain, spec], modules }
|
|
3160
|
+
continue
|
|
3161
|
+
}
|
|
3162
|
+
if (seen.has(abs)) continue
|
|
3163
|
+
if (componentReceipts.has(abs)) continue
|
|
3164
|
+
if (isBackendModule(abs)) return { chain: [...chain, spec], modules: [...modules, abs] }
|
|
3165
|
+
if (SERVER_FN_FILE.test(abs)) continue
|
|
3166
|
+
const content = read(abs)
|
|
3167
|
+
if (content === undefined) continue
|
|
3168
|
+
if (hasBackendOnlyMarker(content)) {
|
|
3169
|
+
return { chain: [...chain, spec], modules: [...modules, abs] }
|
|
3170
|
+
}
|
|
3171
|
+
seen.add(abs)
|
|
3172
|
+
next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] })
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
frontier = next
|
|
3176
|
+
}
|
|
3177
|
+
return undefined
|
|
3178
|
+
}
|
|
3179
|
+
return {
|
|
3180
|
+
walk(start, content) {
|
|
3181
|
+
if (!walks.has(start)) walks.set(start, search(start, content))
|
|
3182
|
+
return walks.get(start)
|
|
3183
|
+
},
|
|
3184
|
+
read,
|
|
3185
|
+
resolve,
|
|
3186
|
+
backendPackage,
|
|
3187
|
+
componentReceipts,
|
|
3188
|
+
facts,
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
/**
|
|
3193
|
+
* The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
|
|
3194
|
+
* specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
|
|
3195
|
+
* graph tests it.
|
|
1830
3196
|
*/
|
|
1831
3197
|
export function walkServerOnlyChain(
|
|
1832
3198
|
routeFile: string,
|
|
@@ -1834,49 +3200,25 @@ export function walkServerOnlyChain(
|
|
|
1834
3200
|
resolve: ModuleResolver,
|
|
1835
3201
|
read: ModuleReader,
|
|
1836
3202
|
facts?: SourceFacts,
|
|
3203
|
+
componentReceipts: ReadonlySet<string> = new Set(),
|
|
1837
3204
|
): readonly string[] | undefined {
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
// since BFS reaches the nearest sink before any deeper one).
|
|
1856
|
-
const sink = directSinkSpecifier(spec)
|
|
1857
|
-
if (sink !== undefined) return [...node.chain, sink]
|
|
1858
|
-
// (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
|
|
1859
|
-
// specifier that isn't a known sink is a leaf - don't follow it into node_modules.
|
|
1860
|
-
if (!isRelativeSpecifier(spec)) continue
|
|
1861
|
-
const abs = resolve(node.abs, spec)
|
|
1862
|
-
if (abs === undefined || seen.has(abs)) continue
|
|
1863
|
-
// (c) Backend code is a sink: the browser build refuses it. A `*.fn.ts` module ships as its
|
|
1864
|
-
// generated stub, so its imports never reach the browser and the walk does not enter it.
|
|
1865
|
-
if (isBackendModule(abs)) return [...node.chain, spec]
|
|
1866
|
-
if (SERVER_FN_FILE.test(abs)) continue
|
|
1867
|
-
const content = read(abs)
|
|
1868
|
-
if (content === undefined) continue // unreadable → can't walk; treat as a leaf
|
|
1869
|
-
// (d) A resolved module that opts into the `backend-only` marker is a sink too.
|
|
1870
|
-
if (BACKEND_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
|
|
1871
|
-
if (visited >= TRANSITIVE_MAX_VISITED) continue
|
|
1872
|
-
visited++
|
|
1873
|
-
seen.add(abs)
|
|
1874
|
-
next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 })
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
frontier = next
|
|
1878
|
-
}
|
|
1879
|
-
return undefined
|
|
3205
|
+
return chainGraph({
|
|
3206
|
+
resolve,
|
|
3207
|
+
read,
|
|
3208
|
+
facts,
|
|
3209
|
+
componentReceipts,
|
|
3210
|
+
}).walk(routeFile, routeContent)?.chain
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
/**
|
|
3214
|
+
* The server-only chain finder for one check: called per browser module, it returns that module's
|
|
3215
|
+
* findings, reusing every module and chain an earlier call already searched.
|
|
3216
|
+
*/
|
|
3217
|
+
export function createServerOnlyChains(
|
|
3218
|
+
source: ServerOnlyChainSource,
|
|
3219
|
+
): (file: string, content: string, browserRoot?: boolean) => TransitiveServerImportFinding[] {
|
|
3220
|
+
const graph = chainGraph(source)
|
|
3221
|
+
return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot)
|
|
1880
3222
|
}
|
|
1881
3223
|
|
|
1882
3224
|
/**
|
|
@@ -1885,7 +3227,9 @@ export function walkServerOnlyChain(
|
|
|
1885
3227
|
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
1886
3228
|
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
1887
3229
|
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
1888
|
-
* de-duplicated to the shortest chain per first-hop specifier.
|
|
3230
|
+
* de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
|
|
3231
|
+
* yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
|
|
3232
|
+
* One check should use {@link createServerOnlyChains}, which shares the work across modules.
|
|
1889
3233
|
*/
|
|
1890
3234
|
export function resolveServerOnlyChains(
|
|
1891
3235
|
file: string,
|
|
@@ -1893,71 +3237,76 @@ export function resolveServerOnlyChains(
|
|
|
1893
3237
|
resolve: ModuleResolver,
|
|
1894
3238
|
read: ModuleReader,
|
|
1895
3239
|
facts?: SourceFacts,
|
|
3240
|
+
componentReceipts: ReadonlySet<string> = new Set(),
|
|
3241
|
+
browserRoot = false,
|
|
1896
3242
|
): TransitiveServerImportFinding[] {
|
|
1897
|
-
|
|
3243
|
+
return createServerOnlyChains({
|
|
3244
|
+
resolve,
|
|
3245
|
+
read,
|
|
3246
|
+
facts,
|
|
3247
|
+
componentReceipts,
|
|
3248
|
+
})(file, content, browserRoot)
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
function serverOnlyChains(
|
|
3252
|
+
graph: ChainGraph,
|
|
3253
|
+
file: string,
|
|
3254
|
+
content: string,
|
|
3255
|
+
browserRoot: boolean,
|
|
3256
|
+
): TransitiveServerImportFinding[] {
|
|
3257
|
+
if (!browserRoot && !isBrowserSource(file)) return []
|
|
1898
3258
|
const lines = content.split("\n")
|
|
1899
3259
|
const out: TransitiveServerImportFinding[] = []
|
|
1900
3260
|
const flaggedSpecifiers = new Set<string>()
|
|
1901
|
-
|
|
1902
|
-
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1903
|
-
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1904
|
-
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
3261
|
+
for (const { specifier, index } of importEdges(content, graph.facts, file)) {
|
|
1905
3262
|
if (flaggedSpecifiers.has(specifier)) continue
|
|
1906
3263
|
const line = lineAt(content, index)
|
|
1907
3264
|
const snippet = (lines[line - 1] ?? "").trim()
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
3265
|
+
const flag = (
|
|
3266
|
+
chain: readonly string[],
|
|
3267
|
+
fallback: boolean,
|
|
3268
|
+
modules: readonly string[] = [file],
|
|
3269
|
+
): void => {
|
|
1911
3270
|
flaggedSpecifiers.add(specifier)
|
|
1912
|
-
out.push({ file, line, snippet, specifier, chain
|
|
3271
|
+
out.push({ file, line, snippet, specifier, chain, modules, fallback })
|
|
3272
|
+
}
|
|
3273
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
|
|
3274
|
+
if (directSinkSpecifier(specifier) !== undefined) {
|
|
3275
|
+
flag([file, specifier], false)
|
|
1913
3276
|
continue
|
|
1914
3277
|
}
|
|
1915
|
-
// (2) A
|
|
1916
|
-
// a sink,
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
flaggedSpecifiers.add(specifier)
|
|
1932
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1933
|
-
continue
|
|
1934
|
-
}
|
|
1935
|
-
if (SERVER_FN_FILE.test(abs)) continue
|
|
1936
|
-
const depContent = read(abs)
|
|
1937
|
-
if (depContent === undefined) continue
|
|
1938
|
-
if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
|
|
1939
|
-
flaggedSpecifiers.add(specifier)
|
|
1940
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1941
|
-
continue
|
|
1942
|
-
}
|
|
1943
|
-
// Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
|
|
1944
|
-
const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
|
|
1945
|
-
if (subChain !== undefined) {
|
|
1946
|
-
flaggedSpecifiers.add(specifier)
|
|
1947
|
-
// subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
|
|
1948
|
-
// route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
|
|
1949
|
-
const chain = [file, specifier, ...subChain.slice(1)]
|
|
1950
|
-
out.push({ file, line, snippet, specifier, chain, fallback: false })
|
|
1951
|
-
continue
|
|
1952
|
-
}
|
|
1953
|
-
// (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
|
|
1954
|
-
// found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
|
|
1955
|
-
// direct edge so we still surface the known-server-only convention rather than going silent.
|
|
1956
|
-
if (SERVER_ONLY.test(specifier)) {
|
|
1957
|
-
flaggedSpecifiers.add(specifier)
|
|
1958
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
|
|
1959
|
-
}
|
|
3278
|
+
// (2) A project import, relative or through an alias: walk from the resolved dependency and, if
|
|
3279
|
+
// it reaches a sink, report the full chain rooted at THIS route's import line.
|
|
3280
|
+
const abs = graph.resolve(file, specifier)
|
|
3281
|
+
if (abs === undefined) {
|
|
3282
|
+
// An installed package that declares itself backend is a sink. Any other name no project file
|
|
3283
|
+
// answers to can't be called server-only, except the `../db` convention, reported as the direct
|
|
3284
|
+
// edge with `fallback` saying the resolve was impossible.
|
|
3285
|
+
if (graph.backendPackage(file, specifier)) flag([file, specifier], false)
|
|
3286
|
+
else if (isServerOnly(specifier)) flag([file, specifier], true)
|
|
3287
|
+
continue
|
|
3288
|
+
}
|
|
3289
|
+
if (graph.componentReceipts.has(abs)) continue
|
|
3290
|
+
// A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
|
|
3291
|
+
if (isBackendModule(abs)) {
|
|
3292
|
+
flag([file, specifier], false, [file, abs])
|
|
3293
|
+
continue
|
|
1960
3294
|
}
|
|
3295
|
+
if (SERVER_FN_FILE.test(abs)) continue
|
|
3296
|
+
const depContent = graph.read(abs)
|
|
3297
|
+
if (depContent === undefined) continue
|
|
3298
|
+
if (hasBackendOnlyMarker(depContent)) {
|
|
3299
|
+
flag([file, specifier], false, [file, abs])
|
|
3300
|
+
continue
|
|
3301
|
+
}
|
|
3302
|
+
const sub = graph.walk(abs, depContent)
|
|
3303
|
+
if (sub !== undefined) {
|
|
3304
|
+
// `sub` starts at the resolved dependency; the route names it by its as-written specifier.
|
|
3305
|
+
flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules])
|
|
3306
|
+
continue
|
|
3307
|
+
}
|
|
3308
|
+
// (3) The `../db` convention with no deeper sink found: still surface the direct edge.
|
|
3309
|
+
if (isServerOnly(specifier)) flag([file, specifier], true)
|
|
1961
3310
|
}
|
|
1962
3311
|
return out
|
|
1963
3312
|
}
|
|
@@ -1990,23 +3339,19 @@ export function scanResponseRoutes(
|
|
|
1990
3339
|
if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
|
|
1991
3340
|
}
|
|
1992
3341
|
const line = lineAt(content, m.index)
|
|
1993
|
-
// A `// nifra-expect raw-response`
|
|
1994
|
-
// be a typed route), silencing this advisory
|
|
1995
|
-
//
|
|
1996
|
-
// leak down
|
|
1997
|
-
//
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
const aboveSuppresses =
|
|
2001
|
-
aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
|
|
2002
|
-
if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
|
|
2003
|
-
out.push({ file, line, snippet: thisLine.trim() })
|
|
3342
|
+
// A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
|
|
3343
|
+
// (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
|
|
3344
|
+
// It counts on the return line or in the comment block directly above, so a TRAILING comment on one
|
|
3345
|
+
// route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
|
|
3346
|
+
// never affects the match); the raw `lines` still carry it.
|
|
3347
|
+
if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined) continue
|
|
3348
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
2004
3349
|
}
|
|
2005
3350
|
return out
|
|
2006
3351
|
}
|
|
2007
3352
|
|
|
2008
|
-
/**
|
|
2009
|
-
const
|
|
3353
|
+
/** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
3354
|
+
const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"]
|
|
2010
3355
|
|
|
2011
3356
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
2012
3357
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
@@ -2054,22 +3399,69 @@ export async function walkSource(
|
|
|
2054
3399
|
readonly includeTests?: boolean
|
|
2055
3400
|
/** Additional project-relative paths to exclude from this scan. */
|
|
2056
3401
|
readonly ignore?: (relativePath: string) => boolean
|
|
3402
|
+
/**
|
|
3403
|
+
* Also visit the other files a build loads: every `.jsx`, `.svelte` and `.vue` file, and `.js`
|
|
3404
|
+
* files in a zone. A `.svelte`/`.vue` file is visited as {@link componentScripts}.
|
|
3405
|
+
*/
|
|
3406
|
+
readonly scripts?: boolean
|
|
2057
3407
|
} = {},
|
|
2058
3408
|
): Promise<void> {
|
|
2059
3409
|
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
|
|
3410
|
+
const pattern =
|
|
3411
|
+
opts.scripts === true
|
|
3412
|
+
? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
|
|
3413
|
+
: "**/*.{ts,tsx,mts,cts}"
|
|
2060
3414
|
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
2061
3415
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
2062
3416
|
const rels: string[] = []
|
|
2063
|
-
for await (const rawRel of new Glob(
|
|
3417
|
+
for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
|
|
2064
3418
|
const rel = normalizeProjectPath(rawRel)
|
|
3419
|
+
// A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
|
|
3420
|
+
if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel)) continue
|
|
2065
3421
|
if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
|
|
2066
3422
|
}
|
|
2067
3423
|
const ignored = await gitIgnored(cwd, rels)
|
|
2068
3424
|
// Glob order is the filesystem's, so findings would come out in a different order on each platform.
|
|
2069
3425
|
for (const rel of rels.sort(codeUnitOrder)) {
|
|
2070
3426
|
if (ignored.has(rel)) continue
|
|
2071
|
-
|
|
3427
|
+
const text = await Bun.file(join(cwd, rel)).text()
|
|
3428
|
+
visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text)
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
const PLAIN_SCRIPT_FILE = /\.[cm]?js$/
|
|
3433
|
+
const ZONED_PATH =
|
|
3434
|
+
/(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/
|
|
3435
|
+
/** A single-file component: its `<script>` blocks are the code a build compiles. */
|
|
3436
|
+
export const COMPONENT_FILE = /\.(?:svelte|vue)$/
|
|
3437
|
+
|
|
3438
|
+
/**
|
|
3439
|
+
* A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
|
|
3440
|
+
* they stand, and every other character blanked to a space with line breaks kept, so a finding's line
|
|
3441
|
+
* and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
|
|
3442
|
+
*/
|
|
3443
|
+
export function componentScripts(text: string): string {
|
|
3444
|
+
const blank = (part: string): string => part.replace(/[^\n]/g, " ")
|
|
3445
|
+
let out = ""
|
|
3446
|
+
let at = 0
|
|
3447
|
+
for (;;) {
|
|
3448
|
+
const open = text.indexOf("<script", at)
|
|
3449
|
+
if (open === -1) break
|
|
3450
|
+
const close = text.indexOf(">", open)
|
|
3451
|
+
if (close === -1) break
|
|
3452
|
+
// `<scripts>` or `<script-x>` is another element.
|
|
3453
|
+
if (!/[\s>]/.test(text[open + 7] ?? "")) {
|
|
3454
|
+
out += blank(text.slice(at, close + 1))
|
|
3455
|
+
at = close + 1
|
|
3456
|
+
continue
|
|
3457
|
+
}
|
|
3458
|
+
const end = text.indexOf("</script", close + 1)
|
|
3459
|
+
const stop = end === -1 ? text.length : end
|
|
3460
|
+
out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop)
|
|
3461
|
+
at = stop
|
|
3462
|
+
if (end === -1) break
|
|
2072
3463
|
}
|
|
3464
|
+
return out + blank(text.slice(at))
|
|
2073
3465
|
}
|
|
2074
3466
|
|
|
2075
3467
|
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|