@nifrajs/cli 4.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- 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 +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -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 +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- 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 +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- 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/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.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 +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -2
- 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 +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- 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 +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -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 +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- 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 +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- 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 +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -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-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.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 +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -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/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.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/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.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 +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -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 +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -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 +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- 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 +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- 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 +218 -44
- 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 +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -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 +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- 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 +94 -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 +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- 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/dev-feed-tool.ts +112 -1
- 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 +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- 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 +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -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
|
|
@@ -264,28 +352,73 @@ export function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefin
|
|
|
264
352
|
return { path, method, start: match.index, end: close + 1 }
|
|
265
353
|
}
|
|
266
354
|
|
|
267
|
-
|
|
355
|
+
const SERVER_CONSTRUCTION = /(?<![.\w])server\s*\(|new\s+Hono(?:<[^>]+>)?\s*\(/
|
|
356
|
+
|
|
357
|
+
function isPotentialBackendSource(
|
|
358
|
+
code: string,
|
|
359
|
+
file?: string,
|
|
360
|
+
project?: ReadonlyMap<string, string>,
|
|
361
|
+
): boolean {
|
|
268
362
|
return (
|
|
269
363
|
code.includes("@nifrajs/core") ||
|
|
270
|
-
|
|
364
|
+
SERVER_CONSTRUCTION.test(code) ||
|
|
271
365
|
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
272
|
-
|
|
366
|
+
(file !== undefined && project !== undefined && callsLocalServerFactory(code, file, project))
|
|
273
367
|
)
|
|
274
368
|
}
|
|
275
369
|
|
|
370
|
+
/**
|
|
371
|
+
* A module that builds its server through a local helper - `newServer().get(...)` with `newServer`
|
|
372
|
+
* imported from a project file that calls `server()` - names neither `@nifrajs/core` nor `server(`
|
|
373
|
+
* itself. One import hop is followed: the imported module must construct a server, so a helper module
|
|
374
|
+
* that merely mentions `@nifrajs/core` (a schema re-export) does not turn every importer into a
|
|
375
|
+
* backend and its `client.get("/x")` calls into routes.
|
|
376
|
+
*/
|
|
377
|
+
function callsLocalServerFactory(
|
|
378
|
+
code: string,
|
|
379
|
+
file: string,
|
|
380
|
+
project: ReadonlyMap<string, string>,
|
|
381
|
+
): boolean {
|
|
382
|
+
for (const [local, binding] of namedImports(code)) {
|
|
383
|
+
if (!new RegExp(`(?<![.\\w$])${local.replaceAll("$", "\\$")}\\s*\\(`).test(code)) continue
|
|
384
|
+
const target = projectModule(project, file, binding.specifier)
|
|
385
|
+
if (target !== undefined && SERVER_CONSTRUCTION.test(stripComments(project.get(target) ?? "")))
|
|
386
|
+
return true
|
|
387
|
+
}
|
|
388
|
+
return false
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Literal route registrations in a file the backend-source gate turned away, so a route the scan
|
|
393
|
+
* could not cover can say where it probably lives. Nothing here is treated as a route.
|
|
394
|
+
*/
|
|
395
|
+
export function scanSkippedRouteText(
|
|
396
|
+
file: string,
|
|
397
|
+
content: string,
|
|
398
|
+
project?: ReadonlyMap<string, string>,
|
|
399
|
+
): StaticRouteFinding[] {
|
|
400
|
+
const code = stripComments(content)
|
|
401
|
+
if (isPotentialBackendSource(code, file, project)) return []
|
|
402
|
+
return [
|
|
403
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
|
|
404
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
|
|
276
408
|
function scanRoutePattern(
|
|
277
409
|
file: string,
|
|
278
410
|
content: string,
|
|
279
411
|
code: string,
|
|
280
412
|
pattern: RegExp,
|
|
281
413
|
facts?: SourceFacts,
|
|
414
|
+
verbs: ReadonlySet<string> = HTTP_VERBS,
|
|
282
415
|
): StaticRouteFinding[] {
|
|
283
416
|
const out: StaticRouteFinding[] = []
|
|
284
417
|
const lines = content.split("\n")
|
|
285
418
|
pattern.lastIndex = 0
|
|
286
419
|
for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
|
|
287
420
|
const method = (m[1] ?? "").toUpperCase()
|
|
288
|
-
if (!
|
|
421
|
+
if (!verbs.has(method)) continue
|
|
289
422
|
const path = parseQuotedLiteral(
|
|
290
423
|
`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
|
|
291
424
|
)
|
|
@@ -306,25 +439,94 @@ function scanRoutePattern(
|
|
|
306
439
|
return out
|
|
307
440
|
}
|
|
308
441
|
|
|
442
|
+
// Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
|
|
443
|
+
// as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
|
|
444
|
+
// is the cost of not tracking parser context.
|
|
445
|
+
const REGEX_AFTER = new Set([
|
|
446
|
+
"(",
|
|
447
|
+
",",
|
|
448
|
+
"=",
|
|
449
|
+
":",
|
|
450
|
+
"[",
|
|
451
|
+
"!",
|
|
452
|
+
"&",
|
|
453
|
+
"|",
|
|
454
|
+
"?",
|
|
455
|
+
"{",
|
|
456
|
+
";",
|
|
457
|
+
"+",
|
|
458
|
+
"-",
|
|
459
|
+
"*",
|
|
460
|
+
"%",
|
|
461
|
+
"<",
|
|
462
|
+
">",
|
|
463
|
+
"~",
|
|
464
|
+
"^",
|
|
465
|
+
])
|
|
466
|
+
const REGEX_AFTER_WORD = new Set([
|
|
467
|
+
"return",
|
|
468
|
+
"typeof",
|
|
469
|
+
"case",
|
|
470
|
+
"do",
|
|
471
|
+
"else",
|
|
472
|
+
"in",
|
|
473
|
+
"of",
|
|
474
|
+
"new",
|
|
475
|
+
"delete",
|
|
476
|
+
"void",
|
|
477
|
+
"throw",
|
|
478
|
+
"yield",
|
|
479
|
+
"await",
|
|
480
|
+
"instanceof",
|
|
481
|
+
])
|
|
482
|
+
const isWordChar = (c: string | undefined): boolean => c !== undefined && /[\w$]/.test(c)
|
|
483
|
+
|
|
484
|
+
/** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
|
|
485
|
+
* literal does not close on its line - then the slash was division after all. */
|
|
486
|
+
function regexLiteralEnd(src: string, i: number): number | undefined {
|
|
487
|
+
const n = src.length
|
|
488
|
+
let j = i + 1
|
|
489
|
+
let inClass = false
|
|
490
|
+
while (j < n) {
|
|
491
|
+
const c = src[j]
|
|
492
|
+
if (c === "\n" || c === "\r") return undefined
|
|
493
|
+
if (c === "\\") {
|
|
494
|
+
j += 2
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
if (inClass) {
|
|
498
|
+
if (c === "]") inClass = false
|
|
499
|
+
} else if (c === "[") {
|
|
500
|
+
inClass = true
|
|
501
|
+
} else if (c === "/") {
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
j++
|
|
505
|
+
}
|
|
506
|
+
if (j >= n) return undefined
|
|
507
|
+
j++
|
|
508
|
+
while (j < n && isWordChar(src[j])) j++
|
|
509
|
+
return j
|
|
510
|
+
}
|
|
511
|
+
|
|
309
512
|
/**
|
|
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}).
|
|
513
|
+
* Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
|
|
514
|
+
* single/double-quoted strings and every delimiter) while preserving every offset and newline.
|
|
515
|
+
* A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
|
|
516
|
+
* an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
|
|
517
|
+
* the string state that follows.
|
|
318
518
|
*/
|
|
319
|
-
|
|
519
|
+
function maskSource(src: string, quoted: "keep" | "blank"): string {
|
|
320
520
|
const out = src.split("")
|
|
321
521
|
const n = src.length
|
|
322
|
-
let i = 0
|
|
323
522
|
const blank = (a: number, b: number): void => {
|
|
324
523
|
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
325
524
|
}
|
|
525
|
+
// The last significant code token: a punctuator character, or a whole identifier/keyword.
|
|
526
|
+
let previous = ""
|
|
527
|
+
let i = 0
|
|
326
528
|
while (i < n) {
|
|
327
|
-
const c = src
|
|
529
|
+
const c = src.charAt(i)
|
|
328
530
|
const d = src[i + 1]
|
|
329
531
|
if (c === "/" && d === "/") {
|
|
330
532
|
let j = i + 2
|
|
@@ -343,62 +545,66 @@ export function stripComments(src: string): string {
|
|
|
343
545
|
if (src[j] === "\\") j++
|
|
344
546
|
j++
|
|
345
547
|
}
|
|
346
|
-
|
|
548
|
+
if (quoted === "keep")
|
|
549
|
+
blank(i + 1, j) // keep the backticks, blank the code-as-text inside
|
|
550
|
+
else blank(i, Math.min(j + 1, n))
|
|
347
551
|
i = j + 1
|
|
552
|
+
previous = "`"
|
|
348
553
|
} else if (c === "'" || c === '"') {
|
|
349
554
|
let j = i + 1
|
|
350
555
|
while (j < n && src[j] !== c) {
|
|
351
556
|
if (src[j] === "\\") j++ // skip the escaped char
|
|
352
557
|
j++
|
|
353
558
|
}
|
|
559
|
+
if (quoted === "blank") blank(i, Math.min(j + 1, n))
|
|
354
560
|
i = j + 1
|
|
561
|
+
previous = c
|
|
562
|
+
} else if (
|
|
563
|
+
c === "/" &&
|
|
564
|
+
(previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))
|
|
565
|
+
) {
|
|
566
|
+
const end = regexLiteralEnd(src, i)
|
|
567
|
+
if (end === undefined) {
|
|
568
|
+
previous = c
|
|
569
|
+
i++
|
|
570
|
+
} else {
|
|
571
|
+
if (quoted === "keep") blank(i + 1, end)
|
|
572
|
+
else blank(i, end)
|
|
573
|
+
previous = "/regex"
|
|
574
|
+
i = end
|
|
575
|
+
}
|
|
576
|
+
} else if (isWordChar(c)) {
|
|
577
|
+
let j = i + 1
|
|
578
|
+
while (j < n && isWordChar(src[j])) j++
|
|
579
|
+
previous = src.slice(i, j)
|
|
580
|
+
i = j
|
|
355
581
|
} else {
|
|
582
|
+
if (!/\s/.test(c)) previous = c
|
|
356
583
|
i++
|
|
357
584
|
}
|
|
358
585
|
}
|
|
359
586
|
return out.join("")
|
|
360
587
|
}
|
|
361
588
|
|
|
362
|
-
/**
|
|
589
|
+
/**
|
|
590
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
591
|
+
* - `//` line comments and block comments;
|
|
592
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
593
|
+
* generators), never a real statement to lint.
|
|
594
|
+
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
|
|
595
|
+
* literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
|
|
596
|
+
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
597
|
+
*/
|
|
598
|
+
export function stripComments(src: string): string {
|
|
599
|
+
return maskSource(src, "keep")
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
|
|
363
603
|
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
364
604
|
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
365
605
|
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
366
606
|
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("")
|
|
607
|
+
return maskSource(src, "blank")
|
|
402
608
|
}
|
|
403
609
|
|
|
404
610
|
const firstArgumentIndex = (content: string, callEnd: number): number => {
|
|
@@ -502,8 +708,8 @@ export function scanFetchText(
|
|
|
502
708
|
file: string,
|
|
503
709
|
content: string,
|
|
504
710
|
externalMounts: readonly string[] = [],
|
|
505
|
-
):
|
|
506
|
-
const out:
|
|
711
|
+
): TypedClientFinding[] {
|
|
712
|
+
const out: TypedClientFinding[] = []
|
|
507
713
|
const lines = content.split("\n")
|
|
508
714
|
const code = codePositionMask(content)
|
|
509
715
|
GLOBAL_FETCH_CALL.lastIndex = 0
|
|
@@ -516,8 +722,7 @@ export function scanFetchText(
|
|
|
516
722
|
if (hasLiteralNifraDataHeader(content, openParen)) continue
|
|
517
723
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
518
724
|
continue
|
|
519
|
-
|
|
520
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
725
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
|
|
521
726
|
}
|
|
522
727
|
return out
|
|
523
728
|
}
|
|
@@ -540,8 +745,8 @@ export function scanStreamText(
|
|
|
540
745
|
file: string,
|
|
541
746
|
content: string,
|
|
542
747
|
externalMounts: readonly string[] = [],
|
|
543
|
-
):
|
|
544
|
-
const out:
|
|
748
|
+
): TypedClientFinding[] {
|
|
749
|
+
const out: TypedClientFinding[] = []
|
|
545
750
|
const lines = content.split("\n")
|
|
546
751
|
const code = codePositionMask(content)
|
|
547
752
|
STREAM_CONSTRUCT.lastIndex = 0
|
|
@@ -552,8 +757,7 @@ export function scanStreamText(
|
|
|
552
757
|
if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
|
|
553
758
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
554
759
|
continue
|
|
555
|
-
|
|
556
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
760
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)))
|
|
557
761
|
}
|
|
558
762
|
return out
|
|
559
763
|
}
|
|
@@ -563,13 +767,210 @@ export function scanStaticRouteText(
|
|
|
563
767
|
file: string,
|
|
564
768
|
content: string,
|
|
565
769
|
facts?: SourceFacts,
|
|
770
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
771
|
+
project?: ReadonlyMap<string, string>,
|
|
566
772
|
): StaticRouteFinding[] {
|
|
567
773
|
const code = stripComments(content)
|
|
568
|
-
if (!isPotentialBackendSource(code)) return []
|
|
774
|
+
if (!isPotentialBackendSource(code, file, project)) return []
|
|
569
775
|
return [
|
|
570
776
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
571
777
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
572
778
|
...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
|
|
779
|
+
...scanRouteArguments(file, content, facts, project).routes,
|
|
780
|
+
].sort(bySite)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** A module-level `const`: its value when the initializer is one string literal, else `undefined`. */
|
|
784
|
+
interface ModuleConstant {
|
|
785
|
+
readonly exported: boolean
|
|
786
|
+
readonly value: string | undefined
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
|
|
790
|
+
function literalEnd(text: string): number {
|
|
791
|
+
const quote = text[0]
|
|
792
|
+
for (let i = 1; i < text.length; i++) {
|
|
793
|
+
if (text[i] === "\\") i++
|
|
794
|
+
else if (text[i] === quote) return i + 1
|
|
795
|
+
}
|
|
796
|
+
return -1
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** The index after the whitespace run at `from`. */
|
|
800
|
+
function skipSpace(text: string, from: number): number {
|
|
801
|
+
let i = from
|
|
802
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++
|
|
803
|
+
return i
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
|
|
808
|
+
* indented, never stands in for a module-level one. The shape is read from the comment-stripped
|
|
809
|
+
* line; the literal itself from the source line, since stripping blanks template contents.
|
|
810
|
+
*/
|
|
811
|
+
function moduleConstantOn(code: string, source: string): [string, ModuleConstant] | undefined {
|
|
812
|
+
const exported = code.startsWith("export ")
|
|
813
|
+
let i = exported ? skipSpace(code, 7) : 0
|
|
814
|
+
if (!code.startsWith("const ", i)) return undefined
|
|
815
|
+
i = skipSpace(code, i + 6)
|
|
816
|
+
const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0]
|
|
817
|
+
if (name === undefined) return undefined
|
|
818
|
+
i = skipSpace(code, i + name.length)
|
|
819
|
+
if (code[i] === ":") i = code.indexOf("=", i)
|
|
820
|
+
if (i === -1 || code[i] !== "=" || code[i + 1] === "=") return undefined
|
|
821
|
+
i = skipSpace(code, i + 1)
|
|
822
|
+
const opener = code[i]
|
|
823
|
+
const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1
|
|
824
|
+
const tail = end === -1 ? undefined : code.slice(i + end).trim()
|
|
825
|
+
const literal = source.slice(i, i + end)
|
|
826
|
+
const value =
|
|
827
|
+
tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
|
|
828
|
+
? undefined
|
|
829
|
+
: opener === "`"
|
|
830
|
+
? /[$\\]/.test(literal)
|
|
831
|
+
? undefined
|
|
832
|
+
: literal.slice(1, -1)
|
|
833
|
+
: parseQuotedLiteral(literal)
|
|
834
|
+
return [name, { exported, value }]
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function moduleConstants(code: string, source: string): ReadonlyMap<string, ModuleConstant> {
|
|
838
|
+
const constants = new Map<string, ModuleConstant>()
|
|
839
|
+
const sourceLines = source.split("\n")
|
|
840
|
+
code.split("\n").forEach((line, index) => {
|
|
841
|
+
const constant = moduleConstantOn(line, sourceLines[index] ?? "")
|
|
842
|
+
if (constant !== undefined) constants.set(constant[0], constant[1])
|
|
843
|
+
})
|
|
844
|
+
return constants
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Local name → the export it imports and from where, for value imports only. */
|
|
848
|
+
function namedImports(
|
|
849
|
+
code: string,
|
|
850
|
+
): ReadonlyMap<string, { readonly specifier: string; readonly name: string }> {
|
|
851
|
+
const imports = new Map<string, { readonly specifier: string; readonly name: string }>()
|
|
852
|
+
NAMED_IMPORTS.lastIndex = 0
|
|
853
|
+
for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
|
|
854
|
+
if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6))) continue
|
|
855
|
+
for (const element of (m[1] ?? "").split(",")) {
|
|
856
|
+
const parts = element.trim().split(/\s+/)
|
|
857
|
+
if (parts[0] === "type" || parts[0] === undefined || parts[0] === "") continue
|
|
858
|
+
const local = parts[1] === "as" ? parts[2] : parts[0]
|
|
859
|
+
if (local !== undefined) imports.set(local, { specifier: m[3] ?? "", name: parts[0] })
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return imports
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
|
|
866
|
+
function projectModule(
|
|
867
|
+
project: ReadonlyMap<string, string>,
|
|
868
|
+
fromFile: string,
|
|
869
|
+
specifier: string,
|
|
870
|
+
): string | undefined {
|
|
871
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
|
|
872
|
+
const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier))
|
|
873
|
+
const stem = base.replace(/\.(?:m?js|jsx)$/, "")
|
|
874
|
+
return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) =>
|
|
875
|
+
project.has(candidate),
|
|
876
|
+
)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Route registrations whose path is a name or a template rather than a quoted literal. A name bound
|
|
881
|
+
* to a module-level string `const`, here or imported from a project file, and a template with no
|
|
882
|
+
* substitution resolve to routes; any other template, and a module-level or imported name nifra
|
|
883
|
+
* cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
|
|
884
|
+
* nothing marks such a call as a route rather than a `map.get(key, fallback)`.
|
|
885
|
+
*/
|
|
886
|
+
function scanRouteArguments(
|
|
887
|
+
file: string,
|
|
888
|
+
content: string,
|
|
889
|
+
facts: SourceFacts | undefined,
|
|
890
|
+
project: ReadonlyMap<string, string> | undefined,
|
|
891
|
+
verbs: ReadonlySet<string> = HTTP_VERBS,
|
|
892
|
+
): { routes: StaticRouteFinding[]; unresolved: UnresolvedRoutePathFinding[] } {
|
|
893
|
+
const routes: StaticRouteFinding[] = []
|
|
894
|
+
const unresolved: UnresolvedRoutePathFinding[] = []
|
|
895
|
+
const code = stripComments(content)
|
|
896
|
+
if (!isPotentialBackendSource(code, file, project)) return { routes, unresolved }
|
|
897
|
+
const lines = content.split("\n")
|
|
898
|
+
const constants = moduleConstants(code, content)
|
|
899
|
+
const imports = namedImports(code)
|
|
900
|
+
const importedFiles = new Map<string, ReadonlyMap<string, ModuleConstant>>()
|
|
901
|
+
const imported = (specifier: string, name: string): ModuleConstant | undefined => {
|
|
902
|
+
const target = project === undefined ? undefined : projectModule(project, file, specifier)
|
|
903
|
+
if (target === undefined) return undefined
|
|
904
|
+
let found = importedFiles.get(target)
|
|
905
|
+
if (found === undefined) {
|
|
906
|
+
const text = project?.get(target) ?? ""
|
|
907
|
+
found = moduleConstants(stripComments(text), text)
|
|
908
|
+
importedFiles.set(target, found)
|
|
909
|
+
}
|
|
910
|
+
const constant = found.get(name)
|
|
911
|
+
return constant?.exported === true ? constant : undefined
|
|
912
|
+
}
|
|
913
|
+
ROUTE_ARGUMENT.lastIndex = 0
|
|
914
|
+
for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
|
|
915
|
+
const method = (m[1] ?? "").toUpperCase()
|
|
916
|
+
if (!verbs.has(method)) continue
|
|
917
|
+
const at = m.index + m[0].lastIndexOf(m[2] ?? "")
|
|
918
|
+
const argument = content.slice(at, at + (m[2] ?? "").length)
|
|
919
|
+
let path: string | undefined
|
|
920
|
+
if (argument.startsWith("`")) {
|
|
921
|
+
path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1)
|
|
922
|
+
} else {
|
|
923
|
+
const binding = imports.get(argument)
|
|
924
|
+
const constant =
|
|
925
|
+
binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name)
|
|
926
|
+
if (binding === undefined && constant === undefined) continue
|
|
927
|
+
path = constant?.value
|
|
928
|
+
}
|
|
929
|
+
// A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
|
|
930
|
+
if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) continue
|
|
931
|
+
if (facts !== undefined) {
|
|
932
|
+
const source = facts.parse(file, content)
|
|
933
|
+
if (
|
|
934
|
+
source !== undefined &&
|
|
935
|
+
facts.isRouteArgumentAt(source, m.index, method, argument) !== true
|
|
936
|
+
)
|
|
937
|
+
continue
|
|
938
|
+
}
|
|
939
|
+
const line = lineAt(content, m.index)
|
|
940
|
+
const snippet = (lines[line - 1] ?? "").trim()
|
|
941
|
+
if (path !== undefined) routes.push({ file, line, snippet, method, path })
|
|
942
|
+
else unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) })
|
|
943
|
+
}
|
|
944
|
+
return { routes, unresolved }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
|
|
948
|
+
export function scanUnresolvedRoutePaths(
|
|
949
|
+
file: string,
|
|
950
|
+
content: string,
|
|
951
|
+
project?: ReadonlyMap<string, string>,
|
|
952
|
+
facts?: SourceFacts,
|
|
953
|
+
): UnresolvedRoutePathFinding[] {
|
|
954
|
+
return [
|
|
955
|
+
...scanRouteArguments(file, content, facts, project).unresolved,
|
|
956
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
|
|
957
|
+
].sort(bySite)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
|
|
961
|
+
export function scanStaticSocketText(
|
|
962
|
+
file: string,
|
|
963
|
+
content: string,
|
|
964
|
+
facts?: SourceFacts,
|
|
965
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
966
|
+
project?: ReadonlyMap<string, string>,
|
|
967
|
+
): StaticRouteFinding[] {
|
|
968
|
+
const code = stripComments(content)
|
|
969
|
+
if (!isPotentialBackendSource(code, file, project)) return []
|
|
970
|
+
return [
|
|
971
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
|
|
972
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
|
|
973
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
|
|
573
974
|
].sort(bySite)
|
|
574
975
|
}
|
|
575
976
|
|
|
@@ -660,6 +1061,25 @@ const SQL_METHODS = new Set([
|
|
|
660
1061
|
const SQL_KEYWORD =
|
|
661
1062
|
/\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
1063
|
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
|
|
1064
|
+
/** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
|
|
1065
|
+
* `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
|
|
1066
|
+
* calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
|
|
1067
|
+
const DRIVER_READ_METHODS = new Set([
|
|
1068
|
+
"all",
|
|
1069
|
+
"get",
|
|
1070
|
+
"each",
|
|
1071
|
+
"any",
|
|
1072
|
+
"one",
|
|
1073
|
+
"none",
|
|
1074
|
+
"many",
|
|
1075
|
+
"oneOrNone",
|
|
1076
|
+
"manyOrNone",
|
|
1077
|
+
"result",
|
|
1078
|
+
"multi",
|
|
1079
|
+
"multiResult",
|
|
1080
|
+
])
|
|
1081
|
+
const STATEMENT_START =
|
|
1082
|
+
/^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i
|
|
663
1083
|
|
|
664
1084
|
interface SqlExpressionShape {
|
|
665
1085
|
readonly staticText: string
|
|
@@ -952,11 +1372,12 @@ const CONST_RESOLUTION_DEPTH = 5
|
|
|
952
1372
|
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
953
1373
|
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
954
1374
|
* 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
|
-
*
|
|
1375
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
|
|
1376
|
+
* resolves (the nearest binding: function or block scope, then module scope; recursively,
|
|
1377
|
+
* depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
|
|
1378
|
+
* `scope.imports` is configured - a module `const` imported by name from another in-project module.
|
|
1379
|
+
* Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
|
|
1380
|
+
* bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
|
|
960
1381
|
*/
|
|
961
1382
|
function resolveStaticSqlText(
|
|
962
1383
|
ts: TypeScriptApi,
|
|
@@ -976,7 +1397,9 @@ function resolveStaticSqlText(
|
|
|
976
1397
|
if (ts.isTemplateExpression(node)) {
|
|
977
1398
|
let text = node.head.text
|
|
978
1399
|
for (const span of node.templateSpans) {
|
|
979
|
-
const part =
|
|
1400
|
+
const part =
|
|
1401
|
+
placeholderNumber(ts, text, span.expression, depth) ??
|
|
1402
|
+
resolveStaticSqlText(ts, span.expression, scope, depth + 1)
|
|
980
1403
|
if (part === undefined) return undefined
|
|
981
1404
|
text += part + span.literal.text
|
|
982
1405
|
}
|
|
@@ -988,7 +1411,11 @@ function resolveStaticSqlText(
|
|
|
988
1411
|
if (whenTrue === undefined || whenFalse === undefined) return undefined
|
|
989
1412
|
return `${whenTrue} ${whenFalse}`
|
|
990
1413
|
}
|
|
1414
|
+
if (ts.isCallExpression(node)) return resolveStaticJoin(ts, node, scope, depth)
|
|
991
1415
|
if (ts.isIdentifier(node)) {
|
|
1416
|
+
const local = localConstInitializer(ts, node)
|
|
1417
|
+
if (local === "shadow") return undefined
|
|
1418
|
+
if (local !== undefined) return resolveStaticSqlText(ts, local, scope, depth + 1)
|
|
992
1419
|
const initializer = scope.module.consts.get(node.text)
|
|
993
1420
|
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
994
1421
|
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
@@ -1017,76 +1444,553 @@ function resolveStaticSqlText(
|
|
|
1017
1444
|
return undefined
|
|
1018
1445
|
}
|
|
1019
1446
|
|
|
1020
|
-
/**
|
|
1021
|
-
*
|
|
1022
|
-
|
|
1447
|
+
/**
|
|
1448
|
+
* The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
|
|
1449
|
+
* nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
|
|
1450
|
+
* when no scope below the module binds it - the module-scope lookup applies then.
|
|
1451
|
+
*/
|
|
1452
|
+
function localConstInitializer(
|
|
1023
1453
|
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
|
|
1454
|
+
node: TSApi.Identifier,
|
|
1455
|
+
): TSApi.Expression | "shadow" | undefined {
|
|
1456
|
+
const source = node.getSourceFile()
|
|
1457
|
+
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1458
|
+
if (ts.isSourceFile(scope)) return undefined
|
|
1459
|
+
const binding = directBinding(ts, source, scope, node.text)
|
|
1460
|
+
if (binding === "shadow") return "shadow"
|
|
1461
|
+
if (binding !== undefined) return binding.constant ? binding.initializer : "shadow"
|
|
1035
1462
|
}
|
|
1036
|
-
return
|
|
1463
|
+
return undefined
|
|
1037
1464
|
}
|
|
1038
1465
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1466
|
+
const unwrapExpression = (ts: TypeScriptApi, node: TSApi.Expression): TSApi.Expression => {
|
|
1467
|
+
let current = node
|
|
1468
|
+
while (
|
|
1469
|
+
ts.isParenthesizedExpression(current) ||
|
|
1470
|
+
ts.isAsExpression(current) ||
|
|
1471
|
+
ts.isSatisfiesExpression(current)
|
|
1472
|
+
) {
|
|
1473
|
+
current = current.expression
|
|
1474
|
+
}
|
|
1475
|
+
return current
|
|
1476
|
+
}
|
|
1044
1477
|
|
|
1045
|
-
/**
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1478
|
+
/** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
|
|
1479
|
+
function frozenArrayLiteral(
|
|
1480
|
+
ts: TypeScriptApi,
|
|
1481
|
+
node: TSApi.Expression,
|
|
1482
|
+
): TSApi.ArrayLiteralExpression | undefined {
|
|
1483
|
+
const call = unwrapExpression(ts, node)
|
|
1484
|
+
if (!ts.isCallExpression(call) || call.arguments.length !== 1) return undefined
|
|
1485
|
+
const callee = call.expression
|
|
1486
|
+
if (
|
|
1487
|
+
!ts.isPropertyAccessExpression(callee) ||
|
|
1488
|
+
callee.name.text !== "freeze" ||
|
|
1489
|
+
!ts.isIdentifier(callee.expression) ||
|
|
1490
|
+
callee.expression.text !== "Object" ||
|
|
1491
|
+
isShadowedAt(ts, callee.expression, "Object")
|
|
1492
|
+
) {
|
|
1493
|
+
return undefined
|
|
1494
|
+
}
|
|
1495
|
+
const array = unwrapExpression(ts, call.arguments[0]!)
|
|
1496
|
+
return ts.isArrayLiteralExpression(array) ? array : undefined
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const ARRAY_READS: ReadonlySet<string> = new Set(["join", "includes", "indexOf"])
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
|
|
1503
|
+
* `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
|
|
1504
|
+
* away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
|
|
1505
|
+
* too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
|
|
1506
|
+
* same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
|
|
1507
|
+
*/
|
|
1508
|
+
function onlyReadsArray(
|
|
1509
|
+
ts: TypeScriptApi,
|
|
1510
|
+
source: TSApi.SourceFile,
|
|
1511
|
+
name: string,
|
|
1512
|
+
declaration: TSApi.Node,
|
|
1513
|
+
pushed?: TSApi.Expression[],
|
|
1514
|
+
): boolean {
|
|
1515
|
+
let ok = true
|
|
1051
1516
|
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
|
-
|
|
1517
|
+
if (!ok) return
|
|
1518
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
|
|
1519
|
+
const access = node.parent
|
|
1520
|
+
const call =
|
|
1521
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1522
|
+
access.expression === node &&
|
|
1523
|
+
ts.isCallExpression(access.parent) &&
|
|
1524
|
+
access.parent.expression === access
|
|
1525
|
+
? access.parent
|
|
1526
|
+
: undefined
|
|
1527
|
+
if (
|
|
1528
|
+
pushed !== undefined &&
|
|
1529
|
+
call !== undefined &&
|
|
1530
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1531
|
+
access.name.text === "push"
|
|
1532
|
+
) {
|
|
1533
|
+
if (call.arguments.some((argument) => ts.isSpreadElement(argument))) ok = false
|
|
1534
|
+
else pushed.push(...call.arguments)
|
|
1535
|
+
// An argument can mention the array again; those uses are checked as well.
|
|
1536
|
+
for (const argument of call.arguments) visit(argument)
|
|
1537
|
+
return
|
|
1538
|
+
}
|
|
1539
|
+
const read =
|
|
1540
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1541
|
+
access.expression === node &&
|
|
1542
|
+
(access.name.text === "length"
|
|
1543
|
+
? !isWrittenTo(ts, access)
|
|
1544
|
+
: ARRAY_READS.has(access.name.text) && call !== undefined)
|
|
1545
|
+
if (!read) ok = false
|
|
1071
1546
|
return
|
|
1072
1547
|
}
|
|
1073
1548
|
ts.forEachChild(node, visit)
|
|
1074
1549
|
}
|
|
1075
1550
|
visit(source)
|
|
1076
|
-
return
|
|
1551
|
+
return ok
|
|
1077
1552
|
}
|
|
1078
1553
|
|
|
1079
|
-
/**
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1554
|
+
/** `node` as the target of an assignment, `++`/`--`, or `delete`. */
|
|
1555
|
+
function isWrittenTo(ts: TypeScriptApi, node: TSApi.Node): boolean {
|
|
1556
|
+
const parent = node.parent
|
|
1557
|
+
if (
|
|
1558
|
+
ts.isBinaryExpression(parent) &&
|
|
1559
|
+
parent.left === node &&
|
|
1560
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1561
|
+
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
1562
|
+
) {
|
|
1563
|
+
return true
|
|
1564
|
+
}
|
|
1565
|
+
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
|
|
1566
|
+
return (
|
|
1567
|
+
parent.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1568
|
+
parent.operator === ts.SyntaxKind.MinusMinusToken
|
|
1569
|
+
)
|
|
1570
|
+
}
|
|
1571
|
+
return ts.isDeleteExpression(parent)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* The element texts of an array `receiver` names, when the array provably still holds them: an
|
|
1576
|
+
* inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
|
|
1577
|
+
* literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
|
|
1578
|
+
* is type-only and proves nothing.
|
|
1579
|
+
*/
|
|
1580
|
+
function staticArrayItems(
|
|
1581
|
+
ts: TypeScriptApi,
|
|
1582
|
+
receiver: TSApi.Expression,
|
|
1583
|
+
scope: SqlScope,
|
|
1584
|
+
depth: number,
|
|
1585
|
+
): readonly string[] | undefined {
|
|
1586
|
+
const node = unwrapExpression(ts, receiver)
|
|
1587
|
+
let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node)
|
|
1588
|
+
let itemScope = scope
|
|
1589
|
+
const pushed: TSApi.Expression[] = []
|
|
1590
|
+
if (array === undefined && ts.isIdentifier(node)) {
|
|
1591
|
+
const local = localConstInitializer(ts, node)
|
|
1592
|
+
if (local === "shadow") return undefined
|
|
1593
|
+
let initializer = local
|
|
1594
|
+
let exported = false
|
|
1595
|
+
if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
|
|
1596
|
+
initializer = scope.module.consts.get(node.text)
|
|
1597
|
+
exported = [...scope.module.localExports.values()].includes(node.text)
|
|
1598
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
1599
|
+
if (edge !== undefined && scope.imports !== undefined) {
|
|
1600
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
|
|
1601
|
+
const found =
|
|
1602
|
+
target === undefined
|
|
1603
|
+
? undefined
|
|
1604
|
+
: resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
|
|
1605
|
+
if (found === undefined) return undefined
|
|
1606
|
+
// Another module's array is accepted only frozen: its own file may mutate it.
|
|
1607
|
+
array = frozenArrayLiteral(ts, found.expression)
|
|
1608
|
+
itemScope = { module: found.scope, imports: scope.imports }
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (array === undefined && initializer !== undefined) {
|
|
1612
|
+
array = frozenArrayLiteral(ts, initializer)
|
|
1613
|
+
const literal = unwrapExpression(ts, initializer)
|
|
1614
|
+
if (
|
|
1615
|
+
array === undefined &&
|
|
1616
|
+
!exported &&
|
|
1617
|
+
ts.isArrayLiteralExpression(literal) &&
|
|
1618
|
+
ts.isVariableDeclaration(initializer.parent) &&
|
|
1619
|
+
onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)
|
|
1620
|
+
) {
|
|
1621
|
+
array = literal
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (array === undefined) return undefined
|
|
1626
|
+
const items: string[] = []
|
|
1627
|
+
// What the array may hold: its literal elements, and anything pushed onto it in any order.
|
|
1628
|
+
for (const element of [...array.elements, ...pushed]) {
|
|
1629
|
+
if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return undefined
|
|
1630
|
+
const text = resolveStaticSqlText(ts, element, itemScope, depth + 1)
|
|
1631
|
+
if (text === undefined) return undefined
|
|
1632
|
+
items.push(text)
|
|
1633
|
+
}
|
|
1634
|
+
return items
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
|
|
1638
|
+
function resolveStaticJoin(
|
|
1639
|
+
ts: TypeScriptApi,
|
|
1640
|
+
call: TSApi.CallExpression,
|
|
1641
|
+
scope: SqlScope,
|
|
1642
|
+
depth: number,
|
|
1643
|
+
): string | undefined {
|
|
1644
|
+
const callee = call.expression
|
|
1645
|
+
if (
|
|
1646
|
+
!ts.isPropertyAccessExpression(callee) ||
|
|
1647
|
+
callee.questionDotToken !== undefined ||
|
|
1648
|
+
callee.name.text !== "join" ||
|
|
1649
|
+
call.arguments.length > 1
|
|
1650
|
+
) {
|
|
1651
|
+
return undefined
|
|
1652
|
+
}
|
|
1653
|
+
const separatorArg = call.arguments[0]
|
|
1654
|
+
const separator =
|
|
1655
|
+
separatorArg === undefined
|
|
1656
|
+
? ","
|
|
1657
|
+
: ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
|
|
1658
|
+
? separatorArg.text
|
|
1659
|
+
: undefined
|
|
1660
|
+
if (separator === undefined) return undefined
|
|
1661
|
+
const items =
|
|
1662
|
+
staticArrayItems(ts, callee.expression, scope, depth + 1) ??
|
|
1663
|
+
mappedItem(ts, callee.expression, scope, depth + 1)
|
|
1664
|
+
return items?.join(separator)
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
|
|
1669
|
+
* for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
|
|
1670
|
+
* many items there are is runtime data, what each one says is not, so one item stands for all.
|
|
1671
|
+
*/
|
|
1672
|
+
function mappedItem(
|
|
1673
|
+
ts: TypeScriptApi,
|
|
1674
|
+
receiver: TSApi.Expression,
|
|
1675
|
+
scope: SqlScope,
|
|
1676
|
+
depth: number,
|
|
1677
|
+
): readonly string[] | undefined {
|
|
1678
|
+
const call = unwrapExpression(ts, receiver)
|
|
1679
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
|
|
1680
|
+
return undefined
|
|
1681
|
+
// Only these return what the callback returns; `filter` and the rest hand back the elements.
|
|
1682
|
+
const method = call.expression.name.text
|
|
1683
|
+
const argument = indexCallbackArgument(ts, call)
|
|
1684
|
+
if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
|
|
1685
|
+
return undefined
|
|
1686
|
+
const fn = call.arguments[argument]
|
|
1687
|
+
if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return undefined
|
|
1688
|
+
const only =
|
|
1689
|
+
ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined
|
|
1690
|
+
const body = ts.isBlock(fn.body)
|
|
1691
|
+
? only !== undefined && ts.isReturnStatement(only)
|
|
1692
|
+
? only.expression
|
|
1693
|
+
: undefined
|
|
1694
|
+
: fn.body
|
|
1695
|
+
const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1)
|
|
1696
|
+
return text === undefined ? undefined : [text]
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** Array methods whose callback receives the element's index, and where in the callback it is. */
|
|
1700
|
+
const INDEX_PARAMETER: ReadonlyMap<string, number> = new Map([
|
|
1701
|
+
["map", 1],
|
|
1702
|
+
["flatMap", 1],
|
|
1703
|
+
["forEach", 1],
|
|
1704
|
+
["filter", 1],
|
|
1705
|
+
["some", 1],
|
|
1706
|
+
["every", 1],
|
|
1707
|
+
["find", 1],
|
|
1708
|
+
["findIndex", 1],
|
|
1709
|
+
["reduce", 2],
|
|
1710
|
+
["reduceRight", 2],
|
|
1711
|
+
])
|
|
1712
|
+
|
|
1713
|
+
/** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
|
|
1714
|
+
* kin, the second for an unshadowed `Array.from(src, fn)`. */
|
|
1715
|
+
function indexCallbackArgument(ts: TypeScriptApi, call: TSApi.CallExpression): number | undefined {
|
|
1716
|
+
const callee = call.expression
|
|
1717
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1718
|
+
return undefined
|
|
1719
|
+
if (INDEX_PARAMETER.has(callee.name.text)) return 0
|
|
1720
|
+
return callee.name.text === "from" &&
|
|
1721
|
+
ts.isIdentifier(callee.expression) &&
|
|
1722
|
+
callee.expression.text === "Array" &&
|
|
1723
|
+
!isShadowedAt(ts, callee.expression, "Array")
|
|
1724
|
+
? 1
|
|
1725
|
+
: undefined
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
|
|
1730
|
+
* Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
|
|
1731
|
+
* there could, so anything not proved numeric stays unresolved.
|
|
1732
|
+
*/
|
|
1733
|
+
function placeholderNumber(
|
|
1734
|
+
ts: TypeScriptApi,
|
|
1735
|
+
before: string,
|
|
1736
|
+
node: TSApi.Expression,
|
|
1737
|
+
depth: number,
|
|
1738
|
+
): string | undefined {
|
|
1739
|
+
return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const NUMERIC_OPERATORS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
|
|
1743
|
+
new Set([
|
|
1744
|
+
ts.SyntaxKind.MinusToken,
|
|
1745
|
+
ts.SyntaxKind.AsteriskToken,
|
|
1746
|
+
ts.SyntaxKind.AsteriskAsteriskToken,
|
|
1747
|
+
ts.SyntaxKind.SlashToken,
|
|
1748
|
+
ts.SyntaxKind.PercentToken,
|
|
1749
|
+
ts.SyntaxKind.AmpersandToken,
|
|
1750
|
+
ts.SyntaxKind.BarToken,
|
|
1751
|
+
ts.SyntaxKind.CaretToken,
|
|
1752
|
+
ts.SyntaxKind.LessThanLessThanToken,
|
|
1753
|
+
ts.SyntaxKind.GreaterThanGreaterThanToken,
|
|
1754
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
|
|
1755
|
+
])
|
|
1756
|
+
const NUMBER_FUNCTIONS: ReadonlySet<string> = new Set(["Number", "parseInt", "parseFloat"])
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
|
|
1760
|
+
* `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
|
|
1761
|
+
* both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
|
|
1762
|
+
* `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
|
|
1763
|
+
* (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
|
|
1764
|
+
* nearby binding builds from a literal. A type annotation proves nothing: it does not run.
|
|
1765
|
+
*/
|
|
1766
|
+
function isNumericExpression(ts: TypeScriptApi, node: TSApi.Expression, depth: number): boolean {
|
|
1767
|
+
if (depth > CONST_RESOLUTION_DEPTH) return false
|
|
1768
|
+
const expression = unwrapExpression(ts, node)
|
|
1769
|
+
if (ts.isNumericLiteral(expression)) return true
|
|
1770
|
+
if (ts.isPrefixUnaryExpression(expression))
|
|
1771
|
+
return expression.operator !== ts.SyntaxKind.ExclamationToken
|
|
1772
|
+
if (ts.isPostfixUnaryExpression(expression)) return true
|
|
1773
|
+
if (ts.isBinaryExpression(expression)) {
|
|
1774
|
+
const operator = expression.operatorToken.kind
|
|
1775
|
+
if (operator === ts.SyntaxKind.PlusToken) {
|
|
1776
|
+
return (
|
|
1777
|
+
isNumericExpression(ts, expression.left, depth + 1) &&
|
|
1778
|
+
isNumericExpression(ts, expression.right, depth + 1)
|
|
1779
|
+
)
|
|
1780
|
+
}
|
|
1781
|
+
return NUMERIC_OPERATORS(ts).has(operator)
|
|
1782
|
+
}
|
|
1783
|
+
if (ts.isIdentifier(expression)) return isNumericName(ts, expression, depth)
|
|
1784
|
+
if (ts.isCallExpression(expression)) {
|
|
1785
|
+
const callee = expression.expression
|
|
1786
|
+
if (ts.isIdentifier(callee))
|
|
1787
|
+
return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text)
|
|
1788
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1789
|
+
return false
|
|
1790
|
+
if (callee.name.text === "push") return true
|
|
1791
|
+
return (
|
|
1792
|
+
ts.isIdentifier(callee.expression) &&
|
|
1793
|
+
callee.expression.text === "Math" &&
|
|
1794
|
+
!isShadowedAt(ts, callee.expression, "Math")
|
|
1795
|
+
)
|
|
1796
|
+
}
|
|
1797
|
+
if (
|
|
1798
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
1799
|
+
expression.name.text === "length" &&
|
|
1800
|
+
ts.isIdentifier(expression.expression)
|
|
1801
|
+
) {
|
|
1802
|
+
const binding = nameBinding(ts, expression.expression)
|
|
1803
|
+
return (
|
|
1804
|
+
binding.kind === "value" &&
|
|
1805
|
+
ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer))
|
|
1806
|
+
)
|
|
1807
|
+
}
|
|
1808
|
+
return false
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** How the nearest scope binds a name: a readable value ({@link directBinding}), a parameter, a `let`
|
|
1812
|
+
* counter a `for` statement declares and updates, or nothing this walk can read. */
|
|
1813
|
+
type NameBinding =
|
|
1814
|
+
| { readonly kind: "value"; readonly initializer: TSApi.Expression }
|
|
1815
|
+
| {
|
|
1816
|
+
readonly kind: "parameter"
|
|
1817
|
+
readonly fn: TSApi.SignatureDeclaration
|
|
1818
|
+
readonly position: number
|
|
1819
|
+
}
|
|
1820
|
+
| { readonly kind: "counter"; readonly initializer: TSApi.Expression }
|
|
1821
|
+
| { readonly kind: "unknown" }
|
|
1822
|
+
|
|
1823
|
+
function nameBinding(ts: TypeScriptApi, node: TSApi.Identifier): NameBinding {
|
|
1824
|
+
const source = node.getSourceFile()
|
|
1825
|
+
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1826
|
+
if (ts.isFunctionLike(scope)) {
|
|
1827
|
+
const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text))
|
|
1828
|
+
if (position !== -1) {
|
|
1829
|
+
return ts.isIdentifier(scope.parameters[position]?.name ?? node)
|
|
1830
|
+
? { kind: "parameter", fn: scope, position }
|
|
1831
|
+
: { kind: "unknown" }
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
const binding = directBinding(ts, source, scope, node.text)
|
|
1835
|
+
if (binding === "shadow") {
|
|
1836
|
+
// `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
|
|
1837
|
+
const list = ts.isForStatement(scope) ? scope.initializer : undefined
|
|
1838
|
+
const declaration =
|
|
1839
|
+
list !== undefined && ts.isVariableDeclarationList(list)
|
|
1840
|
+
? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
|
|
1841
|
+
: undefined
|
|
1842
|
+
return declaration?.initializer === undefined
|
|
1843
|
+
? { kind: "unknown" }
|
|
1844
|
+
: { kind: "counter", initializer: declaration.initializer }
|
|
1845
|
+
}
|
|
1846
|
+
if (binding !== undefined) return { kind: "value", initializer: binding.initializer }
|
|
1847
|
+
if (ts.isSourceFile(scope)) break
|
|
1848
|
+
}
|
|
1849
|
+
return { kind: "unknown" }
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function isNumericName(ts: TypeScriptApi, node: TSApi.Identifier, depth: number): boolean {
|
|
1853
|
+
const source = node.getSourceFile()
|
|
1854
|
+
const binding = nameBinding(ts, node)
|
|
1855
|
+
if (binding.kind === "value") return isNumericExpression(ts, binding.initializer, depth + 1)
|
|
1856
|
+
if (binding.kind === "counter") {
|
|
1857
|
+
return (
|
|
1858
|
+
isNumericExpression(ts, binding.initializer, depth + 1) &&
|
|
1859
|
+
onlyNumericWrites(ts, source, node.text, depth)
|
|
1860
|
+
)
|
|
1861
|
+
}
|
|
1862
|
+
if (binding.kind !== "parameter") return false
|
|
1863
|
+
// An array callback's index parameter, never reassigned.
|
|
1864
|
+
const call = binding.fn.parent
|
|
1865
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return false
|
|
1866
|
+
const argument = indexCallbackArgument(ts, call)
|
|
1867
|
+
const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text)
|
|
1868
|
+
return (
|
|
1869
|
+
argument !== undefined &&
|
|
1870
|
+
call.arguments[argument] === binding.fn &&
|
|
1871
|
+
binding.position === position &&
|
|
1872
|
+
!isReassignedInFile(ts, source, node.text)
|
|
1873
|
+
)
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/** Compound assignments that always store a number. */
|
|
1877
|
+
const NUMERIC_ASSIGNMENTS = (ts: TypeScriptApi): ReadonlySet<TSApi.SyntaxKind> =>
|
|
1878
|
+
new Set([
|
|
1879
|
+
ts.SyntaxKind.MinusEqualsToken,
|
|
1880
|
+
ts.SyntaxKind.AsteriskEqualsToken,
|
|
1881
|
+
ts.SyntaxKind.AsteriskAsteriskEqualsToken,
|
|
1882
|
+
ts.SyntaxKind.SlashEqualsToken,
|
|
1883
|
+
ts.SyntaxKind.PercentEqualsToken,
|
|
1884
|
+
ts.SyntaxKind.AmpersandEqualsToken,
|
|
1885
|
+
ts.SyntaxKind.BarEqualsToken,
|
|
1886
|
+
ts.SyntaxKind.CaretEqualsToken,
|
|
1887
|
+
ts.SyntaxKind.LessThanLessThanEqualsToken,
|
|
1888
|
+
ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
|
|
1889
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
|
1890
|
+
])
|
|
1891
|
+
|
|
1892
|
+
/** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
|
|
1893
|
+
function onlyNumericWrites(
|
|
1894
|
+
ts: TypeScriptApi,
|
|
1895
|
+
source: TSApi.SourceFile,
|
|
1896
|
+
name: string,
|
|
1897
|
+
depth: number,
|
|
1898
|
+
): boolean {
|
|
1899
|
+
let ok = true
|
|
1900
|
+
const visit = (node: TSApi.Node): void => {
|
|
1901
|
+
if (!ok) return
|
|
1902
|
+
if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
|
|
1903
|
+
const parent = node.parent
|
|
1904
|
+
if (ts.isBinaryExpression(parent)) {
|
|
1905
|
+
const operator = parent.operatorToken.kind
|
|
1906
|
+
ok =
|
|
1907
|
+
operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
|
|
1908
|
+
? isNumericExpression(ts, parent.right, depth + 1)
|
|
1909
|
+
: NUMERIC_ASSIGNMENTS(ts).has(operator)
|
|
1910
|
+
} else ok = !ts.isDeleteExpression(parent)
|
|
1911
|
+
return
|
|
1912
|
+
}
|
|
1913
|
+
ts.forEachChild(node, visit)
|
|
1914
|
+
}
|
|
1915
|
+
visit(source)
|
|
1916
|
+
return ok
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
1920
|
+
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
1921
|
+
function templateShape(
|
|
1922
|
+
ts: TypeScriptApi,
|
|
1923
|
+
template: TSApi.TemplateExpression,
|
|
1924
|
+
scope: SqlScope | undefined,
|
|
1925
|
+
): { staticText: string; dynamic: boolean } {
|
|
1926
|
+
let staticText = template.head.text
|
|
1927
|
+
let dynamic = false
|
|
1928
|
+
for (const span of template.templateSpans) {
|
|
1929
|
+
const resolved =
|
|
1930
|
+
scope === undefined
|
|
1931
|
+
? undefined
|
|
1932
|
+
: (placeholderNumber(ts, staticText, span.expression, 0) ??
|
|
1933
|
+
resolveStaticSqlText(ts, span.expression, scope, 0))
|
|
1934
|
+
if (resolved === undefined) dynamic = true
|
|
1935
|
+
else staticText += resolved
|
|
1936
|
+
staticText += span.literal.text
|
|
1937
|
+
}
|
|
1938
|
+
return { staticText, dynamic }
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/** A same-file binding the argument resolver proved holds one expression: a `const`, or a `let` the
|
|
1942
|
+
* whole file never reassigns. `"shadow"` marks a nearer binding of the name we can NOT read through (a
|
|
1943
|
+
* parameter, `var`, destructured, uninitialized, reassigned `let`) - it stops the outward walk so a
|
|
1944
|
+
* farther, resolvable const of the same name is never read in its place. */
|
|
1945
|
+
type ResolvableBinding =
|
|
1946
|
+
| { readonly initializer: TSApi.Expression; readonly constant: boolean }
|
|
1947
|
+
| "shadow"
|
|
1948
|
+
|
|
1949
|
+
/** Whether `name` is written to anywhere in the file - a plain, compound, or `++`/`--` assignment to a
|
|
1950
|
+
* bare identifier of that name. A `let` that is is over-cautiously refused (it might be reassigned from
|
|
1951
|
+
* request data); a `const` never needs this. Over-refusal only ever leaves an interpolation flagged as
|
|
1952
|
+
* before, never the reverse. */
|
|
1953
|
+
function isReassignedInFile(ts: TypeScriptApi, source: TSApi.SourceFile, name: string): boolean {
|
|
1954
|
+
let found = false
|
|
1955
|
+
const visit = (node: TSApi.Node): void => {
|
|
1956
|
+
if (found) return
|
|
1957
|
+
if (
|
|
1958
|
+
ts.isBinaryExpression(node) &&
|
|
1959
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1960
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
|
|
1961
|
+
ts.isIdentifier(node.left) &&
|
|
1962
|
+
node.left.text === name
|
|
1963
|
+
) {
|
|
1964
|
+
found = true
|
|
1965
|
+
return
|
|
1966
|
+
}
|
|
1967
|
+
if (
|
|
1968
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
1969
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1970
|
+
node.operator === ts.SyntaxKind.MinusMinusToken) &&
|
|
1971
|
+
ts.isIdentifier(node.operand) &&
|
|
1972
|
+
node.operand.text === name
|
|
1973
|
+
) {
|
|
1974
|
+
found = true
|
|
1975
|
+
return
|
|
1976
|
+
}
|
|
1977
|
+
ts.forEachChild(node, visit)
|
|
1978
|
+
}
|
|
1979
|
+
visit(source)
|
|
1980
|
+
return found
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** Does `binding` bind `name` (as a plain identifier or nested inside a destructuring pattern)? Mirrors
|
|
1984
|
+
* the pattern walk in {@link isShadowedAt}. */
|
|
1985
|
+
function bindingNameCovers(ts: TypeScriptApi, binding: TSApi.BindingName, name: string): boolean {
|
|
1986
|
+
return ts.isIdentifier(binding)
|
|
1987
|
+
? binding.text === name
|
|
1988
|
+
: binding.elements.some(
|
|
1989
|
+
(element) =>
|
|
1990
|
+
!ts.isOmittedExpression(element) &&
|
|
1991
|
+
(ts.isIdentifier(element.name)
|
|
1992
|
+
? element.name.text === name
|
|
1993
|
+
: bindingNameCovers(ts, element.name, name)),
|
|
1090
1994
|
)
|
|
1091
1995
|
}
|
|
1092
1996
|
|
|
@@ -1109,33 +2013,15 @@ function directBinding(
|
|
|
1109
2013
|
const declared = scope.variableDeclaration
|
|
1110
2014
|
if (declared !== undefined && bindingNameCovers(ts, declared.name, name)) return "shadow"
|
|
1111
2015
|
}
|
|
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
|
-
}
|
|
2016
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
2017
|
+
if (
|
|
2018
|
+
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
2019
|
+
statement.name?.text === name
|
|
2020
|
+
) {
|
|
2021
|
+
return "shadow"
|
|
1129
2022
|
}
|
|
1130
2023
|
}
|
|
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) {
|
|
2024
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
1139
2025
|
for (const declaration of list.declarations) {
|
|
1140
2026
|
if (!bindingNameCovers(ts, declaration.name, name)) continue
|
|
1141
2027
|
// A destructured or uninitialized binding, or a `var` (hoisting + reassignment risk), is not a
|
|
@@ -1144,57 +2030,306 @@ function directBinding(
|
|
|
1144
2030
|
return "shadow"
|
|
1145
2031
|
const isConst = (list.flags & ts.NodeFlags.Const) !== 0
|
|
1146
2032
|
const isLet = (list.flags & ts.NodeFlags.Let) !== 0
|
|
1147
|
-
if (isConst) return { initializer: declaration.initializer }
|
|
2033
|
+
if (isConst) return { initializer: declaration.initializer, constant: true }
|
|
1148
2034
|
if (isLet && !isReassignedInFile(ts, source, name))
|
|
1149
|
-
return { initializer: declaration.initializer }
|
|
2035
|
+
return { initializer: declaration.initializer, constant: false }
|
|
1150
2036
|
return "shadow"
|
|
1151
2037
|
}
|
|
1152
2038
|
}
|
|
1153
2039
|
return undefined
|
|
1154
2040
|
}
|
|
1155
2041
|
|
|
2042
|
+
function scopeStatements(
|
|
2043
|
+
ts: TypeScriptApi,
|
|
2044
|
+
scope: TSApi.Node,
|
|
2045
|
+
): readonly TSApi.Statement[] | undefined {
|
|
2046
|
+
return ts.isSourceFile(scope) ||
|
|
2047
|
+
ts.isBlock(scope) ||
|
|
2048
|
+
ts.isModuleBlock(scope) ||
|
|
2049
|
+
ts.isCaseClause(scope) ||
|
|
2050
|
+
ts.isDefaultClause(scope)
|
|
2051
|
+
? scope.statements
|
|
2052
|
+
: undefined
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
|
|
2056
|
+
function scopeDeclarationLists(
|
|
2057
|
+
ts: TypeScriptApi,
|
|
2058
|
+
scope: TSApi.Node,
|
|
2059
|
+
): TSApi.VariableDeclarationList[] {
|
|
2060
|
+
const lists: TSApi.VariableDeclarationList[] = []
|
|
2061
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
2062
|
+
if (ts.isVariableStatement(statement)) lists.push(statement.declarationList)
|
|
2063
|
+
}
|
|
2064
|
+
if (
|
|
2065
|
+
(ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
|
|
2066
|
+
scope.initializer !== undefined &&
|
|
2067
|
+
ts.isVariableDeclarationList(scope.initializer)
|
|
2068
|
+
) {
|
|
2069
|
+
lists.push(scope.initializer)
|
|
2070
|
+
}
|
|
2071
|
+
return lists
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
|
|
2075
|
+
function mutableDeclaration(
|
|
2076
|
+
ts: TypeScriptApi,
|
|
2077
|
+
scope: TSApi.Node,
|
|
2078
|
+
name: string,
|
|
2079
|
+
): TSApi.VariableDeclaration | undefined {
|
|
2080
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
2081
|
+
if ((list.flags & ts.NodeFlags.Const) !== 0) continue
|
|
2082
|
+
for (const declaration of list.declarations) {
|
|
2083
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) return declaration
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
return undefined
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
interface SqlWrite {
|
|
2090
|
+
readonly value: TSApi.Expression
|
|
2091
|
+
/** `+=`: the value is glued onto what the variable already holds. */
|
|
2092
|
+
readonly appends: boolean
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1156
2095
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
* could be silenced, invisibly, by hoisting the statement to `const q = …` first
|
|
2096
|
+
* Every value a statement variable may hold where the query reads it, so a statement parked in a
|
|
2097
|
+
* variable is scanned as if it had been written inline at the call. This is the counterpart to
|
|
2098
|
+
* {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
|
|
2099
|
+
* statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
|
|
2100
|
+
* could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
|
|
2101
|
+
* in steps (`let q = "SELECT …"; q += …`).
|
|
1162
2102
|
*
|
|
1163
2103
|
* 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
|
-
*
|
|
2104
|
+
* same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
|
|
2105
|
+
* `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
|
|
2106
|
+
* the function (or module) that declares it: over-collected rather than scope-simulated, which only
|
|
2107
|
+
* ever adds a value to check. `key` names the binding, so a value that reads the variable it is
|
|
2108
|
+
* assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
|
|
2109
|
+
* same-file declaration returns `undefined` - which leaves the argument scanned as itself.
|
|
1169
2110
|
*/
|
|
1170
|
-
function
|
|
2111
|
+
function variableWrites(
|
|
1171
2112
|
ts: TypeScriptApi,
|
|
1172
2113
|
node: TSApi.Identifier,
|
|
1173
|
-
|
|
1174
|
-
): TSApi.Expression | undefined {
|
|
1175
|
-
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
2114
|
+
): { readonly key: TSApi.Node; readonly writes: readonly SqlWrite[] } | undefined {
|
|
1176
2115
|
const source = node.getSourceFile()
|
|
1177
2116
|
const name = node.text
|
|
1178
2117
|
for (let scope: TSApi.Node | undefined = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1179
2118
|
const binding = directBinding(ts, source, scope, name)
|
|
1180
|
-
if (binding === "shadow")
|
|
2119
|
+
if (binding === "shadow") {
|
|
2120
|
+
const declaration = mutableDeclaration(ts, scope, name)
|
|
2121
|
+
if (declaration === undefined) return undefined
|
|
2122
|
+
let owner: TSApi.Node = declaration
|
|
2123
|
+
while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent
|
|
2124
|
+
const writes = writesTo(ts, owner, name)
|
|
2125
|
+
return {
|
|
2126
|
+
key: declaration,
|
|
2127
|
+
writes:
|
|
2128
|
+
declaration.initializer === undefined
|
|
2129
|
+
? writes
|
|
2130
|
+
: [{ value: declaration.initializer, appends: false }, ...writes],
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1181
2133
|
if (binding !== undefined) {
|
|
1182
|
-
|
|
1183
|
-
while (ts.isParenthesizedExpression(init)) init = init.expression
|
|
1184
|
-
return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init
|
|
2134
|
+
return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] }
|
|
1185
2135
|
}
|
|
1186
2136
|
if (ts.isSourceFile(scope)) break
|
|
1187
2137
|
}
|
|
1188
2138
|
return undefined
|
|
1189
2139
|
}
|
|
1190
2140
|
|
|
1191
|
-
/**
|
|
2141
|
+
/** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
|
|
2142
|
+
function writesTo(ts: TypeScriptApi, root: TSApi.Node, name: string): SqlWrite[] {
|
|
2143
|
+
const writes: SqlWrite[] = []
|
|
2144
|
+
const visit = (node: TSApi.Node): void => {
|
|
2145
|
+
if (ts.isIdentifier(node) && node.text === name) {
|
|
2146
|
+
const parent = node.parent
|
|
2147
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
2148
|
+
const operator = parent.operatorToken.kind
|
|
2149
|
+
if (operator === ts.SyntaxKind.PlusEqualsToken) {
|
|
2150
|
+
writes.push({ value: parent.right, appends: true })
|
|
2151
|
+
} else if (
|
|
2152
|
+
operator === ts.SyntaxKind.EqualsToken ||
|
|
2153
|
+
operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
|
|
2154
|
+
operator === ts.SyntaxKind.BarBarEqualsToken ||
|
|
2155
|
+
operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken
|
|
2156
|
+
) {
|
|
2157
|
+
writes.push({ value: parent.right, appends: false })
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
ts.forEachChild(node, visit)
|
|
2162
|
+
}
|
|
2163
|
+
visit(root)
|
|
2164
|
+
return writes
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function mentionsName(ts: TypeScriptApi, node: TSApi.Node, name: string): boolean {
|
|
2168
|
+
if (ts.isIdentifier(node)) return node.text === name
|
|
2169
|
+
return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
const UNKNOWN_SQL: SqlExpressionShape = {
|
|
2173
|
+
staticText: "",
|
|
2174
|
+
dynamic: true,
|
|
2175
|
+
concatenated: false,
|
|
2176
|
+
literal: false,
|
|
2177
|
+
parameterizedTag: false,
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function textShape(text: string): SqlExpressionShape {
|
|
2181
|
+
return {
|
|
2182
|
+
staticText: text,
|
|
2183
|
+
dynamic: false,
|
|
2184
|
+
concatenated: false,
|
|
2185
|
+
literal: true,
|
|
2186
|
+
parameterizedTag: false,
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function joinShapes(shapes: readonly SqlExpressionShape[]): SqlExpressionShape {
|
|
2191
|
+
return {
|
|
2192
|
+
staticText: shapes.map((shape) => shape.staticText).join(""),
|
|
2193
|
+
// As with `+`: text glued onto a binding tag's output un-parameterizes it.
|
|
2194
|
+
dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
|
|
2195
|
+
concatenated: true,
|
|
2196
|
+
literal: false,
|
|
2197
|
+
parameterizedTag: false,
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
|
|
2202
|
+
* them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
|
|
2203
|
+
* before inside one. */
|
|
2204
|
+
function sqlRisk(shape: SqlExpressionShape): number {
|
|
2205
|
+
const dynamic = !shape.dynamic
|
|
2206
|
+
? 0
|
|
2207
|
+
: SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
|
|
2208
|
+
? 3
|
|
2209
|
+
: shape.concatenated
|
|
2210
|
+
? 2
|
|
2211
|
+
: 1
|
|
2212
|
+
return dynamic * 2 + (shape.parameterizedTag ? 0 : 1)
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
|
|
2217
|
+
* The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
|
|
2218
|
+
* once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
|
|
2219
|
+
* interpolated `AND …` reads as the one statement it becomes.
|
|
2220
|
+
*/
|
|
2221
|
+
function writtenShape(
|
|
2222
|
+
ts: TypeScriptApi,
|
|
2223
|
+
name: string,
|
|
2224
|
+
writes: readonly SqlWrite[],
|
|
2225
|
+
scope: SqlScope | undefined,
|
|
2226
|
+
trail: readonly TSApi.Node[],
|
|
2227
|
+
): SqlExpressionShape {
|
|
2228
|
+
const replacing: SqlExpressionShape[] = []
|
|
2229
|
+
const building: SqlExpressionShape[] = []
|
|
2230
|
+
for (const write of writes) {
|
|
2231
|
+
const shape = sqlExpressionShape(ts, write.value, scope, trail)
|
|
2232
|
+
if (write.appends || mentionsName(ts, write.value, name)) building.push(shape)
|
|
2233
|
+
else replacing.push(shape)
|
|
2234
|
+
}
|
|
2235
|
+
const candidates =
|
|
2236
|
+
building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])]
|
|
2237
|
+
let worst: SqlExpressionShape | undefined
|
|
2238
|
+
for (const candidate of candidates) {
|
|
2239
|
+
if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst)) worst = candidate
|
|
2240
|
+
}
|
|
2241
|
+
return worst ?? UNKNOWN_SQL
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const TEXT_METHODS = new Set([
|
|
2245
|
+
"toString",
|
|
2246
|
+
"valueOf",
|
|
2247
|
+
"trim",
|
|
2248
|
+
"trimStart",
|
|
2249
|
+
"trimEnd",
|
|
2250
|
+
"toLowerCase",
|
|
2251
|
+
"toUpperCase",
|
|
2252
|
+
])
|
|
2253
|
+
|
|
2254
|
+
/** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
|
|
2255
|
+
function passedThrough(
|
|
2256
|
+
ts: TypeScriptApi,
|
|
2257
|
+
call: TSApi.CallExpression,
|
|
2258
|
+
): TSApi.Expression | undefined {
|
|
2259
|
+
const callee = call.expression
|
|
2260
|
+
if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
|
|
2261
|
+
return call.arguments[0]
|
|
2262
|
+
}
|
|
2263
|
+
if (
|
|
2264
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
2265
|
+
TEXT_METHODS.has(callee.name.text) &&
|
|
2266
|
+
call.arguments.length === 0
|
|
2267
|
+
) {
|
|
2268
|
+
return callee.expression
|
|
2269
|
+
}
|
|
2270
|
+
return undefined
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
|
|
2274
|
+
function joinedParts(
|
|
2275
|
+
ts: TypeScriptApi,
|
|
2276
|
+
call: TSApi.CallExpression,
|
|
2277
|
+
): readonly (TSApi.Expression | string)[] | undefined {
|
|
2278
|
+
const callee = call.expression
|
|
2279
|
+
if (!ts.isPropertyAccessExpression(callee)) return undefined
|
|
2280
|
+
if (callee.name.text === "concat") return [callee.expression, ...call.arguments]
|
|
2281
|
+
const items = unwrapExpression(ts, callee.expression)
|
|
2282
|
+
if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items)) return undefined
|
|
2283
|
+
const separator = call.arguments[0]
|
|
2284
|
+
const glue: TSApi.Expression | string =
|
|
2285
|
+
separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator
|
|
2286
|
+
const parts: (TSApi.Expression | string)[] = []
|
|
2287
|
+
items.elements.forEach((element, index) => {
|
|
2288
|
+
if (index > 0) parts.push(glue)
|
|
2289
|
+
if (ts.isSpreadElement(element)) parts.push(element.expression)
|
|
2290
|
+
else if (!ts.isOmittedExpression(element)) parts.push(element)
|
|
2291
|
+
})
|
|
2292
|
+
return parts
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
|
|
2296
|
+
* which is plain interpolation whatever its name. A parameter, an import, or a driver instance
|
|
2297
|
+
* (`const sql = postgres(url)`) keeps the name's trust. */
|
|
2298
|
+
function definesSqlFunction(ts: TypeScriptApi, tag: TSApi.Identifier): boolean {
|
|
2299
|
+
for (let scope: TSApi.Node | undefined = tag.parent; scope !== undefined; scope = scope.parent) {
|
|
2300
|
+
if (
|
|
2301
|
+
ts.isFunctionLike(scope) &&
|
|
2302
|
+
scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))
|
|
2303
|
+
) {
|
|
2304
|
+
return false
|
|
2305
|
+
}
|
|
2306
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
2307
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text) return true
|
|
2308
|
+
}
|
|
2309
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
2310
|
+
for (const declaration of list.declarations) {
|
|
2311
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text) continue
|
|
2312
|
+
const value =
|
|
2313
|
+
declaration.initializer === undefined
|
|
2314
|
+
? undefined
|
|
2315
|
+
: unwrapExpression(ts, declaration.initializer)
|
|
2316
|
+
return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
return false
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
|
|
2324
|
+
* `trail` holds the variables being read through, innermost last. */
|
|
1192
2325
|
function sqlExpressionShape(
|
|
1193
2326
|
ts: TypeScriptApi,
|
|
1194
|
-
|
|
2327
|
+
expression: TSApi.Expression,
|
|
1195
2328
|
scope?: SqlScope,
|
|
2329
|
+
trail: readonly TSApi.Node[] = [],
|
|
1196
2330
|
): SqlExpressionShape {
|
|
1197
|
-
|
|
2331
|
+
let node = unwrapExpression(ts, expression)
|
|
2332
|
+
while (ts.isNonNullExpression(node)) node = unwrapExpression(ts, node.expression)
|
|
1198
2333
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
1199
2334
|
return {
|
|
1200
2335
|
staticText: node.text,
|
|
@@ -1207,7 +2342,7 @@ function sqlExpressionShape(
|
|
|
1207
2342
|
if (ts.isTaggedTemplateExpression(node)) {
|
|
1208
2343
|
const tag = node.tag
|
|
1209
2344
|
const parameterizedTag =
|
|
1210
|
-
(ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
2345
|
+
(ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
|
|
1211
2346
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
1212
2347
|
const template = node.template
|
|
1213
2348
|
const templated = ts.isTemplateExpression(template)
|
|
@@ -1217,8 +2352,9 @@ function sqlExpressionShape(
|
|
|
1217
2352
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
1218
2353
|
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
1219
2354
|
// 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
|
-
//
|
|
2355
|
+
// deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
|
|
2356
|
+
// function is read as the interpolation it is, but one imported from anywhere is trusted, so
|
|
2357
|
+
// this rule finds mistakes, not an adversary who reads it first.
|
|
1222
2358
|
//
|
|
1223
2359
|
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
1224
2360
|
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
@@ -1240,14 +2376,17 @@ function sqlExpressionShape(
|
|
|
1240
2376
|
return {
|
|
1241
2377
|
staticText: templated.staticText,
|
|
1242
2378
|
dynamic: templated.dynamic,
|
|
1243
|
-
|
|
2379
|
+
// `${a} ${b}` writes no text of its own; it only glues values together.
|
|
2380
|
+
concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every(
|
|
2381
|
+
(part) => part.text.trim() === "",
|
|
2382
|
+
),
|
|
1244
2383
|
literal: false,
|
|
1245
2384
|
parameterizedTag: false,
|
|
1246
2385
|
}
|
|
1247
2386
|
}
|
|
1248
2387
|
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)
|
|
2388
|
+
const left = sqlExpressionShape(ts, node.left, scope, trail)
|
|
2389
|
+
const right = sqlExpressionShape(ts, node.right, scope, trail)
|
|
1251
2390
|
return {
|
|
1252
2391
|
staticText: left.staticText + right.staticText,
|
|
1253
2392
|
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
@@ -1276,55 +2415,163 @@ function sqlExpressionShape(
|
|
|
1276
2415
|
}
|
|
1277
2416
|
}
|
|
1278
2417
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
2418
|
+
if (ts.isCallExpression(node)) {
|
|
2419
|
+
const inner = passedThrough(ts, node)
|
|
2420
|
+
if (inner !== undefined)
|
|
2421
|
+
return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false }
|
|
2422
|
+
const parts = joinedParts(ts, node)
|
|
2423
|
+
if (parts !== undefined) {
|
|
2424
|
+
return joinShapes(
|
|
2425
|
+
parts.map((part) =>
|
|
2426
|
+
typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail),
|
|
2427
|
+
),
|
|
2428
|
+
)
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
// A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
|
|
2432
|
+
// shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
|
|
1281
2433
|
// read exactly as if it were written inline at the call, closing the "extract a variable to launder
|
|
1282
2434
|
// the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
|
|
1283
2435
|
// property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
|
|
1284
2436
|
if (ts.isIdentifier(node)) {
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
2437
|
+
const read = variableWrites(ts, node)
|
|
2438
|
+
// Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
|
|
2439
|
+
// already account for.
|
|
2440
|
+
if (read !== undefined && trail.includes(read.key)) return { ...textShape(""), literal: false }
|
|
2441
|
+
if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
|
|
2442
|
+
const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key])
|
|
2443
|
+
return { ...written, literal: false }
|
|
1288
2444
|
}
|
|
1289
2445
|
}
|
|
1290
|
-
return
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
2446
|
+
return UNKNOWN_SQL
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
interface SqlSink {
|
|
2450
|
+
readonly method: string
|
|
2451
|
+
/** `undefined` for a bare call: `const { query } = db` and then `query(…)`. */
|
|
2452
|
+
readonly receiver: string | undefined
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function sqlMethod(ts: TypeScriptApi, call: TSApi.CallExpression): SqlSink | undefined {
|
|
2456
|
+
const sink = (method: string, receiver: TSApi.Expression | undefined): SqlSink | undefined =>
|
|
2457
|
+
SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
|
|
2458
|
+
? { method, receiver: receiver?.getText() }
|
|
2459
|
+
: undefined
|
|
2460
|
+
const callee = call.expression
|
|
2461
|
+
if (ts.isIdentifier(callee)) return sink(callee.text, undefined)
|
|
2462
|
+
if (ts.isPropertyAccessExpression(callee)) return sink(callee.name.text, callee.expression)
|
|
2463
|
+
if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
|
|
2464
|
+
return sink(callee.argumentExpression.text, callee.expression)
|
|
1296
2465
|
}
|
|
2466
|
+
return undefined
|
|
1297
2467
|
}
|
|
1298
2468
|
|
|
1299
|
-
|
|
2469
|
+
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
|
|
2470
|
+
|
|
2471
|
+
/** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
|
|
2472
|
+
function isAssembledSql(sink: SqlSink, shape: SqlExpressionShape): boolean {
|
|
2473
|
+
if (!shape.dynamic) return false
|
|
2474
|
+
if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
|
|
2475
|
+
return STATEMENT_START.test(shape.staticText)
|
|
2476
|
+
}
|
|
2477
|
+
return (
|
|
2478
|
+
SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver))
|
|
2479
|
+
)
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
|
|
2483
|
+
* (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
|
|
2484
|
+
function statementArgument(
|
|
1300
2485
|
ts: TypeScriptApi,
|
|
1301
|
-
|
|
1302
|
-
):
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
2486
|
+
argument: TSApi.Expression,
|
|
2487
|
+
): TSApi.Expression | undefined {
|
|
2488
|
+
const node = unwrapExpression(ts, argument)
|
|
2489
|
+
if (!ts.isObjectLiteralExpression(node)) return argument
|
|
2490
|
+
for (const property of node.properties) {
|
|
2491
|
+
if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
|
|
2492
|
+
return property.name
|
|
2493
|
+
}
|
|
2494
|
+
if (
|
|
2495
|
+
ts.isPropertyAssignment(property) &&
|
|
2496
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
2497
|
+
STATEMENT_KEYS.has(property.name.text)
|
|
2498
|
+
) {
|
|
2499
|
+
return property.initializer
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return undefined
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const STATEMENT_KEYS = new Set(["text", "sql"])
|
|
2506
|
+
|
|
2507
|
+
interface StatementHelper {
|
|
2508
|
+
readonly position: number
|
|
2509
|
+
readonly sink: SqlSink
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
|
|
2513
|
+
* `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
|
|
2514
|
+
* scanned as a call to that sink. One level deep. */
|
|
2515
|
+
function statementHelpers(
|
|
2516
|
+
ts: TypeScriptApi,
|
|
2517
|
+
source: TSApi.SourceFile,
|
|
2518
|
+
): ReadonlyMap<string, StatementHelper> {
|
|
2519
|
+
const helpers = new Map<string, StatementHelper>()
|
|
2520
|
+
const visit = (node: TSApi.Node): void => {
|
|
2521
|
+
const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined
|
|
2522
|
+
const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined
|
|
2523
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed)
|
|
2524
|
+
const statement = argument === undefined ? undefined : unwrapExpression(ts, argument)
|
|
2525
|
+
if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
|
|
2526
|
+
const binding = nameBinding(ts, statement)
|
|
2527
|
+
const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined
|
|
2528
|
+
if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
|
|
2529
|
+
helpers.set(name, { position: binding.position, sink })
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
ts.forEachChild(node, visit)
|
|
1308
2533
|
}
|
|
2534
|
+
visit(source)
|
|
2535
|
+
return helpers
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function functionName(ts: TypeScriptApi, fn: TSApi.SignatureDeclaration): string | undefined {
|
|
2539
|
+
if (ts.isFunctionDeclaration(fn)) return fn.name?.text
|
|
1309
2540
|
if (
|
|
1310
|
-
ts.
|
|
1311
|
-
|
|
1312
|
-
ts.
|
|
2541
|
+
(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
2542
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
2543
|
+
ts.isIdentifier(fn.parent.name)
|
|
1313
2544
|
) {
|
|
1314
|
-
|
|
1315
|
-
return SQL_METHODS.has(method)
|
|
1316
|
-
? { method, receiver: call.expression.expression.getText() }
|
|
1317
|
-
: undefined
|
|
2545
|
+
return fn.parent.name.text
|
|
1318
2546
|
}
|
|
1319
2547
|
return undefined
|
|
1320
2548
|
}
|
|
1321
2549
|
|
|
1322
|
-
|
|
2550
|
+
/** `content` with every string, template and regex literal blanked, line breaks kept. */
|
|
2551
|
+
function literalsBlanked(ts: TypeScriptApi, source: TSApi.SourceFile, content: string): string {
|
|
2552
|
+
const out = content.split("")
|
|
2553
|
+
const visit = (node: TSApi.Node): void => {
|
|
2554
|
+
if (
|
|
2555
|
+
ts.isStringLiteralLike(node) ||
|
|
2556
|
+
ts.isTemplateHead(node) ||
|
|
2557
|
+
ts.isTemplateMiddle(node) ||
|
|
2558
|
+
ts.isTemplateTail(node) ||
|
|
2559
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
2560
|
+
ts.isJsxText(node)
|
|
2561
|
+
) {
|
|
2562
|
+
for (let i = node.getStart(source); i < node.end; i++) if (out[i] !== "\n") out[i] = " "
|
|
2563
|
+
return
|
|
2564
|
+
}
|
|
2565
|
+
ts.forEachChild(node, visit)
|
|
2566
|
+
}
|
|
2567
|
+
visit(source)
|
|
2568
|
+
return out.join("")
|
|
2569
|
+
}
|
|
1323
2570
|
|
|
1324
|
-
/**
|
|
1325
|
-
*
|
|
1326
|
-
* is mandatory (see {@link
|
|
1327
|
-
const
|
|
2571
|
+
/** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
|
|
2572
|
+
* `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
|
|
2573
|
+
* the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
|
|
2574
|
+
const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"]
|
|
1328
2575
|
|
|
1329
2576
|
/**
|
|
1330
2577
|
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
@@ -1339,7 +2586,17 @@ export function scanInterpolatedSql(
|
|
|
1339
2586
|
ts: TypeScriptApi,
|
|
1340
2587
|
imports?: SqlImports,
|
|
1341
2588
|
): SourceFinding[] {
|
|
1342
|
-
|
|
2589
|
+
return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined)
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
/** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
|
|
2593
|
+
export function scanSqlSites(
|
|
2594
|
+
file: string,
|
|
2595
|
+
content: string,
|
|
2596
|
+
ts: TypeScriptApi,
|
|
2597
|
+
imports?: SqlImports,
|
|
2598
|
+
): SqlFinding[] {
|
|
2599
|
+
const out: SqlFinding[] = []
|
|
1343
2600
|
const lines = content.split("\n")
|
|
1344
2601
|
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
1345
2602
|
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
@@ -1349,27 +2606,37 @@ export function scanInterpolatedSql(
|
|
|
1349
2606
|
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1350
2607
|
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1351
2608
|
const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
|
|
2609
|
+
const helpers = statementHelpers(ts, source)
|
|
2610
|
+
// A marker counts only in a comment: one inside a string the call passes is data, not a reason.
|
|
2611
|
+
let markerLines: readonly string[] | undefined
|
|
1352
2612
|
const visit = (node: TSApi.Node): void => {
|
|
1353
2613
|
if (ts.isCallExpression(node)) {
|
|
1354
|
-
const
|
|
1355
|
-
|
|
2614
|
+
const helper = ts.isIdentifier(node.expression)
|
|
2615
|
+
? helpers.get(node.expression.text)
|
|
2616
|
+
: undefined
|
|
2617
|
+
const sink = helper?.sink ?? sqlMethod(ts, node)
|
|
2618
|
+
const passed = node.arguments[helper?.position ?? 0]
|
|
2619
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed)
|
|
1356
2620
|
if (sink !== undefined && argument !== undefined) {
|
|
1357
2621
|
const shape = sqlExpressionShape(ts, argument, scope)
|
|
2622
|
+
// A helper's own hatch call already answers for passing it a variable.
|
|
1358
2623
|
const unsafeEscape =
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
if (unsafeEscape ||
|
|
2624
|
+
helper === undefined &&
|
|
2625
|
+
sink.receiver !== undefined &&
|
|
2626
|
+
ALWAYS_UNSAFE.has(sink.method) &&
|
|
2627
|
+
!shape.literal &&
|
|
2628
|
+
!shape.parameterizedTag
|
|
2629
|
+
if (unsafeEscape || isAssembledSql(sink, shape)) {
|
|
1365
2630
|
const index = node.getStart(source)
|
|
1366
2631
|
const line = source.getLineAndCharacterOfPosition(index).line + 1
|
|
1367
|
-
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
2632
|
+
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
|
|
2633
|
+
// placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
|
|
2634
|
+
// NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
|
|
1370
2635
|
// greppable audit trail instead of becoming a second laundering trick.
|
|
1371
|
-
|
|
1372
|
-
|
|
2636
|
+
markerLines ??= literalsBlanked(ts, source, content).split("\n")
|
|
2637
|
+
const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason
|
|
2638
|
+
const site = { file, line, snippet: (lines[line - 1] ?? "").trim() }
|
|
2639
|
+
out.push(reason === undefined ? site : { ...site, expected: reason })
|
|
1373
2640
|
}
|
|
1374
2641
|
}
|
|
1375
2642
|
}
|
|
@@ -1656,7 +2923,7 @@ export function scanServerOnlyImports(
|
|
|
1656
2923
|
// Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
|
|
1657
2924
|
// failure keeps the lexical edge so the security rule fails closed.
|
|
1658
2925
|
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
1659
|
-
if (!
|
|
2926
|
+
if (!isServerOnly(specifier)) continue
|
|
1660
2927
|
const line = lineAt(content, index)
|
|
1661
2928
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
|
|
1662
2929
|
}
|
|
@@ -1673,23 +2940,24 @@ export function scanServerOnlyImports(
|
|
|
1673
2940
|
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
1674
2941
|
// ---------------------------------------------------------------------------------------------------
|
|
1675
2942
|
|
|
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
2943
|
// The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
|
|
1682
2944
|
const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
1683
2945
|
// The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
|
|
1684
2946
|
// 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
|
-
|
|
2947
|
+
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g
|
|
2948
|
+
|
|
2949
|
+
/** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
|
|
2950
|
+
* such as a note on how to opt in, marks nothing. */
|
|
2951
|
+
function hasBackendOnlyMarker(content: string): boolean {
|
|
2952
|
+
if (!content.includes("-only")) return false
|
|
2953
|
+
const positions = codePositionMask(content)
|
|
2954
|
+
for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
|
|
2955
|
+
if (positions[match.index] !== " ") return true
|
|
2956
|
+
}
|
|
2957
|
+
return false
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
|
|
1693
2961
|
const isRelativeSpecifier = (spec: string): boolean =>
|
|
1694
2962
|
spec.startsWith("./") || spec.startsWith("../")
|
|
1695
2963
|
|
|
@@ -1715,7 +2983,8 @@ function staticImportEdges(
|
|
|
1715
2983
|
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
1716
2984
|
if (positions[m.index] === " ") continue
|
|
1717
2985
|
if (m[1] === undefined) continue
|
|
1718
|
-
|
|
2986
|
+
// Only a `type` modifier can erase a clause the pattern matched, so other clauses skip the parse.
|
|
2987
|
+
if (facts !== undefined && file !== undefined && /\btype\b/.test(m[0])) {
|
|
1719
2988
|
const source = facts.parse(file, content)
|
|
1720
2989
|
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
|
|
1721
2990
|
}
|
|
@@ -1761,27 +3030,49 @@ function dynamicImportEdges(
|
|
|
1761
3030
|
return edges
|
|
1762
3031
|
}
|
|
1763
3032
|
|
|
1764
|
-
/**
|
|
3033
|
+
/** The literal `require("x")` calls in executable code. Pure. */
|
|
3034
|
+
function requireEdges(
|
|
3035
|
+
content: string,
|
|
3036
|
+
positions: string,
|
|
3037
|
+
): Array<{ specifier: string; index: number }> {
|
|
3038
|
+
const edges: Array<{ specifier: string; index: number }> = []
|
|
3039
|
+
const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags)
|
|
3040
|
+
for (let m = re.exec(content); m !== null; m = re.exec(content)) {
|
|
3041
|
+
if (positions[m.index] === " ") continue // inside a comment or a string
|
|
3042
|
+
const specifier = m[2]
|
|
3043
|
+
if (specifier === undefined) continue
|
|
3044
|
+
if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index))) continue
|
|
3045
|
+
edges.push({ specifier, index: m.index })
|
|
3046
|
+
}
|
|
3047
|
+
return edges
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
|
|
3051
|
+
* order. Pure. */
|
|
1765
3052
|
function importEdges(
|
|
1766
3053
|
content: string,
|
|
1767
3054
|
facts?: SourceFacts,
|
|
1768
3055
|
file?: string,
|
|
1769
3056
|
): Array<{ specifier: string; index: number }> {
|
|
1770
|
-
// Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
|
|
1771
3057
|
const positions = codePositionMask(content)
|
|
1772
3058
|
const edges = staticImportEdges(content, facts, file, positions)
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
)
|
|
3059
|
+
// Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
|
|
3060
|
+
const dynamic = content.includes("import(") || /import\s+\(/.test(content)
|
|
3061
|
+
const required = content.includes("require")
|
|
3062
|
+
if (!dynamic && !required) return edges
|
|
3063
|
+
if (dynamic) edges.push(...dynamicImportEdges(content, facts, file, positions))
|
|
3064
|
+
if (required) edges.push(...requireEdges(content, positions))
|
|
3065
|
+
return edges.sort((a, b) => a.index - b.index)
|
|
1777
3066
|
}
|
|
1778
3067
|
|
|
1779
|
-
/** The runtime imports of a module with the line each sits on, in source order.
|
|
3068
|
+
/** The runtime imports of a module with the line each sits on, in source order. With `facts`, an
|
|
3069
|
+
* import the compiler erases (`import { type X }`) is none. Pure. */
|
|
1780
3070
|
export function importSites(
|
|
1781
3071
|
content: string,
|
|
1782
3072
|
file?: string,
|
|
3073
|
+
facts?: SourceFacts,
|
|
1783
3074
|
): Array<{ readonly specifier: string; readonly line: number }> {
|
|
1784
|
-
return importEdges(content,
|
|
3075
|
+
return importEdges(content, facts, file).map(({ specifier, index }) => ({
|
|
1785
3076
|
specifier,
|
|
1786
3077
|
line: lineAt(content, index),
|
|
1787
3078
|
}))
|
|
@@ -1796,7 +3087,7 @@ export function parseImports(content: string, facts?: SourceFacts, file?: string
|
|
|
1796
3087
|
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1797
3088
|
* itself (it's already the actionable name). */
|
|
1798
3089
|
export function directSinkSpecifier(spec: string): string | undefined {
|
|
1799
|
-
return
|
|
3090
|
+
return isServerOnlySink(spec) ? spec : undefined
|
|
1800
3091
|
}
|
|
1801
3092
|
|
|
1802
3093
|
/** One transitive server-only finding: the route module, the route's offending top-level import (the
|
|
@@ -1805,28 +3096,150 @@ export interface TransitiveServerImportFinding extends ServerImportFinding {
|
|
|
1805
3096
|
/** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
|
|
1806
3097
|
* the route imports the sink directly (same as the regex scan's direct edge). */
|
|
1807
3098
|
readonly chain: readonly string[]
|
|
3099
|
+
/** The project files the chain runs through, as `resolve` named them: the route, each module the
|
|
3100
|
+
* walk entered, and the sink when it is a project file. A change to any of them can make the chain. */
|
|
3101
|
+
readonly modules: readonly string[]
|
|
1808
3102
|
/** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
|
|
1809
3103
|
* so the chain is the honest direct edge rather than a fabricated deeper path. */
|
|
1810
3104
|
readonly fallback: boolean
|
|
1811
3105
|
}
|
|
1812
3106
|
|
|
1813
|
-
/** A resolver from `(fromFile, specifier)` to
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
3107
|
+
/** A resolver from `(fromFile, specifier)` to a project module path, or `undefined` when it names no
|
|
3108
|
+
* project file (a package, a missing file). Abstracted so the BFS is unit-testable with a fake graph (no
|
|
3109
|
+
* real fs). The production resolver follows relative paths, tsconfig `paths` and package.json `imports`. */
|
|
1816
3110
|
export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
|
|
1817
3111
|
/** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
|
|
1818
3112
|
export type ModuleReader = (absPath: string) => string | undefined
|
|
1819
3113
|
|
|
3114
|
+
/** What a server-only chain search reads the project through. */
|
|
3115
|
+
export interface ServerOnlyChainSource {
|
|
3116
|
+
readonly resolve: ModuleResolver
|
|
3117
|
+
readonly read: ModuleReader
|
|
3118
|
+
readonly facts?: SourceFacts | undefined
|
|
3119
|
+
readonly componentReceipts?: ReadonlySet<string>
|
|
3120
|
+
/** Whether a bare specifier `resolve` leaves unanswered names an installed package that declares
|
|
3121
|
+
* `"nifra": { "environment": "backend" }`, which the browser build refuses. */
|
|
3122
|
+
readonly backendPackage?: (fromFile: string, specifier: string) => boolean
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
/** A chain from a module to a sink: `[start, ...as-written specifiers, sink]`, and the project files
|
|
3126
|
+
* it runs through (see {@link TransitiveServerImportFinding.modules}). */
|
|
3127
|
+
interface ChainWalk {
|
|
3128
|
+
readonly chain: readonly string[]
|
|
3129
|
+
readonly modules: readonly string[]
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
/** The chain searches of one check, sharing what they learn about each module. */
|
|
3133
|
+
interface ChainGraph {
|
|
3134
|
+
/** The shortest chain from `start` to a sink. */
|
|
3135
|
+
walk(start: string, content: string): ChainWalk | undefined
|
|
3136
|
+
read(abs: string): string | undefined
|
|
3137
|
+
resolve(from: string, specifier: string): string | undefined
|
|
3138
|
+
backendPackage(from: string, specifier: string): boolean
|
|
3139
|
+
readonly componentReceipts: ReadonlySet<string>
|
|
3140
|
+
readonly facts: SourceFacts | undefined
|
|
3141
|
+
}
|
|
3142
|
+
|
|
1820
3143
|
/**
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
3144
|
+
* Every module is read, parsed and resolved at most once, and the chain from a module is searched at
|
|
3145
|
+
* most once, however many routes reach it: per-route searches repeated all of that for every route
|
|
3146
|
+
* that shared a module, which made a check quadratic in the size of a shared graph.
|
|
3147
|
+
*/
|
|
3148
|
+
function chainGraph(source: ServerOnlyChainSource): ChainGraph {
|
|
3149
|
+
const { facts } = source
|
|
3150
|
+
const componentReceipts = source.componentReceipts ?? new Set<string>()
|
|
3151
|
+
const contents = new Map<string, string | undefined>()
|
|
3152
|
+
const imports = new Map<string, readonly string[]>()
|
|
3153
|
+
const resolutions = new Map<string, string | undefined>()
|
|
3154
|
+
const walks = new Map<string, ChainWalk | undefined>()
|
|
3155
|
+
const packages = new Map<string, boolean>()
|
|
3156
|
+
const read = (abs: string): string | undefined => {
|
|
3157
|
+
if (!contents.has(abs)) contents.set(abs, source.read(abs))
|
|
3158
|
+
return contents.get(abs)
|
|
3159
|
+
}
|
|
3160
|
+
const resolve = (from: string, specifier: string): string | undefined => {
|
|
3161
|
+
const key = `${from}\0${specifier}`
|
|
3162
|
+
if (!resolutions.has(key)) resolutions.set(key, source.resolve(from, specifier))
|
|
3163
|
+
return resolutions.get(key)
|
|
3164
|
+
}
|
|
3165
|
+
const backendPackage = (from: string, specifier: string): boolean => {
|
|
3166
|
+
if (source.backendPackage === undefined || /^[./#]/.test(specifier)) return false
|
|
3167
|
+
const key = `${from}\0${specifier}`
|
|
3168
|
+
let found = packages.get(key)
|
|
3169
|
+
if (found === undefined) {
|
|
3170
|
+
found = source.backendPackage(from, specifier)
|
|
3171
|
+
packages.set(key, found)
|
|
3172
|
+
}
|
|
3173
|
+
return found
|
|
3174
|
+
}
|
|
3175
|
+
const importsOf = (abs: string, content: string): readonly string[] => {
|
|
3176
|
+
let found = imports.get(abs)
|
|
3177
|
+
if (found === undefined) {
|
|
3178
|
+
found = parseImports(content, facts, abs)
|
|
3179
|
+
imports.set(abs, found)
|
|
3180
|
+
}
|
|
3181
|
+
return found
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
|
|
3185
|
+
* dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
|
|
3186
|
+
* never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
|
|
3187
|
+
* importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
|
|
3188
|
+
* entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
|
|
3189
|
+
*/
|
|
3190
|
+
const search = (start: string, startContent: string): ChainWalk | undefined => {
|
|
3191
|
+
interface Node extends ChainWalk {
|
|
3192
|
+
readonly abs: string
|
|
3193
|
+
readonly content: string
|
|
3194
|
+
}
|
|
3195
|
+
const seen = new Set<string>([start])
|
|
3196
|
+
let frontier: Node[] = [{ abs: start, content: startContent, chain: [start], modules: [start] }]
|
|
3197
|
+
while (frontier.length > 0) {
|
|
3198
|
+
const next: Node[] = []
|
|
3199
|
+
for (const node of frontier) {
|
|
3200
|
+
const { chain, modules } = node
|
|
3201
|
+
for (const spec of importsOf(node.abs, node.content)) {
|
|
3202
|
+
const sink = directSinkSpecifier(spec)
|
|
3203
|
+
if (sink !== undefined) return { chain: [...chain, sink], modules }
|
|
3204
|
+
const abs = resolve(node.abs, spec)
|
|
3205
|
+
if (abs === undefined) {
|
|
3206
|
+
if (backendPackage(node.abs, spec)) return { chain: [...chain, spec], modules }
|
|
3207
|
+
continue
|
|
3208
|
+
}
|
|
3209
|
+
if (seen.has(abs)) continue
|
|
3210
|
+
if (componentReceipts.has(abs)) continue
|
|
3211
|
+
if (isBackendModule(abs)) return { chain: [...chain, spec], modules: [...modules, abs] }
|
|
3212
|
+
if (SERVER_FN_FILE.test(abs)) continue
|
|
3213
|
+
const content = read(abs)
|
|
3214
|
+
if (content === undefined) continue
|
|
3215
|
+
if (hasBackendOnlyMarker(content)) {
|
|
3216
|
+
return { chain: [...chain, spec], modules: [...modules, abs] }
|
|
3217
|
+
}
|
|
3218
|
+
seen.add(abs)
|
|
3219
|
+
next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] })
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
frontier = next
|
|
3223
|
+
}
|
|
3224
|
+
return undefined
|
|
3225
|
+
}
|
|
3226
|
+
return {
|
|
3227
|
+
walk(start, content) {
|
|
3228
|
+
if (!walks.has(start)) walks.set(start, search(start, content))
|
|
3229
|
+
return walks.get(start)
|
|
3230
|
+
},
|
|
3231
|
+
read,
|
|
3232
|
+
resolve,
|
|
3233
|
+
backendPackage,
|
|
3234
|
+
componentReceipts,
|
|
3235
|
+
facts,
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
/**
|
|
3240
|
+
* The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
|
|
3241
|
+
* specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
|
|
3242
|
+
* graph tests it.
|
|
1830
3243
|
*/
|
|
1831
3244
|
export function walkServerOnlyChain(
|
|
1832
3245
|
routeFile: string,
|
|
@@ -1834,49 +3247,25 @@ export function walkServerOnlyChain(
|
|
|
1834
3247
|
resolve: ModuleResolver,
|
|
1835
3248
|
read: ModuleReader,
|
|
1836
3249
|
facts?: SourceFacts,
|
|
3250
|
+
componentReceipts: ReadonlySet<string> = new Set(),
|
|
1837
3251
|
): 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
|
|
3252
|
+
return chainGraph({
|
|
3253
|
+
resolve,
|
|
3254
|
+
read,
|
|
3255
|
+
facts,
|
|
3256
|
+
componentReceipts,
|
|
3257
|
+
}).walk(routeFile, routeContent)?.chain
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
/**
|
|
3261
|
+
* The server-only chain finder for one check: called per browser module, it returns that module's
|
|
3262
|
+
* findings, reusing every module and chain an earlier call already searched.
|
|
3263
|
+
*/
|
|
3264
|
+
export function createServerOnlyChains(
|
|
3265
|
+
source: ServerOnlyChainSource,
|
|
3266
|
+
): (file: string, content: string, browserRoot?: boolean) => TransitiveServerImportFinding[] {
|
|
3267
|
+
const graph = chainGraph(source)
|
|
3268
|
+
return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot)
|
|
1880
3269
|
}
|
|
1881
3270
|
|
|
1882
3271
|
/**
|
|
@@ -1885,7 +3274,9 @@ export function walkServerOnlyChain(
|
|
|
1885
3274
|
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
1886
3275
|
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
1887
3276
|
* 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.
|
|
3277
|
+
* de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
|
|
3278
|
+
* yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
|
|
3279
|
+
* One check should use {@link createServerOnlyChains}, which shares the work across modules.
|
|
1889
3280
|
*/
|
|
1890
3281
|
export function resolveServerOnlyChains(
|
|
1891
3282
|
file: string,
|
|
@@ -1893,71 +3284,76 @@ export function resolveServerOnlyChains(
|
|
|
1893
3284
|
resolve: ModuleResolver,
|
|
1894
3285
|
read: ModuleReader,
|
|
1895
3286
|
facts?: SourceFacts,
|
|
3287
|
+
componentReceipts: ReadonlySet<string> = new Set(),
|
|
3288
|
+
browserRoot = false,
|
|
1896
3289
|
): TransitiveServerImportFinding[] {
|
|
1897
|
-
|
|
3290
|
+
return createServerOnlyChains({
|
|
3291
|
+
resolve,
|
|
3292
|
+
read,
|
|
3293
|
+
facts,
|
|
3294
|
+
componentReceipts,
|
|
3295
|
+
})(file, content, browserRoot)
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
function serverOnlyChains(
|
|
3299
|
+
graph: ChainGraph,
|
|
3300
|
+
file: string,
|
|
3301
|
+
content: string,
|
|
3302
|
+
browserRoot: boolean,
|
|
3303
|
+
): TransitiveServerImportFinding[] {
|
|
3304
|
+
if (!browserRoot && !isBrowserSource(file)) return []
|
|
1898
3305
|
const lines = content.split("\n")
|
|
1899
3306
|
const out: TransitiveServerImportFinding[] = []
|
|
1900
3307
|
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)) {
|
|
3308
|
+
for (const { specifier, index } of importEdges(content, graph.facts, file)) {
|
|
1905
3309
|
if (flaggedSpecifiers.has(specifier)) continue
|
|
1906
3310
|
const line = lineAt(content, index)
|
|
1907
3311
|
const snippet = (lines[line - 1] ?? "").trim()
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
3312
|
+
const flag = (
|
|
3313
|
+
chain: readonly string[],
|
|
3314
|
+
fallback: boolean,
|
|
3315
|
+
modules: readonly string[] = [file],
|
|
3316
|
+
): void => {
|
|
1911
3317
|
flaggedSpecifiers.add(specifier)
|
|
1912
|
-
out.push({ file, line, snippet, specifier, chain
|
|
3318
|
+
out.push({ file, line, snippet, specifier, chain, modules, fallback })
|
|
3319
|
+
}
|
|
3320
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
|
|
3321
|
+
if (directSinkSpecifier(specifier) !== undefined) {
|
|
3322
|
+
flag([file, specifier], false)
|
|
1913
3323
|
continue
|
|
1914
3324
|
}
|
|
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
|
-
}
|
|
3325
|
+
// (2) A project import, relative or through an alias: walk from the resolved dependency and, if
|
|
3326
|
+
// it reaches a sink, report the full chain rooted at THIS route's import line.
|
|
3327
|
+
const abs = graph.resolve(file, specifier)
|
|
3328
|
+
if (abs === undefined) {
|
|
3329
|
+
// An installed package that declares itself backend is a sink. Any other name no project file
|
|
3330
|
+
// answers to can't be called server-only, except the `../db` convention, reported as the direct
|
|
3331
|
+
// edge with `fallback` saying the resolve was impossible.
|
|
3332
|
+
if (graph.backendPackage(file, specifier)) flag([file, specifier], false)
|
|
3333
|
+
else if (isServerOnly(specifier)) flag([file, specifier], true)
|
|
3334
|
+
continue
|
|
3335
|
+
}
|
|
3336
|
+
if (graph.componentReceipts.has(abs)) continue
|
|
3337
|
+
// A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
|
|
3338
|
+
if (isBackendModule(abs)) {
|
|
3339
|
+
flag([file, specifier], false, [file, abs])
|
|
3340
|
+
continue
|
|
1960
3341
|
}
|
|
3342
|
+
if (SERVER_FN_FILE.test(abs)) continue
|
|
3343
|
+
const depContent = graph.read(abs)
|
|
3344
|
+
if (depContent === undefined) continue
|
|
3345
|
+
if (hasBackendOnlyMarker(depContent)) {
|
|
3346
|
+
flag([file, specifier], false, [file, abs])
|
|
3347
|
+
continue
|
|
3348
|
+
}
|
|
3349
|
+
const sub = graph.walk(abs, depContent)
|
|
3350
|
+
if (sub !== undefined) {
|
|
3351
|
+
// `sub` starts at the resolved dependency; the route names it by its as-written specifier.
|
|
3352
|
+
flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules])
|
|
3353
|
+
continue
|
|
3354
|
+
}
|
|
3355
|
+
// (3) The `../db` convention with no deeper sink found: still surface the direct edge.
|
|
3356
|
+
if (isServerOnly(specifier)) flag([file, specifier], true)
|
|
1961
3357
|
}
|
|
1962
3358
|
return out
|
|
1963
3359
|
}
|
|
@@ -1990,23 +3386,19 @@ export function scanResponseRoutes(
|
|
|
1990
3386
|
if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
|
|
1991
3387
|
}
|
|
1992
3388
|
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() })
|
|
3389
|
+
// A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
|
|
3390
|
+
// (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
|
|
3391
|
+
// It counts on the return line or in the comment block directly above, so a TRAILING comment on one
|
|
3392
|
+
// route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
|
|
3393
|
+
// never affects the match); the raw `lines` still carry it.
|
|
3394
|
+
if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined) continue
|
|
3395
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
2004
3396
|
}
|
|
2005
3397
|
return out
|
|
2006
3398
|
}
|
|
2007
3399
|
|
|
2008
|
-
/**
|
|
2009
|
-
const
|
|
3400
|
+
/** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
3401
|
+
const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"]
|
|
2010
3402
|
|
|
2011
3403
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
2012
3404
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
@@ -2054,22 +3446,69 @@ export async function walkSource(
|
|
|
2054
3446
|
readonly includeTests?: boolean
|
|
2055
3447
|
/** Additional project-relative paths to exclude from this scan. */
|
|
2056
3448
|
readonly ignore?: (relativePath: string) => boolean
|
|
3449
|
+
/**
|
|
3450
|
+
* Also visit the other files a build loads: every `.jsx`, `.svelte` and `.vue` file, and `.js`
|
|
3451
|
+
* files in a zone. A `.svelte`/`.vue` file is visited as {@link componentScripts}.
|
|
3452
|
+
*/
|
|
3453
|
+
readonly scripts?: boolean
|
|
2057
3454
|
} = {},
|
|
2058
3455
|
): Promise<void> {
|
|
2059
3456
|
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
|
|
3457
|
+
const pattern =
|
|
3458
|
+
opts.scripts === true
|
|
3459
|
+
? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
|
|
3460
|
+
: "**/*.{ts,tsx,mts,cts}"
|
|
2060
3461
|
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
2061
3462
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
2062
3463
|
const rels: string[] = []
|
|
2063
|
-
for await (const rawRel of new Glob(
|
|
3464
|
+
for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
|
|
2064
3465
|
const rel = normalizeProjectPath(rawRel)
|
|
3466
|
+
// A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
|
|
3467
|
+
if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel)) continue
|
|
2065
3468
|
if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
|
|
2066
3469
|
}
|
|
2067
3470
|
const ignored = await gitIgnored(cwd, rels)
|
|
2068
3471
|
// Glob order is the filesystem's, so findings would come out in a different order on each platform.
|
|
2069
3472
|
for (const rel of rels.sort(codeUnitOrder)) {
|
|
2070
3473
|
if (ignored.has(rel)) continue
|
|
2071
|
-
|
|
3474
|
+
const text = await Bun.file(join(cwd, rel)).text()
|
|
3475
|
+
visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text)
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
const PLAIN_SCRIPT_FILE = /\.[cm]?js$/
|
|
3480
|
+
const ZONED_PATH =
|
|
3481
|
+
/(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/
|
|
3482
|
+
/** A single-file component: its `<script>` blocks are the code a build compiles. */
|
|
3483
|
+
export const COMPONENT_FILE = /\.(?:svelte|vue)$/
|
|
3484
|
+
|
|
3485
|
+
/**
|
|
3486
|
+
* A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
|
|
3487
|
+
* they stand, and every other character blanked to a space with line breaks kept, so a finding's line
|
|
3488
|
+
* and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
|
|
3489
|
+
*/
|
|
3490
|
+
export function componentScripts(text: string): string {
|
|
3491
|
+
const blank = (part: string): string => part.replace(/[^\n]/g, " ")
|
|
3492
|
+
let out = ""
|
|
3493
|
+
let at = 0
|
|
3494
|
+
for (;;) {
|
|
3495
|
+
const open = text.indexOf("<script", at)
|
|
3496
|
+
if (open === -1) break
|
|
3497
|
+
const close = text.indexOf(">", open)
|
|
3498
|
+
if (close === -1) break
|
|
3499
|
+
// `<scripts>` or `<script-x>` is another element.
|
|
3500
|
+
if (!/[\s>]/.test(text[open + 7] ?? "")) {
|
|
3501
|
+
out += blank(text.slice(at, close + 1))
|
|
3502
|
+
at = close + 1
|
|
3503
|
+
continue
|
|
3504
|
+
}
|
|
3505
|
+
const end = text.indexOf("</script", close + 1)
|
|
3506
|
+
const stop = end === -1 ? text.length : end
|
|
3507
|
+
out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop)
|
|
3508
|
+
at = stop
|
|
3509
|
+
if (end === -1) break
|
|
2072
3510
|
}
|
|
3511
|
+
return out + blank(text.slice(at))
|
|
2073
3512
|
}
|
|
2074
3513
|
|
|
2075
3514
|
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|