@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/doctor.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import type { Dirent } from "node:fs"
|
|
20
20
|
import { readdir, stat } from "node:fs/promises"
|
|
21
21
|
import { builtinModules } from "node:module"
|
|
22
|
-
import { dirname, join, relative, sep } from "node:path"
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
23
23
|
import {
|
|
24
24
|
collectIdentityParity,
|
|
25
25
|
displayPath,
|
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
resolvedInstalledCopy,
|
|
30
30
|
} from "@nifrajs/web/internal/parity"
|
|
31
31
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
32
|
-
import {
|
|
32
|
+
import { collectStaleMcpPins, type StaleMcpPin } from "./init-agents.ts"
|
|
33
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
34
|
+
import { detectToolingDrift, syncMcpCommand, type ToolingDrift } from "./mcp-root.ts"
|
|
33
35
|
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
34
36
|
import { type ResolvedTarget, resolveTarget } from "./port.ts"
|
|
35
37
|
import { buildScriptName } from "./workspace-link.ts"
|
|
@@ -46,8 +48,10 @@ const BUILTINS: ReadonlySet<string> = new Set<string>([
|
|
|
46
48
|
// import, and CJS require. Anchored with `(?<![.\w$])` so `myimport`/`.import`/`foorequire` never match.
|
|
47
49
|
// Comments are stripped before these run (see stripComments) - else a doc-comment usage example would be
|
|
48
50
|
// flagged as a real import.
|
|
51
|
+
// The clause before `from` is spelled out rather than scanned lazily: a lazy scan restarts at every
|
|
52
|
+
// `export const` line and is quadratic on a long module with no string literal.
|
|
49
53
|
const IMPORT_PATTERNS: readonly RegExp[] = [
|
|
50
|
-
/(?<![.\w$])(?:import|export)\b[^'"]
|
|
54
|
+
/(?<![.\w$])(?:import|export)\b\s*(?:type\b\s*)?(?:[\w$]+\s*,?\s*)?(?:\*\s*(?:as\b\s*[\w$]+\s*)?|\{[^{}'"]*\}\s*)?from\s*['"]([^'"]+)['"]/g,
|
|
51
55
|
/(?<![.\w$])import\s+['"]([^'"]+)['"]/g,
|
|
52
56
|
/(?<![.\w$])import\s*\(\s*['"]([^'"]+)['"]/g,
|
|
53
57
|
/(?<![.\w$])require\s*\(\s*['"]([^'"]+)['"]/g,
|
|
@@ -126,7 +130,7 @@ export function scanUndeclaredImports(
|
|
|
126
130
|
out.push({ file, line, snippet: pkg })
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
|
-
return out.sort((a, b) => a.line - b.line || a.snippet
|
|
133
|
+
return out.sort((a, b) => a.line - b.line || codeUnitOrder(a.snippet, b.snippet))
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
export interface DoctorFinding {
|
|
@@ -174,8 +178,16 @@ export interface DoctorResult {
|
|
|
174
178
|
* supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
|
|
175
179
|
*/
|
|
176
180
|
readonly toolingDrift?: ToolingDrift
|
|
181
|
+
/**
|
|
182
|
+
* Agent files whose MCP launch pins an `@nifrajs/cli` other than the one the project installs, so
|
|
183
|
+
* the agent's server answers for a release the code no longer builds with. Advisory (never folded
|
|
184
|
+
* into `ok`). Present only when at least one file is stale. Computed by {@link collectStaleMcpPins}.
|
|
185
|
+
*/
|
|
186
|
+
readonly staleMcpPins?: { readonly target: string; readonly files: readonly StaleMcpPin[] }
|
|
177
187
|
/** Static production-readiness evidence for the selected deploy target. */
|
|
178
188
|
readonly readiness?: DoctorReadiness
|
|
189
|
+
/** The explicit source boundary used by the dependency scan. */
|
|
190
|
+
readonly scanScope?: DoctorScanScope
|
|
179
191
|
/**
|
|
180
192
|
* Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
|
|
181
193
|
* that exist only because there are two. Absent when the directory is not a nifra app.
|
|
@@ -191,6 +203,12 @@ export interface DoctorResult {
|
|
|
191
203
|
readonly skippedFixes?: readonly DoctorSkippedFix[]
|
|
192
204
|
}
|
|
193
205
|
|
|
206
|
+
export interface DoctorScanScope {
|
|
207
|
+
readonly kind: "standalone" | "workspace-production"
|
|
208
|
+
readonly includeTests: boolean
|
|
209
|
+
readonly excluded: readonly string[]
|
|
210
|
+
}
|
|
211
|
+
|
|
194
212
|
export type DoctorReadinessStatus = "configured" | "absent" | "not-applicable"
|
|
195
213
|
|
|
196
214
|
export interface DoctorReadinessItem {
|
|
@@ -577,7 +595,7 @@ export async function collectStaleWorkspaceDists(
|
|
|
577
595
|
if (worst !== undefined) findings.push(worst)
|
|
578
596
|
}
|
|
579
597
|
}
|
|
580
|
-
return findings.sort((a, b) => a.package
|
|
598
|
+
return findings.sort((a, b) => codeUnitOrder(a.package, b.package))
|
|
581
599
|
}
|
|
582
600
|
|
|
583
601
|
interface ReadinessSource {
|
|
@@ -615,7 +633,7 @@ async function collectDoctorReadiness(
|
|
|
615
633
|
await walkSource(cwd, (file, source) => {
|
|
616
634
|
files.push({ file, source, code: codePositionMask(source) })
|
|
617
635
|
})
|
|
618
|
-
const edgeTarget = /^(?:
|
|
636
|
+
const edgeTarget = /^(?:cloudflare|vercel|workers|edge|static)$/.test(resolved?.target ?? "")
|
|
619
637
|
const configured = (
|
|
620
638
|
id: DoctorReadinessItem["id"],
|
|
621
639
|
label: string,
|
|
@@ -705,13 +723,27 @@ async function ancestorDependencySpec(cwd: string, name: string): Promise<string
|
|
|
705
723
|
const pkg = await readJson(join(dir, "package.json"))
|
|
706
724
|
if (pkg !== undefined) {
|
|
707
725
|
const spec = dependencySpec(pkg, name)
|
|
708
|
-
if (spec !== undefined) return spec
|
|
726
|
+
if (spec !== undefined) return rebaseDependencySpec(spec, dir, cwd)
|
|
709
727
|
}
|
|
710
728
|
const parent = dirname(dir)
|
|
711
729
|
if (parent === dir) return undefined
|
|
712
730
|
}
|
|
713
731
|
}
|
|
714
732
|
|
|
733
|
+
/**
|
|
734
|
+
* A path spec (`file:packages/x`, `link:../x`, `./vendor/x.tgz`) resolves against the package.json
|
|
735
|
+
* that declares it, so a spec copied from an ancestor is re-pointed to the same target from `cwd`.
|
|
736
|
+
*/
|
|
737
|
+
function rebaseDependencySpec(spec: string, declaredIn: string, cwd: string): string {
|
|
738
|
+
const protocol = /^(?:file|link|portal):/.exec(spec)?.[0] ?? ""
|
|
739
|
+
const path = spec.slice(protocol.length)
|
|
740
|
+
const relativePath =
|
|
741
|
+
protocol === "" ? /^\.\.?(?:[\\/]|$)/.test(path) : !isAbsolute(path) && !path.startsWith("~")
|
|
742
|
+
if (!relativePath) return spec
|
|
743
|
+
const rebased = relative(cwd, resolve(declaredIn, path)).replaceAll("\\", "/")
|
|
744
|
+
return protocol + (rebased === ".." || rebased.startsWith("../") ? rebased : `./${rebased}`)
|
|
745
|
+
}
|
|
746
|
+
|
|
715
747
|
async function installedPackageSpec(cwd: string, name: string): Promise<string | undefined> {
|
|
716
748
|
if (!isNpmPackageName(name)) return undefined
|
|
717
749
|
const parts = name.split("/")
|
|
@@ -751,9 +783,29 @@ export async function collectDoctorResult(
|
|
|
751
783
|
const scopes = await doctorPackageScopes(cwd, pkg)
|
|
752
784
|
|
|
753
785
|
const findings: DoctorFinding[] = []
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
786
|
+
// A standalone app owns its tests, so they remain in the declaration check. A workspace root owns
|
|
787
|
+
// the published package source while its root devDependencies intentionally own shared tests,
|
|
788
|
+
// benchmarks, and scaffold copy-surfaces. Keep that boundary explicit instead of reporting a
|
|
789
|
+
// monorepo-wide false failure or silently dropping files from the result.
|
|
790
|
+
const workspaceProduction = workspacePatterns(pkg).length > 0
|
|
791
|
+
const scanScope: DoctorScanScope = Object.freeze({
|
|
792
|
+
kind: workspaceProduction ? "workspace-production" : "standalone",
|
|
793
|
+
includeTests: !workspaceProduction,
|
|
794
|
+
excluded: workspaceProduction
|
|
795
|
+
? Object.freeze([
|
|
796
|
+
"**/test/**",
|
|
797
|
+
"**/test-node/**",
|
|
798
|
+
"bench/**",
|
|
799
|
+
"packages/create-nifra/template*/**",
|
|
800
|
+
])
|
|
801
|
+
: Object.freeze([]),
|
|
802
|
+
})
|
|
803
|
+
const workspaceSurface = (rel: string): boolean =>
|
|
804
|
+
workspaceProduction &&
|
|
805
|
+
(/(^|\/)(?:test|test-node)\//.test(rel) ||
|
|
806
|
+
rel === "bench" ||
|
|
807
|
+
rel.startsWith("bench/") ||
|
|
808
|
+
rel.startsWith("packages/create-nifra/template"))
|
|
757
809
|
await walkSource(
|
|
758
810
|
cwd,
|
|
759
811
|
(rel, content) => {
|
|
@@ -762,9 +814,9 @@ export async function collectDoctorResult(
|
|
|
762
814
|
findings.push({ file: f.file, line: f.line, package: f.snippet })
|
|
763
815
|
}
|
|
764
816
|
},
|
|
765
|
-
{ includeTests:
|
|
817
|
+
{ includeTests: scanScope.includeTests, ignore: workspaceSurface },
|
|
766
818
|
)
|
|
767
|
-
findings.sort((a, b) => a.file
|
|
819
|
+
findings.sort((a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line)
|
|
768
820
|
const identity = await collectAllDuplicateInstalls(cwd, pkg)
|
|
769
821
|
const duplicateInstalls = identity.duplicates
|
|
770
822
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
@@ -776,7 +828,8 @@ export async function collectDoctorResult(
|
|
|
776
828
|
// pass its own version (e.g. the MCP server, which already annotates every result with the drift).
|
|
777
829
|
const toolingDrift =
|
|
778
830
|
opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
|
|
779
|
-
|
|
831
|
+
const staleMcpPins = await collectStaleMcpPins(cwd).catch(() => undefined)
|
|
832
|
+
// `staleDists`, `toolingDrift` and `staleMcpPins` are advisory (see DoctorResult): they never fail `ok`.
|
|
780
833
|
return {
|
|
781
834
|
ok:
|
|
782
835
|
findings.length === 0 &&
|
|
@@ -791,7 +844,9 @@ export async function collectDoctorResult(
|
|
|
791
844
|
...(identity.truncated ? { identityScanTruncated: true } : {}),
|
|
792
845
|
staleDists,
|
|
793
846
|
readiness,
|
|
847
|
+
scanScope,
|
|
794
848
|
...(toolingDrift !== undefined ? { toolingDrift } : {}),
|
|
849
|
+
...(staleMcpPins !== undefined && staleMcpPins.files.length > 0 ? { staleMcpPins } : {}),
|
|
795
850
|
...(pipeline.ran ? { pipeline } : {}),
|
|
796
851
|
}
|
|
797
852
|
}
|
|
@@ -817,7 +872,7 @@ export async function applyDoctorAutoFix(
|
|
|
817
872
|
|
|
818
873
|
const fixed: DoctorAppliedFix[] = []
|
|
819
874
|
const skippedFixes: DoctorSkippedFix[] = []
|
|
820
|
-
for (const [root, names] of [...byRoot.entries()].sort(([a], [b]) => a
|
|
875
|
+
for (const [root, names] of [...byRoot.entries()].sort(([a], [b]) => codeUnitOrder(a, b))) {
|
|
821
876
|
const pkgPath = join(root, "package.json")
|
|
822
877
|
const pkg = await readJson(pkgPath)
|
|
823
878
|
if (pkg === undefined) continue
|
|
@@ -922,6 +977,14 @@ export async function runDoctor(
|
|
|
922
977
|
console.log(` fix: ${f.fix}\n`)
|
|
923
978
|
}
|
|
924
979
|
}
|
|
980
|
+
if (result.scanScope !== undefined) {
|
|
981
|
+
console.log(
|
|
982
|
+
`• dependency scan scope: ${result.scanScope.kind} (${result.scanScope.includeTests ? "includes tests" : "production package source only"})`,
|
|
983
|
+
)
|
|
984
|
+
if (result.scanScope.excluded.length > 0)
|
|
985
|
+
console.log(` excluded: ${result.scanScope.excluded.join(", ")}`)
|
|
986
|
+
console.log("")
|
|
987
|
+
}
|
|
925
988
|
// Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
|
|
926
989
|
// it costs an hour of misdiagnosis when the dev server 500s inside the package.
|
|
927
990
|
if (result.staleDists.length > 0) {
|
|
@@ -963,10 +1026,28 @@ export async function runDoctor(
|
|
|
963
1026
|
"its types, checks, and docs may describe a different version than your code builds with.",
|
|
964
1027
|
)
|
|
965
1028
|
console.log(
|
|
966
|
-
|
|
1029
|
+
` fix: \`${syncMcpCommand(drift.cli)}\` re-pins the project's MCP launch to the nifra it installs; ` +
|
|
1030
|
+
"for the CLI itself, run the project's own (`bunx --bun nifra doctor` from the project directory, " +
|
|
967
1031
|
"or ./node_modules/.bin/nifra)\n",
|
|
968
1032
|
)
|
|
969
1033
|
}
|
|
1034
|
+
// Advisory: the agent's MCP server is launched from these pins, so a stale one answers for an old
|
|
1035
|
+
// release even when the CLI running doctor matches the project.
|
|
1036
|
+
if (result.staleMcpPins !== undefined) {
|
|
1037
|
+
const { target, files } = result.staleMcpPins
|
|
1038
|
+
console.log(
|
|
1039
|
+
`⚠ the MCP launch pins a nifra other than the project's ${target} - an agent's nifra server answers for that release:`,
|
|
1040
|
+
)
|
|
1041
|
+
for (const file of files)
|
|
1042
|
+
console.log(` ${file.path}: @nifrajs/cli@${file.pinned.join(", ")}`)
|
|
1043
|
+
const command =
|
|
1044
|
+
opts.cliVersion !== undefined
|
|
1045
|
+
? syncMcpCommand(opts.cliVersion)
|
|
1046
|
+
: "nifra init-agents --sync-mcp"
|
|
1047
|
+
console.log(
|
|
1048
|
+
` fix: \`${command}\` rewrites only the pinned version, then restart the agent\n`,
|
|
1049
|
+
)
|
|
1050
|
+
}
|
|
970
1051
|
if (result.ok) {
|
|
971
1052
|
console.log(
|
|
972
1053
|
"✓ every imported package is declared and identity-sensitive installs are deduplicated",
|
|
@@ -1007,6 +1088,8 @@ export async function runDoctor(
|
|
|
1007
1088
|
console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
|
|
1008
1089
|
}
|
|
1009
1090
|
console.log(` ${finding.explanation}`)
|
|
1091
|
+
// A planted symlink is the cause no reinstall here can see, so it is named before the fix.
|
|
1092
|
+
if (finding.provenance !== undefined) console.log(` links: ${finding.provenance}`)
|
|
1010
1093
|
// The shape of the split decides which fix can work, so it is printed above the fix itself.
|
|
1011
1094
|
if (finding.topology !== undefined) console.log(` topology: ${finding.topology}`)
|
|
1012
1095
|
// Why a copy this directory never imports still counts. Without it, a workspace-wide answer
|
package/src/env-file.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - A missing file is an error, never a silent no-op. `--env-file .env.prod` that quietly did nothing
|
|
16
16
|
* would make the command look like it verified an environment it never loaded.
|
|
17
17
|
*/
|
|
18
|
-
import { readFile } from "node:fs/promises"
|
|
18
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
19
19
|
import { resolve } from "node:path"
|
|
20
20
|
|
|
21
21
|
const ENV_FILE_FLAG = "--env-file"
|
|
@@ -119,3 +119,80 @@ export async function applyEnvFiles(
|
|
|
119
119
|
}
|
|
120
120
|
return applied
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove from `process.env` what Bun loaded from the `.env` files in `cwd` when this process started,
|
|
125
|
+
* keeping every variable the environment itself set. Returns the names removed. `nifra mcp` runs it
|
|
126
|
+
* first and, when anything was removed, serves from a `bun --no-env-file` copy of itself given the
|
|
127
|
+
* result: Bun hands the values it loaded to every subprocess whatever `process.env` says later. The
|
|
128
|
+
* server is long-lived and runs no project code, so it holds none of the project's `.env`; the project
|
|
129
|
+
* subprocesses it starts load those files again themselves.
|
|
130
|
+
*
|
|
131
|
+
* Bun applies the files under the environment (a variable already set wins) and expands `${NAME}`
|
|
132
|
+
* references, so Bun itself says which values came from a file: a subprocess with an empty
|
|
133
|
+
* environment reports what the files add to it, and a variable with that value is removed. A value that
|
|
134
|
+
* differs is asked again with the rest of the environment, for an expansion that read it; one that
|
|
135
|
+
* still differs was set by the environment, and stays.
|
|
136
|
+
*/
|
|
137
|
+
export async function forgetAutoLoadedEnv(cwd = process.cwd()): Promise<readonly string[]> {
|
|
138
|
+
const names = await readdir(cwd).catch(() => [])
|
|
139
|
+
if (!names.some((name) => name.startsWith(".env"))) return []
|
|
140
|
+
const mode = process.env.NODE_ENV
|
|
141
|
+
const fromFiles = await envFromFiles(cwd, mode === undefined ? {} : { NODE_ENV: mode })
|
|
142
|
+
const removed: string[] = []
|
|
143
|
+
const differing: string[] = []
|
|
144
|
+
for (const [name, value] of Object.entries(fromFiles)) {
|
|
145
|
+
if (name === "NODE_ENV" || process.env[name] === undefined) continue
|
|
146
|
+
if (process.env[name] === value) removed.push(name)
|
|
147
|
+
else differing.push(name)
|
|
148
|
+
}
|
|
149
|
+
if (differing.length > 0) {
|
|
150
|
+
const rest = { ...process.env }
|
|
151
|
+
for (const name of differing) delete rest[name]
|
|
152
|
+
const expanded = await envFromFiles(cwd, rest)
|
|
153
|
+
for (const name of differing) if (process.env[name] === expanded[name]) removed.push(name)
|
|
154
|
+
}
|
|
155
|
+
for (const name of removed) delete process.env[name]
|
|
156
|
+
return removed
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What the `.env` files in `cwd` add to a Bun process started there with `env`. */
|
|
160
|
+
async function envFromFiles(
|
|
161
|
+
cwd: string,
|
|
162
|
+
env: Record<string, string | undefined>,
|
|
163
|
+
): Promise<Record<string, string>> {
|
|
164
|
+
// A child holds more than `env`: Windows copies PATH, SYSTEMROOT and a few more from the parent,
|
|
165
|
+
// and a bunfig preload can set others. A `--no-env-file` copy of the child holds those as well.
|
|
166
|
+
const [loaded, bare] = await Promise.all([
|
|
167
|
+
childEnv(cwd, env, []),
|
|
168
|
+
childEnv(cwd, env, ["--no-env-file"]),
|
|
169
|
+
])
|
|
170
|
+
const added: Record<string, string> = {}
|
|
171
|
+
for (const [name, value] of Object.entries(loaded)) if (bare[name] !== value) added[name] = value
|
|
172
|
+
return added
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What `process.env` holds in a Bun process started in `cwd` with `env` and the runtime `flags`. */
|
|
176
|
+
async function childEnv(
|
|
177
|
+
cwd: string,
|
|
178
|
+
env: Record<string, string | undefined>,
|
|
179
|
+
flags: readonly string[],
|
|
180
|
+
): Promise<Record<string, string>> {
|
|
181
|
+
const proc = Bun.spawn(
|
|
182
|
+
[process.execPath, ...flags, "-e", "process.stdout.write(JSON.stringify(process.env))"],
|
|
183
|
+
{ cwd, env, stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
184
|
+
)
|
|
185
|
+
const [text] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
186
|
+
let value: unknown
|
|
187
|
+
try {
|
|
188
|
+
value = JSON.parse(text)
|
|
189
|
+
} catch {
|
|
190
|
+
return {}
|
|
191
|
+
}
|
|
192
|
+
if (typeof value !== "object" || value === null) return {}
|
|
193
|
+
return Object.fromEntries(
|
|
194
|
+
Object.entries(value).filter(
|
|
195
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}
|
package/src/fix-recipes.ts
CHANGED
|
@@ -31,35 +31,27 @@ registerFixRecipe({
|
|
|
31
31
|
description: "Replace a direct secret comparison with a length check and timing-safe comparison.",
|
|
32
32
|
verify: "nifra check --lints-only",
|
|
33
33
|
async apply(root, diagnostic) {
|
|
34
|
-
if (diagnostic.file === undefined
|
|
34
|
+
if (diagnostic.file === undefined) return []
|
|
35
35
|
const path = await resolveInsideProject(root, diagnostic.file)
|
|
36
36
|
if (path === undefined) return []
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const left = comparison[1] as string
|
|
44
|
-
const operator = comparison[2] as string
|
|
45
|
-
const right = comparison[3] as string
|
|
46
|
-
const expression = `nifraTimingSafeEqual(${left}, ${right})`
|
|
47
|
-
const replacement = operator === "!==" || operator === "!=" ? `!${expression}` : expression
|
|
48
|
-
lines[index] = original.replace(comparison[0], replacement)
|
|
49
|
-
if (!lines.some((line) => line.includes("function nifraTimingSafeEqual"))) {
|
|
50
|
-
lines.unshift(
|
|
51
|
-
'import { timingSafeEqual } from "node:crypto"',
|
|
52
|
-
"",
|
|
53
|
-
"function nifraTimingSafeEqual(left: string, right: string): boolean {",
|
|
54
|
-
" const leftBytes = Buffer.from(left)",
|
|
55
|
-
" const rightBytes = Buffer.from(right)",
|
|
56
|
-
" return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)",
|
|
57
|
-
"}",
|
|
58
|
-
"",
|
|
37
|
+
const { loadProjectTypeScript } = await import("./internal/typescript-import.ts")
|
|
38
|
+
const loaded = await loadProjectTypeScript(root)
|
|
39
|
+
if (loaded.unsupported !== undefined) throw loaded.unsupported
|
|
40
|
+
if (loaded.compiler === undefined)
|
|
41
|
+
throw new Error(
|
|
42
|
+
"[nifra] the timing-safe fix finds each comparison in the syntax tree, so it needs TypeScript - run `bun add -d typescript`, then rerun the fix",
|
|
59
43
|
)
|
|
44
|
+
try {
|
|
45
|
+
const { rewriteSecretComparisons } = await import("./rules/security.ts")
|
|
46
|
+
const source = await readFile(path, "utf8")
|
|
47
|
+
// Every site in the file at once, so the rest of that file's diagnostics find nothing left.
|
|
48
|
+
const rewritten = rewriteSecretComparisons(loaded.compiler, diagnostic.file, source)
|
|
49
|
+
if (rewritten === source) return []
|
|
50
|
+
await writeFile(path, rewritten, "utf8")
|
|
51
|
+
return [diagnostic.file]
|
|
52
|
+
} finally {
|
|
53
|
+
await loaded.session?.close()
|
|
60
54
|
}
|
|
61
|
-
await writeFile(path, lines.join("\n"), "utf8")
|
|
62
|
-
return [diagnostic.file]
|
|
63
55
|
},
|
|
64
56
|
})
|
|
65
57
|
|
|
@@ -101,7 +93,7 @@ registerFixRecipe({
|
|
|
101
93
|
// Refusing is a result the caller has to see. Returning "changed nothing" made `nifra fix
|
|
102
94
|
// --code NF-C010` a silent no-op on the only findings that produce it.
|
|
103
95
|
if (!linked.ok) throw new Error(`[nifra] cannot rebuild ${packageName}: ${linked.reason}`)
|
|
104
|
-
const proc = Bun.spawn([
|
|
96
|
+
const proc = Bun.spawn([process.execPath, "run", linked.buildScript], {
|
|
105
97
|
cwd: linked.dir,
|
|
106
98
|
stdout: "ignore",
|
|
107
99
|
stderr: "pipe",
|
|
@@ -117,6 +109,24 @@ registerFixRecipe({
|
|
|
117
109
|
},
|
|
118
110
|
})
|
|
119
111
|
|
|
112
|
+
registerFixRecipe({
|
|
113
|
+
id: "imports.moved-export",
|
|
114
|
+
description: "Import a name that moved to a subpath from the module that now exports it.",
|
|
115
|
+
verify: "nifra check --lints-only",
|
|
116
|
+
async apply(root, diagnostic) {
|
|
117
|
+
if (diagnostic.file === undefined) return []
|
|
118
|
+
const path = await resolveInsideProject(root, diagnostic.file)
|
|
119
|
+
if (path === undefined) return []
|
|
120
|
+
const { rewriteMovedExports } = await import("./check-scan.ts")
|
|
121
|
+
const source = await readFile(path, "utf8")
|
|
122
|
+
// Rewrites every site in the file at once, so the rest of that file's diagnostics find nothing left.
|
|
123
|
+
const rewritten = rewriteMovedExports(source)
|
|
124
|
+
if (rewritten === source) return []
|
|
125
|
+
await writeFile(path, rewritten, "utf8")
|
|
126
|
+
return [diagnostic.file]
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
120
130
|
/**
|
|
121
131
|
* How many typecheck passes the reserved-segment codemod will run. A nested collision
|
|
122
132
|
* (`/api/delete/then`) only reveals its second site once the first is rewritten, so one pass is not
|
|
@@ -147,11 +157,14 @@ registerFixRecipe({
|
|
|
147
157
|
const changed = new Set<string>()
|
|
148
158
|
const skipped: string[] = []
|
|
149
159
|
for (let pass = 0; pass < RESERVED_SEGMENT_PASSES; pass += 1) {
|
|
150
|
-
const proc = Bun.spawn(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
const proc = Bun.spawn(
|
|
161
|
+
[process.execPath, tscBin, "--noEmit", "--pretty", "false", "-p", tsconfig],
|
|
162
|
+
{
|
|
163
|
+
cwd: root,
|
|
164
|
+
stdout: "pipe",
|
|
165
|
+
stderr: "pipe",
|
|
166
|
+
},
|
|
167
|
+
)
|
|
155
168
|
const [stdout, stderr] = await Promise.all([
|
|
156
169
|
new Response(proc.stdout).text(),
|
|
157
170
|
new Response(proc.stderr).text(),
|
package/src/frontend-guidance.ts
CHANGED
|
@@ -52,11 +52,11 @@ export const FRONTEND_GUIDANCE: readonly GuidanceEntry[] = [
|
|
|
52
52
|
symptom:
|
|
53
53
|
"A client component crashes at build or runtime with a missing Node built-in, a leaked secret, or 'module not found in the browser'.",
|
|
54
54
|
cause:
|
|
55
|
-
"
|
|
56
|
-
fix: "Move the access into
|
|
55
|
+
"Browser code (a page, `frontend/` or `shared/`) imports backend code - `backend/`, a route's `.backend.ts` half, the DB, a secret, `node:*` - so the browser build refuses it.",
|
|
56
|
+
fix: "Move the access into the route's `.backend.ts` half (`loader`/`action` with `loaderOutput`/`actionOutput`) and read the result as `data` in the page. Reach the backend through the typed `api` argument; put code both sides need in `shared/`.",
|
|
57
57
|
verify:
|
|
58
58
|
"nifra_check - its transitive server-import scan flags the exact import chain into a client module.",
|
|
59
|
-
seeAlso: "
|
|
59
|
+
seeAlso: "project structure route backend half import rules",
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
id: "hydration-mismatch",
|
|
@@ -88,10 +88,10 @@ export const FRONTEND_GUIDANCE: readonly GuidanceEntry[] = [
|
|
|
88
88
|
"Loader data is typed `any` in the page, or a hand-written interface silently drifts from what the loader actually returns.",
|
|
89
89
|
cause:
|
|
90
90
|
"The component types its props by hand instead of inferring them from the loader, so a change to the loader is not a type error at the use site.",
|
|
91
|
-
fix:
|
|
91
|
+
fix: 'Type the page from its generated route types: `import type { Route } from "./+types/<name>"` and `props: Route.ComponentProps`. Its `data` is the `loaderOutput` schema\'s type - what actually reaches the browser - while `LoaderData<typeof loader>` also types fields the schema strips. Call the backend through the typed `api` (`Route.LoaderArgs`) so the request/response shape is inferred, not restated.',
|
|
92
92
|
verify:
|
|
93
93
|
"nifra_context prints the exact loader shape and typed call form; nifra_check fails on drift.",
|
|
94
|
-
seeAlso: "
|
|
94
|
+
seeAlso: "route types Route.ComponentProps loaderOutput typed client",
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
id: "list-key",
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra i18n check [entry]` - runs `checkCatalogs()` from `@nifrajs/i18n/check` over an app's locale
|
|
3
|
+
* registry and catalogs. Unlike `nifra check`, this IMPORTS app code: the entry module, which exports
|
|
4
|
+
* `locales` (from `defineLocales`) and `catalogs` (by locale key: a catalog, or a function returning
|
|
5
|
+
* one or a promise of one, such as `() => import("./messages/fr.json")`), plus an optional `ignore`.
|
|
6
|
+
* The default entry is the first of {@link DEFAULT_ENTRIES} that exists.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs"
|
|
9
|
+
import { relative, resolve } from "node:path"
|
|
10
|
+
import { pathToFileURL } from "node:url"
|
|
11
|
+
import type { MessageTree } from "@nifrajs/i18n"
|
|
12
|
+
import type { CatalogCheckOptions, CatalogCheckResult, CatalogFinding } from "@nifrajs/i18n/check"
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_ENTRIES = [
|
|
15
|
+
"shared/i18n.ts",
|
|
16
|
+
"i18n.ts",
|
|
17
|
+
"lib/i18n.ts",
|
|
18
|
+
"src/i18n.ts",
|
|
19
|
+
"src/lib/i18n.ts",
|
|
20
|
+
"app/i18n.ts",
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
export interface I18nCheckOutput {
|
|
24
|
+
/** The entry module, relative to the project directory. */
|
|
25
|
+
readonly entry: string
|
|
26
|
+
readonly result: CatalogCheckResult
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
30
|
+
value !== null && typeof value === "object"
|
|
31
|
+
|
|
32
|
+
/** A loaded module's default export when the value is a module namespace (`import("./fr.json")`). */
|
|
33
|
+
const unwrapModule = (value: unknown): unknown =>
|
|
34
|
+
isObject(value) && Object.prototype.toString.call(value) === "[object Module]"
|
|
35
|
+
? value.default
|
|
36
|
+
: value
|
|
37
|
+
|
|
38
|
+
const isRegistry = (value: unknown): value is CatalogCheckOptions["locales"] =>
|
|
39
|
+
isObject(value) &&
|
|
40
|
+
typeof value.default === "string" &&
|
|
41
|
+
Array.isArray(value.all) &&
|
|
42
|
+
typeof value.get === "function" &&
|
|
43
|
+
typeof value.chain === "function"
|
|
44
|
+
|
|
45
|
+
function resolveEntry(cwd: string, entry: string | undefined): string {
|
|
46
|
+
if (entry !== undefined) {
|
|
47
|
+
const path = resolve(cwd, entry)
|
|
48
|
+
if (!existsSync(path)) throw new Error(`i18n check: ${entry} does not exist`)
|
|
49
|
+
return path
|
|
50
|
+
}
|
|
51
|
+
for (const candidate of DEFAULT_ENTRIES) {
|
|
52
|
+
const path = resolve(cwd, candidate)
|
|
53
|
+
if (existsSync(path)) return path
|
|
54
|
+
}
|
|
55
|
+
throw new Error(
|
|
56
|
+
`i18n check: no entry module found (looked for ${DEFAULT_ENTRIES.join(", ")}). Pass one: nifra i18n check <entry>, a module exporting \`locales\` and \`catalogs\`.`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Import the entry, load every catalog it names, and check them. */
|
|
61
|
+
export async function runI18nCheck(
|
|
62
|
+
cwd: string,
|
|
63
|
+
options: { readonly entry?: string | undefined } = {},
|
|
64
|
+
): Promise<I18nCheckOutput> {
|
|
65
|
+
const path = resolveEntry(cwd, options.entry)
|
|
66
|
+
const mod = (await import(pathToFileURL(path).href)) as Record<string, unknown>
|
|
67
|
+
const source = isObject(mod.default) && mod.locales === undefined ? mod.default : mod
|
|
68
|
+
const { locales, catalogs, ignore } = source
|
|
69
|
+
const entry = relative(cwd, path).replaceAll("\\", "/") || path
|
|
70
|
+
if (!isRegistry(locales)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`i18n check: ${entry} must export \`locales\`, the registry defineLocales() returns`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
if (!isObject(catalogs)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`i18n check: ${entry} must export \`catalogs\`, an object of catalogs by locale key`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
if (ignore !== undefined && !isObject(ignore)) {
|
|
81
|
+
throw new Error(`i18n check: \`ignore\` in ${entry} must be an object of key patterns by check`)
|
|
82
|
+
}
|
|
83
|
+
const loaded: Record<string, MessageTree | undefined> = {}
|
|
84
|
+
for (const key of Object.keys(catalogs)) {
|
|
85
|
+
let value = catalogs[key]
|
|
86
|
+
if (typeof value === "function") value = (value as () => unknown)()
|
|
87
|
+
loaded[key] = unwrapModule(await value) as MessageTree | undefined
|
|
88
|
+
}
|
|
89
|
+
// Loaded on use: the command catalog imports this module on every CLI start.
|
|
90
|
+
const { checkCatalogs } = await import("@nifrajs/i18n/check")
|
|
91
|
+
return {
|
|
92
|
+
entry,
|
|
93
|
+
result: checkCatalogs({
|
|
94
|
+
locales,
|
|
95
|
+
catalogs: loaded,
|
|
96
|
+
ignore: ignore as CatalogCheckOptions["ignore"],
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const MARK = { error: "✖", warning: "⚠", info: "•" } as const
|
|
102
|
+
// Per-key findings that are lists rather than problems one by one: grouped per locale in the text.
|
|
103
|
+
const GROUPED = new Set(["missing-key", "unused-key", "untranslated"])
|
|
104
|
+
const SHOWN_KEYS = 6
|
|
105
|
+
|
|
106
|
+
// Catalog keys and text reach the terminal: control and bidi-override characters print as escapes, so
|
|
107
|
+
// a key can neither drive the terminal nor reorder what the line appears to say.
|
|
108
|
+
const unprintable = (code: number): boolean =>
|
|
109
|
+
code <= 0x1f ||
|
|
110
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
111
|
+
code === 0x200e ||
|
|
112
|
+
code === 0x200f ||
|
|
113
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
114
|
+
(code >= 0x2066 && code <= 0x2069)
|
|
115
|
+
const printable = (line: string): string => {
|
|
116
|
+
let out = ""
|
|
117
|
+
for (const char of line) {
|
|
118
|
+
const code = char.codePointAt(0) ?? 0
|
|
119
|
+
out += unprintable(code) ? `\\u${code.toString(16).padStart(4, "0")}` : char
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const percent = (share: number): string => `${(share * 100).toFixed(share === 1 ? 0 : 1)}%`
|
|
125
|
+
|
|
126
|
+
/** The human report: a coverage table, then findings, grouped where they are key lists. */
|
|
127
|
+
export function renderI18nCheck(out: I18nCheckOutput): readonly string[] {
|
|
128
|
+
const { result } = out
|
|
129
|
+
const lines = [`nifra i18n check - ${out.entry}`, ""]
|
|
130
|
+
const width = Math.max(6, ...result.coverage.map((row) => row.locale.length))
|
|
131
|
+
for (const row of result.coverage) {
|
|
132
|
+
const label = row.default ? "default" : row.draft ? "draft" : ""
|
|
133
|
+
const counts = row.default
|
|
134
|
+
? `${row.total} messages`
|
|
135
|
+
: `${row.translated}/${row.total} (${percent(row.coverage)})${row.own === row.translated ? "" : `, ${row.own} own`}`
|
|
136
|
+
lines.push(` ${row.locale.padEnd(width)} ${label.padEnd(7)} ${counts}`)
|
|
137
|
+
}
|
|
138
|
+
if (result.coverage.length > 0) lines.push("")
|
|
139
|
+
|
|
140
|
+
const groups = new Map<string, CatalogFinding[]>()
|
|
141
|
+
for (const finding of result.findings) {
|
|
142
|
+
if (!GROUPED.has(finding.code) || finding.key === undefined) {
|
|
143
|
+
lines.push(`${MARK[finding.severity]} ${finding.locale} ${finding.code}: ${finding.message}`)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
const id = `${finding.severity}\u0000${finding.locale}\u0000${finding.code}`
|
|
147
|
+
const group = groups.get(id)
|
|
148
|
+
if (group === undefined) groups.set(id, [finding])
|
|
149
|
+
else group.push(finding)
|
|
150
|
+
}
|
|
151
|
+
for (const group of groups.values()) {
|
|
152
|
+
const first = group[0] as CatalogFinding
|
|
153
|
+
const keys = group.map((finding) => finding.key as string)
|
|
154
|
+
const shown = keys.slice(0, SHOWN_KEYS).join(", ")
|
|
155
|
+
const more = keys.length > SHOWN_KEYS ? `, +${keys.length - SHOWN_KEYS} more` : ""
|
|
156
|
+
lines.push(
|
|
157
|
+
`${MARK[first.severity]} ${first.locale} ${first.code} (${keys.length}): ${shown}${more}`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const count = (severity: CatalogFinding["severity"]): number =>
|
|
162
|
+
result.findings.filter((finding) => finding.severity === severity).length
|
|
163
|
+
const errors = count("error")
|
|
164
|
+
const warnings = count("warning")
|
|
165
|
+
if (result.findings.length > 0) lines.push("")
|
|
166
|
+
lines.push(
|
|
167
|
+
errors === 0 && warnings === 0
|
|
168
|
+
? "✓ catalogs are complete and consistent"
|
|
169
|
+
: `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}${errors === 0 ? " - pass --strict to fail on warnings" : ""}`,
|
|
170
|
+
)
|
|
171
|
+
return lines.map(printable)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Errors fail the run; with `strict`, warnings do too. Info never does. */
|
|
175
|
+
export function i18nCheckPassed(out: I18nCheckOutput, strict: boolean): boolean {
|
|
176
|
+
return out.result.findings.every(
|
|
177
|
+
(finding) => finding.severity === "info" || (finding.severity === "warning" && !strict),
|
|
178
|
+
)
|
|
179
|
+
}
|