@nifrajs/cli 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +28 -3
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +958 -71
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +181 -47
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +34 -3
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/sync-manifest.ts +48 -18
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/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) {
|
|
@@ -239,13 +300,13 @@ function isPotentialBackendSource(code) {
|
|
|
239
300
|
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
240
301
|
/new\s+Hono(?:<[^>]+>)?\s*\(/.test(code));
|
|
241
302
|
}
|
|
242
|
-
function scanRoutePattern(file, content, code, pattern, facts) {
|
|
303
|
+
function scanRoutePattern(file, content, code, pattern, facts, verbs = HTTP_VERBS) {
|
|
243
304
|
const out = [];
|
|
244
305
|
const lines = content.split("\n");
|
|
245
306
|
pattern.lastIndex = 0;
|
|
246
307
|
for (let m = pattern.exec(code); m !== null; m = pattern.exec(code)) {
|
|
247
308
|
const method = (m[1] ?? "").toUpperCase();
|
|
248
|
-
if (!
|
|
309
|
+
if (!verbs.has(method))
|
|
249
310
|
continue;
|
|
250
311
|
const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
|
|
251
312
|
if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
|
|
@@ -263,27 +324,100 @@ function scanRoutePattern(file, content, code, pattern, facts) {
|
|
|
263
324
|
}
|
|
264
325
|
return out;
|
|
265
326
|
}
|
|
327
|
+
// Punctuators after which a `/` opens a regex literal rather than dividing. A `)` `]` or `}` is read
|
|
328
|
+
// as the end of an operand, so `/` after it divides - the rare statement-leading regex after a block
|
|
329
|
+
// is the cost of not tracking parser context.
|
|
330
|
+
const REGEX_AFTER = new Set([
|
|
331
|
+
"(",
|
|
332
|
+
",",
|
|
333
|
+
"=",
|
|
334
|
+
":",
|
|
335
|
+
"[",
|
|
336
|
+
"!",
|
|
337
|
+
"&",
|
|
338
|
+
"|",
|
|
339
|
+
"?",
|
|
340
|
+
"{",
|
|
341
|
+
";",
|
|
342
|
+
"+",
|
|
343
|
+
"-",
|
|
344
|
+
"*",
|
|
345
|
+
"%",
|
|
346
|
+
"<",
|
|
347
|
+
">",
|
|
348
|
+
"~",
|
|
349
|
+
"^",
|
|
350
|
+
]);
|
|
351
|
+
const REGEX_AFTER_WORD = new Set([
|
|
352
|
+
"return",
|
|
353
|
+
"typeof",
|
|
354
|
+
"case",
|
|
355
|
+
"do",
|
|
356
|
+
"else",
|
|
357
|
+
"in",
|
|
358
|
+
"of",
|
|
359
|
+
"new",
|
|
360
|
+
"delete",
|
|
361
|
+
"void",
|
|
362
|
+
"throw",
|
|
363
|
+
"yield",
|
|
364
|
+
"await",
|
|
365
|
+
"instanceof",
|
|
366
|
+
]);
|
|
367
|
+
const isWordChar = (c) => c !== undefined && /[\w$]/.test(c);
|
|
368
|
+
/** Where a regex literal opened by the `/` at `i` ends (after its flags), or `undefined` when the
|
|
369
|
+
* literal does not close on its line - then the slash was division after all. */
|
|
370
|
+
function regexLiteralEnd(src, i) {
|
|
371
|
+
const n = src.length;
|
|
372
|
+
let j = i + 1;
|
|
373
|
+
let inClass = false;
|
|
374
|
+
while (j < n) {
|
|
375
|
+
const c = src[j];
|
|
376
|
+
if (c === "\n" || c === "\r")
|
|
377
|
+
return undefined;
|
|
378
|
+
if (c === "\\") {
|
|
379
|
+
j += 2;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (inClass) {
|
|
383
|
+
if (c === "]")
|
|
384
|
+
inClass = false;
|
|
385
|
+
}
|
|
386
|
+
else if (c === "[") {
|
|
387
|
+
inClass = true;
|
|
388
|
+
}
|
|
389
|
+
else if (c === "/") {
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
j++;
|
|
393
|
+
}
|
|
394
|
+
if (j >= n)
|
|
395
|
+
return undefined;
|
|
396
|
+
j++;
|
|
397
|
+
while (j < n && isWordChar(src[j]))
|
|
398
|
+
j++;
|
|
399
|
+
return j;
|
|
400
|
+
}
|
|
266
401
|
/**
|
|
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}).
|
|
402
|
+
* Blank comments, template-literal contents and regex-literal contents (and, with `quoted: "blank"`,
|
|
403
|
+
* single/double-quoted strings and every delimiter) while preserving every offset and newline.
|
|
404
|
+
* A small lexer, not a parser: a `/` opens a regex where an operand may start - after a punctuator,
|
|
405
|
+
* an operator keyword, or at the start of the file - so a quote inside a regex no longer derails
|
|
406
|
+
* the string state that follows.
|
|
275
407
|
*/
|
|
276
|
-
|
|
408
|
+
function maskSource(src, quoted) {
|
|
277
409
|
const out = src.split("");
|
|
278
410
|
const n = src.length;
|
|
279
|
-
let i = 0;
|
|
280
411
|
const blank = (a, b) => {
|
|
281
412
|
for (let k = a; k < b; k++)
|
|
282
413
|
if (out[k] !== "\n")
|
|
283
414
|
out[k] = " ";
|
|
284
415
|
};
|
|
416
|
+
// The last significant code token: a punctuator character, or a whole identifier/keyword.
|
|
417
|
+
let previous = "";
|
|
418
|
+
let i = 0;
|
|
285
419
|
while (i < n) {
|
|
286
|
-
const c = src
|
|
420
|
+
const c = src.charAt(i);
|
|
287
421
|
const d = src[i + 1];
|
|
288
422
|
if (c === "/" && d === "/") {
|
|
289
423
|
let j = i + 2;
|
|
@@ -307,8 +441,12 @@ export function stripComments(src) {
|
|
|
307
441
|
j++;
|
|
308
442
|
j++;
|
|
309
443
|
}
|
|
310
|
-
|
|
444
|
+
if (quoted === "keep")
|
|
445
|
+
blank(i + 1, j); // keep the backticks, blank the code-as-text inside
|
|
446
|
+
else
|
|
447
|
+
blank(i, Math.min(j + 1, n));
|
|
311
448
|
i = j + 1;
|
|
449
|
+
previous = "`";
|
|
312
450
|
}
|
|
313
451
|
else if (c === "'" || c === '"') {
|
|
314
452
|
let j = i + 1;
|
|
@@ -317,63 +455,61 @@ export function stripComments(src) {
|
|
|
317
455
|
j++; // skip the escaped char
|
|
318
456
|
j++;
|
|
319
457
|
}
|
|
458
|
+
if (quoted === "blank")
|
|
459
|
+
blank(i, Math.min(j + 1, n));
|
|
320
460
|
i = j + 1;
|
|
461
|
+
previous = c;
|
|
321
462
|
}
|
|
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;
|
|
463
|
+
else if (c === "/" &&
|
|
464
|
+
(previous === "" || REGEX_AFTER.has(previous) || REGEX_AFTER_WORD.has(previous))) {
|
|
465
|
+
const end = regexLiteralEnd(src, i);
|
|
466
|
+
if (end === undefined) {
|
|
467
|
+
previous = c;
|
|
468
|
+
i++;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
if (quoted === "keep")
|
|
472
|
+
blank(i + 1, end);
|
|
473
|
+
else
|
|
474
|
+
blank(i, end);
|
|
475
|
+
previous = "/regex";
|
|
476
|
+
i = end;
|
|
477
|
+
}
|
|
358
478
|
}
|
|
359
|
-
else if (c
|
|
360
|
-
const quote = c;
|
|
479
|
+
else if (isWordChar(c)) {
|
|
361
480
|
let j = i + 1;
|
|
362
|
-
while (j < n && src[j]
|
|
363
|
-
if (src[j] === "\\")
|
|
364
|
-
j++;
|
|
481
|
+
while (j < n && isWordChar(src[j]))
|
|
365
482
|
j++;
|
|
366
|
-
|
|
367
|
-
j = Math.min(j + 1, n);
|
|
368
|
-
blank(i, j);
|
|
483
|
+
previous = src.slice(i, j);
|
|
369
484
|
i = j;
|
|
370
485
|
}
|
|
371
486
|
else {
|
|
487
|
+
if (!/\s/.test(c))
|
|
488
|
+
previous = c;
|
|
372
489
|
i++;
|
|
373
490
|
}
|
|
374
491
|
}
|
|
375
492
|
return out.join("");
|
|
376
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
496
|
+
* - `//` line comments and block comments;
|
|
497
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
498
|
+
* generators), never a real statement to lint.
|
|
499
|
+
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one); a regex
|
|
500
|
+
* literal keeps its slashes and loses its contents, so a quote inside one cannot open a string.
|
|
501
|
+
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
502
|
+
*/
|
|
503
|
+
export function stripComments(src) {
|
|
504
|
+
return maskSource(src, "keep");
|
|
505
|
+
}
|
|
506
|
+
/** Blank comments and every quoted/template/regex literal while preserving offsets. Unlike
|
|
507
|
+
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
508
|
+
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
509
|
+
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
510
|
+
export function codePositionMask(src) {
|
|
511
|
+
return maskSource(src, "blank");
|
|
512
|
+
}
|
|
377
513
|
const firstArgumentIndex = (content, callEnd) => {
|
|
378
514
|
let index = callEnd;
|
|
379
515
|
while (/\s/.test(content[index] ?? ""))
|
|
@@ -499,8 +635,7 @@ export function scanFetchText(file, content, externalMounts = []) {
|
|
|
499
635
|
continue;
|
|
500
636
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
501
637
|
continue;
|
|
502
|
-
|
|
503
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
638
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
|
|
504
639
|
}
|
|
505
640
|
return out;
|
|
506
641
|
}
|
|
@@ -531,13 +666,14 @@ export function scanStreamText(file, content, externalMounts = []) {
|
|
|
531
666
|
continue;
|
|
532
667
|
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
533
668
|
continue;
|
|
534
|
-
|
|
535
|
-
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
669
|
+
out.push(typedClientFinding(file, lines, lineAt(content, m.index)));
|
|
536
670
|
}
|
|
537
671
|
return out;
|
|
538
672
|
}
|
|
539
673
|
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
540
|
-
export function scanStaticRouteText(file, content, facts
|
|
674
|
+
export function scanStaticRouteText(file, content, facts,
|
|
675
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
676
|
+
project) {
|
|
541
677
|
const code = stripComments(content);
|
|
542
678
|
if (!isPotentialBackendSource(code))
|
|
543
679
|
return [];
|
|
@@ -545,6 +681,180 @@ export function scanStaticRouteText(file, content, facts) {
|
|
|
545
681
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
546
682
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
547
683
|
...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
|
|
684
|
+
...scanRouteArguments(file, content, facts, project).routes,
|
|
685
|
+
].sort(bySite);
|
|
686
|
+
}
|
|
687
|
+
/** The end of the quoted literal opening `text`, or -1 when it does not close on this line. */
|
|
688
|
+
function literalEnd(text) {
|
|
689
|
+
const quote = text[0];
|
|
690
|
+
for (let i = 1; i < text.length; i++) {
|
|
691
|
+
if (text[i] === "\\")
|
|
692
|
+
i++;
|
|
693
|
+
else if (text[i] === quote)
|
|
694
|
+
return i + 1;
|
|
695
|
+
}
|
|
696
|
+
return -1;
|
|
697
|
+
}
|
|
698
|
+
/** The index after the whitespace run at `from`. */
|
|
699
|
+
function skipSpace(text, from) {
|
|
700
|
+
let i = from;
|
|
701
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t"))
|
|
702
|
+
i++;
|
|
703
|
+
return i;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Read `[export ]const NAME[: T] = <init>` written at column 0, so a function-local `const`, which is
|
|
707
|
+
* indented, never stands in for a module-level one. The shape is read from the comment-stripped
|
|
708
|
+
* line; the literal itself from the source line, since stripping blanks template contents.
|
|
709
|
+
*/
|
|
710
|
+
function moduleConstantOn(code, source) {
|
|
711
|
+
const exported = code.startsWith("export ");
|
|
712
|
+
let i = exported ? skipSpace(code, 7) : 0;
|
|
713
|
+
if (!code.startsWith("const ", i))
|
|
714
|
+
return undefined;
|
|
715
|
+
i = skipSpace(code, i + 6);
|
|
716
|
+
const name = /^[A-Za-z_$][\w$]*/.exec(code.slice(i))?.[0];
|
|
717
|
+
if (name === undefined)
|
|
718
|
+
return undefined;
|
|
719
|
+
i = skipSpace(code, i + name.length);
|
|
720
|
+
if (code[i] === ":")
|
|
721
|
+
i = code.indexOf("=", i);
|
|
722
|
+
if (i === -1 || code[i] !== "=" || code[i + 1] === "=")
|
|
723
|
+
return undefined;
|
|
724
|
+
i = skipSpace(code, i + 1);
|
|
725
|
+
const opener = code[i];
|
|
726
|
+
const end = opener === '"' || opener === "'" || opener === "`" ? literalEnd(source.slice(i)) : -1;
|
|
727
|
+
const tail = end === -1 ? undefined : code.slice(i + end).trim();
|
|
728
|
+
const literal = source.slice(i, i + end);
|
|
729
|
+
const value = tail === undefined || !["", ";", "as const", "as const;"].includes(tail)
|
|
730
|
+
? undefined
|
|
731
|
+
: opener === "`"
|
|
732
|
+
? /[$\\]/.test(literal)
|
|
733
|
+
? undefined
|
|
734
|
+
: literal.slice(1, -1)
|
|
735
|
+
: parseQuotedLiteral(literal);
|
|
736
|
+
return [name, { exported, value }];
|
|
737
|
+
}
|
|
738
|
+
function moduleConstants(code, source) {
|
|
739
|
+
const constants = new Map();
|
|
740
|
+
const sourceLines = source.split("\n");
|
|
741
|
+
code.split("\n").forEach((line, index) => {
|
|
742
|
+
const constant = moduleConstantOn(line, sourceLines[index] ?? "");
|
|
743
|
+
if (constant !== undefined)
|
|
744
|
+
constants.set(constant[0], constant[1]);
|
|
745
|
+
});
|
|
746
|
+
return constants;
|
|
747
|
+
}
|
|
748
|
+
/** Local name → the export it imports and from where, for value imports only. */
|
|
749
|
+
function namedImports(code) {
|
|
750
|
+
const imports = new Map();
|
|
751
|
+
NAMED_IMPORTS.lastIndex = 0;
|
|
752
|
+
for (let m = NAMED_IMPORTS.exec(code); m !== null; m = NAMED_IMPORTS.exec(code)) {
|
|
753
|
+
if (/\btype\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index + 6)))
|
|
754
|
+
continue;
|
|
755
|
+
for (const element of (m[1] ?? "").split(",")) {
|
|
756
|
+
const parts = element.trim().split(/\s+/);
|
|
757
|
+
if (parts[0] === "type" || parts[0] === undefined || parts[0] === "")
|
|
758
|
+
continue;
|
|
759
|
+
const local = parts[1] === "as" ? parts[2] : parts[0];
|
|
760
|
+
if (local !== undefined)
|
|
761
|
+
imports.set(local, { specifier: m[3] ?? "", name: parts[0] });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return imports;
|
|
765
|
+
}
|
|
766
|
+
/** The project file a relative import names, tried the way a TypeScript-aware bundler resolves it. */
|
|
767
|
+
function projectModule(project, fromFile, specifier) {
|
|
768
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../"))
|
|
769
|
+
return undefined;
|
|
770
|
+
const base = posix.normalize(posix.join(posix.dirname(fromFile.replaceAll("\\", "/")), specifier));
|
|
771
|
+
const stem = base.replace(/\.(?:m?js|jsx)$/, "");
|
|
772
|
+
return [base, `${stem}.ts`, `${stem}.tsx`, `${stem}.mts`, `${base}/index.ts`].find((candidate) => project.has(candidate));
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Route registrations whose path is a name or a template rather than a quoted literal. A name bound
|
|
776
|
+
* to a module-level string `const`, here or imported from a project file, and a template with no
|
|
777
|
+
* substitution resolve to routes; any other template, and a module-level or imported name nifra
|
|
778
|
+
* cannot read, is unresolved. A name bound anywhere else (a parameter, a local) is not reported:
|
|
779
|
+
* nothing marks such a call as a route rather than a `map.get(key, fallback)`.
|
|
780
|
+
*/
|
|
781
|
+
function scanRouteArguments(file, content, facts, project, verbs = HTTP_VERBS) {
|
|
782
|
+
const routes = [];
|
|
783
|
+
const unresolved = [];
|
|
784
|
+
const code = stripComments(content);
|
|
785
|
+
if (!isPotentialBackendSource(code))
|
|
786
|
+
return { routes, unresolved };
|
|
787
|
+
const lines = content.split("\n");
|
|
788
|
+
const constants = moduleConstants(code, content);
|
|
789
|
+
const imports = namedImports(code);
|
|
790
|
+
const importedFiles = new Map();
|
|
791
|
+
const imported = (specifier, name) => {
|
|
792
|
+
const target = project === undefined ? undefined : projectModule(project, file, specifier);
|
|
793
|
+
if (target === undefined)
|
|
794
|
+
return undefined;
|
|
795
|
+
let found = importedFiles.get(target);
|
|
796
|
+
if (found === undefined) {
|
|
797
|
+
const text = project?.get(target) ?? "";
|
|
798
|
+
found = moduleConstants(stripComments(text), text);
|
|
799
|
+
importedFiles.set(target, found);
|
|
800
|
+
}
|
|
801
|
+
const constant = found.get(name);
|
|
802
|
+
return constant?.exported === true ? constant : undefined;
|
|
803
|
+
};
|
|
804
|
+
ROUTE_ARGUMENT.lastIndex = 0;
|
|
805
|
+
for (let m = ROUTE_ARGUMENT.exec(code); m !== null; m = ROUTE_ARGUMENT.exec(code)) {
|
|
806
|
+
const method = (m[1] ?? "").toUpperCase();
|
|
807
|
+
if (!verbs.has(method))
|
|
808
|
+
continue;
|
|
809
|
+
const at = m.index + m[0].lastIndexOf(m[2] ?? "");
|
|
810
|
+
const argument = content.slice(at, at + (m[2] ?? "").length);
|
|
811
|
+
let path;
|
|
812
|
+
if (argument.startsWith("`")) {
|
|
813
|
+
path = /[$\\]/.test(argument) ? undefined : argument.slice(1, -1);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
const binding = imports.get(argument);
|
|
817
|
+
const constant = binding === undefined ? constants.get(argument) : imported(binding.specifier, binding.name);
|
|
818
|
+
if (binding === undefined && constant === undefined)
|
|
819
|
+
continue;
|
|
820
|
+
path = constant?.value;
|
|
821
|
+
}
|
|
822
|
+
// A name that reads as something other than a path (`settings.get(KEY, fallback)`) is no route.
|
|
823
|
+
if (path !== undefined && (!path.startsWith("/") || path.startsWith("//")))
|
|
824
|
+
continue;
|
|
825
|
+
if (facts !== undefined) {
|
|
826
|
+
const source = facts.parse(file, content);
|
|
827
|
+
if (source !== undefined &&
|
|
828
|
+
facts.isRouteArgumentAt(source, m.index, method, argument) !== true)
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const line = lineAt(content, m.index);
|
|
832
|
+
const snippet = (lines[line - 1] ?? "").trim();
|
|
833
|
+
if (path !== undefined)
|
|
834
|
+
routes.push({ file, line, snippet, method, path });
|
|
835
|
+
else
|
|
836
|
+
unresolved.push({ file, line, snippet, method, expression: argument.slice(0, 120) });
|
|
837
|
+
}
|
|
838
|
+
return { routes, unresolved };
|
|
839
|
+
}
|
|
840
|
+
/** Route registrations whose path the static scan cannot resolve - see {@link scanRouteArguments}. */
|
|
841
|
+
export function scanUnresolvedRoutePaths(file, content, project, facts) {
|
|
842
|
+
return [
|
|
843
|
+
...scanRouteArguments(file, content, facts, project).unresolved,
|
|
844
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).unresolved,
|
|
845
|
+
].sort(bySite);
|
|
846
|
+
}
|
|
847
|
+
/** `app.ws("/path", ...)` registrations, as routes under the pseudo-method `"WS"` reflection uses. */
|
|
848
|
+
export function scanStaticSocketText(file, content, facts,
|
|
849
|
+
/** Project files by forward-slash relative path, to resolve a path constant imported from one. */
|
|
850
|
+
project) {
|
|
851
|
+
const code = stripComments(content);
|
|
852
|
+
if (!isPotentialBackendSource(code))
|
|
853
|
+
return [];
|
|
854
|
+
return [
|
|
855
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
|
|
856
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
|
|
857
|
+
...scanRouteArguments(file, content, facts, project, SOCKET_VERB).routes,
|
|
548
858
|
].sort(bySite);
|
|
549
859
|
}
|
|
550
860
|
/** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
|
|
@@ -615,6 +925,24 @@ const SQL_METHODS = new Set([
|
|
|
615
925
|
]);
|
|
616
926
|
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
927
|
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
|
|
928
|
+
/** Driver calls that take a statement under names that are everyday words elsewhere (`map.get`,
|
|
929
|
+
* `client.get("/users/update")`): node-sqlite3's `all`/`get`/`each` and pg-promise's result-shaping
|
|
930
|
+
* calls. One of these, and any bare call, is judged by whether its text starts like a statement. */
|
|
931
|
+
const DRIVER_READ_METHODS = new Set([
|
|
932
|
+
"all",
|
|
933
|
+
"get",
|
|
934
|
+
"each",
|
|
935
|
+
"any",
|
|
936
|
+
"one",
|
|
937
|
+
"none",
|
|
938
|
+
"many",
|
|
939
|
+
"oneOrNone",
|
|
940
|
+
"manyOrNone",
|
|
941
|
+
"result",
|
|
942
|
+
"multi",
|
|
943
|
+
"multiResult",
|
|
944
|
+
]);
|
|
945
|
+
const STATEMENT_START = /^\s*\(?\s*(?:select|insert|update|delete|with|replace|merge|create|drop|alter|truncate|pragma)\s/i;
|
|
618
946
|
/** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
|
|
619
947
|
* no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
|
|
620
948
|
* module-scope `const` is the only form that provably cannot carry request data. */
|
|
@@ -817,11 +1145,12 @@ const CONST_RESOLUTION_DEPTH = 5;
|
|
|
817
1145
|
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
818
1146
|
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
819
1147
|
* 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
|
-
*
|
|
1148
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a `const` whose initializer
|
|
1149
|
+
* resolves (the nearest binding: function or block scope, then module scope; recursively,
|
|
1150
|
+
* depth-capped), `.join(sep?)` over an array {@link staticArrayItems} proves, and - when
|
|
1151
|
+
* `scope.imports` is configured - a module `const` imported by name from another in-project module.
|
|
1152
|
+
* Everything else - `let`, a parameter, any other call, a member access, a shadowed name, a
|
|
1153
|
+
* bare-specifier import - stays unresolved and the interpolation is flagged exactly as before.
|
|
825
1154
|
*/
|
|
826
1155
|
function resolveStaticSqlText(ts, node, scope, depth) {
|
|
827
1156
|
if (depth > CONST_RESOLUTION_DEPTH)
|
|
@@ -839,7 +1168,8 @@ function resolveStaticSqlText(ts, node, scope, depth) {
|
|
|
839
1168
|
if (ts.isTemplateExpression(node)) {
|
|
840
1169
|
let text = node.head.text;
|
|
841
1170
|
for (const span of node.templateSpans) {
|
|
842
|
-
const part =
|
|
1171
|
+
const part = placeholderNumber(ts, text, span.expression, depth) ??
|
|
1172
|
+
resolveStaticSqlText(ts, span.expression, scope, depth + 1);
|
|
843
1173
|
if (part === undefined)
|
|
844
1174
|
return undefined;
|
|
845
1175
|
text += part + span.literal.text;
|
|
@@ -853,7 +1183,14 @@ function resolveStaticSqlText(ts, node, scope, depth) {
|
|
|
853
1183
|
return undefined;
|
|
854
1184
|
return `${whenTrue} ${whenFalse}`;
|
|
855
1185
|
}
|
|
1186
|
+
if (ts.isCallExpression(node))
|
|
1187
|
+
return resolveStaticJoin(ts, node, scope, depth);
|
|
856
1188
|
if (ts.isIdentifier(node)) {
|
|
1189
|
+
const local = localConstInitializer(ts, node);
|
|
1190
|
+
if (local === "shadow")
|
|
1191
|
+
return undefined;
|
|
1192
|
+
if (local !== undefined)
|
|
1193
|
+
return resolveStaticSqlText(ts, local, scope, depth + 1);
|
|
857
1194
|
const initializer = scope.module.consts.get(node.text);
|
|
858
1195
|
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
|
|
859
1196
|
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
@@ -882,13 +1219,416 @@ function resolveStaticSqlText(ts, node, scope, depth) {
|
|
|
882
1219
|
}
|
|
883
1220
|
return undefined;
|
|
884
1221
|
}
|
|
1222
|
+
/**
|
|
1223
|
+
* The initializer of the `const` a function or block scope binds `node` to, `"shadow"` when the
|
|
1224
|
+
* nearest such binding is anything else (a parameter, `let`, `var`, destructuring), or `undefined`
|
|
1225
|
+
* when no scope below the module binds it - the module-scope lookup applies then.
|
|
1226
|
+
*/
|
|
1227
|
+
function localConstInitializer(ts, node) {
|
|
1228
|
+
const source = node.getSourceFile();
|
|
1229
|
+
for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1230
|
+
if (ts.isSourceFile(scope))
|
|
1231
|
+
return undefined;
|
|
1232
|
+
const binding = directBinding(ts, source, scope, node.text);
|
|
1233
|
+
if (binding === "shadow")
|
|
1234
|
+
return "shadow";
|
|
1235
|
+
if (binding !== undefined)
|
|
1236
|
+
return binding.constant ? binding.initializer : "shadow";
|
|
1237
|
+
}
|
|
1238
|
+
return undefined;
|
|
1239
|
+
}
|
|
1240
|
+
const unwrapExpression = (ts, node) => {
|
|
1241
|
+
let current = node;
|
|
1242
|
+
while (ts.isParenthesizedExpression(current) ||
|
|
1243
|
+
ts.isAsExpression(current) ||
|
|
1244
|
+
ts.isSatisfiesExpression(current)) {
|
|
1245
|
+
current = current.expression;
|
|
1246
|
+
}
|
|
1247
|
+
return current;
|
|
1248
|
+
};
|
|
1249
|
+
/** `Object.freeze([...])` with `Object` unshadowed: the one array form no later code can change. */
|
|
1250
|
+
function frozenArrayLiteral(ts, node) {
|
|
1251
|
+
const call = unwrapExpression(ts, node);
|
|
1252
|
+
if (!ts.isCallExpression(call) || call.arguments.length !== 1)
|
|
1253
|
+
return undefined;
|
|
1254
|
+
const callee = call.expression;
|
|
1255
|
+
if (!ts.isPropertyAccessExpression(callee) ||
|
|
1256
|
+
callee.name.text !== "freeze" ||
|
|
1257
|
+
!ts.isIdentifier(callee.expression) ||
|
|
1258
|
+
callee.expression.text !== "Object" ||
|
|
1259
|
+
isShadowedAt(ts, callee.expression, "Object")) {
|
|
1260
|
+
return undefined;
|
|
1261
|
+
}
|
|
1262
|
+
const array = unwrapExpression(ts, call.arguments[0]);
|
|
1263
|
+
return ts.isArrayLiteralExpression(array) ? array : undefined;
|
|
1264
|
+
}
|
|
1265
|
+
const ARRAY_READS = new Set(["join", "includes", "indexOf"]);
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether every use of `name` in `source` other than its declaration is a call of `.join(`,
|
|
1268
|
+
* `.includes(` or `.indexOf(`, or a `.length` read: then nothing pushes to, sorts, exports or hands
|
|
1269
|
+
* away the array, so it still holds its literal elements. With `pushed`, a `.push(...)` is accepted
|
|
1270
|
+
* too and its arguments collected, for the caller to prove (the `where.push(...)` builder). A
|
|
1271
|
+
* same-named binding elsewhere only adds uses, so the answer can only err toward refusing.
|
|
1272
|
+
*/
|
|
1273
|
+
function onlyReadsArray(ts, source, name, declaration, pushed) {
|
|
1274
|
+
let ok = true;
|
|
1275
|
+
const visit = (node) => {
|
|
1276
|
+
if (!ok)
|
|
1277
|
+
return;
|
|
1278
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration) {
|
|
1279
|
+
const access = node.parent;
|
|
1280
|
+
const call = ts.isPropertyAccessExpression(access) &&
|
|
1281
|
+
access.expression === node &&
|
|
1282
|
+
ts.isCallExpression(access.parent) &&
|
|
1283
|
+
access.parent.expression === access
|
|
1284
|
+
? access.parent
|
|
1285
|
+
: undefined;
|
|
1286
|
+
if (pushed !== undefined &&
|
|
1287
|
+
call !== undefined &&
|
|
1288
|
+
ts.isPropertyAccessExpression(access) &&
|
|
1289
|
+
access.name.text === "push") {
|
|
1290
|
+
if (call.arguments.some((argument) => ts.isSpreadElement(argument)))
|
|
1291
|
+
ok = false;
|
|
1292
|
+
else
|
|
1293
|
+
pushed.push(...call.arguments);
|
|
1294
|
+
// An argument can mention the array again; those uses are checked as well.
|
|
1295
|
+
for (const argument of call.arguments)
|
|
1296
|
+
visit(argument);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const read = ts.isPropertyAccessExpression(access) &&
|
|
1300
|
+
access.expression === node &&
|
|
1301
|
+
(access.name.text === "length"
|
|
1302
|
+
? !isWrittenTo(ts, access)
|
|
1303
|
+
: ARRAY_READS.has(access.name.text) && call !== undefined);
|
|
1304
|
+
if (!read)
|
|
1305
|
+
ok = false;
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
ts.forEachChild(node, visit);
|
|
1309
|
+
};
|
|
1310
|
+
visit(source);
|
|
1311
|
+
return ok;
|
|
1312
|
+
}
|
|
1313
|
+
/** `node` as the target of an assignment, `++`/`--`, or `delete`. */
|
|
1314
|
+
function isWrittenTo(ts, node) {
|
|
1315
|
+
const parent = node.parent;
|
|
1316
|
+
if (ts.isBinaryExpression(parent) &&
|
|
1317
|
+
parent.left === node &&
|
|
1318
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1319
|
+
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
|
|
1323
|
+
return (parent.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1324
|
+
parent.operator === ts.SyntaxKind.MinusMinusToken);
|
|
1325
|
+
}
|
|
1326
|
+
return ts.isDeleteExpression(parent);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* The element texts of an array `receiver` names, when the array provably still holds them: an
|
|
1330
|
+
* inline literal, `Object.freeze([...])` anywhere it is bound or imported from, or a `const` array
|
|
1331
|
+
* literal bound in this file, not exported, and only ever read ({@link onlyReadsArray}). `as const`
|
|
1332
|
+
* is type-only and proves nothing.
|
|
1333
|
+
*/
|
|
1334
|
+
function staticArrayItems(ts, receiver, scope, depth) {
|
|
1335
|
+
const node = unwrapExpression(ts, receiver);
|
|
1336
|
+
let array = ts.isArrayLiteralExpression(node) ? node : frozenArrayLiteral(ts, node);
|
|
1337
|
+
let itemScope = scope;
|
|
1338
|
+
const pushed = [];
|
|
1339
|
+
if (array === undefined && ts.isIdentifier(node)) {
|
|
1340
|
+
const local = localConstInitializer(ts, node);
|
|
1341
|
+
if (local === "shadow")
|
|
1342
|
+
return undefined;
|
|
1343
|
+
let initializer = local;
|
|
1344
|
+
let exported = false;
|
|
1345
|
+
if (initializer === undefined && !isShadowedAt(ts, node, node.text)) {
|
|
1346
|
+
initializer = scope.module.consts.get(node.text);
|
|
1347
|
+
exported = [...scope.module.localExports.values()].includes(node.text);
|
|
1348
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
|
|
1349
|
+
if (edge !== undefined && scope.imports !== undefined) {
|
|
1350
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
|
|
1351
|
+
const found = target === undefined
|
|
1352
|
+
? undefined
|
|
1353
|
+
: resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
|
|
1354
|
+
if (found === undefined)
|
|
1355
|
+
return undefined;
|
|
1356
|
+
// Another module's array is accepted only frozen: its own file may mutate it.
|
|
1357
|
+
array = frozenArrayLiteral(ts, found.expression);
|
|
1358
|
+
itemScope = { module: found.scope, imports: scope.imports };
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (array === undefined && initializer !== undefined) {
|
|
1362
|
+
array = frozenArrayLiteral(ts, initializer);
|
|
1363
|
+
const literal = unwrapExpression(ts, initializer);
|
|
1364
|
+
if (array === undefined &&
|
|
1365
|
+
!exported &&
|
|
1366
|
+
ts.isArrayLiteralExpression(literal) &&
|
|
1367
|
+
ts.isVariableDeclaration(initializer.parent) &&
|
|
1368
|
+
onlyReadsArray(ts, node.getSourceFile(), node.text, initializer.parent.name, pushed)) {
|
|
1369
|
+
array = literal;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
if (array === undefined)
|
|
1374
|
+
return undefined;
|
|
1375
|
+
const items = [];
|
|
1376
|
+
// What the array may hold: its literal elements, and anything pushed onto it in any order.
|
|
1377
|
+
for (const element of [...array.elements, ...pushed]) {
|
|
1378
|
+
if (ts.isSpreadElement(element) || ts.isOmittedExpression(element))
|
|
1379
|
+
return undefined;
|
|
1380
|
+
const text = resolveStaticSqlText(ts, element, itemScope, depth + 1);
|
|
1381
|
+
if (text === undefined)
|
|
1382
|
+
return undefined;
|
|
1383
|
+
items.push(text);
|
|
1384
|
+
}
|
|
1385
|
+
return items;
|
|
1386
|
+
}
|
|
1387
|
+
/** `items.join(sep?)` over a {@link staticArrayItems} array with a literal or absent separator. */
|
|
1388
|
+
function resolveStaticJoin(ts, call, scope, depth) {
|
|
1389
|
+
const callee = call.expression;
|
|
1390
|
+
if (!ts.isPropertyAccessExpression(callee) ||
|
|
1391
|
+
callee.questionDotToken !== undefined ||
|
|
1392
|
+
callee.name.text !== "join" ||
|
|
1393
|
+
call.arguments.length > 1) {
|
|
1394
|
+
return undefined;
|
|
1395
|
+
}
|
|
1396
|
+
const separatorArg = call.arguments[0];
|
|
1397
|
+
const separator = separatorArg === undefined
|
|
1398
|
+
? ","
|
|
1399
|
+
: ts.isStringLiteral(separatorArg) || ts.isNoSubstitutionTemplateLiteral(separatorArg)
|
|
1400
|
+
? separatorArg.text
|
|
1401
|
+
: undefined;
|
|
1402
|
+
if (separator === undefined)
|
|
1403
|
+
return undefined;
|
|
1404
|
+
const items = staticArrayItems(ts, callee.expression, scope, depth + 1) ??
|
|
1405
|
+
mappedItem(ts, callee.expression, scope, depth + 1);
|
|
1406
|
+
return items?.join(separator);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* `xs.map(fn)`, `xs.flatMap(fn)` or `Array.from(src, fn)` whose callback returns text that resolves
|
|
1410
|
+
* for every element - `(_, i) => \`($${i * 2 + 1}, $${i * 2 + 2})\``, the batch `VALUES` idiom. How
|
|
1411
|
+
* many items there are is runtime data, what each one says is not, so one item stands for all.
|
|
1412
|
+
*/
|
|
1413
|
+
function mappedItem(ts, receiver, scope, depth) {
|
|
1414
|
+
const call = unwrapExpression(ts, receiver);
|
|
1415
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
|
|
1416
|
+
return undefined;
|
|
1417
|
+
// Only these return what the callback returns; `filter` and the rest hand back the elements.
|
|
1418
|
+
const method = call.expression.name.text;
|
|
1419
|
+
const argument = indexCallbackArgument(ts, call);
|
|
1420
|
+
if (argument === undefined || !(method === "map" || method === "flatMap" || argument === 1))
|
|
1421
|
+
return undefined;
|
|
1422
|
+
const fn = call.arguments[argument];
|
|
1423
|
+
if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)))
|
|
1424
|
+
return undefined;
|
|
1425
|
+
const only = ts.isBlock(fn.body) && fn.body.statements.length === 1 ? fn.body.statements[0] : undefined;
|
|
1426
|
+
const body = ts.isBlock(fn.body)
|
|
1427
|
+
? only !== undefined && ts.isReturnStatement(only)
|
|
1428
|
+
? only.expression
|
|
1429
|
+
: undefined
|
|
1430
|
+
: fn.body;
|
|
1431
|
+
const text = body === undefined ? undefined : resolveStaticSqlText(ts, body, scope, depth + 1);
|
|
1432
|
+
return text === undefined ? undefined : [text];
|
|
1433
|
+
}
|
|
1434
|
+
/** Array methods whose callback receives the element's index, and where in the callback it is. */
|
|
1435
|
+
const INDEX_PARAMETER = new Map([
|
|
1436
|
+
["map", 1],
|
|
1437
|
+
["flatMap", 1],
|
|
1438
|
+
["forEach", 1],
|
|
1439
|
+
["filter", 1],
|
|
1440
|
+
["some", 1],
|
|
1441
|
+
["every", 1],
|
|
1442
|
+
["find", 1],
|
|
1443
|
+
["findIndex", 1],
|
|
1444
|
+
["reduce", 2],
|
|
1445
|
+
["reduceRight", 2],
|
|
1446
|
+
]);
|
|
1447
|
+
/** Which argument of `call` is a callback handed an array index: the first for `xs.map(fn)` and its
|
|
1448
|
+
* kin, the second for an unshadowed `Array.from(src, fn)`. */
|
|
1449
|
+
function indexCallbackArgument(ts, call) {
|
|
1450
|
+
const callee = call.expression;
|
|
1451
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1452
|
+
return undefined;
|
|
1453
|
+
if (INDEX_PARAMETER.has(callee.name.text))
|
|
1454
|
+
return 0;
|
|
1455
|
+
return callee.name.text === "from" &&
|
|
1456
|
+
ts.isIdentifier(callee.expression) &&
|
|
1457
|
+
callee.expression.text === "Array" &&
|
|
1458
|
+
!isShadowedAt(ts, callee.expression, "Array")
|
|
1459
|
+
? 1
|
|
1460
|
+
: undefined;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* `"1"` for a span written right after a `$` whose expression provably evaluates to a number: a
|
|
1464
|
+
* Postgres placeholder index (`$${i + 1}`), which binds a parameter and can carry no SQL. Any string
|
|
1465
|
+
* there could, so anything not proved numeric stays unresolved.
|
|
1466
|
+
*/
|
|
1467
|
+
function placeholderNumber(ts, before, node, depth) {
|
|
1468
|
+
return before.endsWith("$") && isNumericExpression(ts, node, depth) ? "1" : undefined;
|
|
1469
|
+
}
|
|
1470
|
+
const NUMERIC_OPERATORS = (ts) => new Set([
|
|
1471
|
+
ts.SyntaxKind.MinusToken,
|
|
1472
|
+
ts.SyntaxKind.AsteriskToken,
|
|
1473
|
+
ts.SyntaxKind.AsteriskAsteriskToken,
|
|
1474
|
+
ts.SyntaxKind.SlashToken,
|
|
1475
|
+
ts.SyntaxKind.PercentToken,
|
|
1476
|
+
ts.SyntaxKind.AmpersandToken,
|
|
1477
|
+
ts.SyntaxKind.BarToken,
|
|
1478
|
+
ts.SyntaxKind.CaretToken,
|
|
1479
|
+
ts.SyntaxKind.LessThanLessThanToken,
|
|
1480
|
+
ts.SyntaxKind.GreaterThanGreaterThanToken,
|
|
1481
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken,
|
|
1482
|
+
]);
|
|
1483
|
+
const NUMBER_FUNCTIONS = new Set(["Number", "parseInt", "parseFloat"]);
|
|
1484
|
+
/**
|
|
1485
|
+
* Whether an expression evaluates to a number whatever its inputs hold. Every arithmetic operator but
|
|
1486
|
+
* `+` converts its operands, so `i * 2` is a number even if `i` were a string; `+` is one only when
|
|
1487
|
+
* both sides are. A name proves it when it is a callback's array index, or a `const`, never-reassigned
|
|
1488
|
+
* `let` or `for` counter bound to a number and only ever updated by arithmetic. Also `xs.push(...)`
|
|
1489
|
+
* (the new length), `Number`/`parseInt`/`parseFloat`/`Math.*` calls, and `.length` of an array a
|
|
1490
|
+
* nearby binding builds from a literal. A type annotation proves nothing: it does not run.
|
|
1491
|
+
*/
|
|
1492
|
+
function isNumericExpression(ts, node, depth) {
|
|
1493
|
+
if (depth > CONST_RESOLUTION_DEPTH)
|
|
1494
|
+
return false;
|
|
1495
|
+
const expression = unwrapExpression(ts, node);
|
|
1496
|
+
if (ts.isNumericLiteral(expression))
|
|
1497
|
+
return true;
|
|
1498
|
+
if (ts.isPrefixUnaryExpression(expression))
|
|
1499
|
+
return expression.operator !== ts.SyntaxKind.ExclamationToken;
|
|
1500
|
+
if (ts.isPostfixUnaryExpression(expression))
|
|
1501
|
+
return true;
|
|
1502
|
+
if (ts.isBinaryExpression(expression)) {
|
|
1503
|
+
const operator = expression.operatorToken.kind;
|
|
1504
|
+
if (operator === ts.SyntaxKind.PlusToken) {
|
|
1505
|
+
return (isNumericExpression(ts, expression.left, depth + 1) &&
|
|
1506
|
+
isNumericExpression(ts, expression.right, depth + 1));
|
|
1507
|
+
}
|
|
1508
|
+
return NUMERIC_OPERATORS(ts).has(operator);
|
|
1509
|
+
}
|
|
1510
|
+
if (ts.isIdentifier(expression))
|
|
1511
|
+
return isNumericName(ts, expression, depth);
|
|
1512
|
+
if (ts.isCallExpression(expression)) {
|
|
1513
|
+
const callee = expression.expression;
|
|
1514
|
+
if (ts.isIdentifier(callee))
|
|
1515
|
+
return NUMBER_FUNCTIONS.has(callee.text) && !isShadowedAt(ts, callee, callee.text);
|
|
1516
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined)
|
|
1517
|
+
return false;
|
|
1518
|
+
if (callee.name.text === "push")
|
|
1519
|
+
return true;
|
|
1520
|
+
return (ts.isIdentifier(callee.expression) &&
|
|
1521
|
+
callee.expression.text === "Math" &&
|
|
1522
|
+
!isShadowedAt(ts, callee.expression, "Math"));
|
|
1523
|
+
}
|
|
1524
|
+
if (ts.isPropertyAccessExpression(expression) &&
|
|
1525
|
+
expression.name.text === "length" &&
|
|
1526
|
+
ts.isIdentifier(expression.expression)) {
|
|
1527
|
+
const binding = nameBinding(ts, expression.expression);
|
|
1528
|
+
return (binding.kind === "value" &&
|
|
1529
|
+
ts.isArrayLiteralExpression(unwrapExpression(ts, binding.initializer)));
|
|
1530
|
+
}
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
function nameBinding(ts, node) {
|
|
1534
|
+
const source = node.getSourceFile();
|
|
1535
|
+
for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1536
|
+
if (ts.isFunctionLike(scope)) {
|
|
1537
|
+
const position = scope.parameters.findIndex((p) => bindingNameCovers(ts, p.name, node.text));
|
|
1538
|
+
if (position !== -1) {
|
|
1539
|
+
return ts.isIdentifier(scope.parameters[position]?.name ?? node)
|
|
1540
|
+
? { kind: "parameter", fn: scope, position }
|
|
1541
|
+
: { kind: "unknown" };
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const binding = directBinding(ts, source, scope, node.text);
|
|
1545
|
+
if (binding === "shadow") {
|
|
1546
|
+
// `for (let i = 0; ...; i++)`: the one reassigned `let` worth reading, checked by the caller.
|
|
1547
|
+
const list = ts.isForStatement(scope) ? scope.initializer : undefined;
|
|
1548
|
+
const declaration = list !== undefined && ts.isVariableDeclarationList(list)
|
|
1549
|
+
? list.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === node.text)
|
|
1550
|
+
: undefined;
|
|
1551
|
+
return declaration?.initializer === undefined
|
|
1552
|
+
? { kind: "unknown" }
|
|
1553
|
+
: { kind: "counter", initializer: declaration.initializer };
|
|
1554
|
+
}
|
|
1555
|
+
if (binding !== undefined)
|
|
1556
|
+
return { kind: "value", initializer: binding.initializer };
|
|
1557
|
+
if (ts.isSourceFile(scope))
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
return { kind: "unknown" };
|
|
1561
|
+
}
|
|
1562
|
+
function isNumericName(ts, node, depth) {
|
|
1563
|
+
const source = node.getSourceFile();
|
|
1564
|
+
const binding = nameBinding(ts, node);
|
|
1565
|
+
if (binding.kind === "value")
|
|
1566
|
+
return isNumericExpression(ts, binding.initializer, depth + 1);
|
|
1567
|
+
if (binding.kind === "counter") {
|
|
1568
|
+
return (isNumericExpression(ts, binding.initializer, depth + 1) &&
|
|
1569
|
+
onlyNumericWrites(ts, source, node.text, depth));
|
|
1570
|
+
}
|
|
1571
|
+
if (binding.kind !== "parameter")
|
|
1572
|
+
return false;
|
|
1573
|
+
// An array callback's index parameter, never reassigned.
|
|
1574
|
+
const call = binding.fn.parent;
|
|
1575
|
+
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression))
|
|
1576
|
+
return false;
|
|
1577
|
+
const argument = indexCallbackArgument(ts, call);
|
|
1578
|
+
const position = argument === 1 ? 1 : INDEX_PARAMETER.get(call.expression.name.text);
|
|
1579
|
+
return (argument !== undefined &&
|
|
1580
|
+
call.arguments[argument] === binding.fn &&
|
|
1581
|
+
binding.position === position &&
|
|
1582
|
+
!isReassignedInFile(ts, source, node.text));
|
|
1583
|
+
}
|
|
1584
|
+
/** Compound assignments that always store a number. */
|
|
1585
|
+
const NUMERIC_ASSIGNMENTS = (ts) => new Set([
|
|
1586
|
+
ts.SyntaxKind.MinusEqualsToken,
|
|
1587
|
+
ts.SyntaxKind.AsteriskEqualsToken,
|
|
1588
|
+
ts.SyntaxKind.AsteriskAsteriskEqualsToken,
|
|
1589
|
+
ts.SyntaxKind.SlashEqualsToken,
|
|
1590
|
+
ts.SyntaxKind.PercentEqualsToken,
|
|
1591
|
+
ts.SyntaxKind.AmpersandEqualsToken,
|
|
1592
|
+
ts.SyntaxKind.BarEqualsToken,
|
|
1593
|
+
ts.SyntaxKind.CaretEqualsToken,
|
|
1594
|
+
ts.SyntaxKind.LessThanLessThanEqualsToken,
|
|
1595
|
+
ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
|
|
1596
|
+
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
|
1597
|
+
]);
|
|
1598
|
+
/** Every write to `name` in the file is `++`/`--`, or an assignment that stores a number. */
|
|
1599
|
+
function onlyNumericWrites(ts, source, name, depth) {
|
|
1600
|
+
let ok = true;
|
|
1601
|
+
const visit = (node) => {
|
|
1602
|
+
if (!ok)
|
|
1603
|
+
return;
|
|
1604
|
+
if (ts.isIdentifier(node) && node.text === name && isWrittenTo(ts, node)) {
|
|
1605
|
+
const parent = node.parent;
|
|
1606
|
+
if (ts.isBinaryExpression(parent)) {
|
|
1607
|
+
const operator = parent.operatorToken.kind;
|
|
1608
|
+
ok =
|
|
1609
|
+
operator === ts.SyntaxKind.EqualsToken || operator === ts.SyntaxKind.PlusEqualsToken
|
|
1610
|
+
? isNumericExpression(ts, parent.right, depth + 1)
|
|
1611
|
+
: NUMERIC_ASSIGNMENTS(ts).has(operator);
|
|
1612
|
+
}
|
|
1613
|
+
else
|
|
1614
|
+
ok = !ts.isDeleteExpression(parent);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
ts.forEachChild(node, visit);
|
|
1618
|
+
};
|
|
1619
|
+
visit(source);
|
|
1620
|
+
return ok;
|
|
1621
|
+
}
|
|
885
1622
|
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
886
1623
|
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
887
1624
|
function templateShape(ts, template, scope) {
|
|
888
1625
|
let staticText = template.head.text;
|
|
889
1626
|
let dynamic = false;
|
|
890
1627
|
for (const span of template.templateSpans) {
|
|
891
|
-
const resolved = scope === undefined
|
|
1628
|
+
const resolved = scope === undefined
|
|
1629
|
+
? undefined
|
|
1630
|
+
: (placeholderNumber(ts, staticText, span.expression, 0) ??
|
|
1631
|
+
resolveStaticSqlText(ts, span.expression, scope, 0));
|
|
892
1632
|
if (resolved === undefined)
|
|
893
1633
|
dynamic = true;
|
|
894
1634
|
else
|
|
@@ -953,30 +1693,13 @@ function directBinding(ts, source, scope, name) {
|
|
|
953
1693
|
if (declared !== undefined && bindingNameCovers(ts, declared.name, name))
|
|
954
1694
|
return "shadow";
|
|
955
1695
|
}
|
|
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
|
-
}
|
|
1696
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1697
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
1698
|
+
statement.name?.text === name) {
|
|
1699
|
+
return "shadow";
|
|
972
1700
|
}
|
|
973
1701
|
}
|
|
974
|
-
|
|
975
|
-
scope.initializer !== undefined &&
|
|
976
|
-
ts.isVariableDeclarationList(scope.initializer)) {
|
|
977
|
-
lists.push(scope.initializer);
|
|
978
|
-
}
|
|
979
|
-
for (const list of lists) {
|
|
1702
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
980
1703
|
for (const declaration of list.declarations) {
|
|
981
1704
|
if (!bindingNameCovers(ts, declaration.name, name))
|
|
982
1705
|
continue;
|
|
@@ -987,52 +1710,262 @@ function directBinding(ts, source, scope, name) {
|
|
|
987
1710
|
const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
|
|
988
1711
|
const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
|
|
989
1712
|
if (isConst)
|
|
990
|
-
return { initializer: declaration.initializer };
|
|
1713
|
+
return { initializer: declaration.initializer, constant: true };
|
|
991
1714
|
if (isLet && !isReassignedInFile(ts, source, name))
|
|
992
|
-
return { initializer: declaration.initializer };
|
|
1715
|
+
return { initializer: declaration.initializer, constant: false };
|
|
993
1716
|
return "shadow";
|
|
994
1717
|
}
|
|
995
1718
|
}
|
|
996
1719
|
return undefined;
|
|
997
1720
|
}
|
|
1721
|
+
function scopeStatements(ts, scope) {
|
|
1722
|
+
return ts.isSourceFile(scope) ||
|
|
1723
|
+
ts.isBlock(scope) ||
|
|
1724
|
+
ts.isModuleBlock(scope) ||
|
|
1725
|
+
ts.isCaseClause(scope) ||
|
|
1726
|
+
ts.isDefaultClause(scope)
|
|
1727
|
+
? scope.statements
|
|
1728
|
+
: undefined;
|
|
1729
|
+
}
|
|
1730
|
+
/** The `var`/`let`/`const` lists a scope node declares directly: its statements' and a `for` head's. */
|
|
1731
|
+
function scopeDeclarationLists(ts, scope) {
|
|
1732
|
+
const lists = [];
|
|
1733
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1734
|
+
if (ts.isVariableStatement(statement))
|
|
1735
|
+
lists.push(statement.declarationList);
|
|
1736
|
+
}
|
|
1737
|
+
if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
|
|
1738
|
+
scope.initializer !== undefined &&
|
|
1739
|
+
ts.isVariableDeclarationList(scope.initializer)) {
|
|
1740
|
+
lists.push(scope.initializer);
|
|
1741
|
+
}
|
|
1742
|
+
return lists;
|
|
1743
|
+
}
|
|
1744
|
+
/** The `var` or `let` a scope declares `name` with by a plain identifier, if any. */
|
|
1745
|
+
function mutableDeclaration(ts, scope, name) {
|
|
1746
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
1747
|
+
if ((list.flags & ts.NodeFlags.Const) !== 0)
|
|
1748
|
+
continue;
|
|
1749
|
+
for (const declaration of list.declarations) {
|
|
1750
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
|
|
1751
|
+
return declaration;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return undefined;
|
|
1755
|
+
}
|
|
998
1756
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
* could be silenced, invisibly, by hoisting the statement to `const q = …` first
|
|
1757
|
+
* Every value a statement variable may hold where the query reads it, so a statement parked in a
|
|
1758
|
+
* variable is scanned as if it had been written inline at the call. This is the counterpart to
|
|
1759
|
+
* {@link resolveStaticSqlText}: that folds a const's text INTO a template's shape; this unfolds the
|
|
1760
|
+
* statement back out of the variable it was lifted into. Without it, ANY interpolated-SQL finding
|
|
1761
|
+
* could be silenced, invisibly, by hoisting the statement to `const q = …` first, or by building it
|
|
1762
|
+
* in steps (`let q = "SELECT …"; q += …`).
|
|
1004
1763
|
*
|
|
1005
1764
|
* 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
|
-
*
|
|
1765
|
+
* same-named module const. A `const`, or a `let` the file never reassigns, holds its initializer. A
|
|
1766
|
+
* `var` or reassigned `let` holds its initializer and every value assigned to the name anywhere in
|
|
1767
|
+
* the function (or module) that declares it: over-collected rather than scope-simulated, which only
|
|
1768
|
+
* ever adds a value to check. `key` names the binding, so a value that reads the variable it is
|
|
1769
|
+
* assigned to is recognised. A parameter, an imported or destructured binding, or a name with no
|
|
1770
|
+
* same-file declaration returns `undefined` - which leaves the argument scanned as itself.
|
|
1011
1771
|
*/
|
|
1012
|
-
function
|
|
1013
|
-
if (depth > CONST_RESOLUTION_DEPTH)
|
|
1014
|
-
return undefined;
|
|
1772
|
+
function variableWrites(ts, node) {
|
|
1015
1773
|
const source = node.getSourceFile();
|
|
1016
1774
|
const name = node.text;
|
|
1017
1775
|
for (let scope = node.parent; scope !== undefined; scope = scope.parent) {
|
|
1018
1776
|
const binding = directBinding(ts, source, scope, name);
|
|
1019
|
-
if (binding === "shadow")
|
|
1020
|
-
|
|
1777
|
+
if (binding === "shadow") {
|
|
1778
|
+
const declaration = mutableDeclaration(ts, scope, name);
|
|
1779
|
+
if (declaration === undefined)
|
|
1780
|
+
return undefined;
|
|
1781
|
+
let owner = declaration;
|
|
1782
|
+
while (!ts.isFunctionLike(owner) && !ts.isSourceFile(owner))
|
|
1783
|
+
owner = owner.parent;
|
|
1784
|
+
const writes = writesTo(ts, owner, name);
|
|
1785
|
+
return {
|
|
1786
|
+
key: declaration,
|
|
1787
|
+
writes: declaration.initializer === undefined
|
|
1788
|
+
? writes
|
|
1789
|
+
: [{ value: declaration.initializer, appends: false }, ...writes],
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1021
1792
|
if (binding !== undefined) {
|
|
1022
|
-
|
|
1023
|
-
while (ts.isParenthesizedExpression(init))
|
|
1024
|
-
init = init.expression;
|
|
1025
|
-
return ts.isIdentifier(init) ? resolveArgumentInitializer(ts, init, depth + 1) : init;
|
|
1793
|
+
return { key: binding.initializer, writes: [{ value: binding.initializer, appends: false }] };
|
|
1026
1794
|
}
|
|
1027
1795
|
if (ts.isSourceFile(scope))
|
|
1028
1796
|
break;
|
|
1029
1797
|
}
|
|
1030
1798
|
return undefined;
|
|
1031
1799
|
}
|
|
1032
|
-
/**
|
|
1033
|
-
function
|
|
1034
|
-
|
|
1035
|
-
|
|
1800
|
+
/** The values assigned to `name` under `root`. A numeric compound assignment stores no text. */
|
|
1801
|
+
function writesTo(ts, root, name) {
|
|
1802
|
+
const writes = [];
|
|
1803
|
+
const visit = (node) => {
|
|
1804
|
+
if (ts.isIdentifier(node) && node.text === name) {
|
|
1805
|
+
const parent = node.parent;
|
|
1806
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
1807
|
+
const operator = parent.operatorToken.kind;
|
|
1808
|
+
if (operator === ts.SyntaxKind.PlusEqualsToken) {
|
|
1809
|
+
writes.push({ value: parent.right, appends: true });
|
|
1810
|
+
}
|
|
1811
|
+
else if (operator === ts.SyntaxKind.EqualsToken ||
|
|
1812
|
+
operator === ts.SyntaxKind.QuestionQuestionEqualsToken ||
|
|
1813
|
+
operator === ts.SyntaxKind.BarBarEqualsToken ||
|
|
1814
|
+
operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken) {
|
|
1815
|
+
writes.push({ value: parent.right, appends: false });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
ts.forEachChild(node, visit);
|
|
1820
|
+
};
|
|
1821
|
+
visit(root);
|
|
1822
|
+
return writes;
|
|
1823
|
+
}
|
|
1824
|
+
function mentionsName(ts, node, name) {
|
|
1825
|
+
if (ts.isIdentifier(node))
|
|
1826
|
+
return node.text === name;
|
|
1827
|
+
return ts.forEachChild(node, (child) => mentionsName(ts, child, name) || undefined) === true;
|
|
1828
|
+
}
|
|
1829
|
+
const UNKNOWN_SQL = {
|
|
1830
|
+
staticText: "",
|
|
1831
|
+
dynamic: true,
|
|
1832
|
+
concatenated: false,
|
|
1833
|
+
literal: false,
|
|
1834
|
+
parameterizedTag: false,
|
|
1835
|
+
};
|
|
1836
|
+
function textShape(text) {
|
|
1837
|
+
return {
|
|
1838
|
+
staticText: text,
|
|
1839
|
+
dynamic: false,
|
|
1840
|
+
concatenated: false,
|
|
1841
|
+
literal: true,
|
|
1842
|
+
parameterizedTag: false,
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
function joinShapes(shapes) {
|
|
1846
|
+
return {
|
|
1847
|
+
staticText: shapes.map((shape) => shape.staticText).join(""),
|
|
1848
|
+
// As with `+`: text glued onto a binding tag's output un-parameterizes it.
|
|
1849
|
+
dynamic: shapes.some((shape) => shape.dynamic || shape.parameterizedTag),
|
|
1850
|
+
concatenated: true,
|
|
1851
|
+
literal: false,
|
|
1852
|
+
parameterizedTag: false,
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
/** Orders a variable's candidate statements by how surely {@link scanInterpolatedSql} would flag
|
|
1856
|
+
* them: dynamic SQL text, then dynamic glued text, then any dynamic text; outside a binding tag
|
|
1857
|
+
* before inside one. */
|
|
1858
|
+
function sqlRisk(shape) {
|
|
1859
|
+
const dynamic = !shape.dynamic
|
|
1860
|
+
? 0
|
|
1861
|
+
: SQL_KEYWORD.test(shape.staticText) || STATEMENT_START.test(shape.staticText)
|
|
1862
|
+
? 3
|
|
1863
|
+
: shape.concatenated
|
|
1864
|
+
? 2
|
|
1865
|
+
: 1;
|
|
1866
|
+
return dynamic * 2 + (shape.parameterizedTag ? 0 : 1);
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The riskiest statement a variable may hold. Each value that replaces the text is one candidate.
|
|
1870
|
+
* The values that build on it (`+=`, or `q = q + more`) are glued onto every replacing value at
|
|
1871
|
+
* once, as a builder that takes each branch would, so a static `SELECT … WHERE 1=1` followed by an
|
|
1872
|
+
* interpolated `AND …` reads as the one statement it becomes.
|
|
1873
|
+
*/
|
|
1874
|
+
function writtenShape(ts, name, writes, scope, trail) {
|
|
1875
|
+
const replacing = [];
|
|
1876
|
+
const building = [];
|
|
1877
|
+
for (const write of writes) {
|
|
1878
|
+
const shape = sqlExpressionShape(ts, write.value, scope, trail);
|
|
1879
|
+
if (write.appends || mentionsName(ts, write.value, name))
|
|
1880
|
+
building.push(shape);
|
|
1881
|
+
else
|
|
1882
|
+
replacing.push(shape);
|
|
1883
|
+
}
|
|
1884
|
+
const candidates = building.length === 0 ? replacing : [...replacing, joinShapes([...replacing, ...building])];
|
|
1885
|
+
let worst;
|
|
1886
|
+
for (const candidate of candidates) {
|
|
1887
|
+
if (worst === undefined || sqlRisk(candidate) > sqlRisk(worst))
|
|
1888
|
+
worst = candidate;
|
|
1889
|
+
}
|
|
1890
|
+
return worst ?? UNKNOWN_SQL;
|
|
1891
|
+
}
|
|
1892
|
+
const TEXT_METHODS = new Set([
|
|
1893
|
+
"toString",
|
|
1894
|
+
"valueOf",
|
|
1895
|
+
"trim",
|
|
1896
|
+
"trimStart",
|
|
1897
|
+
"trimEnd",
|
|
1898
|
+
"toLowerCase",
|
|
1899
|
+
"toUpperCase",
|
|
1900
|
+
]);
|
|
1901
|
+
/** The value a text-preserving call returns: `String(x)`, `x.toString()`, `x.trim()`. */
|
|
1902
|
+
function passedThrough(ts, call) {
|
|
1903
|
+
const callee = call.expression;
|
|
1904
|
+
if (ts.isIdentifier(callee) && callee.text === "String" && call.arguments.length === 1) {
|
|
1905
|
+
return call.arguments[0];
|
|
1906
|
+
}
|
|
1907
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
1908
|
+
TEXT_METHODS.has(callee.name.text) &&
|
|
1909
|
+
call.arguments.length === 0) {
|
|
1910
|
+
return callee.expression;
|
|
1911
|
+
}
|
|
1912
|
+
return undefined;
|
|
1913
|
+
}
|
|
1914
|
+
/** The parts `[a, b].join(sep)` and `a.concat(b)` glue together, a separator as its text. */
|
|
1915
|
+
function joinedParts(ts, call) {
|
|
1916
|
+
const callee = call.expression;
|
|
1917
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
1918
|
+
return undefined;
|
|
1919
|
+
if (callee.name.text === "concat")
|
|
1920
|
+
return [callee.expression, ...call.arguments];
|
|
1921
|
+
const items = unwrapExpression(ts, callee.expression);
|
|
1922
|
+
if (callee.name.text !== "join" || !ts.isArrayLiteralExpression(items))
|
|
1923
|
+
return undefined;
|
|
1924
|
+
const separator = call.arguments[0];
|
|
1925
|
+
const glue = separator === undefined ? "," : ts.isStringLiteralLike(separator) ? separator.text : separator;
|
|
1926
|
+
const parts = [];
|
|
1927
|
+
items.elements.forEach((element, index) => {
|
|
1928
|
+
if (index > 0)
|
|
1929
|
+
parts.push(glue);
|
|
1930
|
+
if (ts.isSpreadElement(element))
|
|
1931
|
+
parts.push(element.expression);
|
|
1932
|
+
else if (!ts.isOmittedExpression(element))
|
|
1933
|
+
parts.push(element);
|
|
1934
|
+
});
|
|
1935
|
+
return parts;
|
|
1936
|
+
}
|
|
1937
|
+
/** Whether the `sql` a tag names is a function this file defines (`const sql = (s, ...v) => …`),
|
|
1938
|
+
* which is plain interpolation whatever its name. A parameter, an import, or a driver instance
|
|
1939
|
+
* (`const sql = postgres(url)`) keeps the name's trust. */
|
|
1940
|
+
function definesSqlFunction(ts, tag) {
|
|
1941
|
+
for (let scope = tag.parent; scope !== undefined; scope = scope.parent) {
|
|
1942
|
+
if (ts.isFunctionLike(scope) &&
|
|
1943
|
+
scope.parameters.some((parameter) => bindingNameCovers(ts, parameter.name, tag.text))) {
|
|
1944
|
+
return false;
|
|
1945
|
+
}
|
|
1946
|
+
for (const statement of scopeStatements(ts, scope) ?? []) {
|
|
1947
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === tag.text)
|
|
1948
|
+
return true;
|
|
1949
|
+
}
|
|
1950
|
+
for (const list of scopeDeclarationLists(ts, scope)) {
|
|
1951
|
+
for (const declaration of list.declarations) {
|
|
1952
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== tag.text)
|
|
1953
|
+
continue;
|
|
1954
|
+
const value = declaration.initializer === undefined
|
|
1955
|
+
? undefined
|
|
1956
|
+
: unwrapExpression(ts, declaration.initializer);
|
|
1957
|
+
return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value));
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return false;
|
|
1962
|
+
}
|
|
1963
|
+
/** Describe only the statement's syntax; comments/strings elsewhere cannot influence the result.
|
|
1964
|
+
* `trail` holds the variables being read through, innermost last. */
|
|
1965
|
+
function sqlExpressionShape(ts, expression, scope, trail = []) {
|
|
1966
|
+
let node = unwrapExpression(ts, expression);
|
|
1967
|
+
while (ts.isNonNullExpression(node))
|
|
1968
|
+
node = unwrapExpression(ts, node.expression);
|
|
1036
1969
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
1037
1970
|
return {
|
|
1038
1971
|
staticText: node.text,
|
|
@@ -1044,7 +1977,7 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1044
1977
|
}
|
|
1045
1978
|
if (ts.isTaggedTemplateExpression(node)) {
|
|
1046
1979
|
const tag = node.tag;
|
|
1047
|
-
const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
1980
|
+
const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql" && !definesSqlFunction(ts, tag)) ||
|
|
1048
1981
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
|
|
1049
1982
|
const template = node.template;
|
|
1050
1983
|
const templated = ts.isTemplateExpression(template)
|
|
@@ -1054,8 +1987,9 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1054
1987
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
1055
1988
|
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
1056
1989
|
// 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
|
-
//
|
|
1990
|
+
// deliberate trade, and it is worth stating what it costs: a `sql` this file defines as a
|
|
1991
|
+
// function is read as the interpolation it is, but one imported from anywhere is trusted, so
|
|
1992
|
+
// this rule finds mistakes, not an adversary who reads it first.
|
|
1059
1993
|
//
|
|
1060
1994
|
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
1061
1995
|
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
@@ -1077,14 +2011,15 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1077
2011
|
return {
|
|
1078
2012
|
staticText: templated.staticText,
|
|
1079
2013
|
dynamic: templated.dynamic,
|
|
1080
|
-
|
|
2014
|
+
// `${a} ${b}` writes no text of its own; it only glues values together.
|
|
2015
|
+
concatenated: [node.head, ...node.templateSpans.map((span) => span.literal)].every((part) => part.text.trim() === ""),
|
|
1081
2016
|
literal: false,
|
|
1082
2017
|
parameterizedTag: false,
|
|
1083
2018
|
};
|
|
1084
2019
|
}
|
|
1085
2020
|
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);
|
|
2021
|
+
const left = sqlExpressionShape(ts, node.left, scope, trail);
|
|
2022
|
+
const right = sqlExpressionShape(ts, node.right, scope, trail);
|
|
1088
2023
|
return {
|
|
1089
2024
|
staticText: left.staticText + right.staticText,
|
|
1090
2025
|
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
@@ -1113,47 +2048,132 @@ function sqlExpressionShape(ts, node, scope) {
|
|
|
1113
2048
|
};
|
|
1114
2049
|
}
|
|
1115
2050
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
2051
|
+
if (ts.isCallExpression(node)) {
|
|
2052
|
+
const inner = passedThrough(ts, node);
|
|
2053
|
+
if (inner !== undefined)
|
|
2054
|
+
return { ...sqlExpressionShape(ts, inner, scope, trail), literal: false };
|
|
2055
|
+
const parts = joinedParts(ts, node);
|
|
2056
|
+
if (parts !== undefined) {
|
|
2057
|
+
return joinShapes(parts.map((part) => typeof part === "string" ? textShape(part) : sqlExpressionShape(ts, part, scope, trail)));
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
// A bare name that did not resolve to compile-time text. If it is a same-file binding, scan the
|
|
2061
|
+
// shapes of what it may hold - so a statement hoisted into `const q = …` or built up in a `let` is
|
|
1118
2062
|
// read exactly as if it were written inline at the call, closing the "extract a variable to launder
|
|
1119
2063
|
// the finding" hole. `literal` is forced false: the value arrived through a variable, which is the
|
|
1120
2064
|
// property the `unsafe`/`$queryRawUnsafe` hatch rule keys on.
|
|
1121
2065
|
if (ts.isIdentifier(node)) {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
2066
|
+
const read = variableWrites(ts, node);
|
|
2067
|
+
// Read inside one of its own writes (`q = q + more`), the variable is the text the other writes
|
|
2068
|
+
// already account for.
|
|
2069
|
+
if (read !== undefined && trail.includes(read.key))
|
|
2070
|
+
return { ...textShape(""), literal: false };
|
|
2071
|
+
if (read !== undefined && trail.length <= CONST_RESOLUTION_DEPTH) {
|
|
2072
|
+
const written = writtenShape(ts, node.text, read.writes, scope, [...trail, read.key]);
|
|
2073
|
+
return { ...written, literal: false };
|
|
1125
2074
|
}
|
|
1126
2075
|
}
|
|
1127
|
-
return
|
|
1128
|
-
staticText: "",
|
|
1129
|
-
dynamic: true,
|
|
1130
|
-
concatenated: false,
|
|
1131
|
-
literal: false,
|
|
1132
|
-
parameterizedTag: false,
|
|
1133
|
-
};
|
|
2076
|
+
return UNKNOWN_SQL;
|
|
1134
2077
|
}
|
|
1135
2078
|
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;
|
|
2079
|
+
const sink = (method, receiver) => SQL_METHODS.has(method) || DRIVER_READ_METHODS.has(method)
|
|
2080
|
+
? { method, receiver: receiver?.getText() }
|
|
2081
|
+
: undefined;
|
|
2082
|
+
const callee = call.expression;
|
|
2083
|
+
if (ts.isIdentifier(callee))
|
|
2084
|
+
return sink(callee.text, undefined);
|
|
2085
|
+
if (ts.isPropertyAccessExpression(callee))
|
|
2086
|
+
return sink(callee.name.text, callee.expression);
|
|
2087
|
+
if (ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(callee.argumentExpression)) {
|
|
2088
|
+
return sink(callee.argumentExpression.text, callee.expression);
|
|
1149
2089
|
}
|
|
1150
2090
|
return undefined;
|
|
1151
2091
|
}
|
|
1152
2092
|
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
|
|
1153
|
-
/**
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2093
|
+
/** Whether a statement is SQL assembled from runtime values, judged by where it is sent. */
|
|
2094
|
+
function isAssembledSql(sink, shape) {
|
|
2095
|
+
if (!shape.dynamic)
|
|
2096
|
+
return false;
|
|
2097
|
+
if (sink.receiver === undefined || DRIVER_READ_METHODS.has(sink.method)) {
|
|
2098
|
+
return STATEMENT_START.test(shape.staticText);
|
|
2099
|
+
}
|
|
2100
|
+
return (SQL_KEYWORD.test(shape.staticText) || (shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
|
|
2101
|
+
}
|
|
2102
|
+
/** The statement a call passes: the argument itself, or the `text`/`sql` of a config object
|
|
2103
|
+
* (`{ text, values }` in pg, `{ sql, values }` in mysql2). */
|
|
2104
|
+
function statementArgument(ts, argument) {
|
|
2105
|
+
const node = unwrapExpression(ts, argument);
|
|
2106
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
2107
|
+
return argument;
|
|
2108
|
+
for (const property of node.properties) {
|
|
2109
|
+
if (ts.isShorthandPropertyAssignment(property) && STATEMENT_KEYS.has(property.name.text)) {
|
|
2110
|
+
return property.name;
|
|
2111
|
+
}
|
|
2112
|
+
if (ts.isPropertyAssignment(property) &&
|
|
2113
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
2114
|
+
STATEMENT_KEYS.has(property.name.text)) {
|
|
2115
|
+
return property.initializer;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
return undefined;
|
|
2119
|
+
}
|
|
2120
|
+
const STATEMENT_KEYS = new Set(["text", "sql"]);
|
|
2121
|
+
/** Same-file functions that send one of their parameters as a statement - `run(db, q)` calling
|
|
2122
|
+
* `db.query(q)` - by name, with the parameter's position and the sink it reaches. A call to one is
|
|
2123
|
+
* scanned as a call to that sink. One level deep. */
|
|
2124
|
+
function statementHelpers(ts, source) {
|
|
2125
|
+
const helpers = new Map();
|
|
2126
|
+
const visit = (node) => {
|
|
2127
|
+
const sink = ts.isCallExpression(node) ? sqlMethod(ts, node) : undefined;
|
|
2128
|
+
const passed = sink !== undefined && ts.isCallExpression(node) ? node.arguments[0] : undefined;
|
|
2129
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed);
|
|
2130
|
+
const statement = argument === undefined ? undefined : unwrapExpression(ts, argument);
|
|
2131
|
+
if (sink !== undefined && statement !== undefined && ts.isIdentifier(statement)) {
|
|
2132
|
+
const binding = nameBinding(ts, statement);
|
|
2133
|
+
const name = binding.kind === "parameter" ? functionName(ts, binding.fn) : undefined;
|
|
2134
|
+
if (binding.kind === "parameter" && name !== undefined && !helpers.has(name)) {
|
|
2135
|
+
helpers.set(name, { position: binding.position, sink });
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
ts.forEachChild(node, visit);
|
|
2139
|
+
};
|
|
2140
|
+
visit(source);
|
|
2141
|
+
return helpers;
|
|
2142
|
+
}
|
|
2143
|
+
function functionName(ts, fn) {
|
|
2144
|
+
if (ts.isFunctionDeclaration(fn))
|
|
2145
|
+
return fn.name?.text;
|
|
2146
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
|
|
2147
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
2148
|
+
ts.isIdentifier(fn.parent.name)) {
|
|
2149
|
+
return fn.parent.name.text;
|
|
2150
|
+
}
|
|
2151
|
+
return undefined;
|
|
2152
|
+
}
|
|
2153
|
+
/** `content` with every string, template and regex literal blanked, line breaks kept. */
|
|
2154
|
+
function literalsBlanked(ts, source, content) {
|
|
2155
|
+
const out = content.split("");
|
|
2156
|
+
const visit = (node) => {
|
|
2157
|
+
if (ts.isStringLiteralLike(node) ||
|
|
2158
|
+
ts.isTemplateHead(node) ||
|
|
2159
|
+
ts.isTemplateMiddle(node) ||
|
|
2160
|
+
ts.isTemplateTail(node) ||
|
|
2161
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
2162
|
+
ts.isJsxText(node)) {
|
|
2163
|
+
for (let i = node.getStart(source); i < node.end; i++)
|
|
2164
|
+
if (out[i] !== "\n")
|
|
2165
|
+
out[i] = " ";
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
ts.forEachChild(node, visit);
|
|
2169
|
+
};
|
|
2170
|
+
visit(source);
|
|
2171
|
+
return out.join("");
|
|
2172
|
+
}
|
|
2173
|
+
/** What silences one interpolated-SQL finding: `// nifra-expect NF-C006: <reason>` (or the
|
|
2174
|
+
* `sql-dynamic` slug) on the flagged line or in the comment block directly above it. The reason after
|
|
2175
|
+
* the colon is mandatory (see {@link commentBlockExpectation}). Scoped to the single statement it sits on. */
|
|
2176
|
+
const SQL_DYNAMIC_EXPECTATION = ["NF-C006", "sql-dynamic"];
|
|
1157
2177
|
/**
|
|
1158
2178
|
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
1159
2179
|
*
|
|
@@ -1162,6 +2182,10 @@ const SQL_DYNAMIC_PRAGMA = "nifra-expect sql-dynamic";
|
|
|
1162
2182
|
* to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
|
|
1163
2183
|
*/
|
|
1164
2184
|
export function scanInterpolatedSql(file, content, ts, imports) {
|
|
2185
|
+
return scanSqlSites(file, content, ts, imports).filter((site) => site.expected === undefined);
|
|
2186
|
+
}
|
|
2187
|
+
/** {@link scanInterpolatedSql}, keeping the sites an expectation comment silences, with its reason. */
|
|
2188
|
+
export function scanSqlSites(file, content, ts, imports) {
|
|
1165
2189
|
const out = [];
|
|
1166
2190
|
const lines = content.split("\n");
|
|
1167
2191
|
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
@@ -1173,26 +2197,36 @@ export function scanInterpolatedSql(file, content, ts, imports) {
|
|
|
1173
2197
|
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1174
2198
|
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1175
2199
|
const scope = { module: sqlModuleScope(ts, file, source), imports };
|
|
2200
|
+
const helpers = statementHelpers(ts, source);
|
|
2201
|
+
// A marker counts only in a comment: one inside a string the call passes is data, not a reason.
|
|
2202
|
+
let markerLines;
|
|
1176
2203
|
const visit = (node) => {
|
|
1177
2204
|
if (ts.isCallExpression(node)) {
|
|
1178
|
-
const
|
|
1179
|
-
|
|
2205
|
+
const helper = ts.isIdentifier(node.expression)
|
|
2206
|
+
? helpers.get(node.expression.text)
|
|
2207
|
+
: undefined;
|
|
2208
|
+
const sink = helper?.sink ?? sqlMethod(ts, node);
|
|
2209
|
+
const passed = node.arguments[helper?.position ?? 0];
|
|
2210
|
+
const argument = passed === undefined ? undefined : statementArgument(ts, passed);
|
|
1180
2211
|
if (sink !== undefined && argument !== undefined) {
|
|
1181
2212
|
const shape = sqlExpressionShape(ts, argument, scope);
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
2213
|
+
// A helper's own hatch call already answers for passing it a variable.
|
|
2214
|
+
const unsafeEscape = helper === undefined &&
|
|
2215
|
+
sink.receiver !== undefined &&
|
|
2216
|
+
ALWAYS_UNSAFE.has(sink.method) &&
|
|
2217
|
+
!shape.literal &&
|
|
2218
|
+
!shape.parameterizedTag;
|
|
2219
|
+
if (unsafeEscape || isAssembledSql(sink, shape)) {
|
|
1187
2220
|
const index = node.getStart(source);
|
|
1188
2221
|
const line = source.getLineAndCharacterOfPosition(index).line + 1;
|
|
1189
|
-
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
2222
|
+
// Sanctioned escape hatch for genuinely-dynamic-but-bound SQL (an allowlisted identifier,
|
|
2223
|
+
// placeholders numbered from data the scan cannot prove numeric). The reason is mandatory: a bare `// nifra-expect
|
|
2224
|
+
// NF-C006` with no reason after the colon does NOT silence, so the hatch always leaves a
|
|
1192
2225
|
// greppable audit trail instead of becoming a second laundering trick.
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
2226
|
+
markerLines ??= literalsBlanked(ts, source, content).split("\n");
|
|
2227
|
+
const reason = commentBlockExpectation(markerLines, line, SQL_DYNAMIC_EXPECTATION)?.reason;
|
|
2228
|
+
const site = { file, line, snippet: (lines[line - 1] ?? "").trim() };
|
|
2229
|
+
out.push(reason === undefined ? site : { ...site, expected: reason });
|
|
1196
2230
|
}
|
|
1197
2231
|
}
|
|
1198
2232
|
}
|
|
@@ -1442,7 +2476,7 @@ export function scanServerOnlyImports(file, content, facts) {
|
|
|
1442
2476
|
// Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
|
|
1443
2477
|
// failure keeps the lexical edge so the security rule fails closed.
|
|
1444
2478
|
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
1445
|
-
if (!
|
|
2479
|
+
if (!isServerOnly(specifier))
|
|
1446
2480
|
continue;
|
|
1447
2481
|
const line = lineAt(content, index);
|
|
1448
2482
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
|
|
@@ -1458,21 +2492,24 @@ export function scanServerOnlyImports(file, content, facts) {
|
|
|
1458
2492
|
// local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
|
|
1459
2493
|
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
1460
2494
|
// ---------------------------------------------------------------------------------------------------
|
|
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
2495
|
// The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
|
|
1466
2496
|
const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
1467
2497
|
// The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
|
|
1468
2498
|
// 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
|
-
|
|
2499
|
+
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/g;
|
|
2500
|
+
/** Whether `content` imports the backend-only marker in code. A mention in a comment or a string,
|
|
2501
|
+
* such as a note on how to opt in, marks nothing. */
|
|
2502
|
+
function hasBackendOnlyMarker(content) {
|
|
2503
|
+
if (!content.includes("-only"))
|
|
2504
|
+
return false;
|
|
2505
|
+
const positions = codePositionMask(content);
|
|
2506
|
+
for (const match of content.matchAll(BACKEND_ONLY_MARKER_IMPORT)) {
|
|
2507
|
+
if (positions[match.index] !== " ")
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
return false;
|
|
2511
|
+
}
|
|
2512
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind the SQL import lookup follows. */
|
|
1476
2513
|
const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
|
|
1477
2514
|
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
|
|
1478
2515
|
// per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
|
|
@@ -1534,14 +2571,37 @@ function dynamicImportEdges(content, facts, file, positions = codePositionMask(c
|
|
|
1534
2571
|
}
|
|
1535
2572
|
return edges;
|
|
1536
2573
|
}
|
|
1537
|
-
/**
|
|
2574
|
+
/** The literal `require("x")` calls in executable code. Pure. */
|
|
2575
|
+
function requireEdges(content, positions) {
|
|
2576
|
+
const edges = [];
|
|
2577
|
+
const re = new RegExp(REQUIRE_CALL.source, REQUIRE_CALL.flags);
|
|
2578
|
+
for (let m = re.exec(content); m !== null; m = re.exec(content)) {
|
|
2579
|
+
if (positions[m.index] === " ")
|
|
2580
|
+
continue; // inside a comment or a string
|
|
2581
|
+
const specifier = m[2];
|
|
2582
|
+
if (specifier === undefined)
|
|
2583
|
+
continue;
|
|
2584
|
+
if (TYPE_ALIAS_BEFORE.test(content.slice(Math.max(0, m.index - 80), m.index)))
|
|
2585
|
+
continue;
|
|
2586
|
+
edges.push({ specifier, index: m.index });
|
|
2587
|
+
}
|
|
2588
|
+
return edges;
|
|
2589
|
+
}
|
|
2590
|
+
/** Every import a module's runtime code makes - static, literal dynamic and `require` - in source
|
|
2591
|
+
* order. Pure. */
|
|
1538
2592
|
function importEdges(content, facts, file) {
|
|
1539
|
-
// Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
|
|
1540
2593
|
const positions = codePositionMask(content);
|
|
1541
2594
|
const edges = staticImportEdges(content, facts, file, positions);
|
|
1542
|
-
|
|
2595
|
+
// Without an `import(` or a `require` there is nothing for the other passes to find; skip them.
|
|
2596
|
+
const dynamic = content.includes("import(") || /import\s+\(/.test(content);
|
|
2597
|
+
const required = content.includes("require");
|
|
2598
|
+
if (!dynamic && !required)
|
|
1543
2599
|
return edges;
|
|
1544
|
-
|
|
2600
|
+
if (dynamic)
|
|
2601
|
+
edges.push(...dynamicImportEdges(content, facts, file, positions));
|
|
2602
|
+
if (required)
|
|
2603
|
+
edges.push(...requireEdges(content, positions));
|
|
2604
|
+
return edges.sort((a, b) => a.index - b.index);
|
|
1545
2605
|
}
|
|
1546
2606
|
/** The runtime imports of a module with the line each sits on, in source order. Pure. */
|
|
1547
2607
|
export function importSites(content, file) {
|
|
@@ -1558,63 +2618,130 @@ export function parseImports(content, facts, file) {
|
|
|
1558
2618
|
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1559
2619
|
* itself (it's already the actionable name). */
|
|
1560
2620
|
export function directSinkSpecifier(spec) {
|
|
1561
|
-
return
|
|
2621
|
+
return isServerOnlySink(spec) ? spec : undefined;
|
|
1562
2622
|
}
|
|
1563
2623
|
/**
|
|
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.
|
|
2624
|
+
* Every module is read, parsed and resolved at most once, and the chain from a module is searched at
|
|
2625
|
+
* most once, however many routes reach it: per-route searches repeated all of that for every route
|
|
2626
|
+
* that shared a module, which made a check quadratic in the size of a shared graph.
|
|
1573
2627
|
*/
|
|
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
|
-
|
|
2628
|
+
function chainGraph(source) {
|
|
2629
|
+
const { facts } = source;
|
|
2630
|
+
const componentReceipts = source.componentReceipts ?? new Set();
|
|
2631
|
+
const contents = new Map();
|
|
2632
|
+
const imports = new Map();
|
|
2633
|
+
const resolutions = new Map();
|
|
2634
|
+
const walks = new Map();
|
|
2635
|
+
const packages = new Map();
|
|
2636
|
+
const read = (abs) => {
|
|
2637
|
+
if (!contents.has(abs))
|
|
2638
|
+
contents.set(abs, source.read(abs));
|
|
2639
|
+
return contents.get(abs);
|
|
2640
|
+
};
|
|
2641
|
+
const resolve = (from, specifier) => {
|
|
2642
|
+
const key = `${from}\0${specifier}`;
|
|
2643
|
+
if (!resolutions.has(key))
|
|
2644
|
+
resolutions.set(key, source.resolve(from, specifier));
|
|
2645
|
+
return resolutions.get(key);
|
|
2646
|
+
};
|
|
2647
|
+
const backendPackage = (from, specifier) => {
|
|
2648
|
+
if (source.backendPackage === undefined || /^[./#]/.test(specifier))
|
|
2649
|
+
return false;
|
|
2650
|
+
const key = `${from}\0${specifier}`;
|
|
2651
|
+
let found = packages.get(key);
|
|
2652
|
+
if (found === undefined) {
|
|
2653
|
+
found = source.backendPackage(from, specifier);
|
|
2654
|
+
packages.set(key, found);
|
|
2655
|
+
}
|
|
2656
|
+
return found;
|
|
2657
|
+
};
|
|
2658
|
+
const importsOf = (abs, content) => {
|
|
2659
|
+
let found = imports.get(abs);
|
|
2660
|
+
if (found === undefined) {
|
|
2661
|
+
found = parseImports(content, facts, abs);
|
|
2662
|
+
imports.set(abs, found);
|
|
2663
|
+
}
|
|
2664
|
+
return found;
|
|
2665
|
+
};
|
|
2666
|
+
/**
|
|
2667
|
+
* Breadth-first, so the first sink found is the nearest. A node's edges are its static and literal
|
|
2668
|
+
* dynamic imports; an edge is followed only into a project file `resolve` names, an alias included,
|
|
2669
|
+
* never into node_modules. A by-name sink import (`node:fs`, `postgres`), backend code, or a module
|
|
2670
|
+
* importing the `backend-only` marker ends the chain; a `*.fn.ts` module ships as its stub and is not
|
|
2671
|
+
* entered. The visited set keeps it cycle-free; there is no depth or size cap to cut a chain short.
|
|
2672
|
+
*/
|
|
2673
|
+
const search = (start, startContent) => {
|
|
2674
|
+
const seen = new Set([start]);
|
|
2675
|
+
let frontier = [{ abs: start, content: startContent, chain: [start], modules: [start] }];
|
|
2676
|
+
while (frontier.length > 0) {
|
|
2677
|
+
const next = [];
|
|
2678
|
+
for (const node of frontier) {
|
|
2679
|
+
const { chain, modules } = node;
|
|
2680
|
+
for (const spec of importsOf(node.abs, node.content)) {
|
|
2681
|
+
const sink = directSinkSpecifier(spec);
|
|
2682
|
+
if (sink !== undefined)
|
|
2683
|
+
return { chain: [...chain, sink], modules };
|
|
2684
|
+
const abs = resolve(node.abs, spec);
|
|
2685
|
+
if (abs === undefined) {
|
|
2686
|
+
if (backendPackage(node.abs, spec))
|
|
2687
|
+
return { chain: [...chain, spec], modules };
|
|
2688
|
+
continue;
|
|
2689
|
+
}
|
|
2690
|
+
if (seen.has(abs))
|
|
2691
|
+
continue;
|
|
2692
|
+
if (componentReceipts.has(abs))
|
|
2693
|
+
continue;
|
|
2694
|
+
if (isBackendModule(abs))
|
|
2695
|
+
return { chain: [...chain, spec], modules: [...modules, abs] };
|
|
2696
|
+
if (SERVER_FN_FILE.test(abs))
|
|
2697
|
+
continue;
|
|
2698
|
+
const content = read(abs);
|
|
2699
|
+
if (content === undefined)
|
|
2700
|
+
continue;
|
|
2701
|
+
if (hasBackendOnlyMarker(content)) {
|
|
2702
|
+
return { chain: [...chain, spec], modules: [...modules, abs] };
|
|
2703
|
+
}
|
|
2704
|
+
seen.add(abs);
|
|
2705
|
+
next.push({ abs, content, chain: [...chain, spec], modules: [...modules, abs] });
|
|
2706
|
+
}
|
|
1613
2707
|
}
|
|
2708
|
+
frontier = next;
|
|
1614
2709
|
}
|
|
1615
|
-
|
|
1616
|
-
}
|
|
1617
|
-
return
|
|
2710
|
+
return undefined;
|
|
2711
|
+
};
|
|
2712
|
+
return {
|
|
2713
|
+
walk(start, content) {
|
|
2714
|
+
if (!walks.has(start))
|
|
2715
|
+
walks.set(start, search(start, content));
|
|
2716
|
+
return walks.get(start);
|
|
2717
|
+
},
|
|
2718
|
+
read,
|
|
2719
|
+
resolve,
|
|
2720
|
+
backendPackage,
|
|
2721
|
+
componentReceipts,
|
|
2722
|
+
facts,
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
/**
|
|
2726
|
+
* The shortest import chain from `routeFile` to a server-only sink, `[routeFile, ...as-written
|
|
2727
|
+
* specifiers, sink]`, or `undefined` when none is reachable. Pure given `resolve`/`read`, so a fake
|
|
2728
|
+
* graph tests it.
|
|
2729
|
+
*/
|
|
2730
|
+
export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts, componentReceipts = new Set()) {
|
|
2731
|
+
return chainGraph({
|
|
2732
|
+
resolve,
|
|
2733
|
+
read,
|
|
2734
|
+
facts,
|
|
2735
|
+
componentReceipts,
|
|
2736
|
+
}).walk(routeFile, routeContent)?.chain;
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* The server-only chain finder for one check: called per browser module, it returns that module's
|
|
2740
|
+
* findings, reusing every module and chain an earlier call already searched.
|
|
2741
|
+
*/
|
|
2742
|
+
export function createServerOnlyChains(source) {
|
|
2743
|
+
const graph = chainGraph(source);
|
|
2744
|
+
return (file, content, browserRoot = false) => serverOnlyChains(graph, file, content, browserRoot);
|
|
1618
2745
|
}
|
|
1619
2746
|
/**
|
|
1620
2747
|
* Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
|
|
@@ -1622,77 +2749,76 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read, fact
|
|
|
1622
2749
|
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
1623
2750
|
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
1624
2751
|
* 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.
|
|
2752
|
+
* de-duplicated to the shortest chain per first-hop specifier. A file that is not browser source
|
|
2753
|
+
* yields `[]`, unless `browserRoot` says the browser bundle loads it anyway (an app's `clientModule`).
|
|
2754
|
+
* One check should use {@link createServerOnlyChains}, which shares the work across modules.
|
|
1626
2755
|
*/
|
|
1627
|
-
export function resolveServerOnlyChains(file, content, resolve, read, facts) {
|
|
1628
|
-
|
|
2756
|
+
export function resolveServerOnlyChains(file, content, resolve, read, facts, componentReceipts = new Set(), browserRoot = false) {
|
|
2757
|
+
return createServerOnlyChains({
|
|
2758
|
+
resolve,
|
|
2759
|
+
read,
|
|
2760
|
+
facts,
|
|
2761
|
+
componentReceipts,
|
|
2762
|
+
})(file, content, browserRoot);
|
|
2763
|
+
}
|
|
2764
|
+
function serverOnlyChains(graph, file, content, browserRoot) {
|
|
2765
|
+
if (!browserRoot && !isBrowserSource(file))
|
|
1629
2766
|
return [];
|
|
1630
2767
|
const lines = content.split("\n");
|
|
1631
2768
|
const out = [];
|
|
1632
2769
|
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)) {
|
|
2770
|
+
for (const { specifier, index } of importEdges(content, graph.facts, file)) {
|
|
1637
2771
|
if (flaggedSpecifiers.has(specifier))
|
|
1638
2772
|
continue;
|
|
1639
2773
|
const line = lineAt(content, index);
|
|
1640
2774
|
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) {
|
|
2775
|
+
const flag = (chain, fallback, modules = [file]) => {
|
|
1644
2776
|
flaggedSpecifiers.add(specifier);
|
|
1645
|
-
out.push({ file, line, snippet, specifier, chain
|
|
2777
|
+
out.push({ file, line, snippet, specifier, chain, modules, fallback });
|
|
2778
|
+
};
|
|
2779
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`): the route imports it itself.
|
|
2780
|
+
if (directSinkSpecifier(specifier) !== undefined) {
|
|
2781
|
+
flag([file, specifier], false);
|
|
1646
2782
|
continue;
|
|
1647
2783
|
}
|
|
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
|
-
}
|
|
2784
|
+
// (2) A project import, relative or through an alias: walk from the resolved dependency and, if
|
|
2785
|
+
// it reaches a sink, report the full chain rooted at THIS route's import line.
|
|
2786
|
+
const abs = graph.resolve(file, specifier);
|
|
2787
|
+
if (abs === undefined) {
|
|
2788
|
+
// An installed package that declares itself backend is a sink. Any other name no project file
|
|
2789
|
+
// answers to can't be called server-only, except the `../db` convention, reported as the direct
|
|
2790
|
+
// edge with `fallback` saying the resolve was impossible.
|
|
2791
|
+
if (graph.backendPackage(file, specifier))
|
|
2792
|
+
flag([file, specifier], false);
|
|
2793
|
+
else if (isServerOnly(specifier))
|
|
2794
|
+
flag([file, specifier], true);
|
|
2795
|
+
continue;
|
|
2796
|
+
}
|
|
2797
|
+
if (graph.componentReceipts.has(abs))
|
|
2798
|
+
continue;
|
|
2799
|
+
// A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
|
|
2800
|
+
if (isBackendModule(abs)) {
|
|
2801
|
+
flag([file, specifier], false, [file, abs]);
|
|
2802
|
+
continue;
|
|
2803
|
+
}
|
|
2804
|
+
if (SERVER_FN_FILE.test(abs))
|
|
2805
|
+
continue;
|
|
2806
|
+
const depContent = graph.read(abs);
|
|
2807
|
+
if (depContent === undefined)
|
|
2808
|
+
continue;
|
|
2809
|
+
if (hasBackendOnlyMarker(depContent)) {
|
|
2810
|
+
flag([file, specifier], false, [file, abs]);
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
const sub = graph.walk(abs, depContent);
|
|
2814
|
+
if (sub !== undefined) {
|
|
2815
|
+
// `sub` starts at the resolved dependency; the route names it by its as-written specifier.
|
|
2816
|
+
flag([file, specifier, ...sub.chain.slice(1)], false, [file, ...sub.modules]);
|
|
2817
|
+
continue;
|
|
1695
2818
|
}
|
|
2819
|
+
// (3) The `../db` convention with no deeper sink found: still surface the direct edge.
|
|
2820
|
+
if (isServerOnly(specifier))
|
|
2821
|
+
flag([file, specifier], true);
|
|
1696
2822
|
}
|
|
1697
2823
|
return out;
|
|
1698
2824
|
}
|
|
@@ -1721,22 +2847,19 @@ export function scanResponseRoutes(file, content, facts) {
|
|
|
1721
2847
|
continue;
|
|
1722
2848
|
}
|
|
1723
2849
|
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)
|
|
2850
|
+
// A `// nifra-expect NF-C007` comment (or the `raw-response` slug) marks an intentional raw Response
|
|
2851
|
+
// (a file/redirect that can't be a typed route), silencing this advisory with or without a reason.
|
|
2852
|
+
// It counts on the return line or in the comment block directly above, so a TRAILING comment on one
|
|
2853
|
+
// route's return line can't leak down onto the next route. `stripComments` blanks it in `code` (so it
|
|
2854
|
+
// never affects the match); the raw `lines` still carry it.
|
|
2855
|
+
if (commentBlockExpectation(lines, line, RAW_RESPONSE_EXPECTATION) !== undefined)
|
|
1733
2856
|
continue;
|
|
1734
|
-
out.push({ file, line, snippet:
|
|
2857
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
1735
2858
|
}
|
|
1736
2859
|
return out;
|
|
1737
2860
|
}
|
|
1738
|
-
/**
|
|
1739
|
-
const
|
|
2861
|
+
/** What suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
2862
|
+
const RAW_RESPONSE_EXPECTATION = ["NF-C007", "raw-response"];
|
|
1740
2863
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
1741
2864
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
1742
2865
|
*
|
|
@@ -1777,11 +2900,17 @@ async function gitIgnored(cwd, rels) {
|
|
|
1777
2900
|
}
|
|
1778
2901
|
export async function walkSource(cwd, visit, opts = {}) {
|
|
1779
2902
|
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED;
|
|
2903
|
+
const pattern = opts.scripts === true
|
|
2904
|
+
? "**/*.{ts,tsx,mts,cts,jsx,js,mjs,cjs,svelte,vue}"
|
|
2905
|
+
: "**/*.{ts,tsx,mts,cts}";
|
|
1780
2906
|
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
1781
2907
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
1782
2908
|
const rels = [];
|
|
1783
|
-
for await (const rawRel of new Glob(
|
|
2909
|
+
for await (const rawRel of new Glob(pattern).scan({ cwd, dot: false })) {
|
|
1784
2910
|
const rel = normalizeProjectPath(rawRel);
|
|
2911
|
+
// A `.js` file outside a zone is tooling (a bundler config, a script, a vendored asset), not app code.
|
|
2912
|
+
if (PLAIN_SCRIPT_FILE.test(rel) && !ZONED_PATH.test(rel))
|
|
2913
|
+
continue;
|
|
1785
2914
|
if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel)))
|
|
1786
2915
|
rels.push(rel);
|
|
1787
2916
|
}
|
|
@@ -1790,8 +2919,44 @@ export async function walkSource(cwd, visit, opts = {}) {
|
|
|
1790
2919
|
for (const rel of rels.sort(codeUnitOrder)) {
|
|
1791
2920
|
if (ignored.has(rel))
|
|
1792
2921
|
continue;
|
|
1793
|
-
|
|
2922
|
+
const text = await Bun.file(join(cwd, rel)).text();
|
|
2923
|
+
visit(rel, COMPONENT_FILE.test(rel) ? componentScripts(text) : text);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
const PLAIN_SCRIPT_FILE = /\.[cm]?js$/;
|
|
2927
|
+
const ZONED_PATH = /(^|\/)(?:routes|frontend|shared|backend)\/|\.(?:frontend|shared|backend|fn)\.[^/]+$/;
|
|
2928
|
+
/** A single-file component: its `<script>` blocks are the code a build compiles. */
|
|
2929
|
+
export const COMPONENT_FILE = /\.(?:svelte|vue)$/;
|
|
2930
|
+
/**
|
|
2931
|
+
* A `.svelte` or `.vue` file as the source scanners read it: the text of its `<script>` blocks where
|
|
2932
|
+
* they stand, and every other character blanked to a space with line breaks kept, so a finding's line
|
|
2933
|
+
* and column are the file's own. An unclosed `<script>` runs to the end of the file. Pure.
|
|
2934
|
+
*/
|
|
2935
|
+
export function componentScripts(text) {
|
|
2936
|
+
const blank = (part) => part.replace(/[^\n]/g, " ");
|
|
2937
|
+
let out = "";
|
|
2938
|
+
let at = 0;
|
|
2939
|
+
for (;;) {
|
|
2940
|
+
const open = text.indexOf("<script", at);
|
|
2941
|
+
if (open === -1)
|
|
2942
|
+
break;
|
|
2943
|
+
const close = text.indexOf(">", open);
|
|
2944
|
+
if (close === -1)
|
|
2945
|
+
break;
|
|
2946
|
+
// `<scripts>` or `<script-x>` is another element.
|
|
2947
|
+
if (!/[\s>]/.test(text[open + 7] ?? "")) {
|
|
2948
|
+
out += blank(text.slice(at, close + 1));
|
|
2949
|
+
at = close + 1;
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
const end = text.indexOf("</script", close + 1);
|
|
2953
|
+
const stop = end === -1 ? text.length : end;
|
|
2954
|
+
out += blank(text.slice(at, close + 1)) + text.slice(close + 1, stop);
|
|
2955
|
+
at = stop;
|
|
2956
|
+
if (end === -1)
|
|
2957
|
+
break;
|
|
1794
2958
|
}
|
|
2959
|
+
return out + blank(text.slice(at));
|
|
1795
2960
|
}
|
|
1796
2961
|
const bySite = (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line;
|
|
1797
2962
|
/** Collect own-API `fetch()` findings across the project. */
|