@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/rules/legacy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
|
+
import { reservedKeyFor } from "@nifrajs/client"
|
|
3
4
|
import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
|
|
4
5
|
import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
|
|
5
6
|
import { SINGLE_COPY_REGISTER_SPECIFIER } from "@nifrajs/core/single-copy"
|
|
@@ -7,6 +8,7 @@ import type { CapabilityProjectReport } from "../capabilities-tool.ts"
|
|
|
7
8
|
import type { SourceFinding, StaticRouteFinding } from "../check-scan.ts"
|
|
8
9
|
import {
|
|
9
10
|
IDENT,
|
|
11
|
+
movedExportSites,
|
|
10
12
|
parseSimpleFetchCall,
|
|
11
13
|
REMOVED_IMPORTS,
|
|
12
14
|
SIMPLE_REWRITE_METHODS,
|
|
@@ -19,6 +21,8 @@ import {
|
|
|
19
21
|
diagnosticWithCompatibility,
|
|
20
22
|
} from "../diagnostics.ts"
|
|
21
23
|
import type { DuplicateInstallFinding } from "../doctor.ts"
|
|
24
|
+
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
25
|
+
import { unsupportedTypeScriptDiagnostic } from "../internal/typescript-import.ts"
|
|
22
26
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
23
27
|
|
|
24
28
|
/** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
|
|
@@ -89,7 +93,7 @@ const FETCH_HINT =
|
|
|
89
93
|
const STREAM_HINT =
|
|
90
94
|
"hand-rolled EventSource/WebSocket to your own API - subscribe through client<typeof app> (`.subscribe()` for `app.sse()` routes, `.ws()` for `app.ws()` routes) so the compiler catches drift"
|
|
91
95
|
const SERVER_IMPORT_HINT =
|
|
92
|
-
"server-only
|
|
96
|
+
"server-only code in browser code (a route's frontend half, frontend/ or shared/) - reach it from the route's backend half (x.backend.ts) or a *.fn.ts server function; a dynamic import() is bundled too"
|
|
93
97
|
const RESPONSE_ROUTE_HINT =
|
|
94
98
|
"route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
|
|
95
99
|
const PIPELINE_DOC_HINT =
|
|
@@ -121,6 +125,8 @@ interface LegacyFields {
|
|
|
121
125
|
readonly evidence?: readonly string[]
|
|
122
126
|
readonly chain?: readonly string[]
|
|
123
127
|
readonly fix?: string
|
|
128
|
+
/** A recipe for this one finding, when the rule's findings are not all mechanically fixable. */
|
|
129
|
+
readonly recipe?: Diagnostic["fix"]
|
|
124
130
|
readonly suggestion?: DiagnosticSuggestion
|
|
125
131
|
readonly verify?: string
|
|
126
132
|
readonly includeCode?: boolean
|
|
@@ -141,7 +147,7 @@ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
|
|
|
141
147
|
const code = LEGACY_RULE_CODES[rule]
|
|
142
148
|
if (code === undefined) throw new Error(`unknown legacy rule ${rule}`)
|
|
143
149
|
const evidence = fields.evidence ?? fields.chain
|
|
144
|
-
const fix = canonicalRecipe(rule)
|
|
150
|
+
const fix = fields.recipe ?? canonicalRecipe(rule)
|
|
145
151
|
const canonical: Diagnostic = {
|
|
146
152
|
code,
|
|
147
153
|
severity: fields.severity === "warning" ? "warn" : fields.severity,
|
|
@@ -163,7 +169,7 @@ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
|
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|
|
166
|
-
a.file
|
|
172
|
+
codeUnitOrder(a.file, b.file) || a.line - b.line
|
|
167
173
|
|
|
168
174
|
function oneLineDiff(file: string, line: number, before: string, after: string): string {
|
|
169
175
|
return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
|
|
@@ -198,7 +204,10 @@ function typedClientCall(method: string, path: string): string {
|
|
|
198
204
|
if (segs.length === 0) chain += ".index"
|
|
199
205
|
else {
|
|
200
206
|
for (const seg of segs) {
|
|
201
|
-
|
|
207
|
+
// The proxy answers a reserved name (`post`, `index`, `then`...) itself, so that segment is
|
|
208
|
+
// appended by a call instead of a property read.
|
|
209
|
+
if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
|
|
210
|
+
else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
213
|
return `${chain}.${method.toLowerCase()}()`
|
|
@@ -285,12 +294,13 @@ function serverImportSuggestion(
|
|
|
285
294
|
: undefined
|
|
286
295
|
return {
|
|
287
296
|
kind: "manual",
|
|
288
|
-
title: "Move server-only code behind the route
|
|
297
|
+
title: "Move server-only code behind the route's backend half",
|
|
289
298
|
steps: [
|
|
290
299
|
...(chainStep === undefined ? [] : [chainStep]),
|
|
291
|
-
`Remove the
|
|
292
|
-
"
|
|
293
|
-
|
|
300
|
+
`Remove the import of "${specifier}" from this module: it is browser code, bundled for the browser.`,
|
|
301
|
+
"Do the backend work in the route's backend half (`x.backend.ts` - its `loader` or `action`) and pass the result as loader data, or call a server function (`*.fn.ts`).",
|
|
302
|
+
"Code both sides need belongs in `shared/`, which may import only shared code and third-party packages.",
|
|
303
|
+
"A dynamic `import()` does not keep code off the client: the build bundles its target as a lazy chunk.",
|
|
294
304
|
],
|
|
295
305
|
}
|
|
296
306
|
}
|
|
@@ -398,7 +408,7 @@ const typedClientRule: CheckRule = {
|
|
|
398
408
|
}),
|
|
399
409
|
)
|
|
400
410
|
return [...fetchFindings, ...streamFindings].sort(
|
|
401
|
-
(a, b) => (a.file ?? ""
|
|
411
|
+
(a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
|
|
402
412
|
)
|
|
403
413
|
},
|
|
404
414
|
}
|
|
@@ -408,6 +418,18 @@ const removedImportRule: CheckRule = {
|
|
|
408
418
|
title: TITLES["removed-import"]!,
|
|
409
419
|
async scan(ctx) {
|
|
410
420
|
return [...ctx.project.sourceFindings.removedImports].sort(bySite).map((finding) => {
|
|
421
|
+
const moved = movedExportSites(finding.snippet)[0]
|
|
422
|
+
if (moved !== undefined) {
|
|
423
|
+
const names = moved.moved.map((binding) => `\`${binding.split(/\s+/)[0]}\``).join(", ")
|
|
424
|
+
return legacyDiagnostic("removed-import", {
|
|
425
|
+
severity: "error",
|
|
426
|
+
file: finding.file,
|
|
427
|
+
line: finding.line,
|
|
428
|
+
message: `${finding.snippet} - ${names} moved from "${moved.from}" to "${moved.to}"`,
|
|
429
|
+
fix: `import ${names} from "${moved.to}" - \`nifra fix --code NF-C005\` rewrites it`,
|
|
430
|
+
recipe: { recipe: "imports.moved-export", command: "nifra fix --code NF-C005" },
|
|
431
|
+
})
|
|
432
|
+
}
|
|
411
433
|
const entry = REMOVED_IMPORTS.find(
|
|
412
434
|
(candidate) =>
|
|
413
435
|
finding.snippet.includes(`"${candidate.specifier}`) ||
|
|
@@ -467,7 +489,9 @@ const interpolatedSqlRule: CheckRule = {
|
|
|
467
489
|
title: TITLES["interpolated-sql"]!,
|
|
468
490
|
async scan(ctx) {
|
|
469
491
|
const findings: Diagnostic[] = []
|
|
470
|
-
if (
|
|
492
|
+
if (ctx.project.check.unsupportedTypeScriptVersion !== undefined) {
|
|
493
|
+
findings.push(unsupportedTypeScriptDiagnostic(ctx.project.check.unsupportedTypeScriptVersion))
|
|
494
|
+
} else if (!ctx.project.check.sqlCompilerAvailable) {
|
|
471
495
|
findings.push(
|
|
472
496
|
legacyDiagnostic("interpolated-sql", {
|
|
473
497
|
severity: "warning",
|
|
@@ -528,7 +552,7 @@ const responseRouteRule: CheckRule = {
|
|
|
528
552
|
function copyLines(finding: DuplicateInstallFinding): string[] {
|
|
529
553
|
return finding.copies.map(
|
|
530
554
|
(copy) =>
|
|
531
|
-
`${finding.package}@${copy.version} at ${copy.absolutePath ?? copy.path} - pulled in by ${copy.importers.join(", ")}`,
|
|
555
|
+
`${finding.package}@${copy.version} at ${copy.absolutePath ?? copy.path} - pulled in by ${copy.importers.join(", ")}${copy.links === undefined ? "" : ` through the symlink ${copy.links.join(", ")}`}`,
|
|
532
556
|
)
|
|
533
557
|
}
|
|
534
558
|
|
|
@@ -547,6 +571,9 @@ function duplicateSuggestion(finding: DuplicateInstallFinding): DiagnosticSugges
|
|
|
547
571
|
].join("\n"),
|
|
548
572
|
steps: [
|
|
549
573
|
...lines.map((line) => `Copy: ${line}`),
|
|
574
|
+
...(finding.provenance === undefined
|
|
575
|
+
? []
|
|
576
|
+
: [`Planted links: ${finding.provenance} Try this before the fixes below.`]),
|
|
550
577
|
"Fix 1 - deduplicate: align workspace dependency and peer ranges on one compatible version, remove stale nested installs, and reinstall from the workspace root so every importer resolves one physical copy.",
|
|
551
578
|
`Fix 2 - declare single-copy: apply the package.json and bunfig.toml config printed above; nifra then rewrites every duplicate to this app's copy.${finding.cause === "version-skew" ? " A declaration only covers same-version duplicates, so fix 1's range alignment must land first." : ""}`,
|
|
552
579
|
"Re-run `nifra check`; the gate stays failing until one fix lands.",
|
|
@@ -596,16 +623,6 @@ const doctorRules: readonly CheckRule[] = Object.freeze([
|
|
|
596
623
|
}),
|
|
597
624
|
)
|
|
598
625
|
}
|
|
599
|
-
for (const finding of doctor.deduplicatedInstalls ?? []) {
|
|
600
|
-
const paths = finding.copies.map((copy) => copy.path).join("; ")
|
|
601
|
-
findings.push(
|
|
602
|
-
legacyDiagnostic("duplicate-install", {
|
|
603
|
-
severity: "warning",
|
|
604
|
-
message: `${finding.package} is installed at ${finding.copies.length} paths (${paths}) and is declared single-copy - nifra resolves every duplicate to this app's copy`,
|
|
605
|
-
fix: finding.remediation,
|
|
606
|
-
}),
|
|
607
|
-
)
|
|
608
|
-
}
|
|
609
626
|
return findings
|
|
610
627
|
},
|
|
611
628
|
},
|
|
@@ -732,6 +749,11 @@ const capabilityAssuranceRule: CheckRule = {
|
|
|
732
749
|
finding.code === "unmatched-provenance-seam"
|
|
733
750
|
? "Write the seam exactly as the code imports it, or delete the rule."
|
|
734
751
|
: undefined
|
|
752
|
+
const mountFix =
|
|
753
|
+
finding.code === "opaque-mount-undeclared"
|
|
754
|
+
? 'merge() a nifra server() instead of mounting it, or add { opaque: "<why it cannot be analyzed>" } to the mount.'
|
|
755
|
+
: undefined
|
|
756
|
+
const ownFix = seamFix ?? mountFix
|
|
735
757
|
findings.push(
|
|
736
758
|
legacyDiagnostic("capability-assurance", {
|
|
737
759
|
severity: "error",
|
|
@@ -740,30 +762,40 @@ const capabilityAssuranceRule: CheckRule = {
|
|
|
740
762
|
? {}
|
|
741
763
|
: { chain: truncation.chain }
|
|
742
764
|
: { file: violation.module, chain: violation.chain }),
|
|
743
|
-
message: `${finding.message}${
|
|
744
|
-
fix:
|
|
765
|
+
message: `${finding.message}${ownFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
|
|
766
|
+
fix: ownFix ?? CAPABILITY_HINT,
|
|
745
767
|
suggestion:
|
|
746
|
-
|
|
768
|
+
mountFix !== undefined
|
|
747
769
|
? {
|
|
748
770
|
kind: "manual",
|
|
749
|
-
title: "
|
|
771
|
+
title: "Analyze the mounted routes, or state why they cannot be",
|
|
750
772
|
steps: [
|
|
751
|
-
"
|
|
752
|
-
|
|
753
|
-
"
|
|
754
|
-
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
773
|
+
"If the child is a nifra server() you own, merge() it so its routes join the analyzed app.",
|
|
774
|
+
'If its effects live in code nifra cannot follow (a handler closure from another package), add { opaque: "<reason>" } to the mount.',
|
|
775
|
+
"A declared opaque mount is listed as a known gap on every run; it no longer fails assurance.",
|
|
755
776
|
],
|
|
756
777
|
}
|
|
757
|
-
:
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
778
|
+
: seamFix === undefined
|
|
779
|
+
? {
|
|
780
|
+
kind: "manual",
|
|
781
|
+
title: "Restore declared effect provenance",
|
|
782
|
+
steps: [
|
|
783
|
+
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
784
|
+
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
785
|
+
"For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
|
|
786
|
+
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
787
|
+
],
|
|
788
|
+
}
|
|
789
|
+
: {
|
|
790
|
+
kind: "manual",
|
|
791
|
+
title: "Point the provenance rule at a module that exists",
|
|
792
|
+
steps: [
|
|
793
|
+
"Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
|
|
794
|
+
"Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
|
|
795
|
+
"For a routeModules entry, give the project-relative path of the file that implements the route.",
|
|
796
|
+
"Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
|
|
797
|
+
],
|
|
798
|
+
},
|
|
767
799
|
}),
|
|
768
800
|
)
|
|
769
801
|
}
|
package/src/rules/nano.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type * as TSApi from "typescript"
|
|
2
2
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
3
|
-
import {
|
|
3
|
+
import { scriptKindOf } from "../internal/script-kind.ts"
|
|
4
4
|
import type { CheckRule, SourceIndex } from "./index.ts"
|
|
5
|
+
import { loadRuleTypeScript } from "./typescript.ts"
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The nano lane lints (`@nifrajs/web/nano`). nano is deliberately explicit - every reactive edge is a
|
|
@@ -38,8 +39,13 @@ function parsedTree(
|
|
|
38
39
|
files.set(file, null)
|
|
39
40
|
return undefined
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
42
|
+
const tree = ts.createSourceFile(
|
|
43
|
+
file,
|
|
44
|
+
source,
|
|
45
|
+
ts.ScriptTarget.Latest,
|
|
46
|
+
true,
|
|
47
|
+
scriptKindOf(ts, file),
|
|
48
|
+
)
|
|
43
49
|
files.set(file, { tree })
|
|
44
50
|
return tree
|
|
45
51
|
}
|
|
@@ -165,7 +171,9 @@ export const nanoBindCleanupRule: CheckRule = {
|
|
|
165
171
|
code: "NF-C021",
|
|
166
172
|
title: "nano binding cleanup check",
|
|
167
173
|
async scan(ctx) {
|
|
168
|
-
const
|
|
174
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
175
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
176
|
+
const ts = loaded.compiler
|
|
169
177
|
if (ts === undefined) return [toolchainMissing("NF-C021")]
|
|
170
178
|
const findings: Diagnostic[] = []
|
|
171
179
|
for (const file of ctx.project.source.files) {
|
|
@@ -205,7 +213,9 @@ export const nanoListKeyRule: CheckRule = {
|
|
|
205
213
|
code: "NF-C022",
|
|
206
214
|
title: "nano bindList key check",
|
|
207
215
|
async scan(ctx) {
|
|
208
|
-
const
|
|
216
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
217
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
218
|
+
const ts = loaded.compiler
|
|
209
219
|
if (ts === undefined) return [toolchainMissing("NF-C022")]
|
|
210
220
|
const findings: Diagnostic[] = []
|
|
211
221
|
for (const file of ctx.project.source.files) {
|
|
@@ -246,7 +256,9 @@ export const nanoComputedDepsRule: CheckRule = {
|
|
|
246
256
|
code: "NF-C023",
|
|
247
257
|
title: "nano computed deps check",
|
|
248
258
|
async scan(ctx) {
|
|
249
|
-
const
|
|
259
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
260
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
261
|
+
const ts = loaded.compiler
|
|
250
262
|
if (ts === undefined) return [toolchainMissing("NF-C023")]
|
|
251
263
|
const findings: Diagnostic[] = []
|
|
252
264
|
for (const file of ctx.project.source.files) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NF-C027: a page file whose URL sits under the backend's mount path.
|
|
3
|
+
*
|
|
4
|
+
* `backend/app.ts`'s backend is mounted at `apiPrefix` (default `/api`) ahead of page routing, and
|
|
5
|
+
* its 404 is final, so `routes/api/report.tsx` never renders - every request for it gets the
|
|
6
|
+
* backend's 404. `createWebApp` refuses such an app at startup and `nifra build` refuses to build
|
|
7
|
+
* it; this reports it earlier, without running app code (check's pre-`loadApp` invariant): the
|
|
8
|
+
* routes come from a scan of `routes/`, and `apiPrefix` is read only when backend/framework.ts
|
|
9
|
+
* declares it as a string literal.
|
|
10
|
+
*
|
|
11
|
+
* Mounts other than the backend's hold `app` objects no static read can see, so they are left to the
|
|
12
|
+
* startup and build checks.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
|
+
import { shadowedPages } from "@nifrajs/web/route-manifest"
|
|
18
|
+
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
|
|
19
|
+
import { stripComments } from "../check-scan.ts"
|
|
20
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
21
|
+
import type { CheckRule } from "./index.ts"
|
|
22
|
+
|
|
23
|
+
/** What a static read of a framework config says about `apiPrefix`. */
|
|
24
|
+
export type StaticApiPrefix =
|
|
25
|
+
| { readonly kind: "default" }
|
|
26
|
+
| { readonly kind: "literal"; readonly value: string }
|
|
27
|
+
| { readonly kind: "unreadable" }
|
|
28
|
+
|
|
29
|
+
const LITERAL_EXPORT =
|
|
30
|
+
/\bexport\s+const\s+apiPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')\s*(?:as\s+const\s*)?(?:;|\r?\n|$)/
|
|
31
|
+
const MENTION = /\bapiPrefix\b/
|
|
32
|
+
const BACKEND_EXPORT =
|
|
33
|
+
/\bexport\s+(?:const|let|var|function|class)\s+backend\b|\bexport\s*\{[^}]*\bbackend\b[^}]*\}/
|
|
34
|
+
|
|
35
|
+
/** Read `apiPrefix` from a framework config's source without running it. Pure. */
|
|
36
|
+
export function readStaticApiPrefix(source: string): StaticApiPrefix {
|
|
37
|
+
const code = stripComments(source)
|
|
38
|
+
const literal = LITERAL_EXPORT.exec(code)
|
|
39
|
+
if (literal !== null) return { kind: "literal", value: literal[1] ?? literal[2] ?? "" }
|
|
40
|
+
return MENTION.test(code) ? { kind: "unreadable" } : { kind: "default" }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONFIG_FILES = [FRAMEWORK_FILE, CONFIG_FILE] as const
|
|
44
|
+
|
|
45
|
+
export const shadowedPageRule: CheckRule = {
|
|
46
|
+
code: "NF-C027",
|
|
47
|
+
title: "Page route under the backend mount",
|
|
48
|
+
async scan(ctx) {
|
|
49
|
+
const backendFile = join(ctx.root, BACKEND_APP_FILE)
|
|
50
|
+
const routesDir = join(ctx.root, "routes")
|
|
51
|
+
if (!existsSync(backendFile) || !existsSync(routesDir)) return []
|
|
52
|
+
if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
|
|
53
|
+
|
|
54
|
+
let apiPrefix = "/api"
|
|
55
|
+
// backend/framework.ts first: the generated server entry imports from it, and the build refuses
|
|
56
|
+
// a nifra.config.ts whose forwarded fields differ from it.
|
|
57
|
+
const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
|
|
58
|
+
if (configFile !== undefined) {
|
|
59
|
+
const read = readStaticApiPrefix(readFileSync(join(ctx.root, configFile), "utf8"))
|
|
60
|
+
if (read.kind === "unreadable") {
|
|
61
|
+
return [
|
|
62
|
+
diagnostic({
|
|
63
|
+
code: "NF-C027",
|
|
64
|
+
severity: "info",
|
|
65
|
+
file: configFile,
|
|
66
|
+
message: `apiPrefix in ${configFile} is not a string literal, so check cannot tell which page routes sit under the backend mount; createWebApp still refuses a page under it at startup, and nifra build at build time`,
|
|
67
|
+
verify: "nifra build",
|
|
68
|
+
}),
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
if (read.kind === "literal") apiPrefix = read.value
|
|
72
|
+
}
|
|
73
|
+
if (apiPrefix === "") return []
|
|
74
|
+
|
|
75
|
+
let manifest: ReturnType<typeof discoverRoutes>
|
|
76
|
+
try {
|
|
77
|
+
manifest = discoverRoutes(routesDir)
|
|
78
|
+
} catch {
|
|
79
|
+
// A routes/ tree the manifest refuses fails dev and build with its own error.
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
const findings: Diagnostic[] = []
|
|
83
|
+
for (const page of shadowedPages(manifest, [apiPrefix])) {
|
|
84
|
+
findings.push(
|
|
85
|
+
diagnostic({
|
|
86
|
+
code: "NF-C027",
|
|
87
|
+
severity: "error",
|
|
88
|
+
file: `routes/${page.file}`,
|
|
89
|
+
message: `routes/${page.file} serves ${page.pattern}, under the backend mounted at ${page.mount} - the backend answers every request there before page routing and its 404 is final, so this page can never render. Move the file out of ${page.mount}, serve the URL from backend/app.ts, or change the mount path with \`export const apiPrefix = "/backend"\` in backend/framework.ts`,
|
|
90
|
+
evidence: [`page: ${page.pattern}`, `mount: ${page.mount}`],
|
|
91
|
+
verify: "nifra check",
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return findings
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const pageRules = Object.freeze([shadowedPageRule])
|
package/src/rules/routes.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { RESERVED_KEY_READOUT, reservedKeyFor } from "@nifrajs/client"
|
|
2
|
+
import { RoutePatternOverlapLimitError, routePatternOverlap } from "@nifrajs/core"
|
|
3
|
+
import { expandOptionalParams, paramConstraint } from "@nifrajs/core/pattern"
|
|
2
4
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
3
5
|
import { commentBlockHasMarker } from "./comment-markers.ts"
|
|
4
6
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
@@ -23,6 +25,8 @@ import type { CheckRule, RuleContext } from "./index.ts"
|
|
|
23
25
|
|
|
24
26
|
/** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
|
|
25
27
|
const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
|
|
28
|
+
const ROUTE_OVERLAP_PRAGMA = "nifra-expect route-overlap"
|
|
29
|
+
const PARAM_MODIFIER_PRAGMA = "nifra-expect param-modifier"
|
|
26
30
|
|
|
27
31
|
interface StaticRouteFact {
|
|
28
32
|
readonly file: string
|
|
@@ -50,6 +54,17 @@ function routeFacts(ctx: RuleContext): StaticRouteFact[] {
|
|
|
50
54
|
return out
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/** One fact per registration site, so `all()` or `method([...])` reports its path once, not per method. */
|
|
58
|
+
function pathSites(routes: readonly StaticRouteFact[]): StaticRouteFact[] {
|
|
59
|
+
const seen = new Set<string>()
|
|
60
|
+
return routes.filter((route) => {
|
|
61
|
+
const site = `${route.file}\n${route.line}\n${route.path}`
|
|
62
|
+
if (seen.has(site)) return false
|
|
63
|
+
seen.add(site)
|
|
64
|
+
return true
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/**
|
|
54
69
|
* The typed escape spelling for a colliding segment, e.g. `/api/delete` + `delete` →
|
|
55
70
|
* `api("delete").post()` shown as the chain up to the collision. Best-effort readability: earlier
|
|
@@ -67,7 +82,7 @@ export const reservedSegmentRule: CheckRule = {
|
|
|
67
82
|
async scan(ctx) {
|
|
68
83
|
const findings: Diagnostic[] = []
|
|
69
84
|
const linesByFile = new Map<string, readonly string[]>()
|
|
70
|
-
for (const route of routeFacts(ctx)) {
|
|
85
|
+
for (const route of pathSites(routeFacts(ctx))) {
|
|
71
86
|
for (const segment of route.path.split("/")) {
|
|
72
87
|
if (segment === "") continue
|
|
73
88
|
const collision = reservedKeyFor(segment)
|
|
@@ -112,6 +127,9 @@ export const duplicateRouteRule: CheckRule = {
|
|
|
112
127
|
async scan(ctx) {
|
|
113
128
|
const findings: Diagnostic[] = []
|
|
114
129
|
const byFile = new Map<string, Map<string, StaticRouteFact>>()
|
|
130
|
+
// Two calls that each register several methods collide once per shared method; that is one
|
|
131
|
+
// pair of lines to fix, so it is reported once.
|
|
132
|
+
const reported = new Set<string>()
|
|
115
133
|
for (const route of routeFacts(ctx)) {
|
|
116
134
|
let seen = byFile.get(route.file)
|
|
117
135
|
if (seen === undefined) {
|
|
@@ -124,6 +142,9 @@ export const duplicateRouteRule: CheckRule = {
|
|
|
124
142
|
seen.set(key, route)
|
|
125
143
|
continue
|
|
126
144
|
}
|
|
145
|
+
const pair = `${route.file}\n${route.line}\n${first.line}\n${route.path}`
|
|
146
|
+
if (reported.has(pair)) continue
|
|
147
|
+
reported.add(pair)
|
|
127
148
|
findings.push(
|
|
128
149
|
diagnostic({
|
|
129
150
|
code: "NF-C019",
|
|
@@ -140,4 +161,162 @@ export const duplicateRouteRule: CheckRule = {
|
|
|
140
161
|
},
|
|
141
162
|
}
|
|
142
163
|
|
|
143
|
-
|
|
164
|
+
/**
|
|
165
|
+
* NF-C024: two different same-method route patterns in one source file accept at least one of the
|
|
166
|
+
* same paths. This is deliberately file-scoped, like NF-C019: the static scanner cannot prove
|
|
167
|
+
* which application instance owns routes collected from different files in a monorepo.
|
|
168
|
+
*/
|
|
169
|
+
export const overlappingRouteRule: CheckRule = {
|
|
170
|
+
code: "NF-C024",
|
|
171
|
+
title: "Overlapping route registration",
|
|
172
|
+
async scan(ctx) {
|
|
173
|
+
const findings: Diagnostic[] = []
|
|
174
|
+
const linesByFile = new Map<string, readonly string[]>()
|
|
175
|
+
const byFile = new Map<string, StaticRouteFact[]>()
|
|
176
|
+
for (const route of routeFacts(ctx)) {
|
|
177
|
+
const routes = byFile.get(route.file)
|
|
178
|
+
if (routes === undefined) byFile.set(route.file, [route])
|
|
179
|
+
else routes.push(route)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// As in NF-C019: two multi-method calls overlap once per shared method, reported once.
|
|
183
|
+
const reported = new Set<string>()
|
|
184
|
+
for (const [file, routes] of byFile) {
|
|
185
|
+
const lines = (): readonly string[] => {
|
|
186
|
+
let value = linesByFile.get(file)
|
|
187
|
+
if (value === undefined) {
|
|
188
|
+
value = (ctx.project.source.read(file) ?? "").split("\n")
|
|
189
|
+
linesByFile.set(file, value)
|
|
190
|
+
}
|
|
191
|
+
return value
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (let laterIndex = 0; laterIndex < routes.length; laterIndex += 1) {
|
|
195
|
+
const later = routes[laterIndex]!
|
|
196
|
+
const laterKey = `${later.method} ${later.path}`
|
|
197
|
+
for (let earlierIndex = 0; earlierIndex < laterIndex; earlierIndex += 1) {
|
|
198
|
+
const earlier = routes[earlierIndex]!
|
|
199
|
+
if (earlier.method !== later.method || `${earlier.method} ${earlier.path}` === laterKey)
|
|
200
|
+
continue
|
|
201
|
+
if (commentBlockHasMarker(lines(), later.line, ROUTE_OVERLAP_PRAGMA)) continue
|
|
202
|
+
if (commentBlockHasMarker(lines(), earlier.line, ROUTE_OVERLAP_PRAGMA)) continue
|
|
203
|
+
|
|
204
|
+
let witness: string | undefined
|
|
205
|
+
try {
|
|
206
|
+
witness = routePatternOverlap(earlier.path, later.path)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof RoutePatternOverlapLimitError) {
|
|
209
|
+
findings.push(
|
|
210
|
+
diagnostic({
|
|
211
|
+
code: "NF-C025",
|
|
212
|
+
severity: "error",
|
|
213
|
+
file: later.file,
|
|
214
|
+
line: later.line,
|
|
215
|
+
message: `${laterKey} could not be proven disjoint from ${earlier.method} ${earlier.path}; bounded route-overlap analysis exceeded its safety budget - simplify the patterns or add the ${ROUTE_OVERLAP_PRAGMA} comment only after manual review`,
|
|
216
|
+
evidence: [
|
|
217
|
+
`${earlier.method} ${earlier.path}`,
|
|
218
|
+
`${later.method} ${later.path}`,
|
|
219
|
+
`first registration: line ${earlier.line}`,
|
|
220
|
+
],
|
|
221
|
+
verify: "nifra check --lints-only",
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
// The primary route parser diagnostic owns malformed route patterns. A secondary lint
|
|
226
|
+
// must remain total and never turn an invalid route into a checker crash.
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (witness === undefined) continue
|
|
230
|
+
const pair = `${file}\n${later.line}\n${later.path}\n${earlier.line}\n${earlier.path}`
|
|
231
|
+
if (reported.has(pair)) break
|
|
232
|
+
reported.add(pair)
|
|
233
|
+
|
|
234
|
+
findings.push(
|
|
235
|
+
diagnostic({
|
|
236
|
+
code: "NF-C024",
|
|
237
|
+
severity: "error",
|
|
238
|
+
file: later.file,
|
|
239
|
+
line: later.line,
|
|
240
|
+
message: `${laterKey} overlaps ${earlier.method} ${earlier.path} (first at line ${earlier.line}); witness path: ${witness} - make the patterns disjoint, order them intentionally with a pragma, or add the ${ROUTE_OVERLAP_PRAGMA} comment above either registration`,
|
|
241
|
+
evidence: [
|
|
242
|
+
`${earlier.method} ${earlier.path}`,
|
|
243
|
+
`${later.method} ${later.path}`,
|
|
244
|
+
`witness: ${witness}`,
|
|
245
|
+
`first registration: line ${earlier.line}`,
|
|
246
|
+
],
|
|
247
|
+
verify: "nifra check --lints-only",
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return findings
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A param name directly followed by a character other routers read as a modifier. */
|
|
259
|
+
const PARAM_MODIFIER = /:[A-Za-z_][A-Za-z0-9_]*[?*+{(<]/g
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The first param in `path` whose modifier the router reads as literal text. A `{...}` group the
|
|
263
|
+
* router reads as a constraint (`:id{[0-9]+}`, `:ext{png|jpg}`) is syntax, so it is passed over.
|
|
264
|
+
*/
|
|
265
|
+
function literalModifier(path: string): string | undefined {
|
|
266
|
+
PARAM_MODIFIER.lastIndex = 0
|
|
267
|
+
for (let found = PARAM_MODIFIER.exec(path); found !== null; found = PARAM_MODIFIER.exec(path)) {
|
|
268
|
+
const text = found[0]
|
|
269
|
+
if (!text.endsWith("{")) return text
|
|
270
|
+
const constraint = paramConstraint(path.slice(PARAM_MODIFIER.lastIndex - 1))
|
|
271
|
+
if (constraint === undefined) return text
|
|
272
|
+
const end = PARAM_MODIFIER.lastIndex + constraint.source.length + 1
|
|
273
|
+
// A modifier after the constraint is literal text like any other (`:id{[0-9]+}?/posts`).
|
|
274
|
+
if ("?*+{(<".includes(path[end] ?? "/")) return path.slice(found.index, end + 1)
|
|
275
|
+
PARAM_MODIFIER.lastIndex = end
|
|
276
|
+
}
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* NF-C026: a param followed by `?`, `*`, `+`, `{`, `(` or `<` that the router reads as literal text
|
|
282
|
+
* (`/users/:id?/posts`, `/users/:id{int}`). Only a trailing `?` run and a valid `{...}` are syntax.
|
|
283
|
+
*/
|
|
284
|
+
export const paramModifierRule: CheckRule = {
|
|
285
|
+
code: "NF-C026",
|
|
286
|
+
title: "Route param followed by an unsupported modifier",
|
|
287
|
+
async scan(ctx) {
|
|
288
|
+
const findings: Diagnostic[] = []
|
|
289
|
+
const linesByFile = new Map<string, readonly string[]>()
|
|
290
|
+
for (const route of pathSites(routeFacts(ctx))) {
|
|
291
|
+
const forms = expandOptionalParams(route.path)
|
|
292
|
+
const text = literalModifier(forms[forms.length - 1] ?? route.path)
|
|
293
|
+
if (text === undefined) continue
|
|
294
|
+
let lines = linesByFile.get(route.file)
|
|
295
|
+
if (lines === undefined) {
|
|
296
|
+
lines = (ctx.project.source.read(route.file) ?? "").split("\n")
|
|
297
|
+
linesByFile.set(route.file, lines)
|
|
298
|
+
}
|
|
299
|
+
if (commentBlockHasMarker(lines, route.line, PARAM_MODIFIER_PRAGMA)) continue
|
|
300
|
+
const character = text[text.length - 1]!
|
|
301
|
+
findings.push(
|
|
302
|
+
diagnostic({
|
|
303
|
+
code: "NF-C026",
|
|
304
|
+
severity: "warn",
|
|
305
|
+
file: route.file,
|
|
306
|
+
line: route.line,
|
|
307
|
+
message: `${route.method} ${route.path} - '${character}' after '${text.slice(0, -1)}' is matched as literal text, not as a param modifier${character === "?" ? "; a request path never contains '?', so this route cannot be reached" : ""}. Optional params are supported as the trailing whole segments of a path (\`/users/:id?\`, \`/d/:year?/:month?\`), and a constraint as one character class with an optional count (\`:id{[0-9]+}\`, \`:code{[A-Z]{2}}\`) or a list of two or more values (\`:ext{png|jpg}\`); for anything else register each path, or mark a deliberate literal with \`// ${PARAM_MODIFIER_PRAGMA}\` above the registration`,
|
|
308
|
+
evidence: [`${route.method} ${route.path}`, `literal: ${text}`],
|
|
309
|
+
verify: "nifra check --lints-only",
|
|
310
|
+
}),
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
return findings
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const routeRules = Object.freeze([
|
|
318
|
+
reservedSegmentRule,
|
|
319
|
+
duplicateRouteRule,
|
|
320
|
+
overlappingRouteRule,
|
|
321
|
+
paramModifierRule,
|
|
322
|
+
])
|