@nifrajs/cli 3.3.0 → 4.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 +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 +7 -15
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +162 -713
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +58 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +323 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +145 -54
- 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 +306 -87
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +60 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +298 -33
- 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 +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +56 -16
- 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/diagnostics.d.ts +86 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +85 -0
- package/dist/diagnostics.js.map +1 -1
- 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 +41 -21
- 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 +6 -4
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +93 -74
- 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 +71 -16
- 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.d.ts.map +1 -1
- package/dist/mcp-context.js +5 -6
- 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 +3 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +263 -131
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +20 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +113 -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-path.d.ts +10 -0
- package/dist/mcp-path.d.ts.map +1 -0
- package/dist/mcp-path.js +51 -0
- package/dist/mcp-path.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-reflect.d.ts.map +1 -1
- package/dist/mcp-reflect.js +2 -2
- package/dist/mcp-reflect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +64 -45
- 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 +124 -25
- 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 +159 -55
- 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 +18 -5
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +165 -82
- 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 +982 -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 +28 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +12 -2
- 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 +34 -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 +21 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +823 -0
- package/dist/review.js.map +1 -0
- 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 +3 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +829 -34
- 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 +154 -90
- 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.map +1 -1
- package/dist/sync-manifest.js +2 -1
- 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 +8 -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 +3 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +33 -16
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +4123 -548
- package/docs/types.json +14391 -10060
- package/package.json +19 -11
- 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 +199 -797
- package/src/check-scan.ts +394 -63
- package/src/check.ts +146 -58
- package/src/cli.ts +347 -92
- package/src/command-catalog.ts +407 -45
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +66 -15
- 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/diagnostics.ts +179 -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 +44 -31
- 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 +93 -82
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +144 -32
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +5 -7
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +301 -161
- package/src/mcp-http.ts +22 -5
- package/src/mcp-io.ts +130 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-path.ts +50 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-reflect.ts +3 -2
- package/src/mcp-render.ts +72 -42
- package/src/mcp-root.ts +130 -24
- package/src/mcp-run.ts +190 -56
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +193 -91
- package/src/migrate-layout.ts +1121 -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 +42 -8
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +988 -0
- 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 +947 -35
- 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 +175 -98
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- 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 +27 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +40 -16
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
|
+
])
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NF-C032: what looks like a credential in browser code or in `public/`, checked from source with the
|
|
3
|
+
* scanner every client build runs (`@nifrajs/web/zones`). The build also matches the values of the
|
|
4
|
+
* build environment's private variables in its output; that needs the build's environment, so it
|
|
5
|
+
* stays the build's.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import {
|
|
10
|
+
publicScanFiles,
|
|
11
|
+
type SecretExemption,
|
|
12
|
+
type SecretScanFile,
|
|
13
|
+
scanForSecrets,
|
|
14
|
+
} from "@nifrajs/web/zones"
|
|
15
|
+
import { CONFIG_FILE } from "../app-files.ts"
|
|
16
|
+
import { stripComments } from "../check-scan.ts"
|
|
17
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
18
|
+
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
19
|
+
import type { CheckRule } from "./index.ts"
|
|
20
|
+
import { zonedFiles } from "./zones.ts"
|
|
21
|
+
|
|
22
|
+
const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
|
|
23
|
+
|
|
24
|
+
const EXEMPTIONS_START = /\bexport\s+const\s+secretExemptions\b[^=]*=\s*\[/
|
|
25
|
+
|
|
26
|
+
type Token = { readonly kind: "string" | "word" | "punct"; readonly value: string }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The tokens of an array literal, from just after its `[` to the `]` that closes it. A string is one
|
|
30
|
+
* token, so a bracket or a colon inside one (`"routes/[lang]/index.tsx"`) is text, never syntax.
|
|
31
|
+
*/
|
|
32
|
+
function arrayTokens(src: string, from: number): Token[] {
|
|
33
|
+
const tokens: Token[] = []
|
|
34
|
+
let depth = 0
|
|
35
|
+
let i = from
|
|
36
|
+
while (i < src.length) {
|
|
37
|
+
const c = src[i] as string
|
|
38
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
39
|
+
let value = ""
|
|
40
|
+
let j = i + 1
|
|
41
|
+
while (j < src.length && src[j] !== c) {
|
|
42
|
+
if (src[j] === "\\") {
|
|
43
|
+
value += src[j + 1] ?? ""
|
|
44
|
+
j += 2
|
|
45
|
+
} else value += src[j++]
|
|
46
|
+
}
|
|
47
|
+
tokens.push({ kind: "string", value })
|
|
48
|
+
i = j + 1
|
|
49
|
+
} else if (/[\w$]/.test(c)) {
|
|
50
|
+
let j = i
|
|
51
|
+
while (j < src.length && /[\w$]/.test(src[j] as string)) j++
|
|
52
|
+
tokens.push({ kind: "word", value: src.slice(i, j) })
|
|
53
|
+
i = j
|
|
54
|
+
} else {
|
|
55
|
+
if (c === "]" || c === "}" || c === ")") {
|
|
56
|
+
if (depth === 0) break
|
|
57
|
+
depth--
|
|
58
|
+
} else if (c === "[" || c === "{" || c === "(") depth++
|
|
59
|
+
if (!/\s/.test(c)) tokens.push({ kind: "punct", value: c })
|
|
60
|
+
i++
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return tokens
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isPunct = (token: Token | undefined, value: string): boolean =>
|
|
67
|
+
token?.kind === "punct" && token.value === value
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The app's `secretExemptions`, read statically from `nifra.config.ts` (check never imports app code):
|
|
71
|
+
* an array literal of object literals with string fields, the form the build error suggests.
|
|
72
|
+
*/
|
|
73
|
+
export function readSecretExemptions(appRoot: string): SecretExemption[] {
|
|
74
|
+
const path = join(appRoot, CONFIG_FILE)
|
|
75
|
+
if (!existsSync(path)) return []
|
|
76
|
+
const text = stripComments(readFileSync(path, "utf8"))
|
|
77
|
+
const opened = EXEMPTIONS_START.exec(text)
|
|
78
|
+
if (opened === null) return []
|
|
79
|
+
const tokens = arrayTokens(text, opened.index + opened[0].length)
|
|
80
|
+
const exemptions: SecretExemption[] = []
|
|
81
|
+
let depth = 0
|
|
82
|
+
let fields: Record<string, string> | undefined
|
|
83
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
84
|
+
const token = tokens[i] as Token
|
|
85
|
+
if (token.kind === "punct" && "[{(".includes(token.value)) {
|
|
86
|
+
depth++
|
|
87
|
+
if (depth === 1 && token.value === "{") fields = {}
|
|
88
|
+
} else if (token.kind === "punct" && "]})".includes(token.value)) {
|
|
89
|
+
if (depth === 1 && fields !== undefined) {
|
|
90
|
+
const { rule, reason, file, env } = fields
|
|
91
|
+
if (rule !== undefined && reason !== undefined) {
|
|
92
|
+
exemptions.push({
|
|
93
|
+
rule: rule as SecretExemption["rule"],
|
|
94
|
+
reason,
|
|
95
|
+
...(file === undefined ? {} : { file }),
|
|
96
|
+
...(env === undefined ? {} : { env }),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
fields = undefined
|
|
100
|
+
}
|
|
101
|
+
depth--
|
|
102
|
+
} else if (
|
|
103
|
+
depth === 1 &&
|
|
104
|
+
fields !== undefined &&
|
|
105
|
+
token.kind !== "punct" &&
|
|
106
|
+
isPunct(tokens[i + 1], ":") &&
|
|
107
|
+
tokens[i + 2]?.kind === "string" &&
|
|
108
|
+
(isPunct(tokens[i + 3], ",") || isPunct(tokens[i + 3], "}"))
|
|
109
|
+
) {
|
|
110
|
+
fields[token.value] ??= (tokens[i + 2] as Token).value
|
|
111
|
+
i += 2
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return exemptions
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const secretSourceRule: CheckRule = {
|
|
118
|
+
code: "NF-C032",
|
|
119
|
+
title: "Credential in browser code",
|
|
120
|
+
async scan(ctx) {
|
|
121
|
+
const { files } = zonedFiles(ctx)
|
|
122
|
+
const apps = new Map<string, SecretScanFile[]>()
|
|
123
|
+
for (const { file, app, classification } of files) {
|
|
124
|
+
if (!apps.has(app)) apps.set(app, [])
|
|
125
|
+
if (!BROWSER_CODE.has(classification.zone)) continue
|
|
126
|
+
const text = ctx.sources.read(file)
|
|
127
|
+
if (text === undefined) continue
|
|
128
|
+
const name = app === "" ? file : file.slice(app.length + 1)
|
|
129
|
+
apps.get(app)?.push({ name, text, firstParty: true })
|
|
130
|
+
}
|
|
131
|
+
const findings: Diagnostic[] = []
|
|
132
|
+
for (const [app, sources] of apps) {
|
|
133
|
+
const appRoot = join(ctx.root, app)
|
|
134
|
+
const publicDir = join(appRoot, "public")
|
|
135
|
+
try {
|
|
136
|
+
const found = scanForSecrets({
|
|
137
|
+
sources,
|
|
138
|
+
artifacts: existsSync(publicDir) ? publicScanFiles(publicDir, "public") : [],
|
|
139
|
+
exemptions: readSecretExemptions(appRoot),
|
|
140
|
+
})
|
|
141
|
+
for (const finding of found) {
|
|
142
|
+
const file = app === "" ? finding.file : `${app}/${finding.file}`
|
|
143
|
+
findings.push(
|
|
144
|
+
diagnostic({
|
|
145
|
+
code: "NF-C032",
|
|
146
|
+
severity: "error",
|
|
147
|
+
file,
|
|
148
|
+
line: finding.line,
|
|
149
|
+
message: `${file} carries what looks like a credential (${finding.what}), and it reaches the browser. Read it from the environment in a route's backend half or under backend/, and rotate it if it was ever committed`,
|
|
150
|
+
evidence: [`rule: ${finding.rule}`, `match: ${finding.preview}`],
|
|
151
|
+
verify: "nifra check",
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// A malformed exemption: the build refuses it with the same message.
|
|
157
|
+
findings.push(
|
|
158
|
+
diagnostic({
|
|
159
|
+
code: "NF-C032",
|
|
160
|
+
severity: "error",
|
|
161
|
+
file: join(app, CONFIG_FILE),
|
|
162
|
+
message: error instanceof Error ? error.message : String(error),
|
|
163
|
+
verify: "nifra check",
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return findings.sort(
|
|
169
|
+
(a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
|
|
170
|
+
)
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const secretRules = Object.freeze([secretSourceRule])
|