@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/dist/check-scan.js
CHANGED
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
* source text plus injected module/type resolvers, which keeps the scanner seam independently testable.
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync, realpathSync } from "node:fs";
|
|
8
|
-
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
8
|
+
import { dirname, isAbsolute, join, posix, resolve, sep } from "node:path";
|
|
9
|
+
import { isServerPackageSpecifier } from "@nifrajs/web/zones";
|
|
9
10
|
import { Glob } from "bun";
|
|
10
11
|
import { codeUnitOrder } from "./internal/code-unit-order.js";
|
|
11
12
|
import { scriptKindOf } from "./internal/script-kind.js";
|
|
12
13
|
import { loadProjectTypeScript, } from "./internal/typescript-import.js";
|
|
13
|
-
import {
|
|
14
|
+
import { commentBlockExpectation } from "./rules/comment-markers.js";
|
|
15
|
+
/** What marks a hand-rolled own-API call as intended: a streamed POST, a foreign handler's endpoint,
|
|
16
|
+
* a static file. Only the code form; a reason after the colon is required. */
|
|
17
|
+
const TYPED_CLIENT_EXPECTATION = ["NF-C002"];
|
|
18
|
+
function typedClientFinding(file, lines, line) {
|
|
19
|
+
const finding = { file, line, snippet: (lines[line - 1] ?? "").trim() };
|
|
20
|
+
const expected = commentBlockExpectation(lines, line, TYPED_CLIENT_EXPECTATION);
|
|
21
|
+
return expected === undefined ? finding : { ...finding, expected };
|
|
22
|
+
}
|
|
14
23
|
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
15
24
|
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
16
25
|
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
@@ -18,6 +27,7 @@ import { commentBlockMarkerReason } from "./rules/comment-markers.js";
|
|
|
18
27
|
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
|
|
19
28
|
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
|
|
20
29
|
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
30
|
+
const SOCKET_VERB = new Set(["WS"]);
|
|
21
31
|
export const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
|
|
22
32
|
// Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
|
|
23
33
|
// per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
|
|
@@ -56,9 +66,52 @@ function isBackendModule(file) {
|
|
|
56
66
|
LEGACY_SERVER_FILE.test(path) ||
|
|
57
67
|
(BACKEND_FOLDER.test(path) && !BROWSER_SUFFIX.test(path)));
|
|
58
68
|
}
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
const
|
|
69
|
+
// Node builtins that have no browser counterpart, imported bare. Bare `events`, `buffer`, `path`, `util`
|
|
70
|
+
// and the like stay out: each also names an npm polyfill a browser bundle may legitimately carry.
|
|
71
|
+
const SERVER_BUILTINS = new Set([
|
|
72
|
+
"async_hooks",
|
|
73
|
+
"child_process",
|
|
74
|
+
"cluster",
|
|
75
|
+
"crypto",
|
|
76
|
+
"dgram",
|
|
77
|
+
"diagnostics_channel",
|
|
78
|
+
"dns",
|
|
79
|
+
"dns/promises",
|
|
80
|
+
"fs",
|
|
81
|
+
"fs/promises",
|
|
82
|
+
"http",
|
|
83
|
+
"http2",
|
|
84
|
+
"https",
|
|
85
|
+
"inspector",
|
|
86
|
+
"module",
|
|
87
|
+
"net",
|
|
88
|
+
"os",
|
|
89
|
+
"perf_hooks",
|
|
90
|
+
"readline",
|
|
91
|
+
"readline/promises",
|
|
92
|
+
"repl",
|
|
93
|
+
"tls",
|
|
94
|
+
"trace_events",
|
|
95
|
+
"tty",
|
|
96
|
+
"v8",
|
|
97
|
+
"vm",
|
|
98
|
+
"wasi",
|
|
99
|
+
"worker_threads",
|
|
100
|
+
]);
|
|
101
|
+
// The conventional `./db` module the scaffold generates.
|
|
102
|
+
const DB_MODULE = /^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/;
|
|
103
|
+
/**
|
|
104
|
+
* Whether a specifier names server-only code by name, so the chain ends there: a `node:`/`bun:`
|
|
105
|
+
* module, a bare builtin with no browser counterpart, or a package the browser build refuses
|
|
106
|
+
* (`SERVER_PACKAGES` and the driver entries of `@nifrajs/web/zones`).
|
|
107
|
+
*/
|
|
108
|
+
function isServerOnlySink(specifier) {
|
|
109
|
+
return (/^(?:node:|bun:)/.test(specifier) ||
|
|
110
|
+
SERVER_BUILTINS.has(specifier) ||
|
|
111
|
+
isServerPackageSpecifier(specifier));
|
|
112
|
+
}
|
|
113
|
+
/** A specifier no route module may value-import: a {@link isServerOnlySink} name, or the `./db` module. */
|
|
114
|
+
const isServerOnly = (specifier) => isServerOnlySink(specifier) || DB_MODULE.test(specifier);
|
|
62
115
|
// A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
|
|
63
116
|
// pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
|
|
64
117
|
// build, so they're safe and skipped. Dynamic `import(…)` has `(` right after `import`, so `import\s+`
|
|
@@ -74,6 +127,10 @@ const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)
|
|
|
74
127
|
// Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
|
|
75
128
|
// A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
|
|
76
129
|
const TYPE_QUERY_BEFORE = /\btypeof\s*$/;
|
|
130
|
+
// `require("x")` and `import x = require("x")` pull a module into a bundle as `import` does; the
|
|
131
|
+
// `import type x = require("x")` form is erased.
|
|
132
|
+
const REQUIRE_CALL = /(?<![\w$.])require\s*\(\s*(["'])([^"'\\\r\n]+)\1\s*\)/g;
|
|
133
|
+
const TYPE_ALIAS_BEFORE = /\bimport\s+type\s+[\w$]+\s*=\s*$/;
|
|
77
134
|
const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/;
|
|
78
135
|
const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g;
|
|
79
136
|
const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g;
|
|
@@ -85,6 +142,10 @@ const ALL_ROUTES = /(?<![\w$.])all\s*\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\]
|
|
|
85
142
|
const METHOD_ROUTES = /(?<![\w$.])method\s*\(\s*(\[[^\]]*\]|"[^"\\]*"|'[^'\\]*')\s*,\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g;
|
|
86
143
|
const METHOD_NAME_LITERAL = /(["'])([A-Za-z][A-Za-z0-9-]{0,31})\1/g;
|
|
87
144
|
export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
145
|
+
// `.get(NAME, …)` or `` .get(`…`, …) ``: a path that is not a quoted literal. The comma is required:
|
|
146
|
+
// a registration always has a handler after its path, while `map.get(key)` has nothing.
|
|
147
|
+
const ROUTE_ARGUMENT = /\.([A-Za-z]+)\s*\(\s*([A-Za-z_$][\w$]*|`(?:\\.|[^`\\])*`)\s*,/g;
|
|
148
|
+
const NAMED_IMPORTS = /(?<![\w$.])import\s*\{([^}]*)\}\s*from\s*(["'])([^"'\r\n]+)\2/g;
|
|
88
149
|
/** Line number (1-based) of a match index within `content`. */
|
|
89
150
|
const lineAt = (content, index) => content.slice(0, index).split("\n").length;
|
|
90
151
|
function parseQuotedLiteral(raw) {
|
|
@@ -233,19 +294,50 @@ export function parseSimpleFetchCall(snippet) {
|
|
|
233
294
|
return undefined;
|
|
234
295
|
return { path, method, start: match.index, end: close + 1 };
|
|
235
296
|
}
|
|
236
|
-
|
|
297
|
+
const SERVER_CONSTRUCTION = /(?<![.\w])server\s*\(|new\s+Hono(?:<[^>]+>)?\s*\(/;
|
|
298
|
+
function isPotentialBackendSource(code, file, project) {
|
|
237
299
|
return (code.includes("@nifrajs/core") ||
|
|
238
|
-
|
|
300
|
+
SERVER_CONSTRUCTION.test(code) ||
|
|
239
301
|
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
240
|
-
|
|
302
|
+
(file !== undefined && project !== undefined && callsLocalServerFactory(code, file, project)));
|
|
241
303
|
}
|
|
242
|
-
|
|
304
|
+
/**
|
|
305
|
+
* A module that builds its server through a local helper - `newServer().get(...)` with `newServer`
|
|
306
|
+
* imported from a project file that calls `server()` - names neither `@nifrajs/core` nor `server(`
|
|
307
|
+
* itself. One import hop is followed: the imported module must construct a server, so a helper module
|
|
308
|
+
* that merely mentions `@nifrajs/core` (a schema re-export) does not turn every importer into a
|
|
309
|
+
* backend and its `client.get("/x")` calls into routes.
|
|
310
|
+
*/
|
|
311
|
+
function callsLocalServerFactory(code, file, project) {
|
|
312
|
+
for (const [local, binding] of namedImports(code)) {
|
|
313
|
+
if (!new RegExp(`(?<![.\\w$])${local.replaceAll("$", "\\$")}\\s*\\(`).test(code))
|
|
314
|
+
continue;
|
|
315
|
+
const target = projectModule(project, file, binding.specifier);
|
|
316
|
+
if (target !== undefined && SERVER_CONSTRUCTION.test(stripComments(project.get(target) ?? "")))
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Literal route registrations in a file the backend-source gate turned away, so a route the scan
|
|
323
|
+
* could not cover can say where it probably lives. Nothing here is treated as a route.
|
|
324
|
+
*/
|
|
325
|
+
export function scanSkippedRouteText(file, content, project) {
|
|
326
|
+
const code = stripComments(content);
|
|
327
|
+
if (isPotentialBackendSource(code, file, project))
|
|
328
|
+
return [];
|
|
329
|
+
return [
|
|
330
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
|
|
331
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
function scanRoutePattern(file, content, code, pattern, facts, verbs = HTTP_VERBS) {
|
|
243
335
|
const out = [];
|
|
244
336
|
const lines = content.split("\n");
|
|
245
337
|
pattern.lastIndex = 0;
|
|
246
338
|
for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
|
|
247
339
|
const method = (m[1] ?? "").toUpperCase();
|
|
248
|
-
if (!
|
|
340
|
+
if (!verbs.has(method))
|
|
249
341
|
continue;
|
|
250
342
|
const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
|
|
251
343
|
if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
|
|
@@ -263,27 +355,100 @@ function scanRoutePattern(file, content, code, pattern, facts) {
|
|
|
263
355
|
}
|
|
264
356
|
return out;
|
|
265
357
|
}
|
|
358
|
+
// Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
|
|
359
|
+
// as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
|
|
360
|
+
// is the cost of not tracking parser context.
|
|
361
|
+
const REGEX_AFTER = new Set([
|
|
362
|
+
"(",
|
|
363
|
+
",",
|
|
364
|
+
"=",
|
|
365
|
+
":",
|
|
366
|
+
"[",
|
|
367
|
+
"!",
|
|
368
|
+
"&",
|
|
369
|
+
"|",
|
|
370
|
+
"?",
|
|
371
|
+
"{",
|
|
372
|
+
";",
|
|
373
|
+
"+",
|
|
374
|
+
"-",
|
|
375
|
+
"*",
|
|
376
|
+
"%",
|
|
377
|
+
"<",
|
|
378
|
+
">",
|
|
379
|
+
"~",
|
|
380
|
+
"^",
|
|
381
|
+
]);
|
|
382
|
+
const REGEX_AFTER_WORD = new Set([
|
|
383
|
+
"return",
|
|
384
|
+
"typeof",
|
|
385
|
+
"case",
|
|
386
|
+
"do",
|
|
387
|
+
"else",
|
|
388
|
+
"in",
|
|
389
|
+
"of",
|
|
390
|
+
"new",
|
|
391
|
+
"delete",
|
|
392
|
+
"void",
|
|
393
|
+
"throw",
|
|
394
|
+
"yield",
|
|
395
|
+
"await",
|
|
396
|
+
"instanceof",
|
|
397
|
+
]);
|
|
398
|
+
const isWordChar = (c) => c !== undefined && /[\w$]/.test(c);
|
|
399
|
+
/** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
|
|
400
|
+
* literal does not close on its line - then the slash was division after all. */
|
|
401
|
+
function regexLiteralEnd(src, i) {
|
|
402
|
+
const n = src.length;
|
|
403
|
+
let j = i + 1;
|
|
404
|
+
let inClass = false;
|
|
405
|
+
while (j < n) {
|
|
406
|
+
const c = src[j];
|
|
407
|
+
if (c === "\n" || c === "\r")
|
|
408
|
+
return undefined;
|
|
409
|
+
if (c === "\\") {
|
|
410
|
+
j += 2;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (inClass) {
|
|
414
|
+
if (c === "]")
|
|
415
|
+
inClass = false;
|
|
416
|
+
}
|
|
417
|
+
else if (c === "[") {
|
|
418
|
+
inClass = true;
|
|
419
|
+
}
|
|
420
|
+
else if (c === "/") {
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
j++;
|
|
424
|
+
}
|
|
425
|
+
if (j >= n)
|
|
426
|
+
return undefined;
|
|
427
|
+
j++;
|
|
428
|
+
while (j < n && isWordChar(src[j]))
|
|
429
|
+
j++;
|
|
430
|
+
return j;
|
|
431
|
+
}
|
|
266
432
|
/**
|
|
267
|
-
* Blank
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
|
|
273
|
-
* could mis-skip - in practice the constructs these scanners look for sit before any such regex.
|
|
274
|
-
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
433
|
+
* Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
|
|
434
|
+
* single/double-quoted strings and every delimiter) while preserving every offset and newline.
|
|
435
|
+
* A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
|
|
436
|
+
* an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
|
|
437
|
+
* the string state that follows.
|
|
275
438
|
*/
|
|
276
|
-
|
|
439
|
+
function maskSource(src, quoted) {
|
|
277
440
|
const out = src.split("");
|
|
278
441
|
const n = src.length;
|
|
279
|
-
let i = 0;
|
|
280
442
|
const blank = (a, b) => {
|
|
281
443
|
for (let k = a; k < b; k++)
|
|
282
444
|
if (out[k] !== "\n")
|
|
283
445
|
out[k] = " ";
|
|
284
446
|
};
|
|
447
|
+
// The last significant code token: a punctuator character, or a whole identifier/keyword.
|
|
448
|
+
let previous = "";
|
|
449
|
+
let i = 0;
|
|
285
450
|
while (i < n) {
|
|
286
|
-
const c = src
|
|
451
|
+
const c = src.charAt(i);
|
|
287
452
|
const d = src[i + 1];
|
|
288
453
|
if (c === "/" && d === "/") {
|
|
289
454
|
let j = i + 2;
|
|
@@ -307,8 +472,12 @@ export function stripComments(src) {
|
|
|
307
472
|
j++;
|
|
308
473
|
j++;
|
|
309
474
|
}
|
|
310
|
-
|
|
475
|
+
if (quoted === "keep")
|
|
476
|
+
blank(i + 1, j); // keep the backticks, blank the code-as-text inside
|
|
477
|
+
else
|
|
478
|
+
blank(i, Math.min(j + 1, n));
|
|
311
479
|
i = j + 1;
|
|
480
|
+
previous = "`";
|
|
312
481
|
}
|
|
313
482
|
else if (c === "'" || c === '"') {
|
|
314
483
|
let j = i + 1;
|
|
@@ -317,63 +486,61 @@ export function stripComments(src) {
|
|
|
317
486
|
j++; // skip the escaped char
|
|
318
487
|
j++;
|
|
319
488
|
}
|
|
489
|
+
if (quoted === "blank")
|
|
490
|
+
blank(i, Math.min(j + 1, n));
|
|
320
491
|
i = j + 1;
|
|
492
|
+
previous = c;
|
|
321
493
|
}
|
|
322
|
-
else
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
for (let k = a; k < b; k++)
|
|
338
|
-
if (out[k] !== "\n")
|
|
339
|
-
out[k] = " ";
|
|
340
|
-
};
|
|
341
|
-
while (i < n) {
|
|
342
|
-
const c = src[i];
|
|
343
|
-
const d = src[i + 1];
|
|
344
|
-
if (c === "/" && d === "/") {
|
|
345
|
-
let j = i + 2;
|
|
346
|
-
while (j < n && src[j] !== "\n")
|
|
347
|
-
j++;
|
|
348
|
-
blank(i, j);
|
|
349
|
-
i = j;
|
|
350
|
-
}
|
|
351
|
-
else if (c === "/" && d === "*") {
|
|
352
|
-
let j = i + 2;
|
|
353
|
-
while (j < n && !(src[j] === "*" && src[j + 1] === "/"))
|
|
354
|
-
j++;
|
|
355
|
-
j = Math.min(j + 2, n);
|
|
356
|
-
blank(i, j);
|
|
357
|
-
i = j;
|
|
494
|
+
else if (c === "/" &&
|
|
495
|
+
(previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))) {
|
|
496
|
+
const end = regexLiteralEnd(src, i);
|
|
497
|
+
if (end === undefined) {
|
|
498
|
+
previous = c;
|
|
499
|
+
i++;
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
if (quoted === "keep")
|
|
503
|
+
blank(i + 1, end);
|
|
504
|
+
else
|
|
505
|
+
blank(i, end);
|
|
506
|
+
previous = "/regex";
|
|
507
|
+
i = end;
|
|
508
|
+
}
|
|
358
509
|
}
|
|
359
|
-
else if (c
|
|
360
|
-
const quote = c;
|
|
510
|
+
else if (isWordChar(c)) {
|
|
361
511
|
let j = i + 1;
|
|
362
|
-
while (j < n && src[j]
|
|
363
|
-
if (src[j] === "\\")
|
|
364
|
-
j++;
|
|
512
|
+
while (j < n && isWordChar(src[j]))
|
|
365
513
|
j++;
|
|
366
|
-
|
|
367
|
-
j = Math.min(j + 1, n);
|
|
368
|
-
blank(i, j);
|
|
514
|
+
previous = src.slice(i, j);
|
|
369
515
|
i = j;
|
|
370
516
|
}
|
|
371
517
|
else {
|
|
518
|
+
if (!/\s/.test(c))
|
|
519
|
+
previous = c;
|
|
372
520
|
i++;
|
|
373
521
|
}
|
|
374
522
|
}
|
|
375
523
|
return out.join("");
|
|
376
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
527
|
+
* - `//` line comments and block comments;
|
|
528
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
529
|
+
* generators), never a real statement to lint.
|
|
530
|
+
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
|
|
531
|
+
* literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
|
|
532
|
+
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
533
|
+
*/
|
|
534
|
+
export function stripComments(src) {
|
|
535
|
+
return maskSource(src, "keep");
|
|
536
|
+
}
|
|
537
|
+
/** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
|
|
538
|
+
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
539
|
+
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
540
|
+
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
541
|
+
export function codePositionMask(src) {
|
|
542
|
+
return maskSource(src, "blank");
|
|
543
|
+
}
|
|
377
544
|
const firstArgumentIndex = (content, callEnd) => {
|
|
378
545
|
let index = callEnd;
|
|
379
546
|
while (/\s/.test(content[index] ?? ""))
|
|
@@ -499,8 +666,7 @@ export function scanFetchText(file, content, externalMounts = []) {
|
|
|
499
666
|
continue;
|
|
500
667
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
501
668
|
continue;
|
|
502
|
-
|
|
503
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
669
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
|
|
504
670
|
}
|
|
505
671
|
return out;
|
|
506
672
|
}
|
|
@@ -531,20 +697,195 @@ export function scanStreamText(file, content, externalMounts = []) {
|
|
|
531
697
|
continue;
|
|
532
698
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
533
699
|
continue;
|
|
534
|
-
|
|
535
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
700
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
|
|
536
701
|
}
|
|
537
702
|
return out;
|
|
538
703
|
}
|
|
539
704
|
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
540
|
-
export function scanStaticRouteText(file, content, facts
|
|
705
|
+
export function scanStaticRouteText(file, content, facts,
|
|
706
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
707
|
+
project) {
|
|
541
708
|
const code = stripComments(content);
|
|
542
|
-
if (!isPotentialBackendSource(code))
|
|
709
|
+
if (!isPotentialBackendSource(code, file, project))
|
|
543
710
|
return [];
|
|
544
711
|
return [
|
|
545
712
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
546
713
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
547
714
|
...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
|
|
715
|
+
...scanRouteArguments(file, content, facts, project).routes,
|
|
716
|
+
].sort(bySite);
|
|
717
|
+
}
|
|
718
|
+
/** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
|
|
719
|
+
function literalEnd(text) {
|
|
720
|
+
const quote = text[0];
|
|
721
|
+
for (let i = 1; i < text.length; i++) {
|
|
722
|
+
if (text[i] === "\\")
|
|
723
|
+
i++;
|
|
724
|
+
else if (text[i] === quote)
|
|
725
|
+
return i + 1;
|
|
726
|
+
}
|
|
727
|
+
return -1;
|
|
728
|
+
}
|
|
729
|
+
/** The index after the whitespace run at `from`. */
|
|
730
|
+
function skipSpace(text, from) {
|
|
731
|
+
let i = from;
|
|
732
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t"))
|
|
733
|
+
i++;
|
|
734
|
+
return i;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
|
|
738
|
+
* indented, never stands in for a module-level one. The shape is read from the comment-stripped
|
|
739
|
+
* line; the literal itself from the source line, since stripping blanks template contents.
|
|
740
|
+
*/
|
|
741
|
+
function moduleConstantOn(code, source) {
|
|
742
|
+
const exported = code.startsWith("export ");
|
|
743
|
+
let i = exported ? skipSpace(code, 7) : 0;
|
|
744
|
+
if (!code.startsWith("const ", i))
|
|
745
|
+
return undefined;
|
|
746
|
+
i = skipSpace(code, i + 6);
|
|
747
|
+
const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0];
|
|
748
|
+
if (name === undefined)
|
|
749
|
+
return undefined;
|
|
750
|
+
i = skipSpace(code, i + name.length);
|
|
751
|
+
if (code[i] === ":")
|
|
752
|
+
i = code.indexOf("=", i);
|
|
753
|
+
if (i === -1 || code[i] !== "=" || code[i + 1] === "=")
|
|
754
|
+
return undefined;
|
|
755
|
+
i = skipSpace(code, i + 1);
|
|
756
|
+
const opener = code[i];
|
|
757
|
+
const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1;
|
|
758
|
+
const tail = end === -1 ? undefined : code.slice(i + end).trim();
|
|
759
|
+
const literal = source.slice(i, i + end);
|
|
760
|
+
const value = tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
|
|
761
|
+
? undefined
|
|
762
|
+
: opener === "`"
|
|
763
|
+
? /[$\\]/.test(literal)
|
|
764
|
+
? undefined
|
|
765
|
+
: literal.slice(1, -1)
|
|
766
|
+
: parseQuotedLiteral(literal);
|
|
767
|
+
return [name, { exported, value }];
|
|
768
|
+
}
|
|
769
|
+
function moduleConstants(code, source) {
|
|
770
|
+
const constants = new Map();
|
|
771
|
+
const sourceLines = source.split("\n");
|
|
772
|
+
code.split("\n").forEach((line, index) => {
|
|
773
|
+
const constant = moduleConstantOn(line, sourceLines[index] ?? "");
|
|
774
|
+
if (constant !== undefined)
|
|
775
|
+
constants.set(constant[0], constant[1]);
|
|
776
|
+
});
|
|
777
|
+
return constants;
|
|
778
|
+
}
|
|
779
|
+
/** Local name → the export it imports and from where, for value imports only. */
|
|
780
|
+
function namedImports(code) {
|
|
781
|
+
const imports = new Map();
|
|
782
|
+
NAMED_IMPORTS.lastIndex = 0;
|
|
783
|
+
for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
|
|
784
|
+
if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6)))
|
|
785
|
+
continue;
|
|
786
|
+
for (const element of (m[1] ?? "").split(",")) {
|
|
787
|
+
const parts = element.trim().split(/\s+/);
|
|
788
|
+
if (parts[0] === "type" || parts[0] === undefined || parts[0] === "")
|
|
789
|
+
continue;
|
|
790
|
+
const local = parts[1] === "as" ? parts[2] : parts[0];
|
|
791
|
+
if (local !== undefined)
|
|
792
|
+
imports.set(local, { specifier: m[3] ?? "", name: parts[0] });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return imports;
|
|
796
|
+
}
|
|
797
|
+
/** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
|
|
798
|
+
function projectModule(project, fromFile, specifier) {
|
|
799
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../"))
|
|
800
|
+
return undefined;
|
|
801
|
+
const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier));
|
|
802
|
+
const stem = base.replace(/\.(?:m?js|jsx)$/, "");
|
|
803
|
+
return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) => project.has(candidate));
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Route registrations whose path is a name or a template rather than a quoted literal. A name bound
|
|
807
|
+
* to a module-level string `const`, here or imported from a project file, and a template with no
|
|
808
|
+
* substitution resolve to routes; any other template, and a module-level or imported name nifra
|
|
809
|
+
* cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
|
|
810
|
+
* nothing marks such a call as a route rather than a `map.get(key, fallback)`.
|
|
811
|
+
*/
|
|
812
|
+
function scanRouteArguments(file, content, facts, project, verbs = HTTP_VERBS) {
|
|
813
|
+
const routes = [];
|
|
814
|
+
const unresolved = [];
|
|
815
|
+
const code = stripComments(content);
|
|
816
|
+
if (!isPotentialBackendSource(code, file, project))
|
|
817
|
+
return { routes, unresolved };
|
|
818
|
+
const lines = content.split("\n");
|
|
819
|
+
const constants = moduleConstants(code, content);
|
|
820
|
+
const imports = namedImports(code);
|
|
821
|
+
const importedFiles = new Map();
|
|
822
|
+
const imported = (specifier, name) => {
|
|
823
|
+
const target = project === undefined ? undefined : projectModule(project, file, specifier);
|
|
824
|
+
if (target === undefined)
|
|
825
|
+
return undefined;
|
|
826
|
+
let found = importedFiles.get(target);
|
|
827
|
+
if (found === undefined) {
|
|
828
|
+
const text = project?.get(target) ?? "";
|
|
829
|
+
found = moduleConstants(stripComments(text), text);
|
|
830
|
+
importedFiles.set(target, found);
|
|
831
|
+
}
|
|
832
|
+
const constant = found.get(name);
|
|
833
|
+
return constant?.exported === true ? constant : undefined;
|
|
834
|
+
};
|
|
835
|
+
ROUTE_ARGUMENT.lastIndex = 0;
|
|
836
|
+
for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
|
|
837
|
+
const method = (m[1] ?? "").toUpperCase();
|
|
838
|
+
if (!verbs.has(method))
|
|
839
|
+
continue;
|
|
840
|
+
const at = m.index + m[0].lastIndexOf(m[2] ?? "");
|
|
841
|
+
const argument = content.slice(at, at + (m[2] ?? "").length);
|
|
842
|
+
let path;
|
|
843
|
+
if (argument.startsWith("`")) {
|
|
844
|
+
path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1);
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
const binding = imports.get(argument);
|
|
848
|
+
const constant = binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name);
|
|
849
|
+
if (binding === undefined && constant === undefined)
|
|
850
|
+
continue;
|
|
851
|
+
path = constant?.value;
|
|
852
|
+
}
|
|
853
|
+
// A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
|
|
854
|
+
if (path !== undefined && (!path.startsWith("/") || path.startsWith("//")))
|
|
855
|
+
continue;
|
|
856
|
+
if (facts !== undefined) {
|
|
857
|
+
const source = facts.parse(file, content);
|
|
858
|
+
if (source !== undefined &&
|
|
859
|
+
facts.isRouteArgumentAt(source, m.index, method, argument) !== true)
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const line = lineAt(content, m.index);
|
|
863
|
+
const snippet = (lines[line - 1] ?? "").trim();
|
|
864
|
+
if (path !== undefined)
|
|
865
|
+
routes.push({ file, line, snippet, method, path });
|
|
866
|
+
else
|
|
867
|
+
unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) });
|
|
868
|
+
}
|
|
869
|
+
return { routes, unresolved };
|
|
870
|
+
}
|
|
871
|
+
/** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
|
|
872
|
+
export function scanUnresolvedRoutePaths(file, content, project, facts) {
|
|
873
|
+
return [
|
|
874
|
+
...scanRouteArguments(file, content, facts, project).unresolved,
|
|
875
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
|
|
876
|
+
].sort(bySite);
|
|
877
|
+
}
|
|
878
|
+
/** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
|
|
879
|
+
export function scanStaticSocketText(file, content, facts,
|
|
880
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
881
|
+
project) {
|
|
882
|
+
const code = stripComments(content);
|
|
883
|
+
if (!isPotentialBackendSource(code, file, project))
|
|
884
|
+
return [];
|
|
885
|
+
return [
|
|
886
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
|
|
887
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
|
|
888
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
|
|
548
889
|
].sort(bySite);
|
|
549
890
|
}
|
|
550
891
|
/** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
|
|
@@ -615,6 +956,24 @@ const SQL_METHODS = new Set([
|
|
|
615
956
|
]);
|
|
616
957
|
const SQL_KEYWORD = /\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;
|
|
617
958
|
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
|
|
959
|
+
/** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
|
|
960
|
+
* `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
|
|
961
|
+
* calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
|
|
962
|
+
const DRIVER_READ_METHODS = new Set([
|
|
963
|
+
"all",
|
|
964
|
+
"get",
|
|
965
|
+
"each",
|
|
966
|
+
"any",
|
|
967
|
+
"one",
|
|
968
|
+
"none",
|
|
969
|
+
"many",
|
|
970
|
+
"oneOrNone",
|
|
971
|
+
"manyOrNone",
|
|
972
|
+
"result",
|
|
973
|
+
"multi",
|
|
974
|
+
"multiResult",
|
|
975
|
+
]);
|
|
976
|
+
const STATEMENT_START = /^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i;
|
|
618
977
|
/** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
|
|
619
978
|
* no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
|
|
620
979
|
* module-scope `const` is the only form that provably cannot carry request data. */
|
|
@@ -817,11 +1176,12 @@ const CONST_RESOLUTION_DEPTH = 5;
|
|
|
817
1176
|
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
818
1177
|
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
819
1178
|
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
820
|
-
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a
|
|
821
|
-
*
|
|
822
|
-
* `
|
|
823
|
-
*
|
|
824
|
-
*
|
|
1179
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
|
|
1180
|
+
* resolves (the nearest binding: function or block scope, then module scope; recursively,
|
|
1181
|
+
* depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
|
|
1182
|
+
* `scope.imports` is configured - a module `const` imported by name from another in-project module.
|
|
1183
|
+
* Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
|
|
1184
|
+
* bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
|
|
825
1185
|
*/
|
|
826
1186
|
function resolveStaticSqlText(ts, node, scope, depth) {
|
|
827
1187
|
if (depth > CONST_RESOLUTION_DEPTH)
|
|
@@ -839,7 +1199,8 @@ function resolveStaticSqlText(ts, node, scope, depth) {
|
|
|
839
1199
|
if (ts.isTemplateExpression(node)) {
|
|
840
1200
|
let text = node.head.text;
|
|
841
1201
|
for (const span of node.templateSpans) {
|
|
842
|
-
const part =
|
|
1202
|
+
const part = placeholderNumber(ts, text, span.expression, depth) ??
|
|
1203
|
+
resolveStaticSqlText(ts, span.expression, scope, depth + 1);
|
|
843
1204
|
if (part === undefined)
|
|
844
1205
|
return undefined;
|
|
845
1206
|
text += part + span.literal.text;
|
|
@@ -853,7 +1214,14 @@ function resolveStaticSqlText(ts, node, scope, depth) {
|
|
|
853
1214
|
return undefined;
|
|
854
1215
|
return `${whenTrue} ${whenFalse}`;
|
|
855
1216
|
}
|
|
1217
|
+
if (ts.isCallExpression(node))
|
|
1218
|
+
return resolveStaticJoin(ts, node, scope, depth);
|
|
856
1219
|
if (ts.isIdentifier(node)) {
|
|
1220
|
+
const local = localConstInitializer(ts, node);
|
|
1221
|
+
if (local === "shadow")
|
|
1222
|
+
return undefined;
|
|
1223
|
+
if (local !== undefined)
|
|
1224
|
+
return resolveStaticSqlText(ts, local, scope, depth + 1);
|
|
857
1225
|
const initializer = scope.module.consts.get(node.text);
|
|
858
1226
|
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
|
|
859
1227
|
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
@@ -882,13 +1250,416 @@ function resolveStaticSqlText(ts, node, scope, depth) {
|
|
|
882
1250
|
}
|
|
883
1251
|
return undefined;
|
|
884
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
|
|
1255
|
+
* nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
|
|
1256
|
+
* when no scope below the module binds it - the module-scope lookup applies then.
|
|
1257
|
+
*/
|
|
1258
|
+
function localConstInitializer(ts, node) {
|
|
1259
|
+
const source = node.getSourceFile();
|
|
1260
|
+
for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1261
|
+
if (ts.isSourceFile(scope))
|
|
1262
|
+
return undefined;
|
|
1263
|
+
const binding = directBinding(ts, source, scope, node.text);
|
|
1264
|
+
if (binding === "shadow")
|
|
1265
|
+
return "shadow";
|
|
1266
|
+
if (binding !== undefined)
|
|
1267
|
+
return binding.constant ? binding.initializer : "shadow";
|
|
1268
|
+
}
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
const unwrapExpression = (ts, node) => {
|
|
1272
|
+
let current = node;
|
|
1273
|
+
while (ts.isParenthesizedExpression(current) ||
|
|
1274
|
+
ts.isAsExpression(current) ||
|
|
1275
|
+
ts.isSatisfiesExpression(current)) {
|
|
1276
|
+
current = current.expression;
|
|
1277
|
+
}
|
|
1278
|
+
return current;
|
|
1279
|
+
};
|
|
1280
|
+
/** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
|
|
1281
|
+
function frozenArrayLiteral(ts, node) {
|
|
1282
|
+
const call = unwrapExpression(ts, node);
|
|
1283
|
+
if (!ts.isCallExpression(call) || call.arguments.length !== 1)
|
|
1284
|
+
return undefined;
|
|
1285
|
+
const callee = call.expression;
|
|
1286
|
+
if (!ts.isPropertyAccessExpression(callee) ||
|
|
1287
|
+
callee.name.text !== "freeze" ||
|
|
1288
|
+
!ts.isIdentifier(callee.expression) ||
|
|
1289
|
+
callee.expression.text !== "Object" ||
|
|
1290
|
+
isShadowedAt(ts, callee.expression, "Object")) {
|
|
1291
|
+
return undefined;
|
|
1292
|
+
}
|
|
1293
|
+
const array = unwrapExpression(ts, call.arguments[0]);
|
|
1294
|
+
return ts.isArrayLiteralExpression(array) ? array : undefined;
|
|
1295
|
+
}
|
|
1296
|
+
const ARRAY_READS = new Set(["join", "includes", "indexOf"]);
|
|
1297
|
+
/**
|
|
1298
|
+
* Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
|
|
1299
|
+
* `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
|
|
1300
|
+
* away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
|
|
1301
|
+
* too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
|
|
1302
|
+
* same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
|
|
1303
|
+
*/
|
|
1304
|
+
function onlyReadsArray(ts, source, name, declaration, pushed) {
|
|
1305
|
+
let ok = true;
|
|
1306
|
+
const visit = (node) => {
|
|
1307
|
+
if (!ok)
|
|
1308
|
+
return;
|
|
1309
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
|
|
1310
|
+
const access = node.parent;
|
|
1311
|
+
const call = ts.isPropertyAccessExpression(access) &&
|
|
1312
|
+
access.expression === node &&
|
|
1313
|
+
ts.isCallExpression(access.parent) &&
|
|
1314
|
+
access.parent.expression === access
|
|
1315
|
+
? access.parent
|
|
1316
|
+
: undefined;
|
|
1317
|
+
if (pushed !== undefined &&
|
|
1318
|
+
call !== undefined &&
|
|
1319
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1320
|
+
access.name.text === "push") {
|
|
1321
|
+
if (call.arguments.some((argument) => ts.isSpreadElement(argument)))
|
|
1322
|
+
ok = false;
|
|
1323
|
+
else
|
|
1324
|
+
pushed.push(...call.arguments);
|
|
1325
|
+
// An argument can mention the array again; those uses are checked as well.
|
|
1326
|
+
for (const argument of call.arguments)
|
|
1327
|
+
visit(argument);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const read = ts.isPropertyAccessExpression(access) &&
|
|
1331
|
+
access.expression === node &&
|
|
1332
|
+
(access.name.text === "length"
|
|
1333
|
+
? !isWrittenTo(ts, access)
|
|
1334
|
+
: ARRAY_READS.has(access.name.text) && call !== undefined);
|
|
1335
|
+
if (!read)
|
|
1336
|
+
ok = false;
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
ts.forEachChild(node, visit);
|
|
1340
|
+
};
|
|
1341
|
+
visit(source);
|
|
1342
|
+
return ok;
|
|
1343
|
+
}
|
|
1344
|
+
/** `node` as the target of an assignment, `++`/`--`, or `delete`. */
|
|
1345
|
+
function isWrittenTo(ts, node) {
|
|
1346
|
+
const parent = node.parent;
|
|
1347
|
+
if (ts.isBinaryExpression(parent) &&
|
|
1348
|
+
parent.left === node &&
|
|
1349
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1350
|
+
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
|
|
1354
|
+
return (parent.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1355
|
+
parent.operator === ts.SyntaxKind.MinusMinusToken);
|
|
1356
|
+
}
|
|
1357
|
+
return ts.isDeleteExpression(parent);
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* The element texts of an array `receiver` names, when the array provably still holds them: an
|
|
1361
|
+
* inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
|
|
1362
|
+
* literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
|
|
1363
|
+
* is type-only and proves nothing.
|
|
1364
|
+
*/
|
|
1365
|
+
function staticArrayItems(ts, receiver, scope, depth) {
|
|
1366
|
+
const node = unwrapExpression(ts, receiver);
|
|
1367
|
+
let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node);
|
|
1368
|
+
let itemScope = scope;
|
|
1369
|
+
const pushed = [];
|
|
1370
|
+
if (array === undefined && ts.isIdentifier(node)) {
|
|
1371
|
+
const local = localConstInitializer(ts, node);
|
|
1372
|
+
if (local === "shadow")
|
|
1373
|
+
return undefined;
|
|
1374
|
+
let initializer = local;
|
|
1375
|
+
let exported = false;
|
|
1376
|
+
if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
|
|
1377
|
+
initializer = scope.module.consts.get(node.text);
|
|
1378
|
+
exported = [...scope.module.localExports.values()].includes(node.text);
|
|
1379
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
|
|
1380
|
+
if (edge !== undefined && scope.imports !== undefined) {
|
|
1381
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
|
|
1382
|
+
const found = target === undefined
|
|
1383
|
+
? undefined
|
|
1384
|
+
: resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
|
|
1385
|
+
if (found === undefined)
|
|
1386
|
+
return undefined;
|
|
1387
|
+
// Another module's array is accepted only frozen: its own file may mutate it.
|
|
1388
|
+
array = frozenArrayLiteral(ts, found.expression);
|
|
1389
|
+
itemScope = { module: found.scope, imports: scope.imports };
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
if (array === undefined && initializer !== undefined) {
|
|
1393
|
+
array = frozenArrayLiteral(ts, initializer);
|
|
1394
|
+
const literal = unwrapExpression(ts, initializer);
|
|
1395
|
+
if (array === undefined &&
|
|
1396
|
+
!exported &&
|
|
1397
|
+
ts.isArrayLiteralExpression(literal) &&
|
|
1398
|
+
ts.isVariableDeclaration(initializer.parent) &&
|
|
1399
|
+
onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)) {
|
|
1400
|
+
array = literal;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (array === undefined)
|
|
1405
|
+
return undefined;
|
|
1406
|
+
const items = [];
|
|
1407
|
+
// What the array may hold: its literal elements, and anything pushed onto it in any order.
|
|
1408
|
+
for (const element of [...array.elements, ...pushed]) {
|
|
1409
|
+
if (ts.isSpreadElement(element) || ts.isOmittedExpression(element))
|
|
1410
|
+
return undefined;
|
|
1411
|
+
const text = resolveStaticSqlText(ts, element, itemScope, depth + 1);
|
|
1412
|
+
if (text === undefined)
|
|
1413
|
+
return undefined;
|
|
1414
|
+
items.push(text);
|
|
1415
|
+
}
|
|
1416
|
+
return items;
|
|
1417
|
+
}
|
|
1418
|
+
/** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
|
|
1419
|
+
function resolveStaticJoin(ts, call, scope, depth) {
|
|
1420
|
+
const callee = call.expression;
|
|
1421
|
+
if (!ts.isPropertyAccessExpression(callee) ||
|
|
1422
|
+
callee.questionDotToken !== undefined ||
|
|
1423
|
+
callee.name.text !== "join" ||
|
|
1424
|
+
call.arguments.length > 1) {
|
|
1425
|
+
return undefined;
|
|
1426
|
+
}
|
|
1427
|
+
const separatorArg = call.arguments[0];
|
|
1428
|
+
const separator = separatorArg === undefined
|
|
1429
|
+
? ","
|
|
1430
|
+
: ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
|
|
1431
|
+
? separatorArg.text
|
|
1432
|
+
: undefined;
|
|
1433
|
+
if (separator === undefined)
|
|
1434
|
+
return undefined;
|
|
1435
|
+
const items = staticArrayItems(ts, callee.expression, scope, depth + 1) ??
|
|
1436
|
+
mappedItem(ts, callee.expression, scope, depth + 1);
|
|
1437
|
+
return items?.join(separator);
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
|
|
1441
|
+
* for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
|
|
1442
|
+
* many items there are is runtime data, what each one says is not, so one item stands for all.
|
|
1443
|
+
*/
|
|
1444
|
+
function mappedItem(ts, receiver, scope, depth) {
|
|
1445
|
+
const call = unwrapExpression(ts, receiver);
|
|
1446
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
|
|
1447
|
+
return undefined;
|
|
1448
|
+
// Only these return what the callback returns; `filter` and the rest hand back the elements.
|
|
1449
|
+
const method = call.expression.name.text;
|
|
1450
|
+
const argument = indexCallbackArgument(ts, call);
|
|
1451
|
+
if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
|
|
1452
|
+
return undefined;
|
|
1453
|
+
const fn = call.arguments[argument];
|
|
1454
|
+
if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)))
|
|
1455
|
+
return undefined;
|
|
1456
|
+
const only = ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined;
|
|
1457
|
+
const body = ts.isBlock(fn.body)
|
|
1458
|
+
? only !== undefined && ts.isReturnStatement(only)
|
|
1459
|
+
? only.expression
|
|
1460
|
+
: undefined
|
|
1461
|
+
: fn.body;
|
|
1462
|
+
const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1);
|
|
1463
|
+
return text === undefined ? undefined : [text];
|
|
1464
|
+
}
|
|
1465
|
+
/** Array methods whose callback receives the element's index, and where in the callback it is. */
|
|
1466
|
+
const INDEX_PARAMETER = new Map([
|
|
1467
|
+
["map", 1],
|
|
1468
|
+
["flatMap", 1],
|
|
1469
|
+
["forEach", 1],
|
|
1470
|
+
["filter", 1],
|
|
1471
|
+
["some", 1],
|
|
1472
|
+
["every", 1],
|
|
1473
|
+
["find", 1],
|
|
1474
|
+
["findIndex", 1],
|
|
1475
|
+
["reduce", 2],
|
|
1476
|
+
["reduceRight", 2],
|
|
1477
|
+
]);
|
|
1478
|
+
/** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
|
|
1479
|
+
* kin, the second for an unshadowed `Array.from(src, fn)`. */
|
|
1480
|
+
function indexCallbackArgument(ts, call) {
|
|
1481
|
+
const callee = call.expression;
|
|
1482
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1483
|
+
return undefined;
|
|
1484
|
+
if (INDEX_PARAMETER.has(callee.name.text))
|
|
1485
|
+
return 0;
|
|
1486
|
+
return callee.name.text === "from" &&
|
|
1487
|
+
ts.isIdentifier(callee.expression) &&
|
|
1488
|
+
callee.expression.text === "Array" &&
|
|
1489
|
+
!isShadowedAt(ts, callee.expression, "Array")
|
|
1490
|
+
? 1
|
|
1491
|
+
: undefined;
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
|
|
1495
|
+
* Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
|
|
1496
|
+
* there could, so anything not proved numeric stays unresolved.
|
|
1497
|
+
*/
|
|
1498
|
+
function placeholderNumber(ts, before, node, depth) {
|
|
1499
|
+
return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined;
|
|
1500
|
+
}
|
|
1501
|
+
const NUMERIC_OPERATORS = (ts) => new Set([
|
|
1502
|
+
ts.SyntaxKind.MinusToken,
|
|
1503
|
+
ts.SyntaxKind.AsteriskToken,
|
|
1504
|
+
ts.SyntaxKind.AsteriskAsteriskToken,
|
|
1505
|
+
ts.SyntaxKind.SlashToken,
|
|
1506
|
+
ts.SyntaxKind.PercentToken,
|
|
1507
|
+
ts.SyntaxKind.AmpersandToken,
|
|
1508
|
+
ts.SyntaxKind.BarToken,
|
|
1509
|
+
ts.SyntaxKind.CaretToken,
|
|
1510
|
+
ts.SyntaxKind.LessThanLessThanToken,
|
|
1511
|
+
ts.SyntaxKind.GreaterThanGreaterThanToken,
|
|
1512
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
|
|
1513
|
+
]);
|
|
1514
|
+
const NUMBER_FUNCTIONS = new Set(["Number", "parseInt", "parseFloat"]);
|
|
1515
|
+
/**
|
|
1516
|
+
* Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
|
|
1517
|
+
* `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
|
|
1518
|
+
* both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
|
|
1519
|
+
* `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
|
|
1520
|
+
* (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
|
|
1521
|
+
* nearby binding builds from a literal. A type annotation proves nothing: it does not run.
|
|
1522
|
+
*/
|
|
1523
|
+
function isNumericExpression(ts, node, depth) {
|
|
1524
|
+
if (depth > CONST_RESOLUTION_DEPTH)
|
|
1525
|
+
return false;
|
|
1526
|
+
const expression = unwrapExpression(ts, node);
|
|
1527
|
+
if (ts.isNumericLiteral(expression))
|
|
1528
|
+
return true;
|
|
1529
|
+
if (ts.isPrefixUnaryExpression(expression))
|
|
1530
|
+
return expression.operator !== ts.SyntaxKind.ExclamationToken;
|
|
1531
|
+
if (ts.isPostfixUnaryExpression(expression))
|
|
1532
|
+
return true;
|
|
1533
|
+
if (ts.isBinaryExpression(expression)) {
|
|
1534
|
+
const operator = expression.operatorToken.kind;
|
|
1535
|
+
if (operator === ts.SyntaxKind.PlusToken) {
|
|
1536
|
+
return (isNumericExpression(ts, expression.left, depth + 1) &&
|
|
1537
|
+
isNumericExpression(ts, expression.right, depth + 1));
|
|
1538
|
+
}
|
|
1539
|
+
return NUMERIC_OPERATORS(ts).has(operator);
|
|
1540
|
+
}
|
|
1541
|
+
if (ts.isIdentifier(expression))
|
|
1542
|
+
return isNumericName(ts, expression, depth);
|
|
1543
|
+
if (ts.isCallExpression(expression)) {
|
|
1544
|
+
const callee = expression.expression;
|
|
1545
|
+
if (ts.isIdentifier(callee))
|
|
1546
|
+
return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text);
|
|
1547
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1548
|
+
return false;
|
|
1549
|
+
if (callee.name.text === "push")
|
|
1550
|
+
return true;
|
|
1551
|
+
return (ts.isIdentifier(callee.expression) &&
|
|
1552
|
+
callee.expression.text === "Math" &&
|
|
1553
|
+
!isShadowedAt(ts, callee.expression, "Math"));
|
|
1554
|
+
}
|
|
1555
|
+
if (ts.isPropertyAccessExpression(expression) &&
|
|
1556
|
+
expression.name.text === "length" &&
|
|
1557
|
+
ts.isIdentifier(expression.expression)) {
|
|
1558
|
+
const binding = nameBinding(ts, expression.expression);
|
|
1559
|
+
return (binding.kind === "value" &&
|
|
1560
|
+
ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer)));
|
|
1561
|
+
}
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
function nameBinding(ts, node) {
|
|
1565
|
+
const source = node.getSourceFile();
|
|
1566
|
+
for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1567
|
+
if (ts.isFunctionLike(scope)) {
|
|
1568
|
+
const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text));
|
|
1569
|
+
if (position !== -1) {
|
|
1570
|
+
return ts.isIdentifier(scope.parameters[position]?.name ?? node)
|
|
1571
|
+
? { kind: "parameter", fn: scope, position }
|
|
1572
|
+
: { kind: "unknown" };
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const binding = directBinding(ts, source, scope, node.text);
|
|
1576
|
+
if (binding === "shadow") {
|
|
1577
|
+
// `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
|
|
1578
|
+
const list = ts.isForStatement(scope) ? scope.initializer : undefined;
|
|
1579
|
+
const declaration = list !== undefined && ts.isVariableDeclarationList(list)
|
|
1580
|
+
? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
|
|
1581
|
+
: undefined;
|
|
1582
|
+
return declaration?.initializer === undefined
|
|
1583
|
+
? { kind: "unknown" }
|
|
1584
|
+
: { kind: "counter", initializer: declaration.initializer };
|
|
1585
|
+
}
|
|
1586
|
+
if (binding !== undefined)
|
|
1587
|
+
return { kind: "value", initializer: binding.initializer };
|
|
1588
|
+
if (ts.isSourceFile(scope))
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
return { kind: "unknown" };
|
|
1592
|
+
}
|
|
1593
|
+
function isNumericName(ts, node, depth) {
|
|
1594
|
+
const source = node.getSourceFile();
|
|
1595
|
+
const binding = nameBinding(ts, node);
|
|
1596
|
+
if (binding.kind === "value")
|
|
1597
|
+
return isNumericExpression(ts, binding.initializer, depth + 1);
|
|
1598
|
+
if (binding.kind === "counter") {
|
|
1599
|
+
return (isNumericExpression(ts, binding.initializer, depth + 1) &&
|
|
1600
|
+
onlyNumericWrites(ts, source, node.text, depth));
|
|
1601
|
+
}
|
|
1602
|
+
if (binding.kind !== "parameter")
|
|
1603
|
+
return false;
|
|
1604
|
+
// An array callback's index parameter, never reassigned.
|
|
1605
|
+
const call = binding.fn.parent;
|
|
1606
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
|
|
1607
|
+
return false;
|
|
1608
|
+
const argument = indexCallbackArgument(ts, call);
|
|
1609
|
+
const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text);
|
|
1610
|
+
return (argument !== undefined &&
|
|
1611
|
+
call.arguments[argument] === binding.fn &&
|
|
1612
|
+
binding.position === position &&
|
|
1613
|
+
!isReassignedInFile(ts, source, node.text));
|
|
1614
|
+
}
|
|
1615
|
+
/** Compound assignments that always store a number. */
|
|
1616
|
+
const NUMERIC_ASSIGNMENTS = (ts) => new Set([
|
|
1617
|
+
ts.SyntaxKind.MinusEqualsToken,
|
|
1618
|
+
ts.SyntaxKind.AsteriskEqualsToken,
|
|
1619
|
+
ts.SyntaxKind.AsteriskAsteriskEqualsToken,
|
|
1620
|
+
ts.SyntaxKind.SlashEqualsToken,
|
|
1621
|
+
ts.SyntaxKind.PercentEqualsToken,
|
|
1622
|
+
ts.SyntaxKind.AmpersandEqualsToken,
|
|
1623
|
+
ts.SyntaxKind.BarEqualsToken,
|
|
1624
|
+
ts.SyntaxKind.CaretEqualsToken,
|
|
1625
|
+
ts.SyntaxKind.LessThanLessThanEqualsToken,
|
|
1626
|
+
ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
|
|
1627
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
|
1628
|
+
]);
|
|
1629
|
+
/** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
|
|
1630
|
+
function onlyNumericWrites(ts, source, name, depth) {
|
|
1631
|
+
let ok = true;
|
|
1632
|
+
const visit = (node) => {
|
|
1633
|
+
if (!ok)
|
|
1634
|
+
return;
|
|
1635
|
+
if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
|
|
1636
|
+
const parent = node.parent;
|
|
1637
|
+
if (ts.isBinaryExpression(parent)) {
|
|
1638
|
+
const operator = parent.operatorToken.kind;
|
|
1639
|
+
ok =
|
|
1640
|
+
operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
|
|
1641
|
+
? isNumericExpression(ts, parent.right, depth + 1)
|
|
1642
|
+
: NUMERIC_ASSIGNMENTS(ts).has(operator);
|
|
1643
|
+
}
|
|
1644
|
+
else
|
|
1645
|
+
ok = !ts.isDeleteExpression(parent);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
ts.forEachChild(node, visit);
|
|
1649
|
+
};
|
|
1650
|
+
visit(source);
|
|
1651
|
+
return ok;
|
|
1652
|
+
}
|
|
885
1653
|
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
886
1654
|
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
887
1655
|
function templateShape(ts, template, scope) {
|
|
888
1656
|
let staticText = template.head.text;
|
|
889
1657
|
let dynamic = false;
|
|
890
1658
|
for (const span of template.templateSpans) {
|
|
891
|
-
const resolved = scope === undefined
|
|
1659
|
+
const resolved = scope === undefined
|
|
1660
|
+
? undefined
|
|
1661
|
+
: (placeholderNumber(ts, staticText, span.expression, 0) ??
|
|
1662
|
+
resolveStaticSqlText(ts, span.expression, scope, 0));
|
|
892
1663
|
if (resolved === undefined)
|
|
893
1664
|
dynamic = true;
|
|
894
1665
|
else
|
|
@@ -953,30 +1724,13 @@ function directBinding(ts, source, scope, name) {
|
|
|
953
1724
|
if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
|
|
954
1725
|
return "shadow";
|
|
955
1726
|
}
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
: ts.isCaseClause(scope) || ts.isDefaultClause(scope)
|
|
961
|
-
? scope.statements
|
|
962
|
-
: undefined;
|
|
963
|
-
const lists = [];
|
|
964
|
-
if (statements !== undefined) {
|
|
965
|
-
for (const statement of statements) {
|
|
966
|
-
if (ts.isVariableStatement(statement))
|
|
967
|
-
lists.push(statement.declarationList);
|
|
968
|
-
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
969
|
-
statement.name?.text === name) {
|
|
970
|
-
return "shadow";
|
|
971
|
-
}
|
|
1727
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1728
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
1729
|
+
statement.name?.text === name) {
|
|
1730
|
+
return "shadow";
|
|
972
1731
|
}
|
|
973
1732
|
}
|
|
974
|
-
|
|
975
|
-
scope.initializer !== undefined &&
|
|
976
|
-
ts.isVariableDeclarationList(scope.initializer)) {
|
|
977
|
-
lists.push(scope.initializer);
|
|
978
|
-
}
|
|
979
|
-
for (const list of lists) {
|
|
1733
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
980
1734
|
for (const declaration of list.declarations) {
|
|
981
1735
|
if (!bindingNameCovers(ts, declaration.name, name))
|
|
982
1736
|
continue;
|
|
@@ -987,52 +1741,262 @@ function directBinding(ts, source, scope, name) {
|
|
|
987
1741
|
const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
|
|
988
1742
|
const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
|
|
989
1743
|
if (isConst)
|
|
990
|
-
return { initializer: declaration.initializer };
|
|
1744
|
+
return { initializer: declaration.initializer, constant: true };
|
|
991
1745
|
if (isLet && !isReassignedInFile(ts, source, name))
|
|
992
|
-
return { initializer: declaration.initializer };
|
|
1746
|
+
return { initializer: declaration.initializer, constant: false };
|
|
993
1747
|
return "shadow";
|
|
994
1748
|
}
|
|
995
1749
|
}
|
|
996
1750
|
return undefined;
|
|
997
1751
|
}
|
|
1752
|
+
function scopeStatements(ts, scope) {
|
|
1753
|
+
return ts.isSourceFile(scope) ||
|
|
1754
|
+
ts.isBlock(scope) ||
|
|
1755
|
+
ts.isModuleBlock(scope) ||
|
|
1756
|
+
ts.isCaseClause(scope) ||
|
|
1757
|
+
ts.isDefaultClause(scope)
|
|
1758
|
+
? scope.statements
|
|
1759
|
+
: undefined;
|
|
1760
|
+
}
|
|
1761
|
+
/** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
|
|
1762
|
+
function scopeDeclarationLists(ts, scope) {
|
|
1763
|
+
const lists = [];
|
|
1764
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1765
|
+
if (ts.isVariableStatement(statement))
|
|
1766
|
+
lists.push(statement.declarationList);
|
|
1767
|
+
}
|
|
1768
|
+
if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
|
|
1769
|
+
scope.initializer !== undefined &&
|
|
1770
|
+
ts.isVariableDeclarationList(scope.initializer)) {
|
|
1771
|
+
lists.push(scope.initializer);
|
|
1772
|
+
}
|
|
1773
|
+
return lists;
|
|
1774
|
+
}
|
|
1775
|
+
/** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
|
|
1776
|
+
function mutableDeclaration(ts, scope, name) {
|
|
1777
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
1778
|
+
if ((list.flags & ts.NodeFlags.Const) !== 0)
|
|
1779
|
+
continue;
|
|
1780
|
+
for (const declaration of list.declarations) {
|
|
1781
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
|
|
1782
|
+
return declaration;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return undefined;
|
|
1786
|
+
}
|
|
998
1787
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
* could be silenced, invisibly, by hoisting the statement to `const q = …` first
|
|
1788
|
+
* Every value a statement variable may hold where the query reads it, so a statement parked in a
|
|
1789
|
+
* variable is scanned as if it had been written inline at the call. This is the counterpart to
|
|
1790
|
+
* {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
|
|
1791
|
+
* statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
|
|
1792
|
+
* could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
|
|
1793
|
+
* in steps (`let q = "SELECT …"; q += …`).
|
|
1004
1794
|
*
|
|
1005
1795
|
* Same-file only. Nearest enclosing declaration wins, so a shadowing local is read and never a
|
|
1006
|
-
* same-named module const.
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1796
|
+
* same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
|
|
1797
|
+
* `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
|
|
1798
|
+
* the function (or module) that declares it: over-collected rather than scope-simulated, which only
|
|
1799
|
+
* ever adds a value to check. `key` names the binding, so a value that reads the variable it is
|
|
1800
|
+
* assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
|
|
1801
|
+
* same-file declaration returns `undefined` - which leaves the argument scanned as itself.
|
|
1011
1802
|
*/
|
|
1012
|
-
function
|
|
1013
|
-
if (depth > CONST_RESOLUTION_DEPTH)
|
|
1014
|
-
return undefined;
|
|
1803
|
+
function variableWrites(ts, node) {
|
|
1015
1804
|
const source = node.getSourceFile();
|
|
1016
1805
|
const name = node.text;
|
|
1017
1806
|
for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1018
1807
|
const binding = directBinding(ts, source, scope, name);
|
|
1019
|
-
if (binding === "shadow")
|
|
1020
|
-
|
|
1808
|
+
if (binding === "shadow") {
|
|
1809
|
+
const declaration = mutableDeclaration(ts, scope, name);
|
|
1810
|
+
if (declaration === undefined)
|
|
1811
|
+
return undefined;
|
|
1812
|
+
let owner = declaration;
|
|
1813
|
+
while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner))
|
|
1814
|
+
owner = owner.parent;
|
|
1815
|
+
const writes = writesTo(ts, owner, name);
|
|
1816
|
+
return {
|
|
1817
|
+
key: declaration,
|
|
1818
|
+
writes: declaration.initializer === undefined
|
|
1819
|
+
? writes
|
|
1820
|
+
: [{ value: declaration.initializer, appends: false }, ...writes],
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1021
1823
|
if (binding !== undefined) {
|
|
1022
|
-
|
|
1023
|
-
while (ts.isParenthesizedExpression(init))
|
|
1024
|
-
init = init.expression;
|
|
1025
|
-
return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init;
|
|
1824
|
+
return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] };
|
|
1026
1825
|
}
|
|
1027
1826
|
if (ts.isSourceFile(scope))
|
|
1028
1827
|
break;
|
|
1029
1828
|
}
|
|
1030
1829
|
return undefined;
|
|
1031
1830
|
}
|
|
1032
|
-
/**
|
|
1033
|
-
function
|
|
1034
|
-
|
|
1035
|
-
|
|
1831
|
+
/** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
|
|
1832
|
+
function writesTo(ts, root, name) {
|
|
1833
|
+
const writes = [];
|
|
1834
|
+
const visit = (node) => {
|
|
1835
|
+
if (ts.isIdentifier(node) && node.text === name) {
|
|
1836
|
+
const parent = node.parent;
|
|
1837
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
1838
|
+
const operator = parent.operatorToken.kind;
|
|
1839
|
+
if (operator === ts.SyntaxKind.PlusEqualsToken) {
|
|
1840
|
+
writes.push({ value: parent.right, appends: true });
|
|
1841
|
+
}
|
|
1842
|
+
else if (operator === ts.SyntaxKind.EqualsToken ||
|
|
1843
|
+
operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
|
|
1844
|
+
operator === ts.SyntaxKind.BarBarEqualsToken ||
|
|
1845
|
+
operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken) {
|
|
1846
|
+
writes.push({ value: parent.right, appends: false });
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
ts.forEachChild(node, visit);
|
|
1851
|
+
};
|
|
1852
|
+
visit(root);
|
|
1853
|
+
return writes;
|
|
1854
|
+
}
|
|
1855
|
+
function mentionsName(ts, node, name) {
|
|
1856
|
+
if (ts.isIdentifier(node))
|
|
1857
|
+
return node.text === name;
|
|
1858
|
+
return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true;
|
|
1859
|
+
}
|
|
1860
|
+
const UNKNOWN_SQL = {
|
|
1861
|
+
staticText: "",
|
|
1862
|
+
dynamic: true,
|
|
1863
|
+
concatenated: false,
|
|
1864
|
+
literal: false,
|
|
1865
|
+
parameterizedTag: false,
|
|
1866
|
+
};
|
|
1867
|
+
function textShape(text) {
|
|
1868
|
+
return {
|
|
1869
|
+
staticText: text,
|
|
1870
|
+
dynamic: false,
|
|
1871
|
+
concatenated: false,
|
|
1872
|
+
literal: true,
|
|
1873
|
+
parameterizedTag: false,
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
function joinShapes(shapes) {
|
|
1877
|
+
return {
|
|
1878
|
+
staticText: shapes.map((shape) => shape.staticText).join(""),
|
|
1879
|
+
// As with `+`: text glued onto a binding tag's output un-parameterizes it.
|
|
1880
|
+
dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
|
|
1881
|
+
concatenated: true,
|
|
1882
|
+
literal: false,
|
|
1883
|
+
parameterizedTag: false,
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
/** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
|
|
1887
|
+
* them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
|
|
1888
|
+
* before inside one. */
|
|
1889
|
+
function sqlRisk(shape) {
|
|
1890
|
+
const dynamic = !shape.dynamic
|
|
1891
|
+
? 0
|
|
1892
|
+
: SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
|
|
1893
|
+
? 3
|
|
1894
|
+
: shape.concatenated
|
|
1895
|
+
? 2
|
|
1896
|
+
: 1;
|
|
1897
|
+
return dynamic * 2 + (shape.parameterizedTag ? 0 : 1);
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
|
|
1901
|
+
* The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
|
|
1902
|
+
* once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
|
|
1903
|
+
* interpolated `AND …` reads as the one statement it becomes.
|
|
1904
|
+
*/
|
|
1905
|
+
function writtenShape(ts, name, writes, scope, trail) {
|
|
1906
|
+
const replacing = [];
|
|
1907
|
+
const building = [];
|
|
1908
|
+
for (const write of writes) {
|
|
1909
|
+
const shape = sqlExpressionShape(ts, write.value, scope, trail);
|
|
1910
|
+
if (write.appends || mentionsName(ts, write.value, name))
|
|
1911
|
+
building.push(shape);
|
|
1912
|
+
else
|
|
1913
|
+
replacing.push(shape);
|
|
1914
|
+
}
|
|
1915
|
+
const candidates = building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])];
|
|
1916
|
+
let worst;
|
|
1917
|
+
for (const candidate of candidates) {
|
|
1918
|
+
if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst))
|
|
1919
|
+
worst = candidate;
|
|
1920
|
+
}
|
|
1921
|
+
return worst ?? UNKNOWN_SQL;
|
|
1922
|
+
}
|
|
1923
|
+
const TEXT_METHODS = new Set([
|
|
1924
|
+
"toString",
|
|
1925
|
+
"valueOf",
|
|
1926
|
+
"trim",
|
|
1927
|
+
"trimStart",
|
|
1928
|
+
"trimEnd",
|
|
1929
|
+
"toLowerCase",
|
|
1930
|
+
"toUpperCase",
|
|
1931
|
+
]);
|
|
1932
|
+
/** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
|
|
1933
|
+
function passedThrough(ts, call) {
|
|
1934
|
+
const callee = call.expression;
|
|
1935
|
+
if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
|
|
1936
|
+
return call.arguments[0];
|
|
1937
|
+
}
|
|
1938
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
1939
|
+
TEXT_METHODS.has(callee.name.text) &&
|
|
1940
|
+
call.arguments.length === 0) {
|
|
1941
|
+
return callee.expression;
|
|
1942
|
+
}
|
|
1943
|
+
return undefined;
|
|
1944
|
+
}
|
|
1945
|
+
/** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
|
|
1946
|
+
function joinedParts(ts, call) {
|
|
1947
|
+
const callee = call.expression;
|
|
1948
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
1949
|
+
return undefined;
|
|
1950
|
+
if (callee.name.text === "concat")
|
|
1951
|
+
return [callee.expression, ...call.arguments];
|
|
1952
|
+
const items = unwrapExpression(ts, callee.expression);
|
|
1953
|
+
if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items))
|
|
1954
|
+
return undefined;
|
|
1955
|
+
const separator = call.arguments[0];
|
|
1956
|
+
const glue = separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator;
|
|
1957
|
+
const parts = [];
|
|
1958
|
+
items.elements.forEach((element, index) => {
|
|
1959
|
+
if (index > 0)
|
|
1960
|
+
parts.push(glue);
|
|
1961
|
+
if (ts.isSpreadElement(element))
|
|
1962
|
+
parts.push(element.expression);
|
|
1963
|
+
else if (!ts.isOmittedExpression(element))
|
|
1964
|
+
parts.push(element);
|
|
1965
|
+
});
|
|
1966
|
+
return parts;
|
|
1967
|
+
}
|
|
1968
|
+
/** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
|
|
1969
|
+
* which is plain interpolation whatever its name. A parameter, an import, or a driver instance
|
|
1970
|
+
* (`const sql = postgres(url)`) keeps the name's trust. */
|
|
1971
|
+
function definesSqlFunction(ts, tag) {
|
|
1972
|
+
for (let scope = tag.parent; scope !== undefined; scope = scope.parent) {
|
|
1973
|
+
if (ts.isFunctionLike(scope) &&
|
|
1974
|
+
scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))) {
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1978
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text)
|
|
1979
|
+
return true;
|
|
1980
|
+
}
|
|
1981
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
1982
|
+
for (const declaration of list.declarations) {
|
|
1983
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text)
|
|
1984
|
+
continue;
|
|
1985
|
+
const value = declaration.initializer === undefined
|
|
1986
|
+
? undefined
|
|
1987
|
+
: unwrapExpression(ts, declaration.initializer);
|
|
1988
|
+
return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value));
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return false;
|
|
1993
|
+
}
|
|
1994
|
+
/** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
|
|
1995
|
+
* `trail` holds the variables being read through, innermost last. */
|
|
1996
|
+
function sqlExpressionShape(ts, expression, scope, trail = []) {
|
|
1997
|
+
let node = unwrapExpression(ts, expression);
|
|
1998
|
+
while (ts.isNonNullExpression(node))
|
|
1999
|
+
node = unwrapExpression(ts, node.expression);
|
|
1036
2000
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
1037
2001
|
return {
|
|
1038
2002
|
staticText: node.text,
|
|
@@ -1044,7 +2008,7 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1044
2008
|
}
|
|
1045
2009
|
if (ts.isTaggedTemplateExpression(node)) {
|
|
1046
2010
|
const tag = node.tag;
|
|
1047
|
-
const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
2011
|
+
const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
|
|
1048
2012
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
|
|
1049
2013
|
const template = node.template;
|
|
1050
2014
|
const templated = ts.isTemplateExpression(template)
|
|
@@ -1054,8 +2018,9 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1054
2018
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
1055
2019
|
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
1056
2020
|
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
1057
|
-
// deliberate trade, and it is worth stating what it costs: a
|
|
1058
|
-
//
|
|
2021
|
+
// deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
|
|
2022
|
+
// function is read as the interpolation it is, but one imported from anywhere is trusted, so
|
|
2023
|
+
// this rule finds mistakes, not an adversary who reads it first.
|
|
1059
2024
|
//
|
|
1060
2025
|
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
1061
2026
|
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
@@ -1077,14 +2042,15 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1077
2042
|
return {
|
|
1078
2043
|
staticText: templated.staticText,
|
|
1079
2044
|
dynamic: templated.dynamic,
|
|
1080
|
-
|
|
2045
|
+
// `${a} ${b}` writes no text of its own; it only glues values together.
|
|
2046
|
+
concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every((part) => part.text.trim() === ""),
|
|
1081
2047
|
literal: false,
|
|
1082
2048
|
parameterizedTag: false,
|
|
1083
2049
|
};
|
|
1084
2050
|
}
|
|
1085
2051
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
1086
|
-
const left = sqlExpressionShape(ts, node.left, scope);
|
|
1087
|
-
const right = sqlExpressionShape(ts, node.right, scope);
|
|
2052
|
+
const left = sqlExpressionShape(ts, node.left, scope, trail);
|
|
2053
|
+
const right = sqlExpressionShape(ts, node.right, scope, trail);
|
|
1088
2054
|
return {
|
|
1089
2055
|
staticText: left.staticText + right.staticText,
|
|
1090
2056
|
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
@@ -1113,47 +2079,132 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1113
2079
|
};
|
|
1114
2080
|
}
|
|
1115
2081
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
2082
|
+
if (ts.isCallExpression(node)) {
|
|
2083
|
+
const inner = passedThrough(ts, node);
|
|
2084
|
+
if (inner !== undefined)
|
|
2085
|
+
return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false };
|
|
2086
|
+
const parts = joinedParts(ts, node);
|
|
2087
|
+
if (parts !== undefined) {
|
|
2088
|
+
return joinShapes(parts.map((part) => typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail)));
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
// A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
|
|
2092
|
+
// shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
|
|
1118
2093
|
// read exactly as if it were written inline at the call, closing the "extract a variable to launder
|
|
1119
2094
|
// the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
|
|
1120
2095
|
// property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
|
|
1121
2096
|
if (ts.isIdentifier(node)) {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
2097
|
+
const read = variableWrites(ts, node);
|
|
2098
|
+
// Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
|
|
2099
|
+
// already account for.
|
|
2100
|
+
if (read !== undefined && trail.includes(read.key))
|
|
2101
|
+
return { ...textShape(""), literal: false };
|
|
2102
|
+
if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
|
|
2103
|
+
const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key]);
|
|
2104
|
+
return { ...written, literal: false };
|
|
1125
2105
|
}
|
|
1126
2106
|
}
|
|
1127
|
-
return
|
|
1128
|
-
staticText: "",
|
|
1129
|
-
dynamic: true,
|
|
1130
|
-
concatenated: false,
|
|
1131
|
-
literal: false,
|
|
1132
|
-
parameterizedTag: false,
|
|
1133
|
-
};
|
|
2107
|
+
return UNKNOWN_SQL;
|
|
1134
2108
|
}
|
|
1135
2109
|
function sqlMethod(ts, call) {
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
if (ts.
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
return SQL_METHODS.has(method)
|
|
1147
|
-
? { method, receiver: call.expression.expression.getText() }
|
|
1148
|
-
: undefined;
|
|
2110
|
+
const sink = (method, receiver) => SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
|
|
2111
|
+
? { method, receiver: receiver?.getText() }
|
|
2112
|
+
: undefined;
|
|
2113
|
+
const callee = call.expression;
|
|
2114
|
+
if (ts.isIdentifier(callee))
|
|
2115
|
+
return sink(callee.text, undefined);
|
|
2116
|
+
if (ts.isPropertyAccessExpression(callee))
|
|
2117
|
+
return sink(callee.name.text, callee.expression);
|
|
2118
|
+
if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
|
|
2119
|
+
return sink(callee.argumentExpression.text, callee.expression);
|
|
1149
2120
|
}
|
|
1150
2121
|
return undefined;
|
|
1151
2122
|
}
|
|
1152
2123
|
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
|
|
1153
|
-
/**
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2124
|
+
/** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
|
|
2125
|
+
function isAssembledSql(sink, shape) {
|
|
2126
|
+
if (!shape.dynamic)
|
|
2127
|
+
return false;
|
|
2128
|
+
if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
|
|
2129
|
+
return STATEMENT_START.test(shape.staticText);
|
|
2130
|
+
}
|
|
2131
|
+
return (SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
|
|
2132
|
+
}
|
|
2133
|
+
/** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
|
|
2134
|
+
* (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
|
|
2135
|
+
function statementArgument(ts, argument) {
|
|
2136
|
+
const node = unwrapExpression(ts, argument);
|
|
2137
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
2138
|
+
return argument;
|
|
2139
|
+
for (const property of node.properties) {
|
|
2140
|
+
if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
|
|
2141
|
+
return property.name;
|
|
2142
|
+
}
|
|
2143
|
+
if (ts.isPropertyAssignment(property) &&
|
|
2144
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
2145
|
+
STATEMENT_KEYS.has(property.name.text)) {
|
|
2146
|
+
return property.initializer;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return undefined;
|
|
2150
|
+
}
|
|
2151
|
+
const STATEMENT_KEYS = new Set(["text", "sql"]);
|
|
2152
|
+
/** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
|
|
2153
|
+
* `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
|
|
2154
|
+
* scanned as a call to that sink. One level deep. */
|
|
2155
|
+
function statementHelpers(ts, source) {
|
|
2156
|
+
const helpers = new Map();
|
|
2157
|
+
const visit = (node) => {
|
|
2158
|
+
const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined;
|
|
2159
|
+
const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined;
|
|
2160
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed);
|
|
2161
|
+
const statement = argument === undefined ? undefined : unwrapExpression(ts, argument);
|
|
2162
|
+
if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
|
|
2163
|
+
const binding = nameBinding(ts, statement);
|
|
2164
|
+
const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined;
|
|
2165
|
+
if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
|
|
2166
|
+
helpers.set(name, { position: binding.position, sink });
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
ts.forEachChild(node, visit);
|
|
2170
|
+
};
|
|
2171
|
+
visit(source);
|
|
2172
|
+
return helpers;
|
|
2173
|
+
}
|
|
2174
|
+
function functionName(ts, fn) {
|
|
2175
|
+
if (ts.isFunctionDeclaration(fn))
|
|
2176
|
+
return fn.name?.text;
|
|
2177
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
2178
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
2179
|
+
ts.isIdentifier(fn.parent.name)) {
|
|
2180
|
+
return fn.parent.name.text;
|
|
2181
|
+
}
|
|
2182
|
+
return undefined;
|
|
2183
|
+
}
|
|
2184
|
+
/** `content` with every string, template and regex literal blanked, line breaks kept. */
|
|
2185
|
+
function literalsBlanked(ts, source, content) {
|
|
2186
|
+
const out = content.split("");
|
|
2187
|
+
const visit = (node) => {
|
|
2188
|
+
if (ts.isStringLiteralLike(node) ||
|
|
2189
|
+
ts.isTemplateHead(node) ||
|
|
2190
|
+
ts.isTemplateMiddle(node) ||
|
|
2191
|
+
ts.isTemplateTail(node) ||
|
|
2192
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
2193
|
+
ts.isJsxText(node)) {
|
|
2194
|
+
for (let i = node.getStart(source); i < node.end; i++)
|
|
2195
|
+
if (out[i] !== "\n")
|
|
2196
|
+
out[i] = " ";
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
ts.forEachChild(node, visit);
|
|
2200
|
+
};
|
|
2201
|
+
visit(source);
|
|
2202
|
+
return out.join("");
|
|
2203
|
+
}
|
|
2204
|
+
/** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
|
|
2205
|
+
* `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
|
|
2206
|
+
* the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
|
|
2207
|
+
const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"];
|
|
1157
2208
|
/**
|
|
1158
2209
|
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
1159
2210
|
*
|
|
@@ -1162,6 +2213,10 @@ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
|
|
|
1162
2213
|
* to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
|
|
1163
2214
|
*/
|
|
1164
2215
|
export function scanInterpolatedSql(file, content, ts, imports) {
|
|
2216
|
+
return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined);
|
|
2217
|
+
}
|
|
2218
|
+
/** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
|
|
2219
|
+
export function scanSqlSites(file, content, ts, imports) {
|
|
1165
2220
|
const out = [];
|
|
1166
2221
|
const lines = content.split("\n");
|
|
1167
2222
|
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
@@ -1173,26 +2228,36 @@ export function scanInterpolatedSql(file, content, ts, imports) {
|
|
|
1173
2228
|
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1174
2229
|
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1175
2230
|
const scope = { module: sqlModuleScope(ts, file, source), imports };
|
|
2231
|
+
const helpers = statementHelpers(ts, source);
|
|
2232
|
+
// A marker counts only in a comment: one inside a string the call passes is data, not a reason.
|
|
2233
|
+
let markerLines;
|
|
1176
2234
|
const visit = (node) => {
|
|
1177
2235
|
if (ts.isCallExpression(node)) {
|
|
1178
|
-
const
|
|
1179
|
-
|
|
2236
|
+
const helper = ts.isIdentifier(node.expression)
|
|
2237
|
+
? helpers.get(node.expression.text)
|
|
2238
|
+
: undefined;
|
|
2239
|
+
const sink = helper?.sink ?? sqlMethod(ts, node);
|
|
2240
|
+
const passed = node.arguments[helper?.position ?? 0];
|
|
2241
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed);
|
|
1180
2242
|
if (sink !== undefined && argument !== undefined) {
|
|
1181
2243
|
const shape = sqlExpressionShape(ts, argument, scope);
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
2244
|
+
// A helper's own hatch call already answers for passing it a variable.
|
|
2245
|
+
const unsafeEscape = helper === undefined &&
|
|
2246
|
+
sink.receiver !== undefined &&
|
|
2247
|
+
ALWAYS_UNSAFE.has(sink.method) &&
|
|
2248
|
+
!shape.literal &&
|
|
2249
|
+
!shape.parameterizedTag;
|
|
2250
|
+
if (unsafeEscape || isAssembledSql(sink, shape)) {
|
|
1187
2251
|
const index = node.getStart(source);
|
|
1188
2252
|
const line = source.getLineAndCharacterOfPosition(index).line + 1;
|
|
1189
|
-
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
2253
|
+
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
|
|
2254
|
+
// placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
|
|
2255
|
+
// NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
|
|
1192
2256
|
// greppable audit trail instead of becoming a second laundering trick.
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
2257
|
+
markerLines ??= literalsBlanked(ts, source, content).split("\n");
|
|
2258
|
+
const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason;
|
|
2259
|
+
const site = { file, line, snippet: (lines[line - 1] ?? "").trim() };
|
|
2260
|
+
out.push(reason === undefined ? site : { ...site, expected: reason });
|
|
1196
2261
|
}
|
|
1197
2262
|
}
|
|
1198
2263
|
}
|
|
@@ -1442,7 +2507,7 @@ export function scanServerOnlyImports(file, content, facts) {
|
|
|
1442
2507
|
// Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
|
|
1443
2508
|
// failure keeps the lexical edge so the security rule fails closed.
|
|
1444
2509
|
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
1445
|
-
if (!
|
|
2510
|
+
if (!isServerOnly(specifier))
|
|
1446
2511
|
continue;
|
|
1447
2512
|
const line = lineAt(content, index);
|
|
1448
2513
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
|
|
@@ -1458,21 +2523,24 @@ export function scanServerOnlyImports(file, content, facts) {
|
|
|
1458
2523
|
// local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
|
|
1459
2524
|
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
1460
2525
|
// ---------------------------------------------------------------------------------------------------
|
|
1461
|
-
// Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
|
|
1462
|
-
// npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
|
|
1463
|
-
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
1464
|
-
const SERVER_ONLY_SINK = /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/;
|
|
1465
2526
|
// The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
|
|
1466
2527
|
const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
1467
2528
|
// The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
|
|
1468
2529
|
// module opting into the client-leak guard. A resolved file carrying this side-effect import is a sink.
|
|
1469
|
-
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2530
|
+
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g;
|
|
2531
|
+
/** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
|
|
2532
|
+
* such as a note on how to opt in, marks nothing. */
|
|
2533
|
+
function hasBackendOnlyMarker(content) {
|
|
2534
|
+
if (!content.includes("-only"))
|
|
2535
|
+
return false;
|
|
2536
|
+
const positions = codePositionMask(content);
|
|
2537
|
+
for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
|
|
2538
|
+
if (positions[match.index] !== " ")
|
|
2539
|
+
return true;
|
|
2540
|
+
}
|
|
2541
|
+
return false;
|
|
2542
|
+
}
|
|
2543
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
|
|
1476
2544
|
const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
|
|
1477
2545
|
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
|
|
1478
2546
|
// per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
|
|
@@ -1492,7 +2560,8 @@ function staticImportEdges(content, facts, file, positions = codePositionMask(co
|
|
|
1492
2560
|
continue;
|
|
1493
2561
|
if (m[1] === undefined)
|
|
1494
2562
|
continue;
|
|
1495
|
-
|
|
2563
|
+
// Only a `type` modifier can erase a clause the pattern matched, so other clauses skip the parse.
|
|
2564
|
+
if (facts !== undefined && file !== undefined && /\btype\b/.test(m[0])) {
|
|
1496
2565
|
const source = facts.parse(file, content);
|
|
1497
2566
|
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false)
|
|
1498
2567
|
continue;
|
|
@@ -1534,18 +2603,42 @@ function dynamicImportEdges(content, facts, file, positions = codePositionMask(c
|
|
|
1534
2603
|
}
|
|
1535
2604
|
return edges;
|
|
1536
2605
|
}
|
|
1537
|
-
/**
|
|
2606
|
+
/** The literal `require("x")` calls in executable code. Pure. */
|
|
2607
|
+
function requireEdges(content, positions) {
|
|
2608
|
+
const edges = [];
|
|
2609
|
+
const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags);
|
|
2610
|
+
for (let m = re.exec(content); m !== null; m = re.exec(content)) {
|
|
2611
|
+
if (positions[m.index] === " ")
|
|
2612
|
+
continue; // inside a comment or a string
|
|
2613
|
+
const specifier = m[2];
|
|
2614
|
+
if (specifier === undefined)
|
|
2615
|
+
continue;
|
|
2616
|
+
if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index)))
|
|
2617
|
+
continue;
|
|
2618
|
+
edges.push({ specifier, index: m.index });
|
|
2619
|
+
}
|
|
2620
|
+
return edges;
|
|
2621
|
+
}
|
|
2622
|
+
/** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
|
|
2623
|
+
* order. Pure. */
|
|
1538
2624
|
function importEdges(content, facts, file) {
|
|
1539
|
-
// Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
|
|
1540
2625
|
const positions = codePositionMask(content);
|
|
1541
2626
|
const edges = staticImportEdges(content, facts, file, positions);
|
|
1542
|
-
|
|
2627
|
+
// Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
|
|
2628
|
+
const dynamic = content.includes("import(") || /import\s+\(/.test(content);
|
|
2629
|
+
const required = content.includes("require");
|
|
2630
|
+
if (!dynamic && !required)
|
|
1543
2631
|
return edges;
|
|
1544
|
-
|
|
2632
|
+
if (dynamic)
|
|
2633
|
+
edges.push(...dynamicImportEdges(content, facts, file, positions));
|
|
2634
|
+
if (required)
|
|
2635
|
+
edges.push(...requireEdges(content, positions));
|
|
2636
|
+
return edges.sort((a, b) => a.index - b.index);
|
|
1545
2637
|
}
|
|
1546
|
-
/** The runtime imports of a module with the line each sits on, in source order.
|
|
1547
|
-
|
|
1548
|
-
|
|
2638
|
+
/** The runtime imports of a module with the line each sits on, in source order. With `facts`, an
|
|
2639
|
+
* import the compiler erases (`import { type X }`) is none. Pure. */
|
|
2640
|
+
export function importSites(content, file, facts) {
|
|
2641
|
+
return importEdges(content, facts, file).map(({ specifier, index }) => ({
|
|
1549
2642
|
specifier,
|
|
1550
2643
|
line: lineAt(content, index),
|
|
1551
2644
|
}));
|
|
@@ -1558,63 +2651,130 @@ export function parseImports(content, facts, file) {
|
|
|
1558
2651
|
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1559
2652
|
* itself (it's already the actionable name). */
|
|
1560
2653
|
export function directSinkSpecifier(spec) {
|
|
1561
|
-
return
|
|
2654
|
+
return isServerOnlySink(spec) ? spec : undefined;
|
|
1562
2655
|
}
|
|
1563
2656
|
/**
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
* that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
|
|
1568
|
-
* unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
|
|
1569
|
-
* `server-only`-marked dependency terminates the chain; a `*.server` dependency is never entered, since
|
|
1570
|
-
* the client build empties it. Bounded by depth + a visited set, so
|
|
1571
|
-
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
1572
|
-
* - pure given those, so it's unit-testable with a fake graph.
|
|
2657
|
+
* Every module is read, parsed and resolved at most once, and the chain from a module is searched at
|
|
2658
|
+
* most once, however many routes reach it: per-route searches repeated all of that for every route
|
|
2659
|
+
* that shared a module, which made a check quadratic in the size of a shared graph.
|
|
1573
2660
|
*/
|
|
1574
|
-
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2661
|
+
function chainGraph(source) {
|
|
2662
|
+
const { facts } = source;
|
|
2663
|
+
const componentReceipts = source.componentReceipts ?? new Set();
|
|
2664
|
+
const contents = new Map();
|
|
2665
|
+
const imports = new Map();
|
|
2666
|
+
const resolutions = new Map();
|
|
2667
|
+
const walks = new Map();
|
|
2668
|
+
const packages = new Map();
|
|
2669
|
+
const read = (abs) => {
|
|
2670
|
+
if (!contents.has(abs))
|
|
2671
|
+
contents.set(abs, source.read(abs));
|
|
2672
|
+
return contents.get(abs);
|
|
2673
|
+
};
|
|
2674
|
+
const resolve = (from, specifier) => {
|
|
2675
|
+
const key = `${from}\0${specifier}`;
|
|
2676
|
+
if (!resolutions.has(key))
|
|
2677
|
+
resolutions.set(key, source.resolve(from, specifier));
|
|
2678
|
+
return resolutions.get(key);
|
|
2679
|
+
};
|
|
2680
|
+
const backendPackage = (from, specifier) => {
|
|
2681
|
+
if (source.backendPackage === undefined || /^[./#]/.test(specifier))
|
|
2682
|
+
return false;
|
|
2683
|
+
const key = `${from}\0${specifier}`;
|
|
2684
|
+
let found = packages.get(key);
|
|
2685
|
+
if (found === undefined) {
|
|
2686
|
+
found = source.backendPackage(from, specifier);
|
|
2687
|
+
packages.set(key, found);
|
|
2688
|
+
}
|
|
2689
|
+
return found;
|
|
2690
|
+
};
|
|
2691
|
+
const importsOf = (abs, content) => {
|
|
2692
|
+
let found = imports.get(abs);
|
|
2693
|
+
if (found === undefined) {
|
|
2694
|
+
found = parseImports(content, facts, abs);
|
|
2695
|
+
imports.set(abs, found);
|
|
2696
|
+
}
|
|
2697
|
+
return found;
|
|
2698
|
+
};
|
|
2699
|
+
/**
|
|
2700
|
+
* Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
|
|
2701
|
+
* dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
|
|
2702
|
+
* never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
|
|
2703
|
+
* importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
|
|
2704
|
+
* entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
|
|
2705
|
+
*/
|
|
2706
|
+
const search = (start, startContent) => {
|
|
2707
|
+
const seen = new Set([start]);
|
|
2708
|
+
let frontier = [{ abs: start, content: startContent, chain: [start], modules: [start] }];
|
|
2709
|
+
while (frontier.length > 0) {
|
|
2710
|
+
const next = [];
|
|
2711
|
+
for (const node of frontier) {
|
|
2712
|
+
const { chain, modules } = node;
|
|
2713
|
+
for (const spec of importsOf(node.abs, node.content)) {
|
|
2714
|
+
const sink = directSinkSpecifier(spec);
|
|
2715
|
+
if (sink !== undefined)
|
|
2716
|
+
return { chain: [...chain, sink], modules };
|
|
2717
|
+
const abs = resolve(node.abs, spec);
|
|
2718
|
+
if (abs === undefined) {
|
|
2719
|
+
if (backendPackage(node.abs, spec))
|
|
2720
|
+
return { chain: [...chain, spec], modules };
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (seen.has(abs))
|
|
2724
|
+
continue;
|
|
2725
|
+
if (componentReceipts.has(abs))
|
|
2726
|
+
continue;
|
|
2727
|
+
if (isBackendModule(abs))
|
|
2728
|
+
return { chain: [...chain, spec], modules: [...modules, abs] };
|
|
2729
|
+
if (SERVER_FN_FILE.test(abs))
|
|
2730
|
+
continue;
|
|
2731
|
+
const content = read(abs);
|
|
2732
|
+
if (content === undefined)
|
|
2733
|
+
continue;
|
|
2734
|
+
if (hasBackendOnlyMarker(content)) {
|
|
2735
|
+
return { chain: [...chain, spec], modules: [...modules, abs] };
|
|
2736
|
+
}
|
|
2737
|
+
seen.add(abs);
|
|
2738
|
+
next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] });
|
|
2739
|
+
}
|
|
1613
2740
|
}
|
|
2741
|
+
frontier = next;
|
|
1614
2742
|
}
|
|
1615
|
-
|
|
1616
|
-
}
|
|
1617
|
-
return
|
|
2743
|
+
return undefined;
|
|
2744
|
+
};
|
|
2745
|
+
return {
|
|
2746
|
+
walk(start, content) {
|
|
2747
|
+
if (!walks.has(start))
|
|
2748
|
+
walks.set(start, search(start, content));
|
|
2749
|
+
return walks.get(start);
|
|
2750
|
+
},
|
|
2751
|
+
read,
|
|
2752
|
+
resolve,
|
|
2753
|
+
backendPackage,
|
|
2754
|
+
componentReceipts,
|
|
2755
|
+
facts,
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
|
|
2760
|
+
* specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
|
|
2761
|
+
* graph tests it.
|
|
2762
|
+
*/
|
|
2763
|
+
export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts, componentReceipts = new Set()) {
|
|
2764
|
+
return chainGraph({
|
|
2765
|
+
resolve,
|
|
2766
|
+
read,
|
|
2767
|
+
facts,
|
|
2768
|
+
componentReceipts,
|
|
2769
|
+
}).walk(routeFile, routeContent)?.chain;
|
|
2770
|
+
}
|
|
2771
|
+
/**
|
|
2772
|
+
* The server-only chain finder for one check: called per browser module, it returns that module's
|
|
2773
|
+
* findings, reusing every module and chain an earlier call already searched.
|
|
2774
|
+
*/
|
|
2775
|
+
export function createServerOnlyChains(source) {
|
|
2776
|
+
const graph = chainGraph(source);
|
|
2777
|
+
return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot);
|
|
1618
2778
|
}
|
|
1619
2779
|
/**
|
|
1620
2780
|
* Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
|
|
@@ -1622,77 +2782,76 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
|
|
|
1622
2782
|
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
1623
2783
|
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
1624
2784
|
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
1625
|
-
* de-duplicated to the shortest chain per first-hop specifier.
|
|
2785
|
+
* de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
|
|
2786
|
+
* yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
|
|
2787
|
+
* One check should use {@link createServerOnlyChains}, which shares the work across modules.
|
|
1626
2788
|
*/
|
|
1627
|
-
export function resolveServerOnlyChains(file, content, resolve, read, facts) {
|
|
1628
|
-
|
|
2789
|
+
export function resolveServerOnlyChains(file, content, resolve, read, facts, componentReceipts = new Set(), browserRoot = false) {
|
|
2790
|
+
return createServerOnlyChains({
|
|
2791
|
+
resolve,
|
|
2792
|
+
read,
|
|
2793
|
+
facts,
|
|
2794
|
+
componentReceipts,
|
|
2795
|
+
})(file, content, browserRoot);
|
|
2796
|
+
}
|
|
2797
|
+
function serverOnlyChains(graph, file, content, browserRoot) {
|
|
2798
|
+
if (!browserRoot && !isBrowserSource(file))
|
|
1629
2799
|
return [];
|
|
1630
2800
|
const lines = content.split("\n");
|
|
1631
2801
|
const out = [];
|
|
1632
2802
|
const flaggedSpecifiers = new Set();
|
|
1633
|
-
|
|
1634
|
-
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1635
|
-
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1636
|
-
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
2803
|
+
for (const { specifier, index } of importEdges(content, graph.facts, file)) {
|
|
1637
2804
|
if (flaggedSpecifiers.has(specifier))
|
|
1638
2805
|
continue;
|
|
1639
2806
|
const line = lineAt(content, index);
|
|
1640
2807
|
const snippet = (lines[line - 1] ?? "").trim();
|
|
1641
|
-
|
|
1642
|
-
// direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
|
|
1643
|
-
if (directSinkSpecifier(specifier) !== undefined) {
|
|
2808
|
+
const flag = (chain, fallback, modules = [file]) => {
|
|
1644
2809
|
flaggedSpecifiers.add(specifier);
|
|
1645
|
-
out.push({ file, line, snippet, specifier, chain
|
|
2810
|
+
out.push({ file, line, snippet, specifier, chain, modules, fallback });
|
|
2811
|
+
};
|
|
2812
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
|
|
2813
|
+
if (directSinkSpecifier(specifier) !== undefined) {
|
|
2814
|
+
flag([file, specifier], false);
|
|
1646
2815
|
continue;
|
|
1647
2816
|
}
|
|
1648
|
-
// (2) A
|
|
1649
|
-
// a sink,
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
// subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
|
|
1683
|
-
// route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
|
|
1684
|
-
const chain = [file, specifier, ...subChain.slice(1)];
|
|
1685
|
-
out.push({ file, line, snippet, specifier, chain, fallback: false });
|
|
1686
|
-
continue;
|
|
1687
|
-
}
|
|
1688
|
-
// (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
|
|
1689
|
-
// found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
|
|
1690
|
-
// direct edge so we still surface the known-server-only convention rather than going silent.
|
|
1691
|
-
if (SERVER_ONLY.test(specifier)) {
|
|
1692
|
-
flaggedSpecifiers.add(specifier);
|
|
1693
|
-
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
|
|
1694
|
-
}
|
|
2817
|
+
// (2) A project import, relative or through an alias: walk from the resolved dependency and, if
|
|
2818
|
+
// it reaches a sink, report the full chain rooted at THIS route's import line.
|
|
2819
|
+
const abs = graph.resolve(file, specifier);
|
|
2820
|
+
if (abs === undefined) {
|
|
2821
|
+
// An installed package that declares itself backend is a sink. Any other name no project file
|
|
2822
|
+
// answers to can't be called server-only, except the `../db` convention, reported as the direct
|
|
2823
|
+
// edge with `fallback` saying the resolve was impossible.
|
|
2824
|
+
if (graph.backendPackage(file, specifier))
|
|
2825
|
+
flag([file, specifier], false);
|
|
2826
|
+
else if (isServerOnly(specifier))
|
|
2827
|
+
flag([file, specifier], true);
|
|
2828
|
+
continue;
|
|
2829
|
+
}
|
|
2830
|
+
if (graph.componentReceipts.has(abs))
|
|
2831
|
+
continue;
|
|
2832
|
+
// A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
|
|
2833
|
+
if (isBackendModule(abs)) {
|
|
2834
|
+
flag([file, specifier], false, [file, abs]);
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
if (SERVER_FN_FILE.test(abs))
|
|
2838
|
+
continue;
|
|
2839
|
+
const depContent = graph.read(abs);
|
|
2840
|
+
if (depContent === undefined)
|
|
2841
|
+
continue;
|
|
2842
|
+
if (hasBackendOnlyMarker(depContent)) {
|
|
2843
|
+
flag([file, specifier], false, [file, abs]);
|
|
2844
|
+
continue;
|
|
2845
|
+
}
|
|
2846
|
+
const sub = graph.walk(abs, depContent);
|
|
2847
|
+
if (sub !== undefined) {
|
|
2848
|
+
// `sub` starts at the resolved dependency; the route names it by its as-written specifier.
|
|
2849
|
+
flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules]);
|
|
2850
|
+
continue;
|
|
1695
2851
|
}
|
|
2852
|
+
// (3) The `../db` convention with no deeper sink found: still surface the direct edge.
|
|
2853
|
+
if (isServerOnly(specifier))
|
|
2854
|
+
flag([file, specifier], true);
|
|
1696
2855
|
}
|
|
1697
2856
|
return out;
|
|
1698
2857
|
}
|
|
@@ -1721,22 +2880,19 @@ export function scanResponseRoutes(file, content, facts) {
|
|
|
1721
2880
|
continue;
|
|
1722
2881
|
}
|
|
1723
2882
|
const line = lineAt(content, m.index);
|
|
1724
|
-
// A `// nifra-expect raw-response`
|
|
1725
|
-
// be a typed route), silencing this advisory
|
|
1726
|
-
//
|
|
1727
|
-
// leak down
|
|
1728
|
-
//
|
|
1729
|
-
|
|
1730
|
-
const aboveLine = lines[line - 2] ?? "";
|
|
1731
|
-
const aboveSuppresses = aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA);
|
|
1732
|
-
if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses)
|
|
2883
|
+
// A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
|
|
2884
|
+
// (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
|
|
2885
|
+
// It counts on the return line or in the comment block directly above, so a TRAILING comment on one
|
|
2886
|
+
// route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
|
|
2887
|
+
// never affects the match); the raw `lines` still carry it.
|
|
2888
|
+
if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined)
|
|
1733
2889
|
continue;
|
|
1734
|
-
out.push({ file, line, snippet:
|
|
2890
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
1735
2891
|
}
|
|
1736
2892
|
return out;
|
|
1737
2893
|
}
|
|
1738
|
-
/**
|
|
1739
|
-
const
|
|
2894
|
+
/** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
2895
|
+
const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"];
|
|
1740
2896
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
1741
2897
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
1742
2898
|
*
|
|
@@ -1777,11 +2933,17 @@ async function gitIgnored(cwd, rels) {
|
|
|
1777
2933
|
}
|
|
1778
2934
|
export async function walkSource(cwd, visit, opts = {}) {
|
|
1779
2935
|
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED;
|
|
2936
|
+
const pattern = opts.scripts === true
|
|
2937
|
+
? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
|
|
2938
|
+
: "**/*.{ts,tsx,mts,cts}";
|
|
1780
2939
|
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
1781
2940
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
1782
2941
|
const rels = [];
|
|
1783
|
-
for await (const rawRel of new Glob(
|
|
2942
|
+
for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
|
|
1784
2943
|
const rel = normalizeProjectPath(rawRel);
|
|
2944
|
+
// A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
|
|
2945
|
+
if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel))
|
|
2946
|
+
continue;
|
|
1785
2947
|
if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel)))
|
|
1786
2948
|
rels.push(rel);
|
|
1787
2949
|
}
|
|
@@ -1790,8 +2952,44 @@ export async function walkSource(cwd, visit, opts = {}) {
|
|
|
1790
2952
|
for (const rel of rels.sort(codeUnitOrder)) {
|
|
1791
2953
|
if (ignored.has(rel))
|
|
1792
2954
|
continue;
|
|
1793
|
-
|
|
2955
|
+
const text = await Bun.file(join(cwd, rel)).text();
|
|
2956
|
+
visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
const PLAIN_SCRIPT_FILE = /\.[cm]?js$/;
|
|
2960
|
+
const ZONED_PATH = /(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/;
|
|
2961
|
+
/** A single-file component: its `<script>` blocks are the code a build compiles. */
|
|
2962
|
+
export const COMPONENT_FILE = /\.(?:svelte|vue)$/;
|
|
2963
|
+
/**
|
|
2964
|
+
* A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
|
|
2965
|
+
* they stand, and every other character blanked to a space with line breaks kept, so a finding's line
|
|
2966
|
+
* and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
|
|
2967
|
+
*/
|
|
2968
|
+
export function componentScripts(text) {
|
|
2969
|
+
const blank = (part) => part.replace(/[^\n]/g, " ");
|
|
2970
|
+
let out = "";
|
|
2971
|
+
let at = 0;
|
|
2972
|
+
for (;;) {
|
|
2973
|
+
const open = text.indexOf("<script", at);
|
|
2974
|
+
if (open === -1)
|
|
2975
|
+
break;
|
|
2976
|
+
const close = text.indexOf(">", open);
|
|
2977
|
+
if (close === -1)
|
|
2978
|
+
break;
|
|
2979
|
+
// `<scripts>` or `<script-x>` is another element.
|
|
2980
|
+
if (!/[\s>]/.test(text[open + 7] ?? "")) {
|
|
2981
|
+
out += blank(text.slice(at, close + 1));
|
|
2982
|
+
at = close + 1;
|
|
2983
|
+
continue;
|
|
2984
|
+
}
|
|
2985
|
+
const end = text.indexOf("</script", close + 1);
|
|
2986
|
+
const stop = end === -1 ? text.length : end;
|
|
2987
|
+
out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop);
|
|
2988
|
+
at = stop;
|
|
2989
|
+
if (end === -1)
|
|
2990
|
+
break;
|
|
1794
2991
|
}
|
|
2992
|
+
return out + blank(text.slice(at));
|
|
1795
2993
|
}
|
|
1796
2994
|
const bySite = (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line;
|
|
1797
2995
|
/** Collect own-API `fetch()` findings across the project. */
|