@nifrajs/cli 4.0.2 → 5.1.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 +10 -2
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -2
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +218 -44
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +94 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/dev-feed-tool.ts +112 -1
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/fix-recipes.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { resolveWorkspaceLinkedPackage } from "./workspace-link.ts"
|
|
|
7
7
|
export interface FixRecipe {
|
|
8
8
|
readonly id: string
|
|
9
9
|
readonly description: string
|
|
10
|
+
/** Run only by `nifra fix --code <code>`: the change it makes is a decision a person reviews first. */
|
|
11
|
+
readonly onlyWhenNamed?: true
|
|
10
12
|
apply(root: string, diagnostic: Diagnostic): Promise<readonly string[]>
|
|
11
13
|
readonly verify: string
|
|
12
14
|
}
|
|
@@ -42,10 +44,15 @@ registerFixRecipe({
|
|
|
42
44
|
"[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",
|
|
43
45
|
)
|
|
44
46
|
try {
|
|
45
|
-
const { rewriteSecretComparisons } = await import("./rules/security.ts")
|
|
47
|
+
const { provenBrowserFile, rewriteSecretComparisons } = await import("./rules/security.ts")
|
|
46
48
|
const source = await readFile(path, "utf8")
|
|
47
49
|
// Every site in the file at once, so the rest of that file's diagnostics find nothing left.
|
|
48
|
-
const rewritten = rewriteSecretComparisons(
|
|
50
|
+
const rewritten = rewriteSecretComparisons(
|
|
51
|
+
loaded.compiler,
|
|
52
|
+
diagnostic.file,
|
|
53
|
+
source,
|
|
54
|
+
provenBrowserFile(root, diagnostic.file),
|
|
55
|
+
)
|
|
49
56
|
if (rewritten === source) return []
|
|
50
57
|
await writeFile(path, rewritten, "utf8")
|
|
51
58
|
return [diagnostic.file]
|
|
@@ -74,9 +81,44 @@ registerFixRecipe({
|
|
|
74
81
|
},
|
|
75
82
|
})
|
|
76
83
|
|
|
84
|
+
registerFixRecipe({
|
|
85
|
+
id: "framework.api-prefix",
|
|
86
|
+
description:
|
|
87
|
+
"Serve the backend over HTTP at /api by exporting apiPrefix from the framework config.",
|
|
88
|
+
verify: "nifra check",
|
|
89
|
+
async apply(root) {
|
|
90
|
+
const { CONFIG_FILE, FRAMEWORK_FILE } = await import("./app-files.ts")
|
|
91
|
+
const { apiPrefixFix } = await import("./rules/pages.ts")
|
|
92
|
+
const frameworkPath = await resolveInsideProject(root, FRAMEWORK_FILE)
|
|
93
|
+
const configPath = await resolveInsideProject(root, CONFIG_FILE)
|
|
94
|
+
if (frameworkPath === undefined || configPath === undefined) return []
|
|
95
|
+
const source = async (path: string): Promise<string | undefined> => {
|
|
96
|
+
const file = Bun.file(path)
|
|
97
|
+
return (await file.exists()) ? await file.text() : undefined
|
|
98
|
+
}
|
|
99
|
+
const next = apiPrefixFix({
|
|
100
|
+
framework: await source(frameworkPath),
|
|
101
|
+
config: await source(configPath),
|
|
102
|
+
})
|
|
103
|
+
const edits: readonly (readonly [string, string, string | undefined])[] = [
|
|
104
|
+
[FRAMEWORK_FILE, frameworkPath, next.framework],
|
|
105
|
+
[CONFIG_FILE, configPath, next.config],
|
|
106
|
+
]
|
|
107
|
+
const changed: string[] = []
|
|
108
|
+
for (const [file, path, text] of edits) {
|
|
109
|
+
if (text === undefined) continue
|
|
110
|
+
await writeFile(path, text, "utf8")
|
|
111
|
+
changed.push(file)
|
|
112
|
+
}
|
|
113
|
+
return changed
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
77
117
|
registerFixRecipe({
|
|
78
118
|
id: "contracts.snapshot",
|
|
79
119
|
description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
|
|
120
|
+
// Rewriting the lock accepts whatever the routes changed to, which is the review the lock exists for.
|
|
121
|
+
onlyWhenNamed: true,
|
|
80
122
|
verify: "nifra contracts check",
|
|
81
123
|
async apply(root) {
|
|
82
124
|
const { snapshotContracts } = await import("./contracts.ts")
|
|
@@ -135,6 +177,78 @@ registerFixRecipe({
|
|
|
135
177
|
},
|
|
136
178
|
})
|
|
137
179
|
|
|
180
|
+
registerFixRecipe({
|
|
181
|
+
id: "route.split",
|
|
182
|
+
description:
|
|
183
|
+
"Move a route frontend file's server-only exports, and the code only they use, into its x.backend.ts.",
|
|
184
|
+
verify: "nifra check",
|
|
185
|
+
async apply(root, diagnostic) {
|
|
186
|
+
const file = diagnostic.file
|
|
187
|
+
if (file === undefined || (await resolveInsideProject(root, file)) === undefined) return []
|
|
188
|
+
const { splitRouteFile } = await import("./migrate-layout.ts")
|
|
189
|
+
return splitRouteFile(root, file)
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
registerFixRecipe({
|
|
194
|
+
id: "layout.migrate",
|
|
195
|
+
description:
|
|
196
|
+
"Move the app onto the frontend/backend layout, as `nifra migrate layout --write` does.",
|
|
197
|
+
onlyWhenNamed: true,
|
|
198
|
+
verify: "nifra check --lints-only",
|
|
199
|
+
async apply(root) {
|
|
200
|
+
const { migrateLayout } = await import("./migrate-layout.ts")
|
|
201
|
+
const result = await migrateLayout(root, { write: true })
|
|
202
|
+
return [
|
|
203
|
+
...new Set([
|
|
204
|
+
...result.moves.map((move) => move.to),
|
|
205
|
+
...result.splits.flatMap((split) => [split.file, split.backend]),
|
|
206
|
+
...result.rewritten,
|
|
207
|
+
...result.tooling.map((edit) => edit.file),
|
|
208
|
+
]),
|
|
209
|
+
].sort()
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
registerFixRecipe({
|
|
214
|
+
id: "schemas.draft",
|
|
215
|
+
description:
|
|
216
|
+
"Draft the missing loader or action output schema from the function's return type, for the author to review.",
|
|
217
|
+
verify: "nifra check --lints-only",
|
|
218
|
+
async apply(root, diagnostic) {
|
|
219
|
+
const file = diagnostic.file
|
|
220
|
+
if (file === undefined) return []
|
|
221
|
+
const path = await resolveInsideProject(root, file)
|
|
222
|
+
if (path === undefined) return []
|
|
223
|
+
const schema = diagnostic.evidence
|
|
224
|
+
?.find((line) => line.startsWith("missing: "))
|
|
225
|
+
?.slice("missing: ".length)
|
|
226
|
+
if (schema !== "loaderOutput" && schema !== "actionOutput") return []
|
|
227
|
+
const { planOutputSchemas } = await import("./output-schemas.ts")
|
|
228
|
+
const source = await readFile(path, "utf8")
|
|
229
|
+
// Only this declaration: other schemas in the file, generated or hand-written, stay as they are.
|
|
230
|
+
const plan = await planOutputSchemas(
|
|
231
|
+
root,
|
|
232
|
+
[file],
|
|
233
|
+
(read) => (read === file ? source : undefined),
|
|
234
|
+
{
|
|
235
|
+
draft: (at, name) => at === file && name === schema,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
const blocked =
|
|
239
|
+
plan.warnings.length > 0
|
|
240
|
+
? plan.warnings.join("; ")
|
|
241
|
+
: plan.entries.find((entry) => entry.schema === schema && entry.reason !== undefined)
|
|
242
|
+
?.reason
|
|
243
|
+
if (blocked !== undefined)
|
|
244
|
+
throw new Error(`[nifra] no ${schema} was drafted in ${file}: ${blocked}`)
|
|
245
|
+
const drafted = plan.edits.get(file)
|
|
246
|
+
if (drafted === undefined) return []
|
|
247
|
+
await writeFile(path, drafted, "utf8")
|
|
248
|
+
return [file]
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
|
|
138
252
|
/**
|
|
139
253
|
* How many typecheck passes the reserved-segment codemod will run. A nested collision
|
|
140
254
|
* (`/api/delete/then`) only reveals its second site once the first is rewritten, so one pass is not
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A route manifest built by hand from a list that names no backend half. `buildManifest(files, load)`
|
|
3
|
+
* pairs a page with its `.backend.*` half only when both are in `files`: it is pure, so it cannot know
|
|
4
|
+
* a half is missing. A test or server that spells the list out (an array literal, or `import.meta.glob`
|
|
5
|
+
* patterns) and leaves the halves out runs every route without its loader, action and guards.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
8
|
+
import { dirname, join } from "node:path"
|
|
9
|
+
import { Glob } from "bun"
|
|
10
|
+
import { codePositionMask } from "./check.ts"
|
|
11
|
+
import { expressionEnd } from "./rules/data-guard.ts"
|
|
12
|
+
|
|
13
|
+
/** A `buildManifest(...)` call whose file list reaches pages under `routes/` but not their halves. */
|
|
14
|
+
export interface HandBuiltManifest {
|
|
15
|
+
readonly file: string
|
|
16
|
+
readonly line: number
|
|
17
|
+
/** How the list was spelled out. */
|
|
18
|
+
readonly source: "array" | "glob"
|
|
19
|
+
/** The backend halves, cwd-relative, of pages the list reaches but whose half it does not. */
|
|
20
|
+
readonly missing: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A `buildManifest(...)` call and the literal entries or glob patterns of its file list. */
|
|
24
|
+
export interface ManifestList {
|
|
25
|
+
readonly file: string
|
|
26
|
+
readonly line: number
|
|
27
|
+
readonly source: "array" | "glob"
|
|
28
|
+
readonly entries: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mirrors `isRouteBackendFile` in @nifrajs/web's manifest.
|
|
32
|
+
const BACKEND_HALF = /\.backend\.(?:[cm]?[jt]s)$/
|
|
33
|
+
const LITERAL = /(["'`])((?:\\.|(?!\1)[^\\])*)\1/g
|
|
34
|
+
|
|
35
|
+
const lineAt = (text: string, index: number): number => text.slice(0, index).split("\n").length
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `buildManifest(` calls in `content` whose first argument is spelled out in source: an array of
|
|
39
|
+
* string literals, or an expression around `import.meta.glob(...)` with literal patterns. A list built
|
|
40
|
+
* any other way (a variable, a spread, a template with a substitution) is not read.
|
|
41
|
+
*/
|
|
42
|
+
export function manifestListsIn(file: string, content: string): ManifestList[] {
|
|
43
|
+
if (!content.includes("buildManifest")) return []
|
|
44
|
+
const mask = codePositionMask(content)
|
|
45
|
+
const lists: ManifestList[] = []
|
|
46
|
+
for (const call of mask.matchAll(/\bbuildManifest\s*\(/g)) {
|
|
47
|
+
const start = call.index + call[0].length
|
|
48
|
+
const end = expressionEnd(mask, start)
|
|
49
|
+
const argument = mask.slice(start, end).trim()
|
|
50
|
+
const literals = [...content.slice(start, end).matchAll(LITERAL)].map((m) => m[2] ?? "")
|
|
51
|
+
if (literals.some((text) => text.includes("${"))) continue
|
|
52
|
+
let source: ManifestList["source"]
|
|
53
|
+
if (argument.startsWith("[") && argument.endsWith("]")) {
|
|
54
|
+
// Every element a literal: the mask, with literals blanked, holds only brackets and commas.
|
|
55
|
+
if (!/^\[[\s,]*\]$/.test(argument)) continue
|
|
56
|
+
source = "array"
|
|
57
|
+
} else if (argument.includes("import.meta.glob(")) {
|
|
58
|
+
source = "glob"
|
|
59
|
+
} else continue
|
|
60
|
+
lists.push({ file, line: lineAt(content, call.index), source, entries: literals })
|
|
61
|
+
}
|
|
62
|
+
return lists
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function filesUnder(routesDir: string): readonly string[] {
|
|
66
|
+
try {
|
|
67
|
+
return readdirSync(routesDir, { recursive: true, encoding: "utf8" }).map((rel) =>
|
|
68
|
+
rel.replaceAll("\\", "/"),
|
|
69
|
+
)
|
|
70
|
+
} catch {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const stem = (file: string): string => file.replace(/\.[^./]+$/, "")
|
|
76
|
+
|
|
77
|
+
/** A glob pattern from source, relative to `routes/` when it names that directory. */
|
|
78
|
+
function routesRelative(pattern: string): string {
|
|
79
|
+
const at = pattern.lastIndexOf("routes/")
|
|
80
|
+
return at === -1 ? pattern.replace(/^(?:\.{0,2}\/)+/, "") : pattern.slice(at + "routes/".length)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reaches(list: ManifestList): (file: string) => boolean {
|
|
84
|
+
if (list.source === "array") {
|
|
85
|
+
const listed = new Set(list.entries.map((entry) => entry.replace(/^\.\//, "")))
|
|
86
|
+
return (file) => listed.has(file)
|
|
87
|
+
}
|
|
88
|
+
const glob = (pattern: string): ((file: string) => boolean) => {
|
|
89
|
+
const compiled = new Glob(routesRelative(pattern))
|
|
90
|
+
return (file) => compiled.match(file) || compiled.match(`routes/${file}`)
|
|
91
|
+
}
|
|
92
|
+
const include = list.entries.filter((p) => !p.startsWith("!")).map(glob)
|
|
93
|
+
const exclude = list.entries.filter((p) => p.startsWith("!")).map((p) => glob(p.slice(1)))
|
|
94
|
+
return (file) => include.some((match) => match(file)) && !exclude.some((match) => match(file))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The `routes/` directory nearest above `file`, up to and including `cwd`'s own, as a cwd-relative path. */
|
|
98
|
+
function routesDirFor(cwd: string, file: string): string | undefined {
|
|
99
|
+
for (let dir = dirname(file); ; dir = dirname(dir)) {
|
|
100
|
+
const candidate = dir === "." ? "routes" : `${dir}/routes`
|
|
101
|
+
const abs = join(cwd, candidate)
|
|
102
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) return candidate
|
|
103
|
+
if (dir === "." || dir === "/" || dir === "") return undefined
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The lists that reach a page under their app's `routes/` but not the backend half beside it. */
|
|
108
|
+
export function handBuiltManifests(
|
|
109
|
+
cwd: string,
|
|
110
|
+
lists: readonly ManifestList[],
|
|
111
|
+
): HandBuiltManifest[] {
|
|
112
|
+
const found: HandBuiltManifest[] = []
|
|
113
|
+
const filesByDir = new Map<string, readonly string[]>()
|
|
114
|
+
for (const list of lists) {
|
|
115
|
+
const routesDir = routesDirFor(cwd, list.file)
|
|
116
|
+
if (routesDir === undefined) continue
|
|
117
|
+
let files = filesByDir.get(routesDir)
|
|
118
|
+
if (files === undefined) {
|
|
119
|
+
files = filesUnder(join(cwd, routesDir))
|
|
120
|
+
filesByDir.set(routesDir, files)
|
|
121
|
+
}
|
|
122
|
+
const halves = files.filter((file) => BACKEND_HALF.test(file))
|
|
123
|
+
if (halves.length === 0) continue
|
|
124
|
+
const listed = reaches(list)
|
|
125
|
+
const missing = halves.filter((half) => {
|
|
126
|
+
if (listed(half)) return false
|
|
127
|
+
const page = stem(half.replace(BACKEND_HALF, ""))
|
|
128
|
+
return files.some((file) => !BACKEND_HALF.test(file) && stem(file) === page && listed(file))
|
|
129
|
+
})
|
|
130
|
+
if (missing.length > 0) {
|
|
131
|
+
const shown = missing.map((half) => `${routesDir}/${half}`)
|
|
132
|
+
found.push({ file: list.file, line: list.line, source: list.source, missing: shown })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return found
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The advisory `nifra doctor` prints for one finding. */
|
|
139
|
+
export function handBuiltManifestMessage(finding: HandBuiltManifest): string {
|
|
140
|
+
const list = finding.source === "array" ? "a file list" : "import.meta.glob patterns"
|
|
141
|
+
const shown = finding.missing.slice(0, 3)
|
|
142
|
+
const more = finding.missing.length > 3 ? ` and ${finding.missing.length - 3} more` : ""
|
|
143
|
+
return `${finding.file}:${finding.line} builds a route manifest from ${list} that leaves out ${finding.missing.length} backend half(s) of pages it lists (${shown.join(", ")}${more}). buildManifest pairs a page with its .backend.* half only when both are listed, so those pages run without their loaders, actions and guards. Use discoverRoutes("./routes") from @nifrajs/web/fs, or the generated server-manifest.`
|
|
144
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra routes --hooks`: which hooks run for each route and for each prefix's unmatched paths, and the
|
|
3
|
+
* diff between two such reports (`--since <file>`, and `nifra upgrade`, which captures one before it
|
|
4
|
+
* writes). The report itself is `reflectHooks` from `@nifrajs/core/reflection`.
|
|
5
|
+
*/
|
|
6
|
+
import type { ReflectedHook, ReflectedHookReport } from "@nifrajs/core/reflection"
|
|
7
|
+
|
|
8
|
+
/** Where `nifra upgrade --write` keeps the report it captured before editing anything. */
|
|
9
|
+
export const HOOKS_BEFORE_UPGRADE_FILE = ".nifra/hooks-before-upgrade.json"
|
|
10
|
+
|
|
11
|
+
export async function collectHookReport(backend: unknown): Promise<ReflectedHookReport> {
|
|
12
|
+
const { reflectHooks } = await import("@nifrajs/core/reflection")
|
|
13
|
+
return reflectHooks(backend)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const entryText = (hook: ReflectedHook): string =>
|
|
17
|
+
`${hook.kind} ${hook.name}${hook.headers === undefined ? "" : ` [${hook.headers.join(", ")}]`} (${hook.site})`
|
|
18
|
+
|
|
19
|
+
/** One line per hook, keyed by `METHOD /path` for routes and `unmatched /prefix` for the rest. */
|
|
20
|
+
export function hookReportTargets(report: ReflectedHookReport): Map<string, readonly string[]> {
|
|
21
|
+
const targets = new Map<string, readonly string[]>()
|
|
22
|
+
for (const route of report.routes) {
|
|
23
|
+
targets.set(`${route.method} ${route.path}`, route.hooks.map(entryText))
|
|
24
|
+
}
|
|
25
|
+
for (const entry of report.unmatched) {
|
|
26
|
+
targets.set(`unmatched ${entry.prefix}`, entry.hooks.map(entryText))
|
|
27
|
+
}
|
|
28
|
+
return targets
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderHookReport(report: ReflectedHookReport): string {
|
|
32
|
+
const targets = hookReportTargets(report)
|
|
33
|
+
if (targets.size === 0) return "no backend routes to report hooks for"
|
|
34
|
+
const lines: string[] = []
|
|
35
|
+
for (const [target, hooks] of targets) {
|
|
36
|
+
lines.push(target)
|
|
37
|
+
if (hooks.length === 0) lines.push(" (no hooks)")
|
|
38
|
+
for (const hook of hooks) lines.push(` ${hook}`)
|
|
39
|
+
}
|
|
40
|
+
if (!report.complete) {
|
|
41
|
+
lines.push(
|
|
42
|
+
"",
|
|
43
|
+
"Some hooks have no recorded origin (site `unknown`): origins are recorded only outside production builds.",
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HookReportChange {
|
|
50
|
+
readonly target: string
|
|
51
|
+
readonly added: readonly string[]
|
|
52
|
+
readonly removed: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What runs differently: per route or unmatched prefix, the hooks it gained and lost. */
|
|
56
|
+
export function diffHookReports(
|
|
57
|
+
before: ReflectedHookReport,
|
|
58
|
+
after: ReflectedHookReport,
|
|
59
|
+
): readonly HookReportChange[] {
|
|
60
|
+
const old = hookReportTargets(before)
|
|
61
|
+
const now = hookReportTargets(after)
|
|
62
|
+
const changes: HookReportChange[] = []
|
|
63
|
+
const minus = (from: readonly string[], take: readonly string[]): string[] => {
|
|
64
|
+
const left = [...take]
|
|
65
|
+
return from.filter((entry) => {
|
|
66
|
+
const index = left.indexOf(entry)
|
|
67
|
+
if (index === -1) return true
|
|
68
|
+
left.splice(index, 1)
|
|
69
|
+
return false
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
const targets = [...new Set([...old.keys(), ...now.keys()])].sort()
|
|
73
|
+
for (const target of targets) {
|
|
74
|
+
const was = old.get(target) ?? []
|
|
75
|
+
const is = now.get(target) ?? []
|
|
76
|
+
const added = minus(is, was)
|
|
77
|
+
const removed = minus(was, is)
|
|
78
|
+
if (added.length > 0 || removed.length > 0) changes.push({ target, added, removed })
|
|
79
|
+
}
|
|
80
|
+
return changes
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderHookDiff(changes: readonly HookReportChange[]): string {
|
|
84
|
+
if (changes.length === 0) return "hook scope unchanged"
|
|
85
|
+
const lines: string[] = []
|
|
86
|
+
for (const change of changes) {
|
|
87
|
+
lines.push(change.target)
|
|
88
|
+
for (const entry of change.added) lines.push(` + ${entry}`)
|
|
89
|
+
for (const entry of change.removed) lines.push(` - ${entry}`)
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const field = (value: unknown, key: string): unknown =>
|
|
95
|
+
typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined
|
|
96
|
+
|
|
97
|
+
const hooksValid = (row: unknown): boolean => {
|
|
98
|
+
const hooks = field(row, "hooks")
|
|
99
|
+
return (
|
|
100
|
+
Array.isArray(hooks) &&
|
|
101
|
+
hooks.every(
|
|
102
|
+
(hook) =>
|
|
103
|
+
typeof field(hook, "kind") === "string" &&
|
|
104
|
+
typeof field(hook, "name") === "string" &&
|
|
105
|
+
typeof field(hook, "site") === "string",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isHookReport(value: unknown): value is ReflectedHookReport {
|
|
111
|
+
const routes = field(value, "routes")
|
|
112
|
+
const unmatched = field(value, "unmatched")
|
|
113
|
+
return (
|
|
114
|
+
typeof field(value, "complete") === "boolean" &&
|
|
115
|
+
Array.isArray(routes) &&
|
|
116
|
+
Array.isArray(unmatched) &&
|
|
117
|
+
routes.every(
|
|
118
|
+
(row) =>
|
|
119
|
+
typeof field(row, "method") === "string" &&
|
|
120
|
+
typeof field(row, "path") === "string" &&
|
|
121
|
+
hooksValid(row),
|
|
122
|
+
) &&
|
|
123
|
+
unmatched.every((row) => typeof field(row, "prefix") === "string" && hooksValid(row))
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A saved report, parsed; `undefined` when the text is not one. */
|
|
128
|
+
export function parseHookReport(text: string): ReflectedHookReport | undefined {
|
|
129
|
+
try {
|
|
130
|
+
const value: unknown = JSON.parse(text)
|
|
131
|
+
return isHookReport(value) ? value : undefined
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/init-agents.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* `## Project structure` section, appended when missing (or a minimal
|
|
10
10
|
* file if none exists)
|
|
11
11
|
*
|
|
12
|
-
* The generators
|
|
13
|
-
*
|
|
12
|
+
* The generators come from `./agent-files.ts`, a byte-identical copy of the module `create-nifra` uses at
|
|
13
|
+
* scaffold time, so a retrofitted app and a freshly scaffolded one get byte-identical configs.
|
|
14
14
|
*
|
|
15
15
|
* Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
|
|
16
16
|
* customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
MCP_SERVER_COMMAND,
|
|
45
45
|
mcpJson,
|
|
46
46
|
STRUCTURE_HEADING,
|
|
47
|
-
} from "
|
|
47
|
+
} from "./agent-files.ts"
|
|
48
48
|
import { installedNifraVersion, resolveRootState } from "./mcp-root.ts"
|
|
49
49
|
|
|
50
50
|
/** What happened to one file during the retrofit, for the printed report + the `--json` shape. */
|
|
@@ -121,13 +121,17 @@ async function assertSafeParents(cwd: string, abs: string): Promise<void> {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
|
|
124
|
+
/** Never write anything that is not a plain absent-or-regular file: a symlink here would redirect
|
|
125
|
+
* the write, and a directory or device node would make it fail in a confusing way. Such a file is
|
|
126
|
+
* reported and left alone rather than stopping the run - a symlinked CLAUDE.md is a common alias
|
|
127
|
+
* for AGENTS.md. */
|
|
128
|
+
async function nonRegularTarget(
|
|
129
|
+
abs: string,
|
|
130
|
+
rel: string,
|
|
131
|
+
): Promise<InitAgentsFileResult | undefined> {
|
|
127
132
|
const stat = await lstatOrMissing(abs)
|
|
128
|
-
if (stat
|
|
129
|
-
|
|
130
|
-
}
|
|
133
|
+
if (stat === undefined || (stat.isFile() && !stat.isSymbolicLink())) return undefined
|
|
134
|
+
return { path: rel, action: "skipped", note: "not a regular file - left alone" }
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/**
|
|
@@ -142,7 +146,8 @@ async function writeOwned(
|
|
|
142
146
|
): Promise<InitAgentsFileResult> {
|
|
143
147
|
const abs = safeJoin(cwd, rel)
|
|
144
148
|
await assertSafeParents(cwd, abs)
|
|
145
|
-
await
|
|
149
|
+
const irregular = await nonRegularTarget(abs, rel)
|
|
150
|
+
if (irregular !== undefined) return irregular
|
|
146
151
|
if (!force && (await fileExists(abs))) {
|
|
147
152
|
return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
|
|
148
153
|
}
|
|
@@ -180,7 +185,8 @@ async function atomicWrite(
|
|
|
180
185
|
async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
181
186
|
const abs = safeJoin(cwd, AGENTS_MD_PATH)
|
|
182
187
|
await assertSafeParents(cwd, abs)
|
|
183
|
-
await
|
|
188
|
+
const irregular = await nonRegularTarget(abs, AGENTS_MD_PATH)
|
|
189
|
+
if (irregular !== undefined) return irregular
|
|
184
190
|
const sections = [{ heading: "## MCP server", text: agentsMcpSection() }]
|
|
185
191
|
if (await isDirectory(safeJoin(cwd, "routes"))) {
|
|
186
192
|
sections.push({ heading: STRUCTURE_HEADING, text: agentsStructureSection() })
|
|
@@ -252,7 +258,7 @@ function nameMember(
|
|
|
252
258
|
return { text: rewritten, named }
|
|
253
259
|
}
|
|
254
260
|
|
|
255
|
-
interface PinScan {
|
|
261
|
+
export interface PinScan {
|
|
256
262
|
/** The input with every stale pin rewritten - every other character untouched. */
|
|
257
263
|
readonly text: string
|
|
258
264
|
/** Pins found, current or stale. */
|
|
@@ -296,8 +302,9 @@ function repinRegistry(text: string, target: string, member?: string): PinScan {
|
|
|
296
302
|
return named ? { ...scan, text: rewritten, named: member } : scan
|
|
297
303
|
}
|
|
298
304
|
|
|
299
|
-
/** Every file that carries the pin, and the part of it --sync-mcp
|
|
300
|
-
|
|
305
|
+
/** Every file that carries the pin, and the part of it --sync-mcp and `nifra upgrade --write` may
|
|
306
|
+
* rewrite. */
|
|
307
|
+
export const PIN_SITES: readonly {
|
|
301
308
|
readonly path: string
|
|
302
309
|
readonly repin: (text: string, target: string, member?: string) => PinScan
|
|
303
310
|
}[] = [
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module path a nifra config names, read from its source without running it. The loader reads a
|
|
3
|
+
* string from the app root (`"./src/client.tsx"`, or an alias or package name the bundler resolves);
|
|
4
|
+
* `resolve`/`join` from `node:path` of the config's own folder and literal segments, or
|
|
5
|
+
* `fileURLToPath(new URL("x", import.meta.url))`, names a file relative to the config itself.
|
|
6
|
+
*/
|
|
7
|
+
import { posix } from "node:path"
|
|
8
|
+
import type * as TS from "typescript"
|
|
9
|
+
|
|
10
|
+
export interface StaticConfigPath {
|
|
11
|
+
/** `"app"`: read from the app root as the loader reads a string. `"config"`: from the config's folder. */
|
|
12
|
+
readonly base: "app" | "config"
|
|
13
|
+
readonly path: string
|
|
14
|
+
/** The string literal's text, quotes excluded, for an `"app"` path: [start, end). */
|
|
15
|
+
readonly span?: readonly [number, number]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OWN_DIR = new Set(["import.meta.dir", "import.meta.dirname", "__dirname"])
|
|
19
|
+
const PATH_MODULES = new Set(["node:path", "path", "node:path/posix", "path/posix"])
|
|
20
|
+
const URL_MODULES = new Set(["node:url", "url"])
|
|
21
|
+
|
|
22
|
+
interface PathBindings {
|
|
23
|
+
/** Local names of `resolve` and `join`. */
|
|
24
|
+
readonly calls: ReadonlySet<string>
|
|
25
|
+
/** Default and namespace imports of `node:path`. */
|
|
26
|
+
readonly namespaces: ReadonlySet<string>
|
|
27
|
+
readonly fileURLToPath: ReadonlySet<string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathBindings(ts: typeof TS, sf: TS.SourceFile): PathBindings {
|
|
31
|
+
const calls = new Set<string>()
|
|
32
|
+
const namespaces = new Set<string>()
|
|
33
|
+
const fileURLToPath = new Set<string>()
|
|
34
|
+
for (const statement of sf.statements) {
|
|
35
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
36
|
+
continue
|
|
37
|
+
const from = statement.moduleSpecifier.text
|
|
38
|
+
const clause = statement.importClause
|
|
39
|
+
if (clause === undefined || clause.isTypeOnly) continue
|
|
40
|
+
const isPath = PATH_MODULES.has(from)
|
|
41
|
+
if (!isPath && !URL_MODULES.has(from)) continue
|
|
42
|
+
if (isPath && clause.name !== undefined) namespaces.add(clause.name.text)
|
|
43
|
+
const bindings = clause.namedBindings
|
|
44
|
+
if (bindings === undefined) continue
|
|
45
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
46
|
+
if (isPath) namespaces.add(bindings.name.text)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
for (const element of bindings.elements) {
|
|
50
|
+
if (element.isTypeOnly) continue
|
|
51
|
+
const imported = (element.propertyName ?? element.name).text
|
|
52
|
+
if (isPath && (imported === "resolve" || imported === "join")) calls.add(element.name.text)
|
|
53
|
+
else if (!isPath && imported === "fileURLToPath") fileURLToPath.add(element.name.text)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { calls, namespaces, fileURLToPath }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const unwrap = (ts: typeof TS, node: TS.Expression): TS.Expression => {
|
|
60
|
+
let current = node
|
|
61
|
+
while (
|
|
62
|
+
ts.isAsExpression(current) ||
|
|
63
|
+
ts.isSatisfiesExpression(current) ||
|
|
64
|
+
ts.isParenthesizedExpression(current) ||
|
|
65
|
+
ts.isNonNullExpression(current)
|
|
66
|
+
)
|
|
67
|
+
current = current.expression
|
|
68
|
+
return current
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isLiteral = (
|
|
72
|
+
ts: typeof TS,
|
|
73
|
+
node: TS.Node | undefined,
|
|
74
|
+
): node is TS.StringLiteral | TS.NoSubstitutionTemplateLiteral =>
|
|
75
|
+
node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
76
|
+
|
|
77
|
+
/** A readable static path, normalized; `undefined` for an absolute one or a URL. */
|
|
78
|
+
function relativeSegments(segments: readonly string[]): string | undefined {
|
|
79
|
+
if (segments.some((segment) => segment.startsWith("/") || /^[a-z][a-z\d+.-]*:/i.test(segment)))
|
|
80
|
+
return undefined
|
|
81
|
+
const path = posix.normalize(posix.join(...segments))
|
|
82
|
+
return path === "." ? undefined : path
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create a reader for one config file. It answers for an expression the config exports, so a path
|
|
87
|
+
* built any other way (a variable, `process.cwd()`, a template with substitutions) is `undefined`.
|
|
88
|
+
*/
|
|
89
|
+
export function configPathReader(
|
|
90
|
+
ts: typeof TS,
|
|
91
|
+
sf: TS.SourceFile,
|
|
92
|
+
): (node: TS.Expression | undefined) => StaticConfigPath | undefined {
|
|
93
|
+
const bindings = pathBindings(ts, sf)
|
|
94
|
+
const isPathCall = (callee: TS.Expression): boolean =>
|
|
95
|
+
ts.isIdentifier(callee)
|
|
96
|
+
? bindings.calls.has(callee.text)
|
|
97
|
+
: ts.isPropertyAccessExpression(callee) &&
|
|
98
|
+
ts.isIdentifier(callee.expression) &&
|
|
99
|
+
bindings.namespaces.has(callee.expression.text) &&
|
|
100
|
+
(callee.name.text === "resolve" || callee.name.text === "join")
|
|
101
|
+
return (node) => {
|
|
102
|
+
if (node === undefined) return undefined
|
|
103
|
+
const value = unwrap(ts, node)
|
|
104
|
+
if (isLiteral(ts, value))
|
|
105
|
+
return { base: "app", path: value.text, span: [value.getStart(sf) + 1, value.getEnd() - 1] }
|
|
106
|
+
if (!ts.isCallExpression(value)) return undefined
|
|
107
|
+
const [first, ...rest] = value.arguments
|
|
108
|
+
if (isPathCall(value.expression)) {
|
|
109
|
+
if (first === undefined || !OWN_DIR.has(first.getText(sf)) || rest.length === 0)
|
|
110
|
+
return undefined
|
|
111
|
+
const segments: string[] = []
|
|
112
|
+
for (const segment of rest) {
|
|
113
|
+
if (!isLiteral(ts, segment)) return undefined
|
|
114
|
+
segments.push(segment.text)
|
|
115
|
+
}
|
|
116
|
+
const path = relativeSegments(segments)
|
|
117
|
+
return path === undefined ? undefined : { base: "config", path }
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
ts.isIdentifier(value.expression) &&
|
|
121
|
+
bindings.fileURLToPath.has(value.expression.text) &&
|
|
122
|
+
first !== undefined &&
|
|
123
|
+
ts.isNewExpression(first) &&
|
|
124
|
+
first.expression.getText(sf) === "URL" &&
|
|
125
|
+
first.arguments?.length === 2 &&
|
|
126
|
+
first.arguments[1]?.getText(sf) === "import.meta.url" &&
|
|
127
|
+
isLiteral(ts, first.arguments[0])
|
|
128
|
+
) {
|
|
129
|
+
const path = relativeSegments([first.arguments[0].text])
|
|
130
|
+
return path === undefined ? undefined : { base: "config", path }
|
|
131
|
+
}
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The `export const <name> = ...` initializer in a config, or `undefined`. */
|
|
137
|
+
export function exportedInitializer(
|
|
138
|
+
ts: typeof TS,
|
|
139
|
+
sf: TS.SourceFile,
|
|
140
|
+
name: string,
|
|
141
|
+
): TS.Expression | undefined {
|
|
142
|
+
for (const statement of sf.statements) {
|
|
143
|
+
if (
|
|
144
|
+
!ts.isVariableStatement(statement) ||
|
|
145
|
+
!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
149
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
|
|
150
|
+
return declaration.initializer
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** The manifest of the installed package `name`, as Node finds it: the nearest `node_modules/<name>`
|
|
5
|
+
* at or above the importing file. A workspace link resolves through its symlink. */
|
|
6
|
+
export function installedManifest(fromFile: string, name: string): string | undefined {
|
|
7
|
+
let dir = dirname(fromFile)
|
|
8
|
+
for (;;) {
|
|
9
|
+
const manifest = join(dir, "node_modules", name, "package.json")
|
|
10
|
+
if (existsSync(manifest)) return manifest
|
|
11
|
+
const parent = dirname(dir)
|
|
12
|
+
if (parent === dir) return undefined
|
|
13
|
+
dir = parent
|
|
14
|
+
}
|
|
15
|
+
}
|