@nifrajs/cli 3.5.0 → 4.0.2
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 +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +4 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +13 -0
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +49 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +291 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -63
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +262 -82
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +62 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +275 -35
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +52 -13
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +48 -22
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +5 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -27
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +67 -17
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.js +1 -1
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +2 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +132 -108
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +17 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +14 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +45 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-render.js +2 -2
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +110 -23
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -9
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +16 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +81 -53
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +1155 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +5 -0
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +36 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +12 -8
- package/dist/review.js.map +1 -1
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +71 -38
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +132 -87
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +44 -19
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +29 -13
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +3807 -580
- package/docs/types.json +14229 -10939
- package/package.json +19 -12
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +17 -0
- package/src/check-scan.ts +358 -63
- package/src/check.ts +97 -62
- package/src/cli.ts +296 -87
- package/src/command-catalog.ts +389 -48
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +54 -12
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +53 -32
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +90 -27
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +140 -33
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +1 -1
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +160 -132
- package/src/mcp-http.ts +19 -5
- package/src/mcp-io.ts +57 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-render.ts +2 -2
- package/src/mcp-root.ts +117 -22
- package/src/mcp-run.ts +104 -10
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +106 -64
- package/src/migrate-layout.ts +1338 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +5 -0
- package/src/recipes/4.0.0.ts +37 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +13 -8
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +71 -39
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +147 -95
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +50 -19
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +13 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +27 -13
package/src/check-scan.ts
CHANGED
|
@@ -9,8 +9,14 @@ import { readFileSync, realpathSync } from "node:fs"
|
|
|
9
9
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path"
|
|
10
10
|
import { Glob } from "bun"
|
|
11
11
|
import type * as TSApi from "typescript"
|
|
12
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
13
|
+
import { scriptKindOf } from "./internal/script-kind.ts"
|
|
12
14
|
import type { SourceFacts } from "./internal/source-facts.ts"
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
loadProjectTypeScript,
|
|
17
|
+
type TypeScriptApi,
|
|
18
|
+
type TypeScriptSourceIndex,
|
|
19
|
+
} from "./internal/typescript-import.ts"
|
|
14
20
|
import { commentBlockMarkerReason } from "./rules/comment-markers.ts"
|
|
15
21
|
|
|
16
22
|
export interface SourceFinding {
|
|
@@ -48,21 +54,67 @@ const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
|
|
|
48
54
|
* them stable across runtimes so rules, diagnostics, ignore globs, and import chains have one shape. */
|
|
49
55
|
const normalizeProjectPath = (path: string): string => path.replaceAll("\\", "/")
|
|
50
56
|
|
|
51
|
-
//
|
|
52
|
-
|
|
57
|
+
// Browser code by path, mirroring `@nifrajs/web/zones`: a suffix decides first, then the folder. A
|
|
58
|
+
// route's backend half (`x.backend.ts`), `backend/` and `*.fn.ts` run on the server only.
|
|
59
|
+
const BACKEND_SUFFIX = /\.backend\.[cm]?[jt]sx?$/
|
|
60
|
+
const BROWSER_SUFFIX = /\.(?:frontend|shared)\.[^/]+$/
|
|
61
|
+
const BROWSER_FOLDER = /(^|\/)(?:routes|frontend|shared)\//
|
|
62
|
+
const BACKEND_FOLDER = /(^|\/)backend\//
|
|
63
|
+
const SERVER_FN_FILE = /\.fn\.[cm]?[jt]sx?$/
|
|
64
|
+
|
|
65
|
+
/** Whether a project file is browser code: a route's frontend half, `frontend/` or `shared/`. Pure. */
|
|
66
|
+
export function isBrowserSource(file: string): boolean {
|
|
67
|
+
const path = normalizeProjectPath(file)
|
|
68
|
+
if (BACKEND_SUFFIX.test(path) || SERVER_FN_FILE.test(path)) return false
|
|
69
|
+
if (BROWSER_SUFFIX.test(path)) return true
|
|
70
|
+
return BROWSER_FOLDER.test(path) && !BACKEND_FOLDER.test(path)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether a resolved module is backend code a browser import may not reach (a `*.fn.ts` module ships
|
|
74
|
+
* as its generated stub, so it is not). A legacy `*.server` module counts: the build refuses it. */
|
|
75
|
+
function isBackendModule(file: string): boolean {
|
|
76
|
+
const path = normalizeProjectPath(file)
|
|
77
|
+
if (SERVER_FN_FILE.test(path)) return false
|
|
78
|
+
return (
|
|
79
|
+
BACKEND_SUFFIX.test(path) ||
|
|
80
|
+
LEGACY_SERVER_FILE.test(path) ||
|
|
81
|
+
(BACKEND_FOLDER.test(path) && !BROWSER_SUFFIX.test(path))
|
|
82
|
+
)
|
|
83
|
+
}
|
|
53
84
|
|
|
54
85
|
// Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
|
|
55
86
|
// DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
|
|
56
87
|
const SERVER_ONLY =
|
|
57
88
|
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b|^(?:\.\.?\/)+db(?:\.[cm]?[jt]sx?)?$/
|
|
58
89
|
|
|
59
|
-
// A static, non-type import with a string specifier
|
|
60
|
-
//
|
|
61
|
-
// after `import`, so `import\s+`
|
|
62
|
-
|
|
90
|
+
// A static, non-type import with a string specifier, or a re-export (`export … from "x"`), which
|
|
91
|
+
// pulls its module into the bundle the same way. `import type …` and `export type …` are erased at
|
|
92
|
+
// build, so they're safe and skipped. Dynamic `import(…)` has `(` right after `import`, so `import\s+`
|
|
93
|
+
// never matches it; it is read by DYNAMIC_IMPORT below.
|
|
94
|
+
// The clause between the keyword and `from` is spelled out (a default binding, then `* as ns` or a
|
|
95
|
+
// `{ … }` list) rather than scanned lazily: a lazy scan restarts at every `export const` line and is
|
|
96
|
+
// quadratic on a long semicolon-free module.
|
|
97
|
+
const STATIC_IMPORT =
|
|
98
|
+
/\b(?:import\s*(?!type\b)(?:(?:[\w$]+\s*,?\s*)?(?:\*\s*(?:as\b\s*[\w$]+\s*)?|\{[^{}'"]*\}\s*)?from\s*)?|export\s*(?!type\b)(?:\*\s*(?:as\b\s*[\w$]+\s*)?|\{[^{}'"]*\}\s*)from\s*)['"]([^'"]+)['"]/g
|
|
99
|
+
// A dynamic `import("x")` whose specifier is one string literal. The client build bundles its target
|
|
100
|
+
// as a lazy chunk, so it reaches the browser bundle like a static import does - also from inside a
|
|
101
|
+
// loader, which ships with the route module. A computed specifier cannot be followed and is skipped.
|
|
102
|
+
const DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*(["'`])([^"'`$\\\r\n]+)\1\s*[,)]/g
|
|
103
|
+
// Without a parser: `typeof import("x")` and `import("x").Name` are type positions, erased at build.
|
|
104
|
+
// A runtime `import("x")` is a promise, so the only members read straight off it are its methods.
|
|
105
|
+
const TYPE_QUERY_BEFORE = /\btypeof\s*$/
|
|
106
|
+
const TYPE_QUALIFIER_AFTER = /^\s*\.\s*(?!(?:then|catch|finally)\b)[A-Za-z_$]/
|
|
63
107
|
|
|
64
108
|
const ROUTE_REGISTRATION_DQ = /\.([A-Za-z]+)\s*\(\s*"((?:\\.|[^"\\])*)"/g
|
|
65
109
|
const ROUTE_REGISTRATION_SQ = /\.([A-Za-z]+)\s*\(\s*'((?:\\.|[^'\\])*)'/g
|
|
110
|
+
// `all("/path", ...)` and `method("PURGE", "/path", ...)` / `method(["GET", "PURGE"], "/path", ...)`
|
|
111
|
+
// from `@nifrajs/core/methods`. They are free function calls, so the member-call patterns above do
|
|
112
|
+
// not see them.
|
|
113
|
+
const METHODS_SUBPATH = "@nifrajs/core/methods"
|
|
114
|
+
const ALL_ROUTES = /(?<![\w$.])all\s*\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g
|
|
115
|
+
const METHOD_ROUTES =
|
|
116
|
+
/(?<![\w$.])method\s*\(\s*(\[[^\]]*\]|"[^"\\]*"|'[^'\\]*')\s*,\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')/g
|
|
117
|
+
const METHOD_NAME_LITERAL = /(["'])([A-Za-z][A-Za-z0-9-]{0,31})\1/g
|
|
66
118
|
export const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
67
119
|
|
|
68
120
|
export interface StaticRouteFinding extends SourceFinding {
|
|
@@ -517,9 +569,66 @@ export function scanStaticRouteText(
|
|
|
517
569
|
return [
|
|
518
570
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
519
571
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
572
|
+
...(code.includes(METHODS_SUBPATH) ? scanMethodRoutes(file, content, code, facts) : []),
|
|
520
573
|
].sort(bySite)
|
|
521
574
|
}
|
|
522
575
|
|
|
576
|
+
/** Routes from `all()` and `method()`, one per literal method name; a computed name is not guessed. */
|
|
577
|
+
function scanMethodRoutes(
|
|
578
|
+
file: string,
|
|
579
|
+
content: string,
|
|
580
|
+
code: string,
|
|
581
|
+
facts?: SourceFacts,
|
|
582
|
+
): StaticRouteFinding[] {
|
|
583
|
+
const out: StaticRouteFinding[] = []
|
|
584
|
+
const lines = content.split("\n")
|
|
585
|
+
const collect = (
|
|
586
|
+
index: number,
|
|
587
|
+
callee: string,
|
|
588
|
+
pathIndex: number,
|
|
589
|
+
rawPath: string | undefined,
|
|
590
|
+
quote: string,
|
|
591
|
+
methods: readonly string[],
|
|
592
|
+
): void => {
|
|
593
|
+
const path = parseQuotedLiteral(`${quote}${rawPath ?? ""}${quote}`)
|
|
594
|
+
if (path === undefined || !path.startsWith("/") || path.startsWith("//")) return
|
|
595
|
+
if (facts !== undefined) {
|
|
596
|
+
const source = facts.parse(file, content)
|
|
597
|
+
// Same contract as a member-call registration: a parsed source is authoritative, an
|
|
598
|
+
// unparseable one keeps the lexical finding.
|
|
599
|
+
if (
|
|
600
|
+
source !== undefined &&
|
|
601
|
+
facts.isFunctionRouteCallAt(source, index, callee, pathIndex, path) !== true
|
|
602
|
+
)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
const line = lineAt(content, index)
|
|
606
|
+
const snippet = (lines[line - 1] ?? "").trim()
|
|
607
|
+
for (const method of methods) out.push({ file, line, snippet, method, path })
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
ALL_ROUTES.lastIndex = 0
|
|
611
|
+
for (let m = ALL_ROUTES.exec(code); m !== null; m = ALL_ROUTES.exec(code)) {
|
|
612
|
+
const double = m[1] !== undefined
|
|
613
|
+
collect(m.index, "all", 0, double ? m[1] : m[2], double ? '"' : "'", [...HTTP_VERBS])
|
|
614
|
+
}
|
|
615
|
+
METHOD_ROUTES.lastIndex = 0
|
|
616
|
+
for (let m = METHOD_ROUTES.exec(code); m !== null; m = METHOD_ROUTES.exec(code)) {
|
|
617
|
+
const names = new Set<string>()
|
|
618
|
+
METHOD_NAME_LITERAL.lastIndex = 0
|
|
619
|
+
for (
|
|
620
|
+
let name = METHOD_NAME_LITERAL.exec(m[1] ?? "");
|
|
621
|
+
name !== null;
|
|
622
|
+
name = METHOD_NAME_LITERAL.exec(m[1] ?? "")
|
|
623
|
+
) {
|
|
624
|
+
names.add((name[2] ?? "").toUpperCase())
|
|
625
|
+
}
|
|
626
|
+
const double = m[2] !== undefined
|
|
627
|
+
collect(m.index, "method", 1, double ? m[2] : m[3], double ? '"' : "'", [...names])
|
|
628
|
+
}
|
|
629
|
+
return out
|
|
630
|
+
}
|
|
631
|
+
|
|
523
632
|
/**
|
|
524
633
|
* SQL built by interpolating a value into the statement text. The value is not a parameter, it is
|
|
525
634
|
* STATEMENT, so anything the caller controls can end the literal and continue as SQL.
|
|
@@ -700,8 +809,15 @@ function parseSqlSource(
|
|
|
700
809
|
file: string,
|
|
701
810
|
content: string,
|
|
702
811
|
): TSApi.SourceFile | undefined {
|
|
703
|
-
const kind =
|
|
704
|
-
|
|
812
|
+
const kind = scriptKindOf(ts, file)
|
|
813
|
+
let source: TSApi.SourceFile
|
|
814
|
+
try {
|
|
815
|
+
source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
816
|
+
} catch {
|
|
817
|
+
// TypeScript 7 parses only the files its session preloaded; an imported module outside that set
|
|
818
|
+
// proves no constant, like one that does not parse.
|
|
819
|
+
return undefined
|
|
820
|
+
}
|
|
705
821
|
const parseDiagnostics = (
|
|
706
822
|
source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
707
823
|
).parseDiagnostics
|
|
@@ -1272,14 +1388,25 @@ export function scanInterpolatedSql(
|
|
|
1272
1388
|
* answer a security rule must never give when it did not run.
|
|
1273
1389
|
*/
|
|
1274
1390
|
export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1391
|
+
const sourceFiles: Array<{ readonly file: string; readonly content: string }> = []
|
|
1392
|
+
await walkSource(cwd, (file, content) => sourceFiles.push({ file, content }))
|
|
1393
|
+
const contents = new Map(sourceFiles.map(({ file, content }) => [file, content]))
|
|
1394
|
+
const sourceIndex: TypeScriptSourceIndex = {
|
|
1395
|
+
files: sourceFiles.map(({ file }) => file),
|
|
1396
|
+
read: (file) => contents.get(file),
|
|
1397
|
+
}
|
|
1398
|
+
const loaded = await loadProjectTypeScript(cwd, undefined, sourceIndex)
|
|
1399
|
+
if (loaded.compiler === undefined) return undefined
|
|
1277
1400
|
const out: SourceFinding[] = []
|
|
1278
1401
|
const imports = createProjectSqlImports(cwd)
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1402
|
+
try {
|
|
1403
|
+
for (const { file, content } of sourceFiles) {
|
|
1404
|
+
out.push(...scanInterpolatedSql(file, content, loaded.compiler, imports))
|
|
1405
|
+
}
|
|
1406
|
+
return out.sort(bySite)
|
|
1407
|
+
} finally {
|
|
1408
|
+
await loaded.session?.close()
|
|
1409
|
+
}
|
|
1283
1410
|
}
|
|
1284
1411
|
|
|
1285
1412
|
/** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
|
|
@@ -1378,6 +1505,18 @@ export const REMOVED_IMPORTS: ReadonlyArray<{
|
|
|
1378
1505
|
replacement:
|
|
1379
1506
|
'import from "@nifrajs/core/budget" - the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
|
|
1380
1507
|
},
|
|
1508
|
+
{
|
|
1509
|
+
specifier: "@nifrajs/web/server-only",
|
|
1510
|
+
since: "4.0",
|
|
1511
|
+
replacement:
|
|
1512
|
+
'import "@nifrajs/web/backend-only" - and keep backend code under backend/ or in a route\'s x.backend.ts, where no browser build reaches it',
|
|
1513
|
+
},
|
|
1514
|
+
{
|
|
1515
|
+
specifier: "@nifrajs/web/plugins/vite-server-only",
|
|
1516
|
+
since: "4.0",
|
|
1517
|
+
replacement:
|
|
1518
|
+
"nothing - buildClientVite and the Vite dev server enforce the frontend/backend zones themselves; for a hand-built Vite config use viteLeakGuard from @nifrajs/web/plugins/vite-leak-guard",
|
|
1519
|
+
},
|
|
1381
1520
|
{
|
|
1382
1521
|
specifier: "@nifrajs/core/ws",
|
|
1383
1522
|
since: "2.0",
|
|
@@ -1396,12 +1535,13 @@ function isSideEffectImport(content: string, index: number): boolean {
|
|
|
1396
1535
|
return /^import\s*["'`]/.test(content.slice(index, index + 32))
|
|
1397
1536
|
}
|
|
1398
1537
|
|
|
1399
|
-
/** Flag an import of a specifier that no longer exists
|
|
1400
|
-
* of it, so `@nifrajs/budget/x`
|
|
1538
|
+
/** Flag an import of a specifier that no longer exists, or of a name that moved to a subpath
|
|
1539
|
+
* ({@link MOVED_EXPORTS}). Pure; matches the exact specifier or a subpath of it, so `@nifrajs/budget/x`
|
|
1540
|
+
* is caught alongside `@nifrajs/budget`. */
|
|
1401
1541
|
export function scanRemovedImports(file: string, content: string): SourceFinding[] {
|
|
1402
1542
|
const out: SourceFinding[] = []
|
|
1403
1543
|
const lines = content.split("\n")
|
|
1404
|
-
for (const edge of
|
|
1544
|
+
for (const edge of importEdges(content)) {
|
|
1405
1545
|
const removed = REMOVED_IMPORTS.find(
|
|
1406
1546
|
(entry) =>
|
|
1407
1547
|
edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
|
|
@@ -1411,35 +1551,113 @@ export function scanRemovedImports(file: string, content: string): SourceFinding
|
|
|
1411
1551
|
const line = lineAt(content, edge.index)
|
|
1412
1552
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
1413
1553
|
}
|
|
1554
|
+
// A moved name's snippet is the whole statement on one line: the rule re-reads the site from it.
|
|
1555
|
+
for (const site of movedExportSites(content)) {
|
|
1556
|
+
const snippet = content.slice(site.index, site.end).replace(/\s+/g, " ")
|
|
1557
|
+
out.push({ file, line: lineAt(content, site.index), snippet })
|
|
1558
|
+
}
|
|
1559
|
+
return out
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Named exports that left a module for one of its subpaths, and where they went.
|
|
1564
|
+
*
|
|
1565
|
+
* Unlike a removed specifier, a moved name does fail the typecheck - but that error names neither the
|
|
1566
|
+
* new home nor a fix. Listing it here gives the diagnostic both, and `nifra fix --code NF-C005`
|
|
1567
|
+
* rewrites the import ({@link rewriteMovedExports}).
|
|
1568
|
+
*/
|
|
1569
|
+
export const MOVED_EXPORTS: ReadonlyArray<{
|
|
1570
|
+
readonly from: string
|
|
1571
|
+
readonly names: readonly string[]
|
|
1572
|
+
readonly to: string
|
|
1573
|
+
}> = [
|
|
1574
|
+
// Compiler plugins sit on their own subpath so the adapter root, which every server and edge bundle
|
|
1575
|
+
// links, never pulls in build-time code.
|
|
1576
|
+
{ from: "@nifrajs/web-solid", names: ["solidBunPlugin"], to: "@nifrajs/web-solid/plugin" },
|
|
1577
|
+
{ from: "@nifrajs/web-svelte", names: ["svelteBunPlugin"], to: "@nifrajs/web-svelte/plugin" },
|
|
1578
|
+
]
|
|
1579
|
+
|
|
1580
|
+
/** `import { a, b as c } from "x"` and `export { a } from "x"` - the forms a moved name is reached by.
|
|
1581
|
+
* `import type` / `export type` never match, so a type-only import is left to tsc as elsewhere here. */
|
|
1582
|
+
const NAMED_FROM = /\b(import|export)\s*\{([^{}]*)\}\s*from\s*(["'])([^"'\n]+)\3;?/g
|
|
1583
|
+
|
|
1584
|
+
export interface MovedExportSite {
|
|
1585
|
+
/** Statement span in the scanned source, `end` exclusive (a trailing `;` included). */
|
|
1586
|
+
readonly index: number
|
|
1587
|
+
readonly end: number
|
|
1588
|
+
readonly keyword: string
|
|
1589
|
+
readonly quote: string
|
|
1590
|
+
readonly from: string
|
|
1591
|
+
readonly to: string
|
|
1592
|
+
/** Binding texts as written (`a`, `a as b`), split by whether they move. */
|
|
1593
|
+
readonly kept: readonly string[]
|
|
1594
|
+
readonly moved: readonly string[]
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** Every import/re-export statement naming a {@link MOVED_EXPORTS} binding, in source order. Pure. */
|
|
1598
|
+
export function movedExportSites(content: string): MovedExportSite[] {
|
|
1599
|
+
const positions = codePositionMask(content)
|
|
1600
|
+
const sites: MovedExportSite[] = []
|
|
1601
|
+
const re = new RegExp(NAMED_FROM.source, NAMED_FROM.flags)
|
|
1602
|
+
for (let m = re.exec(content); m !== null; m = re.exec(content)) {
|
|
1603
|
+
if (positions[m.index] === " ") continue
|
|
1604
|
+
const entry = MOVED_EXPORTS.find((candidate) => candidate.from === m?.[4])
|
|
1605
|
+
if (entry === undefined) continue
|
|
1606
|
+
const bindings = (m[2] ?? "")
|
|
1607
|
+
.split(",")
|
|
1608
|
+
.map((binding) => binding.trim())
|
|
1609
|
+
.filter((binding) => binding !== "")
|
|
1610
|
+
const moves = (binding: string): boolean =>
|
|
1611
|
+
!binding.startsWith("type ") && entry.names.includes(binding.split(/\s+/)[0] ?? "")
|
|
1612
|
+
const moved = bindings.filter(moves)
|
|
1613
|
+
if (moved.length === 0) continue
|
|
1614
|
+
sites.push({
|
|
1615
|
+
index: m.index,
|
|
1616
|
+
end: m.index + m[0].length,
|
|
1617
|
+
keyword: m[1] ?? "import",
|
|
1618
|
+
quote: m[3] ?? '"',
|
|
1619
|
+
from: entry.from,
|
|
1620
|
+
to: entry.to,
|
|
1621
|
+
kept: bindings.filter((binding) => !moves(binding)),
|
|
1622
|
+
moved,
|
|
1623
|
+
})
|
|
1624
|
+
}
|
|
1625
|
+
return sites
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/** Point every moved binding at the module it moved to, leaving the rest of its statement in place.
|
|
1629
|
+
* Returns `content` unchanged when nothing moved, so applying it twice is a no-op. Pure. */
|
|
1630
|
+
export function rewriteMovedExports(content: string): string {
|
|
1631
|
+
let out = content
|
|
1632
|
+
for (const site of movedExportSites(content).reverse()) {
|
|
1633
|
+
const semicolon = content[site.end - 1] === ";" ? ";" : ""
|
|
1634
|
+
const statement = (bindings: readonly string[], from: string): string =>
|
|
1635
|
+
`${site.keyword} { ${bindings.join(", ")} } from ${site.quote}${from}${site.quote}${semicolon}`
|
|
1636
|
+
const replacement =
|
|
1637
|
+
site.kept.length === 0
|
|
1638
|
+
? statement(site.moved, site.to)
|
|
1639
|
+
: `${statement(site.kept, site.from)}\n${statement(site.moved, site.to)}`
|
|
1640
|
+
out = out.slice(0, site.index) + replacement + out.slice(site.end)
|
|
1641
|
+
}
|
|
1414
1642
|
return out
|
|
1415
1643
|
}
|
|
1416
1644
|
|
|
1417
|
-
/** Scan a
|
|
1418
|
-
*
|
|
1419
|
-
* the offending `specifier` so the diagnostic can render the `
|
|
1645
|
+
/** Scan a browser module for server-only imports, static or a literal dynamic `import()`. Returns `[]`
|
|
1646
|
+
* for server code ({@link isBrowserSource}), where a server-only import is fine. Each finding carries
|
|
1647
|
+
* the offending `specifier` so the diagnostic can render the `file → specifier` chain. Pure. */
|
|
1420
1648
|
export function scanServerOnlyImports(
|
|
1421
1649
|
file: string,
|
|
1422
1650
|
content: string,
|
|
1423
1651
|
facts?: SourceFacts,
|
|
1424
1652
|
): ServerImportFinding[] {
|
|
1425
|
-
if (!
|
|
1653
|
+
if (!isBrowserSource(file)) return []
|
|
1426
1654
|
const out: ServerImportFinding[] = []
|
|
1427
1655
|
const lines = content.split("\n")
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
|
|
1432
|
-
if (positions[m.index] === " ") continue
|
|
1433
|
-
const specifier = m[1] ?? ""
|
|
1656
|
+
// Inline `import { type X } from "…"` is erased just like `import type` and is not an edge; a parse
|
|
1657
|
+
// failure keeps the lexical edge so the security rule fails closed.
|
|
1658
|
+
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
1434
1659
|
if (!SERVER_ONLY.test(specifier)) continue
|
|
1435
|
-
|
|
1436
|
-
const source = facts.parse(file, content)
|
|
1437
|
-
// Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
|
|
1438
|
-
// runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
|
|
1439
|
-
if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
|
|
1440
|
-
continue
|
|
1441
|
-
}
|
|
1442
|
-
const line = lineAt(content, m.index)
|
|
1660
|
+
const line = lineAt(content, index)
|
|
1443
1661
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
|
|
1444
1662
|
}
|
|
1445
1663
|
return out
|
|
@@ -1460,11 +1678,11 @@ export function scanServerOnlyImports(
|
|
|
1460
1678
|
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
1461
1679
|
const SERVER_ONLY_SINK =
|
|
1462
1680
|
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
|
|
1463
|
-
// The
|
|
1464
|
-
const
|
|
1465
|
-
// The explicit poison-import marker (`@nifrajs/web/server-only`) - a
|
|
1466
|
-
// guard. A resolved file
|
|
1467
|
-
const
|
|
1681
|
+
// The retired `*.server` convention. Builds refuse such a module in browser code rather than empty it.
|
|
1682
|
+
const LEGACY_SERVER_FILE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
1683
|
+
// The explicit poison-import marker (`@nifrajs/web/backend-only`, or its retired `server-only` name) - a
|
|
1684
|
+
// module opting into the client-leak guard. A resolved file carrying this side-effect import is a sink.
|
|
1685
|
+
const BACKEND_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/(?:backend|server)-only["']/
|
|
1468
1686
|
// Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
|
|
1469
1687
|
// few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
|
|
1470
1688
|
const TRANSITIVE_MAX_DEPTH = 8
|
|
@@ -1489,11 +1707,11 @@ function staticImportEdges(
|
|
|
1489
1707
|
content: string,
|
|
1490
1708
|
facts?: SourceFacts,
|
|
1491
1709
|
file?: string,
|
|
1710
|
+
positions: string = codePositionMask(content),
|
|
1492
1711
|
): Array<{ specifier: string; index: number }> {
|
|
1493
1712
|
const edges: Array<{ specifier: string; index: number }> = []
|
|
1494
1713
|
const re = staticImportRegex()
|
|
1495
1714
|
const code = stripComments(content)
|
|
1496
|
-
const positions = codePositionMask(content)
|
|
1497
1715
|
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
1498
1716
|
if (positions[m.index] === " ") continue
|
|
1499
1717
|
if (m[1] === undefined) continue
|
|
@@ -1512,6 +1730,68 @@ export function parseStaticImports(content: string, facts?: SourceFacts, file?:
|
|
|
1512
1730
|
return staticImportEdges(content, facts, file).map((e) => e.specifier)
|
|
1513
1731
|
}
|
|
1514
1732
|
|
|
1733
|
+
/** The literal dynamic `import("x")` calls in executable code, skipping type positions. With `facts`
|
|
1734
|
+
* the parser decides; without it (or when it cannot tell) the lexical rule does. Pure. */
|
|
1735
|
+
function dynamicImportEdges(
|
|
1736
|
+
content: string,
|
|
1737
|
+
facts?: SourceFacts,
|
|
1738
|
+
file?: string,
|
|
1739
|
+
positions: string = codePositionMask(content),
|
|
1740
|
+
): Array<{ specifier: string; index: number }> {
|
|
1741
|
+
const edges: Array<{ specifier: string; index: number }> = []
|
|
1742
|
+
const re = new RegExp(DYNAMIC_IMPORT.source, DYNAMIC_IMPORT.flags)
|
|
1743
|
+
for (let m = re.exec(content); m !== null; m = re.exec(content)) {
|
|
1744
|
+
if (positions[m.index] === " ") continue // inside a comment or a string
|
|
1745
|
+
const specifier = m[2]
|
|
1746
|
+
if (specifier === undefined) continue
|
|
1747
|
+
const source =
|
|
1748
|
+
facts !== undefined && file !== undefined ? facts.parse(file, content) : undefined
|
|
1749
|
+
const verdict =
|
|
1750
|
+
source === undefined || facts === undefined
|
|
1751
|
+
? undefined
|
|
1752
|
+
: facts.isDynamicImportAt(source, m.index, specifier)
|
|
1753
|
+
if (verdict === false) continue
|
|
1754
|
+
if (verdict === undefined) {
|
|
1755
|
+
if (TYPE_QUERY_BEFORE.test(content.slice(Math.max(0, m.index - 16), m.index))) continue
|
|
1756
|
+
const end = m.index + m[0].length
|
|
1757
|
+
if (m[0].endsWith(")") && TYPE_QUALIFIER_AFTER.test(content.slice(end, end + 64))) continue
|
|
1758
|
+
}
|
|
1759
|
+
edges.push({ specifier, index: m.index })
|
|
1760
|
+
}
|
|
1761
|
+
return edges
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** Every import a module's runtime code makes - static and literal dynamic - in source order. Pure. */
|
|
1765
|
+
function importEdges(
|
|
1766
|
+
content: string,
|
|
1767
|
+
facts?: SourceFacts,
|
|
1768
|
+
file?: string,
|
|
1769
|
+
): Array<{ specifier: string; index: number }> {
|
|
1770
|
+
// Without an `import(` there is nothing for the dynamic pass to find; skip its regex.
|
|
1771
|
+
const positions = codePositionMask(content)
|
|
1772
|
+
const edges = staticImportEdges(content, facts, file, positions)
|
|
1773
|
+
if (!content.includes("import(") && !/import\s+\(/.test(content)) return edges
|
|
1774
|
+
return [...edges, ...dynamicImportEdges(content, facts, file, positions)].sort(
|
|
1775
|
+
(a, b) => a.index - b.index,
|
|
1776
|
+
)
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/** The runtime imports of a module with the line each sits on, in source order. Pure. */
|
|
1780
|
+
export function importSites(
|
|
1781
|
+
content: string,
|
|
1782
|
+
file?: string,
|
|
1783
|
+
): Array<{ readonly specifier: string; readonly line: number }> {
|
|
1784
|
+
return importEdges(content, undefined, file).map(({ specifier, index }) => ({
|
|
1785
|
+
specifier,
|
|
1786
|
+
line: lineAt(content, index),
|
|
1787
|
+
}))
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** The specifiers {@link importEdges} finds, static and literal dynamic, in source order. Pure. */
|
|
1791
|
+
export function parseImports(content: string, facts?: SourceFacts, file?: string): string[] {
|
|
1792
|
+
return importEdges(content, facts, file).map((e) => e.specifier)
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1515
1795
|
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
1516
1796
|
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
1517
1797
|
* itself (it's already the actionable name). */
|
|
@@ -1540,10 +1820,11 @@ export type ModuleReader = (absPath: string) => string | undefined
|
|
|
1540
1820
|
/**
|
|
1541
1821
|
* BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
|
|
1542
1822
|
* sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
|
|
1543
|
-
* node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
|
|
1823
|
+
* node's outgoing edges are its static and literal dynamic imports; an edge is followed only when it's a RELATIVE specifier
|
|
1544
1824
|
* that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
|
|
1545
1825
|
* unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
|
|
1546
|
-
*
|
|
1826
|
+
* `server-only`-marked dependency terminates the chain; a `*.server` dependency is never entered, since
|
|
1827
|
+
* the client build empties it. Bounded by depth + a visited set, so
|
|
1547
1828
|
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
1548
1829
|
* - pure given those, so it's unit-testable with a fake graph.
|
|
1549
1830
|
*/
|
|
@@ -1569,7 +1850,7 @@ export function walkServerOnlyChain(
|
|
|
1569
1850
|
const next: Node[] = []
|
|
1570
1851
|
for (const node of frontier) {
|
|
1571
1852
|
if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
|
|
1572
|
-
for (const spec of
|
|
1853
|
+
for (const spec of parseImports(node.content, facts, node.abs)) {
|
|
1573
1854
|
// (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
|
|
1574
1855
|
// since BFS reaches the nearest sink before any deeper one).
|
|
1575
1856
|
const sink = directSinkSpecifier(spec)
|
|
@@ -1579,13 +1860,14 @@ export function walkServerOnlyChain(
|
|
|
1579
1860
|
if (!isRelativeSpecifier(spec)) continue
|
|
1580
1861
|
const abs = resolve(node.abs, spec)
|
|
1581
1862
|
if (abs === undefined || seen.has(abs)) continue
|
|
1582
|
-
// (c)
|
|
1583
|
-
//
|
|
1584
|
-
if (
|
|
1863
|
+
// (c) Backend code is a sink: the browser build refuses it. A `*.fn.ts` module ships as its
|
|
1864
|
+
// generated stub, so its imports never reach the browser and the walk does not enter it.
|
|
1865
|
+
if (isBackendModule(abs)) return [...node.chain, spec]
|
|
1866
|
+
if (SERVER_FN_FILE.test(abs)) continue
|
|
1585
1867
|
const content = read(abs)
|
|
1586
1868
|
if (content === undefined) continue // unreadable → can't walk; treat as a leaf
|
|
1587
|
-
// (d) A resolved module that opts into the `
|
|
1588
|
-
if (
|
|
1869
|
+
// (d) A resolved module that opts into the `backend-only` marker is a sink too.
|
|
1870
|
+
if (BACKEND_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
|
|
1589
1871
|
if (visited >= TRANSITIVE_MAX_VISITED) continue
|
|
1590
1872
|
visited++
|
|
1591
1873
|
seen.add(abs)
|
|
@@ -1612,14 +1894,14 @@ export function resolveServerOnlyChains(
|
|
|
1612
1894
|
read: ModuleReader,
|
|
1613
1895
|
facts?: SourceFacts,
|
|
1614
1896
|
): TransitiveServerImportFinding[] {
|
|
1615
|
-
if (!
|
|
1897
|
+
if (!isBrowserSource(file)) return []
|
|
1616
1898
|
const lines = content.split("\n")
|
|
1617
1899
|
const out: TransitiveServerImportFinding[] = []
|
|
1618
1900
|
const flaggedSpecifiers = new Set<string>()
|
|
1619
1901
|
// Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
|
|
1620
1902
|
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1621
1903
|
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1622
|
-
for (const { specifier, index } of
|
|
1904
|
+
for (const { specifier, index } of importEdges(content, facts, file)) {
|
|
1623
1905
|
if (flaggedSpecifiers.has(specifier)) continue
|
|
1624
1906
|
const line = lineAt(content, index)
|
|
1625
1907
|
const snippet = (lines[line - 1] ?? "").trim()
|
|
@@ -1644,15 +1926,16 @@ export function resolveServerOnlyChains(
|
|
|
1644
1926
|
}
|
|
1645
1927
|
continue
|
|
1646
1928
|
}
|
|
1647
|
-
//
|
|
1648
|
-
if (
|
|
1929
|
+
// A backend first hop and a marked module are sinks; a `*.fn.ts` hop ships as its stub.
|
|
1930
|
+
if (isBackendModule(abs)) {
|
|
1649
1931
|
flaggedSpecifiers.add(specifier)
|
|
1650
1932
|
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1651
1933
|
continue
|
|
1652
1934
|
}
|
|
1935
|
+
if (SERVER_FN_FILE.test(abs)) continue
|
|
1653
1936
|
const depContent = read(abs)
|
|
1654
1937
|
if (depContent === undefined) continue
|
|
1655
|
-
if (
|
|
1938
|
+
if (BACKEND_ONLY_MARKER_IMPORT.test(depContent)) {
|
|
1656
1939
|
flaggedSpecifiers.add(specifier)
|
|
1657
1940
|
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
1658
1941
|
continue
|
|
@@ -1767,7 +2050,11 @@ async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<str
|
|
|
1767
2050
|
export async function walkSource(
|
|
1768
2051
|
cwd: string,
|
|
1769
2052
|
visit: (rel: string, content: string) => void,
|
|
1770
|
-
opts: {
|
|
2053
|
+
opts: {
|
|
2054
|
+
readonly includeTests?: boolean
|
|
2055
|
+
/** Additional project-relative paths to exclude from this scan. */
|
|
2056
|
+
readonly ignore?: (relativePath: string) => boolean
|
|
2057
|
+
} = {},
|
|
1771
2058
|
): Promise<void> {
|
|
1772
2059
|
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
|
|
1773
2060
|
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
@@ -1775,17 +2062,18 @@ export async function walkSource(
|
|
|
1775
2062
|
const rels: string[] = []
|
|
1776
2063
|
for await (const rawRel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
|
|
1777
2064
|
const rel = normalizeProjectPath(rawRel)
|
|
1778
|
-
if (!skip.test(rel)) rels.push(rel)
|
|
2065
|
+
if (!skip.test(rel) && (opts.ignore === undefined || !opts.ignore(rel))) rels.push(rel)
|
|
1779
2066
|
}
|
|
1780
2067
|
const ignored = await gitIgnored(cwd, rels)
|
|
1781
|
-
|
|
2068
|
+
// Glob order is the filesystem's, so findings would come out in a different order on each platform.
|
|
2069
|
+
for (const rel of rels.sort(codeUnitOrder)) {
|
|
1782
2070
|
if (ignored.has(rel)) continue
|
|
1783
2071
|
visit(rel, await Bun.file(join(cwd, rel)).text())
|
|
1784
2072
|
}
|
|
1785
2073
|
}
|
|
1786
2074
|
|
|
1787
2075
|
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|
|
1788
|
-
a.file
|
|
2076
|
+
codeUnitOrder(a.file, b.file) || a.line - b.line
|
|
1789
2077
|
|
|
1790
2078
|
/** Collect own-API `fetch()` findings across the project. */
|
|
1791
2079
|
export async function scanProject(cwd: string): Promise<SourceFinding[]> {
|
|
@@ -1805,8 +2093,11 @@ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
|
|
|
1805
2093
|
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
|
|
1806
2094
|
// locate the routes dir relative to the manifest, and to strip to route-relative keys.
|
|
1807
2095
|
const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
|
|
1808
|
-
//
|
|
2096
|
+
// The files route discovery collects (mirrors `@nifrajs/web/fs`'s filter): route files by extension,
|
|
2097
|
+
// each route's backend half, and `_middleware` modules.
|
|
1809
2098
|
const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
|
|
2099
|
+
const ROUTE_BACKEND_HALF = /\.backend\.(?:[cm]?[jt]s)$/
|
|
2100
|
+
const MIDDLEWARE_FILE = /(?:^|\/)_middleware\.(?:ts|js)$/
|
|
1810
2101
|
|
|
1811
2102
|
export interface ManifestDriftFinding {
|
|
1812
2103
|
/** The committed server-manifest file (relative to cwd). */
|
|
@@ -1845,10 +2136,14 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
|
|
|
1845
2136
|
let discovered: string[]
|
|
1846
2137
|
try {
|
|
1847
2138
|
discovered = (
|
|
1848
|
-
await Array.fromAsync(
|
|
2139
|
+
await Array.fromAsync(
|
|
2140
|
+
new Glob("**/*.{tsx,jsx,svelte,vue,mdx,ts,js}").scan({ cwd: routesDir }),
|
|
2141
|
+
)
|
|
1849
2142
|
)
|
|
1850
2143
|
.map((f) => f.replaceAll("\\", "/"))
|
|
1851
|
-
.filter(
|
|
2144
|
+
.filter(
|
|
2145
|
+
(f) => ROUTE_FILE_EXT.test(f) || ROUTE_BACKEND_HALF.test(f) || MIDDLEWARE_FILE.test(f),
|
|
2146
|
+
)
|
|
1852
2147
|
} catch {
|
|
1853
2148
|
continue // routes dir gone/unreadable - not a drift we can assess
|
|
1854
2149
|
}
|
|
@@ -1857,5 +2152,5 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
|
|
|
1857
2152
|
findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
|
|
1858
2153
|
}
|
|
1859
2154
|
}
|
|
1860
|
-
return findings.sort((a, b) => a.file
|
|
2155
|
+
return findings.sort((a, b) => codeUnitOrder(a.file, b.file))
|
|
1861
2156
|
}
|