@nifrajs/cli 4.0.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +16 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +133 -29
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +21 -2
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +778 -64
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +166 -37
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +25 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +885 -63
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/rules/legacy.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
|
|
|
5
5
|
import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
|
|
6
6
|
import { SINGLE_COPY_REGISTER_SPECIFIER } from "@nifrajs/core/single-copy"
|
|
7
7
|
import type { CapabilityProjectReport } from "../capabilities-tool.ts"
|
|
8
|
-
import type { SourceFinding, StaticRouteFinding } from "../check-scan.ts"
|
|
8
|
+
import type { SourceFinding, StaticRouteFinding, TypedClientFinding } from "../check-scan.ts"
|
|
9
9
|
import {
|
|
10
10
|
IDENT,
|
|
11
11
|
movedExportSites,
|
|
@@ -22,7 +22,11 @@ import {
|
|
|
22
22
|
} from "../diagnostics.ts"
|
|
23
23
|
import type { DuplicateInstallFinding } from "../doctor.ts"
|
|
24
24
|
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
25
|
+
import { createWebAppProbe, withApiKindHint } from "../internal/nifra-project.ts"
|
|
25
26
|
import { unsupportedTypeScriptDiagnostic } from "../internal/typescript-import.ts"
|
|
27
|
+
import { zoneFolderOf } from "../typecheck-coverage.ts"
|
|
28
|
+
import { versionDriftMessage } from "../version-drift.ts"
|
|
29
|
+
import { MISSING_EXPECTATION_REASON } from "./comment-markers.ts"
|
|
26
30
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
27
31
|
|
|
28
32
|
/** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
|
|
@@ -89,13 +93,13 @@ export const LEGACY_RULE_ORDER: readonly string[] = Object.freeze([
|
|
|
89
93
|
])
|
|
90
94
|
|
|
91
95
|
const FETCH_HINT =
|
|
92
|
-
"hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
|
|
96
|
+
"hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift; for a call no typed client covers (a foreign handler, a static file), add a `// nifra-expect NF-C002: <reason>` comment"
|
|
93
97
|
const STREAM_HINT =
|
|
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"
|
|
98
|
+
"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; for a stream no typed client covers, add a `// nifra-expect NF-C002: <reason>` comment"
|
|
95
99
|
const SERVER_IMPORT_HINT =
|
|
96
100
|
"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"
|
|
97
101
|
const RESPONSE_ROUTE_HINT =
|
|
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
|
|
102
|
+
"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 NF-C007: <reason>` comment to mark it and silence this"
|
|
99
103
|
const PIPELINE_DOC_HINT =
|
|
100
104
|
"nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
|
|
101
105
|
const UNDECLARED_DEP_HINT =
|
|
@@ -124,6 +128,7 @@ interface LegacyFields {
|
|
|
124
128
|
readonly message: string
|
|
125
129
|
readonly evidence?: readonly string[]
|
|
126
130
|
readonly chain?: readonly string[]
|
|
131
|
+
readonly modules?: readonly string[]
|
|
127
132
|
readonly fix?: string
|
|
128
133
|
/** A recipe for this one finding, when the rule's findings are not all mechanically fixable. */
|
|
129
134
|
readonly recipe?: Diagnostic["fix"]
|
|
@@ -163,6 +168,7 @@ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
|
|
|
163
168
|
...(fields.fix === undefined ? {} : { fix: fields.fix }),
|
|
164
169
|
...(fields.suggestion === undefined ? {} : { suggestion: fields.suggestion }),
|
|
165
170
|
...(fields.chain === undefined ? {} : { chain: fields.chain }),
|
|
171
|
+
...(fields.modules === undefined ? {} : { modules: fields.modules }),
|
|
166
172
|
...(fields.includeCode === true ? { includeCode: true } : {}),
|
|
167
173
|
}
|
|
168
174
|
return diagnosticWithCompatibility(canonical, compatibility)
|
|
@@ -312,7 +318,7 @@ function responseRouteSuggestion(): DiagnosticSuggestion {
|
|
|
312
318
|
steps: [
|
|
313
319
|
"Prefer returning a plain object from JSON routes; nifra serializes it for you.",
|
|
314
320
|
"For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
|
|
315
|
-
"If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect
|
|
321
|
+
"If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect NF-C007: <reason>` comment above the return to mark it intentional and silence this advisory.",
|
|
316
322
|
],
|
|
317
323
|
}
|
|
318
324
|
}
|
|
@@ -378,35 +384,93 @@ const typecheckRule: CheckRule = {
|
|
|
378
384
|
)
|
|
379
385
|
}
|
|
380
386
|
}
|
|
387
|
+
findings.push(...outsideProgramFindings(tc.outsideProgram ?? []))
|
|
381
388
|
return findings
|
|
382
389
|
},
|
|
383
390
|
}
|
|
384
391
|
|
|
392
|
+
/** One finding per zone folder: the fix (tsconfig `include`) is per folder, not per file. */
|
|
393
|
+
function outsideProgramFindings(files: readonly string[]): Diagnostic[] {
|
|
394
|
+
const byFolder = new Map<string, { readonly first: string; readonly group: string[] }>()
|
|
395
|
+
for (const file of files) {
|
|
396
|
+
const folder = zoneFolderOf(file) ?? file
|
|
397
|
+
const entry = byFolder.get(folder)
|
|
398
|
+
if (entry === undefined) byFolder.set(folder, { first: file, group: [file] })
|
|
399
|
+
else entry.group.push(file)
|
|
400
|
+
}
|
|
401
|
+
return [...byFolder].map(([folder, { first, group }]) => {
|
|
402
|
+
const shown = group.slice(0, 3).join(", ")
|
|
403
|
+
const more = group.length > 3 ? ` and ${group.length - 3} more` : ""
|
|
404
|
+
return legacyDiagnostic("typecheck", {
|
|
405
|
+
severity: "error",
|
|
406
|
+
file: first,
|
|
407
|
+
message: `${group.length} file(s) under ${folder}/ are outside tsc's program, so the typecheck passed without reading them: ${shown}${more}`,
|
|
408
|
+
evidence: group,
|
|
409
|
+
fix: `Add "${folder}" to tsconfig.json "include" (or list the files), then run \`nifra check\` again`,
|
|
410
|
+
suggestion: {
|
|
411
|
+
kind: "manual",
|
|
412
|
+
title: "Bring the folder into the TypeScript program",
|
|
413
|
+
steps: [
|
|
414
|
+
`Add "${folder}" to the "include" array in tsconfig.json.`,
|
|
415
|
+
'Files kept out on purpose: name them in the tsconfig "exclude", or under "typecheck": { "ignore": [...] } in nifra.check.json.',
|
|
416
|
+
"Run `nifra check` again; the newly checked files may report type errors.",
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* One NF-C002 finding. A `// nifra-expect NF-C002: <reason>` comment keeps it in the report as `info`,
|
|
425
|
+
* carrying the comment as evidence; the same comment without a reason silences nothing.
|
|
426
|
+
*/
|
|
427
|
+
function typedClientDiagnostic(
|
|
428
|
+
finding: TypedClientFinding,
|
|
429
|
+
hint: string,
|
|
430
|
+
kind: string,
|
|
431
|
+
suggestion: DiagnosticSuggestion,
|
|
432
|
+
): Diagnostic {
|
|
433
|
+
const reason = finding.expected?.reason
|
|
434
|
+
if (reason !== undefined) {
|
|
435
|
+
return legacyDiagnostic("typed-client", {
|
|
436
|
+
severity: "info",
|
|
437
|
+
file: finding.file,
|
|
438
|
+
line: finding.line,
|
|
439
|
+
message: `${finding.snippet} - ${kind} is expected here`,
|
|
440
|
+
evidence: [`nifra-expect NF-C002: ${reason}`],
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
const unreasoned = finding.expected === undefined ? "" : `; ${MISSING_EXPECTATION_REASON}`
|
|
444
|
+
return legacyDiagnostic("typed-client", {
|
|
445
|
+
severity: "error",
|
|
446
|
+
file: finding.file,
|
|
447
|
+
line: finding.line,
|
|
448
|
+
message: `${finding.snippet} - ${hint}${unreasoned}`,
|
|
449
|
+
fix: hint,
|
|
450
|
+
suggestion,
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
385
454
|
const typedClientRule: CheckRule = {
|
|
386
455
|
code: LEGACY_RULE_CODES["typed-client"]!,
|
|
387
456
|
title: TITLES["typed-client"]!,
|
|
388
457
|
async scan(ctx) {
|
|
389
458
|
const routes = staticRouteMap(ctx.project.routes)
|
|
390
|
-
const fetchFindings = [...ctx.project.sourceFindings.fetches]
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const streamFindings = [...ctx.project.sourceFindings.streams]
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
message: `${finding.snippet} - ${STREAM_HINT}`,
|
|
406
|
-
fix: STREAM_HINT,
|
|
407
|
-
suggestion: ownStreamSuggestion(),
|
|
408
|
-
}),
|
|
409
|
-
)
|
|
459
|
+
const fetchFindings = [...ctx.project.sourceFindings.fetches]
|
|
460
|
+
.sort(bySite)
|
|
461
|
+
.map((finding) =>
|
|
462
|
+
typedClientDiagnostic(
|
|
463
|
+
finding,
|
|
464
|
+
FETCH_HINT,
|
|
465
|
+
"own-API fetch",
|
|
466
|
+
ownFetchSuggestion(finding, routes),
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
const streamFindings = [...ctx.project.sourceFindings.streams]
|
|
470
|
+
.sort(bySite)
|
|
471
|
+
.map((finding) =>
|
|
472
|
+
typedClientDiagnostic(finding, STREAM_HINT, "own-API stream", ownStreamSuggestion()),
|
|
473
|
+
)
|
|
410
474
|
return [...fetchFindings, ...streamFindings].sort(
|
|
411
475
|
(a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
|
|
412
476
|
)
|
|
@@ -468,19 +532,31 @@ const serverOnlyImportRule: CheckRule = {
|
|
|
468
532
|
code: LEGACY_RULE_CODES["server-only-import"]!,
|
|
469
533
|
title: TITLES["server-only-import"]!,
|
|
470
534
|
async scan(ctx) {
|
|
471
|
-
|
|
535
|
+
const inWebApp = createWebAppProbe(ctx.root)
|
|
536
|
+
const findings: Diagnostic[] = []
|
|
537
|
+
// No component module got a receipt, so each is judged as an ordinary import: say why.
|
|
538
|
+
const components = ctx.project.check.componentCheckUnsupported
|
|
539
|
+
if (components !== undefined)
|
|
540
|
+
findings.push(unsupportedTypeScriptDiagnostic(components.version, components.message))
|
|
541
|
+
for (const finding of [...ctx.project.importGraph].sort(bySite)) {
|
|
472
542
|
const chain = finding.chain
|
|
473
543
|
const sink = chain[chain.length - 1] ?? finding.specifier
|
|
474
|
-
|
|
544
|
+
const dir = finding.file.slice(0, Math.max(finding.file.lastIndexOf("/"), 0))
|
|
545
|
+
const found = legacyDiagnostic("server-only-import", {
|
|
475
546
|
severity: "error",
|
|
476
547
|
file: finding.file,
|
|
477
548
|
line: finding.line,
|
|
478
|
-
message:
|
|
549
|
+
message: (inWebApp(dir) ? (text: string) => text : withApiKindHint)(
|
|
550
|
+
`${finding.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${finding.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
|
|
551
|
+
),
|
|
479
552
|
fix: SERVER_IMPORT_HINT,
|
|
480
553
|
chain,
|
|
554
|
+
modules: finding.modules,
|
|
481
555
|
suggestion: serverImportSuggestion(finding.specifier, chain, finding.fallback),
|
|
482
556
|
})
|
|
483
|
-
|
|
557
|
+
findings.push(found)
|
|
558
|
+
}
|
|
559
|
+
return findings
|
|
484
560
|
},
|
|
485
561
|
}
|
|
486
562
|
|
|
@@ -507,6 +583,18 @@ const interpolatedSqlRule: CheckRule = {
|
|
|
507
583
|
)
|
|
508
584
|
}
|
|
509
585
|
for (const finding of [...ctx.project.sourceFindings.interpolatedSql].sort(bySite)) {
|
|
586
|
+
if (finding.expected !== undefined) {
|
|
587
|
+
findings.push(
|
|
588
|
+
legacyDiagnostic("interpolated-sql", {
|
|
589
|
+
severity: "info",
|
|
590
|
+
file: finding.file,
|
|
591
|
+
line: finding.line,
|
|
592
|
+
message: `${finding.snippet} - interpolated SQL is expected here`,
|
|
593
|
+
evidence: [`nifra-expect NF-C006: ${finding.expected}`],
|
|
594
|
+
}),
|
|
595
|
+
)
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
510
598
|
findings.push(
|
|
511
599
|
legacyDiagnostic("interpolated-sql", {
|
|
512
600
|
severity: "error",
|
|
@@ -522,7 +610,7 @@ const interpolatedSqlRule: CheckRule = {
|
|
|
522
610
|
"Pass the value as an argument alongside the statement, so the driver binds it.",
|
|
523
611
|
`Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
|
|
524
612
|
"An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
|
|
525
|
-
"If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect
|
|
613
|
+
"If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect NF-C006: <reason>` comment on the line above - the reason is required.",
|
|
526
614
|
],
|
|
527
615
|
},
|
|
528
616
|
}),
|
|
@@ -556,31 +644,61 @@ function copyLines(finding: DuplicateInstallFinding): string[] {
|
|
|
556
644
|
)
|
|
557
645
|
}
|
|
558
646
|
|
|
559
|
-
|
|
560
|
-
|
|
647
|
+
/** One fix for duplicates that share a cause and topology, so a linked sibling repository that
|
|
648
|
+
* duplicates every package reads as one finding instead of one paragraph per package. */
|
|
649
|
+
function duplicateSuggestion(group: readonly DuplicateInstallFinding[]): DiagnosticSuggestion {
|
|
650
|
+
const names = group.map((finding) => `"${finding.package}"`).join(", ")
|
|
561
651
|
return {
|
|
562
652
|
kind: "manual",
|
|
563
|
-
title:
|
|
653
|
+
title:
|
|
654
|
+
group.length === 1
|
|
655
|
+
? `Resolve ${group[0]?.package} to one loaded copy`
|
|
656
|
+
: `Resolve each of ${group.length} packages to one loaded copy`,
|
|
564
657
|
diff: [
|
|
565
658
|
"package.json:",
|
|
566
|
-
` "nifra": { "singleCopy": [
|
|
659
|
+
` "nifra": { "singleCopy": [${names}] }`,
|
|
567
660
|
"bunfig.toml:",
|
|
568
661
|
` preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`,
|
|
569
662
|
" [test]",
|
|
570
663
|
` preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`,
|
|
571
664
|
].join("\n"),
|
|
572
665
|
steps: [
|
|
573
|
-
...
|
|
574
|
-
...(finding
|
|
575
|
-
|
|
576
|
-
|
|
666
|
+
...group.flatMap(copyLines).map((line) => `Copy: ${line}`),
|
|
667
|
+
...group.flatMap((finding) =>
|
|
668
|
+
finding.provenance === undefined
|
|
669
|
+
? []
|
|
670
|
+
: [
|
|
671
|
+
`Planted links${group.length === 1 ? "" : ` (${finding.package})`}: ${finding.provenance} Try this before the fixes below.`,
|
|
672
|
+
],
|
|
673
|
+
),
|
|
577
674
|
"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.",
|
|
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." : ""}`,
|
|
675
|
+
`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.${group.some((finding) => finding.cause === "version-skew") ? " A declaration only covers same-version duplicates, so fix 1's range alignment must land first." : ""}`,
|
|
579
676
|
"Re-run `nifra check`; the gate stays failing until one fix lands.",
|
|
580
677
|
],
|
|
581
678
|
}
|
|
582
679
|
}
|
|
583
680
|
|
|
681
|
+
function duplicateDiagnostic(group: readonly DuplicateInstallFinding[]): Diagnostic | undefined {
|
|
682
|
+
const first = group[0]
|
|
683
|
+
if (first === undefined) return undefined
|
|
684
|
+
const context = `${first.topology === undefined ? "" : `. Topology: ${first.topology}`}${first.scope === undefined ? "" : `. Scope: ${first.scope}`}`
|
|
685
|
+
const skew = first.cause === "version-skew"
|
|
686
|
+
const names = group.map((finding) => finding.package)
|
|
687
|
+
return legacyDiagnostic("duplicate-install", {
|
|
688
|
+
severity: "error",
|
|
689
|
+
message:
|
|
690
|
+
group.length === 1
|
|
691
|
+
? `${first.package} identity preflight found ${first.cause} - ${first.explanation}${context}`
|
|
692
|
+
: `${group.length} packages share one identity preflight finding, ${first.cause} - ${names.join(", ")} ${skew ? "each resolve to multiple versions and physical paths" : "are each loaded from more than one physical path"}, so module state and symbols are not shared${context}`,
|
|
693
|
+
fix:
|
|
694
|
+
group.length === 1 || skew
|
|
695
|
+
? first.remediation
|
|
696
|
+
: `Deduplicate so each resolves to a single physical path, or declare them single-copy: add "nifra": { "singleCopy": [${names.map((name) => `"${name}"`).join(", ")}] } to package.json and preload "${SINGLE_COPY_REGISTER_SPECIFIER}" from bunfig.toml.`,
|
|
697
|
+
evidence: group.flatMap(copyLines),
|
|
698
|
+
suggestion: duplicateSuggestion(group),
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
|
|
584
702
|
const doctorRules: readonly CheckRule[] = Object.freeze([
|
|
585
703
|
{
|
|
586
704
|
code: LEGACY_RULE_CODES["undeclared-dependency"]!,
|
|
@@ -610,20 +728,14 @@ const doctorRules: readonly CheckRule[] = Object.freeze([
|
|
|
610
728
|
async scan(ctx: RuleContext) {
|
|
611
729
|
const doctor = ctx.project.packages.doctor
|
|
612
730
|
if (!doctor.ran) return []
|
|
613
|
-
|
|
731
|
+
// Duplicates with one cause and one layout (every package a linked sibling repo installs again)
|
|
732
|
+
// have one fix, so they are one finding; `identityPreflight` in --json keeps each package.
|
|
733
|
+
const groups = new Map<string, DuplicateInstallFinding[]>()
|
|
614
734
|
for (const finding of doctor.duplicateInstalls) {
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
legacyDiagnostic("duplicate-install", {
|
|
618
|
-
severity: "error",
|
|
619
|
-
message: `${finding.package} identity preflight found ${finding.cause} - ${finding.explanation}${finding.topology === undefined ? "" : `. Topology: ${finding.topology}`}${finding.scope === undefined ? "" : `. Scope: ${finding.scope}`}`,
|
|
620
|
-
fix: finding.remediation,
|
|
621
|
-
evidence: copies,
|
|
622
|
-
suggestion: duplicateSuggestion(finding),
|
|
623
|
-
}),
|
|
624
|
-
)
|
|
735
|
+
const key = [finding.cause, finding.topology ?? "", finding.scope ?? ""].join("\0")
|
|
736
|
+
groups.set(key, [...(groups.get(key) ?? []), finding])
|
|
625
737
|
}
|
|
626
|
-
return
|
|
738
|
+
return [...groups.values()].flatMap((group) => duplicateDiagnostic(group) ?? [])
|
|
627
739
|
},
|
|
628
740
|
},
|
|
629
741
|
{
|
|
@@ -658,6 +770,26 @@ const doctorRules: readonly CheckRule[] = Object.freeze([
|
|
|
658
770
|
},
|
|
659
771
|
])
|
|
660
772
|
|
|
773
|
+
/** Read from the doctor result check already collected; it spawns nothing and edits nothing. */
|
|
774
|
+
const versionDriftRule: CheckRule = {
|
|
775
|
+
code: "NF-D002",
|
|
776
|
+
title: "Mixed nifra versions",
|
|
777
|
+
async scan(ctx) {
|
|
778
|
+
const drift = ctx.project.packages.doctor.versionDrift
|
|
779
|
+
if (drift === undefined) return []
|
|
780
|
+
return [
|
|
781
|
+
diagnostic({
|
|
782
|
+
code: "NF-D002",
|
|
783
|
+
severity: "info",
|
|
784
|
+
message: versionDriftMessage(drift),
|
|
785
|
+
evidence: drift.versions.map(({ version }) => version),
|
|
786
|
+
fix: { recipe: "upgrade.align-versions", command: `nifra upgrade ${drift.highest}` },
|
|
787
|
+
verify: "nifra doctor",
|
|
788
|
+
}),
|
|
789
|
+
]
|
|
790
|
+
},
|
|
791
|
+
}
|
|
792
|
+
|
|
661
793
|
const pipelineRule: CheckRule = {
|
|
662
794
|
code: LEGACY_RULE_CODES.pipeline!,
|
|
663
795
|
title: TITLES.pipeline!,
|
|
@@ -995,6 +1127,7 @@ export const legacyRules: readonly CheckRule[] = Object.freeze([
|
|
|
995
1127
|
interpolatedSqlRule,
|
|
996
1128
|
responseRouteRule,
|
|
997
1129
|
...doctorRules,
|
|
1130
|
+
versionDriftRule,
|
|
998
1131
|
pipelineRule,
|
|
999
1132
|
serverManifestRule,
|
|
1000
1133
|
manifestRule,
|
package/src/rules/pages.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* NF-C027: a page file whose URL sits under the backend's mount path.
|
|
3
3
|
*
|
|
4
|
-
* `backend/app.ts`'s backend is mounted at `apiPrefix
|
|
4
|
+
* `backend/app.ts`'s backend is mounted at `apiPrefix`, when one is set, ahead of page routing, and
|
|
5
5
|
* its 404 is final, so `routes/api/report.tsx` never renders - every request for it gets the
|
|
6
6
|
* backend's 404. `createWebApp` refuses such an app at startup and `nifra build` refuses to build
|
|
7
7
|
* it; this reports it earlier, without running app code (check's pre-`loadApp` invariant): the
|
|
@@ -15,10 +15,15 @@ import { existsSync, readFileSync } from "node:fs"
|
|
|
15
15
|
import { join } from "node:path"
|
|
16
16
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
17
|
import { shadowedPages } from "@nifrajs/web/route-manifest"
|
|
18
|
+
import type * as TSApi from "typescript"
|
|
18
19
|
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
|
|
19
20
|
import { stripComments } from "../check-scan.ts"
|
|
20
21
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
22
|
+
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
23
|
+
import { hasWebConfig } from "../internal/nifra-project.ts"
|
|
24
|
+
import { scriptKindOf } from "../internal/script-kind.ts"
|
|
21
25
|
import type { CheckRule } from "./index.ts"
|
|
26
|
+
import { loadRuleTypeScript } from "./typescript.ts"
|
|
22
27
|
|
|
23
28
|
/** What a static read of a framework config says about `apiPrefix`. */
|
|
24
29
|
export type StaticApiPrefix =
|
|
@@ -51,7 +56,7 @@ export const shadowedPageRule: CheckRule = {
|
|
|
51
56
|
if (!existsSync(backendFile) || !existsSync(routesDir)) return []
|
|
52
57
|
if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
|
|
53
58
|
|
|
54
|
-
let apiPrefix = "
|
|
59
|
+
let apiPrefix = ""
|
|
55
60
|
// backend/framework.ts first: the generated server entry imports from it, and the build refuses
|
|
56
61
|
// a nifra.config.ts whose forwarded fields differ from it.
|
|
57
62
|
const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
|
|
@@ -96,4 +101,234 @@ export const shadowedPageRule: CheckRule = {
|
|
|
96
101
|
},
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
|
|
104
|
+
const IN_LAYOUTS = /\binLayouts\b/
|
|
105
|
+
|
|
106
|
+
/** An import of the root layout from a page beside it, statically or dynamically. */
|
|
107
|
+
const importsRootLayout = (code: string, layoutFile: string): boolean => {
|
|
108
|
+
const stem = layoutFile.replace(/\.[^./]+$/, "")
|
|
109
|
+
return [`"./${stem}`, `'./${stem}`].some((quoted) =>
|
|
110
|
+
[`${quoted}"`, `${quoted}'`, `${quoted}.`].some((opening) => {
|
|
111
|
+
for (let at = code.indexOf(opening); at !== -1; at = code.indexOf(opening, at + 1)) {
|
|
112
|
+
const before = code.slice(Math.max(0, at - 16), at)
|
|
113
|
+
if (/(?:\bfrom|\bimport)\s*\(?\s*$/.test(before)) return true
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* NF-C034: a root status page (`_404`, `_<status>`) beside a root `_layout` that imports that layout
|
|
122
|
+
* to wrap itself. Status pages render inside their layouts, so such a page renders the layout twice.
|
|
123
|
+
* A page that names `inLayouts` at all has decided, so it is not reported.
|
|
124
|
+
*/
|
|
125
|
+
export const statusLayoutRule: CheckRule = {
|
|
126
|
+
code: "NF-C034",
|
|
127
|
+
title: "Root status page wraps the root layout itself",
|
|
128
|
+
async scan(ctx) {
|
|
129
|
+
const routesDir = join(ctx.root, "routes")
|
|
130
|
+
if (!existsSync(routesDir)) return []
|
|
131
|
+
let manifest: ReturnType<typeof discoverRoutes>
|
|
132
|
+
try {
|
|
133
|
+
manifest = discoverRoutes(routesDir)
|
|
134
|
+
} catch {
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
const layout = manifest.layouts._layout
|
|
138
|
+
if (layout === undefined) return []
|
|
139
|
+
const pages = [
|
|
140
|
+
...(manifest.notFound === undefined ? [] : [manifest.notFound.file]),
|
|
141
|
+
...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
|
|
142
|
+
]
|
|
143
|
+
const findings: Diagnostic[] = []
|
|
144
|
+
for (const file of pages.sort()) {
|
|
145
|
+
const source = stripComments(readFileSync(join(routesDir, file), "utf8"))
|
|
146
|
+
if (IN_LAYOUTS.test(source) || !importsRootLayout(source, layout.file)) continue
|
|
147
|
+
findings.push(
|
|
148
|
+
diagnostic({
|
|
149
|
+
code: "NF-C034",
|
|
150
|
+
severity: "warn",
|
|
151
|
+
file: `routes/${file}`,
|
|
152
|
+
message: `routes/${file} imports routes/${layout.file} to wrap itself, and status pages render inside their layouts, so it renders routes/${layout.file} twice. Remove the wrap, or add \`export const inLayouts = false\` to keep rendering the page alone.`,
|
|
153
|
+
evidence: [`layout: routes/${layout.file}`],
|
|
154
|
+
verify: "nifra check",
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return findings
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* NF-C035: `backend/app.ts` exports a backend and the framework config sets no `apiPrefix`, so the
|
|
164
|
+
* backend is not served over HTTP - a browser call to `/api` reaches page routing. Loader-only is a
|
|
165
|
+
* valid choice; the finding asks for it to be stated. An unreadable `apiPrefix` is NF-C027's to report.
|
|
166
|
+
*/
|
|
167
|
+
export const implicitApiMountRule: CheckRule = {
|
|
168
|
+
code: "NF-C035",
|
|
169
|
+
title: "Backend not served over HTTP",
|
|
170
|
+
async scan(ctx) {
|
|
171
|
+
const backendFile = join(ctx.root, BACKEND_APP_FILE)
|
|
172
|
+
// An API-only app serves its backend itself; there is no page routing to fall into.
|
|
173
|
+
if (!existsSync(backendFile) || !hasWebConfig(ctx.root, ".")) return []
|
|
174
|
+
if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
|
|
175
|
+
const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
|
|
176
|
+
if (
|
|
177
|
+
configFile !== undefined &&
|
|
178
|
+
readStaticApiPrefix(readFileSync(join(ctx.root, configFile), "utf8")).kind !== "default"
|
|
179
|
+
) {
|
|
180
|
+
return []
|
|
181
|
+
}
|
|
182
|
+
const file = configFile ?? FRAMEWORK_FILE
|
|
183
|
+
return [
|
|
184
|
+
diagnostic({
|
|
185
|
+
code: "NF-C035",
|
|
186
|
+
severity: "info",
|
|
187
|
+
file,
|
|
188
|
+
message: `${BACKEND_APP_FILE}'s backend is not served over HTTP, because ${file} sets no apiPrefix: a browser call to /api reaches page routing instead. Add \`export const apiPrefix = "/api"\` to ${file} to serve it there, as 4.x did by default, or \`export const apiPrefix = ""\` to keep it loader-only.`,
|
|
189
|
+
fix: { recipe: "framework.api-prefix", command: "nifra fix --code NF-C035" },
|
|
190
|
+
verify: "nifra check",
|
|
191
|
+
}),
|
|
192
|
+
]
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Append `export const apiPrefix = "/api"` to a framework config that sets none, creating it if needed. */
|
|
197
|
+
export function withDefaultApiPrefix(source: string | undefined): string | undefined {
|
|
198
|
+
if (source !== undefined && readStaticApiPrefix(source).kind !== "default") return undefined
|
|
199
|
+
const line = 'export const apiPrefix = "/api"\n'
|
|
200
|
+
if (source === undefined || source === "") return line
|
|
201
|
+
return `${source}${source.endsWith("\n") ? "" : "\n"}${line}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const LOADER_EXPORT =
|
|
205
|
+
/\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+loader\b|\bexport\s*\{[^}]*\bloader\b[^}]*\}/
|
|
206
|
+
const SCRIPT_LAYOUT = /\.[cm]?[jt]sx?$/
|
|
207
|
+
|
|
208
|
+
const hasModifier = (ts: typeof TSApi, node: TSApi.Node, kind: TSApi.SyntaxKind): boolean =>
|
|
209
|
+
ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === kind)
|
|
210
|
+
|
|
211
|
+
/** The function an exported `meta` names: a declaration, or a const initialized with one. */
|
|
212
|
+
function exportedMetaFunction(
|
|
213
|
+
ts: typeof TSApi,
|
|
214
|
+
tree: TSApi.SourceFile,
|
|
215
|
+
): TSApi.FunctionDeclaration | TSApi.ArrowFunction | TSApi.FunctionExpression | undefined {
|
|
216
|
+
for (const statement of tree.statements) {
|
|
217
|
+
if (!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword)) continue
|
|
218
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === "meta") return statement
|
|
219
|
+
if (!ts.isVariableStatement(statement)) continue
|
|
220
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
221
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "meta") continue
|
|
222
|
+
let init = declaration.initializer
|
|
223
|
+
while (
|
|
224
|
+
init !== undefined &&
|
|
225
|
+
(ts.isParenthesizedExpression(init) ||
|
|
226
|
+
ts.isAsExpression(init) ||
|
|
227
|
+
ts.isSatisfiesExpression(init))
|
|
228
|
+
) {
|
|
229
|
+
init = init.expression
|
|
230
|
+
}
|
|
231
|
+
if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
|
|
232
|
+
return init
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Where an exported function `meta` reads its args' `data`: a destructured `data`, or `args.data`. */
|
|
239
|
+
export function metaDataReads(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
|
|
240
|
+
const meta = exportedMetaFunction(ts, tree)
|
|
241
|
+
const param = meta?.parameters[0]
|
|
242
|
+
if (param === undefined) return []
|
|
243
|
+
const lineOf = (node: TSApi.Node) =>
|
|
244
|
+
tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
245
|
+
if (ts.isObjectBindingPattern(param.name)) {
|
|
246
|
+
return param.name.elements
|
|
247
|
+
.filter((element) => (element.propertyName ?? element.name).getText(tree) === "data")
|
|
248
|
+
.map(lineOf)
|
|
249
|
+
}
|
|
250
|
+
if (!ts.isIdentifier(param.name) || meta?.body === undefined) return []
|
|
251
|
+
const args = param.name.text
|
|
252
|
+
const reads: number[] = []
|
|
253
|
+
const visit = (node: TSApi.Node): void => {
|
|
254
|
+
if (
|
|
255
|
+
ts.isPropertyAccessExpression(node) &&
|
|
256
|
+
ts.isIdentifier(node.expression) &&
|
|
257
|
+
node.expression.text === args &&
|
|
258
|
+
node.name.text === "data"
|
|
259
|
+
) {
|
|
260
|
+
reads.push(lineOf(node))
|
|
261
|
+
}
|
|
262
|
+
ts.forEachChild(node, visit)
|
|
263
|
+
}
|
|
264
|
+
visit(meta.body)
|
|
265
|
+
return reads
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* NF-C040: a layout whose function `meta` reads `data` and which has no loader. A layout's `meta`
|
|
270
|
+
* receives its own loader data, `null` without a loader, so `data` there is always `null`; the page's
|
|
271
|
+
* data is `pageData`. Until 5.0 a layout's `meta` received the page's data as `data`, which is what such
|
|
272
|
+
* a `meta` was written against. A layout with a loader reading `data` reads its own, so it is not reported.
|
|
273
|
+
*/
|
|
274
|
+
export const layoutMetaDataRule: CheckRule = {
|
|
275
|
+
code: "NF-C040",
|
|
276
|
+
title: "Layout meta reads data its layout never loads",
|
|
277
|
+
async scan(ctx) {
|
|
278
|
+
const routesDir = join(ctx.root, "routes")
|
|
279
|
+
if (!existsSync(routesDir)) return []
|
|
280
|
+
let manifest: ReturnType<typeof discoverRoutes>
|
|
281
|
+
try {
|
|
282
|
+
manifest = discoverRoutes(routesDir)
|
|
283
|
+
} catch {
|
|
284
|
+
return []
|
|
285
|
+
}
|
|
286
|
+
const layouts = Object.values(manifest.layouts)
|
|
287
|
+
.filter((layout) => SCRIPT_LAYOUT.test(layout.file))
|
|
288
|
+
.sort((a, b) => codeUnitOrder(a.file, b.file))
|
|
289
|
+
if (layouts.length === 0) return []
|
|
290
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
291
|
+
const ts = loaded.compiler
|
|
292
|
+
if (ts === undefined) return []
|
|
293
|
+
const findings: Diagnostic[] = []
|
|
294
|
+
for (const layout of layouts) {
|
|
295
|
+
const halves = [layout.file, ...(layout.backend === undefined ? [] : [layout.backend])]
|
|
296
|
+
const loads = halves.some((file) =>
|
|
297
|
+
LOADER_EXPORT.test(stripComments(readFileSync(join(routesDir, file), "utf8"))),
|
|
298
|
+
)
|
|
299
|
+
if (loads) continue
|
|
300
|
+
const text = readFileSync(join(routesDir, layout.file), "utf8")
|
|
301
|
+
const tree = ts.createSourceFile(
|
|
302
|
+
layout.file,
|
|
303
|
+
text,
|
|
304
|
+
ts.ScriptTarget.Latest,
|
|
305
|
+
true,
|
|
306
|
+
scriptKindOf(ts, layout.file),
|
|
307
|
+
)
|
|
308
|
+
for (const line of metaDataReads(ts, tree)) {
|
|
309
|
+
findings.push(
|
|
310
|
+
diagnostic({
|
|
311
|
+
code: "NF-C040",
|
|
312
|
+
severity: "warn",
|
|
313
|
+
file: `routes/${layout.file}`,
|
|
314
|
+
line,
|
|
315
|
+
message: `routes/${layout.file}'s meta reads \`data\`, which is this layout's own loader data, and the layout has no loader, so it is always null. Read the page's data from \`pageData\` instead.`,
|
|
316
|
+
evidence: [
|
|
317
|
+
"data: the layout's loader data (null without a loader)",
|
|
318
|
+
"pageData: the page's loader data",
|
|
319
|
+
],
|
|
320
|
+
verify: "nifra check",
|
|
321
|
+
}),
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return findings
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const pageRules = Object.freeze([
|
|
330
|
+
shadowedPageRule,
|
|
331
|
+
statusLayoutRule,
|
|
332
|
+
implicitApiMountRule,
|
|
333
|
+
layoutMetaDataRule,
|
|
334
|
+
])
|