@nifrajs/cli 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +28 -3
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +958 -71
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +181 -47
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +34 -3
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/sync-manifest.ts +48 -18
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/db-tool.ts
CHANGED
|
@@ -34,8 +34,10 @@ import { readProjectEnv } from "./db-config.ts"
|
|
|
34
34
|
import {
|
|
35
35
|
CHILD_OUTPUT_MAX_BYTES,
|
|
36
36
|
CHILD_TIMEOUT_MS,
|
|
37
|
+
OWN_PROCESS_GROUP,
|
|
37
38
|
readBoundedLines,
|
|
38
39
|
readBoundedStream,
|
|
40
|
+
signalProcessGroup,
|
|
39
41
|
} from "./mcp-io.ts"
|
|
40
42
|
|
|
41
43
|
/** Added to the declared `timeoutMs` before the subprocess is killed. */
|
|
@@ -134,12 +136,13 @@ export async function runDbChild(
|
|
|
134
136
|
stdin: "pipe",
|
|
135
137
|
stdout: "pipe",
|
|
136
138
|
stderr: "pipe",
|
|
139
|
+
detached: OWN_PROCESS_GROUP,
|
|
137
140
|
})
|
|
138
141
|
let killedFor: "startup" | "deadline" | "cancel" | undefined
|
|
139
142
|
const kill = (reason: NonNullable<typeof killedFor>): void => {
|
|
140
143
|
if (killedFor !== undefined) return
|
|
141
144
|
killedFor = reason
|
|
142
|
-
proc
|
|
145
|
+
signalProcessGroup(proc, "SIGKILL")
|
|
143
146
|
}
|
|
144
147
|
let deadlineMs: number | undefined
|
|
145
148
|
let timer = setTimeout(() => kill("startup"), startupMs)
|
|
@@ -565,7 +568,7 @@ export interface DbRoleInput {
|
|
|
565
568
|
export const dbRoleSpec: CommandSpec<DbRoleInput, DbToolOutput> = {
|
|
566
569
|
name: "db-role",
|
|
567
570
|
summary:
|
|
568
|
-
"Print the SQL that creates a read-only Postgres role for the declared development database (
|
|
571
|
+
"Print the SQL that creates a read-only Postgres role for the declared development database (GRANT SELECT on each allowed schema, with a REVOKE per excluded table). Nothing is run.",
|
|
569
572
|
input: {
|
|
570
573
|
jsonSchema: {
|
|
571
574
|
type: "object",
|
package/src/dev-bun-config.ts
CHANGED
|
@@ -42,10 +42,29 @@
|
|
|
42
42
|
import { timingSafeEqual } from "node:crypto"
|
|
43
43
|
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
44
44
|
import { relative, resolve } from "node:path"
|
|
45
|
+
import { readSingleCopyDeclaration } from "@nifrajs/core/single-copy"
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
const TOML_ESCAPES: Readonly<Record<string, string>> = {
|
|
48
|
+
"\\": "\\\\",
|
|
49
|
+
'"': '\\"',
|
|
50
|
+
"\b": "\\b",
|
|
51
|
+
"\t": "\\t",
|
|
52
|
+
"\n": "\\n",
|
|
53
|
+
"\f": "\\f",
|
|
54
|
+
"\r": "\\r",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** TOML string literal: basic (double-quoted) form. A basic string may hold no raw control
|
|
58
|
+
* character, so each one is escaped along with the quote and the backslash. */
|
|
47
59
|
function tomlString(value: string): string {
|
|
48
|
-
|
|
60
|
+
let out = '"'
|
|
61
|
+
for (const char of value) {
|
|
62
|
+
const code = char.charCodeAt(0)
|
|
63
|
+
out +=
|
|
64
|
+
TOML_ESCAPES[char] ??
|
|
65
|
+
(code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, "0")}` : char)
|
|
66
|
+
}
|
|
67
|
+
return `${out}"`
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
/** A parsed bunfig: plain data from `Bun.TOML.parse`. */
|
|
@@ -198,24 +217,40 @@ function relativeSpecifier(fromDir: string, target: string): string {
|
|
|
198
217
|
* `servePlugins` are the app's own bunfig `[serve.static] plugins`, composed last and in order rather
|
|
199
218
|
* than listed beside this module, so that every plugin reaching the bundler registers through
|
|
200
219
|
* `reserveDevSpecifiers` (see `@nifrajs/web/internal/dev-reserved`).
|
|
220
|
+
*
|
|
221
|
+
* `singleCopy` is whether `package.json` declares `"nifra": { "singleCopy": [...] }`. The declared
|
|
222
|
+
* resolver then runs where `buildClient` runs it, after the zone guard; an app that also lists
|
|
223
|
+
* `singleCopyPlugin()` in `clientPlugins` gets its own instance in that place, once.
|
|
201
224
|
*/
|
|
202
225
|
export function renderBoundaryPluginModule(
|
|
203
226
|
configPath?: string,
|
|
204
227
|
pluginDir?: string,
|
|
205
228
|
servePlugins: readonly string[] = [],
|
|
229
|
+
singleCopy = false,
|
|
206
230
|
): string {
|
|
207
231
|
const appImport =
|
|
208
232
|
configPath !== undefined && pluginDir !== undefined
|
|
209
233
|
? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
|
|
210
234
|
: ""
|
|
211
|
-
const
|
|
235
|
+
const appPluginsSetup =
|
|
212
236
|
appImport === ""
|
|
213
|
-
? ""
|
|
237
|
+
? " const appPlugins = []\n"
|
|
214
238
|
: ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
|
|
215
239
|
const field = appConfig.clientPlugins
|
|
216
240
|
const appPlugins = (typeof field === "function" ? await field() : field) ?? []
|
|
217
|
-
for (const p of appPlugins) await p.setup(guarded)
|
|
218
241
|
`
|
|
242
|
+
const singleCopySetup = singleCopy
|
|
243
|
+
? ` const listedSingleCopy = appPlugins.find((p) => p?.name === "nifra-single-copy")
|
|
244
|
+
;(listedSingleCopy ?? singleCopyPlugin({ cwd: appRoot })).setup(guarded)
|
|
245
|
+
`
|
|
246
|
+
: ""
|
|
247
|
+
// Without a declaration the app's plugins are read where they are set up, as before.
|
|
248
|
+
const appSetup =
|
|
249
|
+
appImport === ""
|
|
250
|
+
? ""
|
|
251
|
+
: singleCopy
|
|
252
|
+
? " for (const p of appPlugins) if (p !== listedSingleCopy) await p.setup(guarded)\n"
|
|
253
|
+
: `${appPluginsSetup} for (const p of appPlugins) await p.setup(guarded)\n`
|
|
219
254
|
const serveSetup =
|
|
220
255
|
servePlugins.length === 0
|
|
221
256
|
? ""
|
|
@@ -232,10 +267,11 @@ export function renderBoundaryPluginModule(
|
|
|
232
267
|
import { serverFnStubPlugin, zoneGuardPlugin } from "@nifrajs/web/build"
|
|
233
268
|
import { reserveDevSpecifiers } from "@nifrajs/web/internal/dev-reserved"
|
|
234
269
|
import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
|
|
235
|
-
${appImport}
|
|
270
|
+
${singleCopy ? 'import { singleCopyPlugin } from "@nifrajs/core/single-copy"\n' : ""}${appImport}
|
|
236
271
|
// This module lives at <app>/.nifra/dev-bun/, so the app root is two directories up.
|
|
272
|
+
const appRoot = Bun.fileURLToPath(new URL("../../", import.meta.url))
|
|
237
273
|
const zones = zoneGuardPlugin({
|
|
238
|
-
appRoot
|
|
274
|
+
appRoot,${
|
|
239
275
|
appImport === ""
|
|
240
276
|
? ""
|
|
241
277
|
: `
|
|
@@ -251,10 +287,10 @@ export default {
|
|
|
251
287
|
// Every plugin below registers through this view: a resolve filter matching the dev entry script
|
|
252
288
|
// or Bun's built-in React refresh runtime breaks the page even when its handler declines.
|
|
253
289
|
const guarded = reserveDevSpecifiers(build)
|
|
254
|
-
// Boundary first: an app plugin must never get to transform a module that the boundary is about
|
|
290
|
+
${singleCopy ? appPluginsSetup : ""} // Boundary first: an app plugin must never get to transform a module that the boundary is about
|
|
255
291
|
// to refuse or stub, or the transform decides what ships instead of the boundary.
|
|
256
292
|
zones.setup(guarded)
|
|
257
|
-
fn.setup(guarded)
|
|
293
|
+
${singleCopySetup} fn.setup(guarded)
|
|
258
294
|
cssModules.setup(guarded)
|
|
259
295
|
${appSetup}${serveSetup} },
|
|
260
296
|
}
|
|
@@ -287,7 +323,12 @@ export async function writeBunDevConfig(
|
|
|
287
323
|
const user = parseUserBunfig(userToml)
|
|
288
324
|
writeFileSync(
|
|
289
325
|
pluginPath,
|
|
290
|
-
renderBoundaryPluginModule(
|
|
326
|
+
renderBoundaryPluginModule(
|
|
327
|
+
configPath,
|
|
328
|
+
dir,
|
|
329
|
+
userServePlugins(user, appRoot),
|
|
330
|
+
readSingleCopyDeclaration(appRoot) !== undefined,
|
|
331
|
+
),
|
|
291
332
|
)
|
|
292
333
|
const bunfigPath = resolve(dir, "bunfig.toml")
|
|
293
334
|
writeFileSync(bunfigPath, renderDevBunfig(pluginPath, user, appRoot, define))
|
package/src/diagnostics.ts
CHANGED
|
@@ -21,6 +21,9 @@ export interface DiagnosticCompatibility {
|
|
|
21
21
|
readonly suggestion?: DiagnosticSuggestion
|
|
22
22
|
/** The import path evidence used by the legacy server-only-import view. */
|
|
23
23
|
readonly chain?: readonly string[]
|
|
24
|
+
/** The project files a server-only chain runs through, so a diff review keeps a finding whose
|
|
25
|
+
* route is unchanged but whose chain a changed module made. */
|
|
26
|
+
readonly modules?: readonly string[]
|
|
24
27
|
/** Preserve the old optional `CheckDiagnostic.code` field exactly where it existed. */
|
|
25
28
|
readonly includeCode?: boolean
|
|
26
29
|
}
|
package/src/diff-tool.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* framework config or routes/ directory (unlike the eager `loadApp`).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync } from "node:fs"
|
|
18
|
-
import { resolve } from "node:path"
|
|
17
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
18
|
+
import { isAbsolute, relative, resolve } from "node:path"
|
|
19
19
|
import {
|
|
20
20
|
diffRouteSnapshots,
|
|
21
21
|
type RouteChange,
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type RoutesDiff,
|
|
24
24
|
snapshotRoutes,
|
|
25
25
|
} from "@nifrajs/core/diff"
|
|
26
|
-
import { BACKEND_APP_FILE } from "./app-files.ts"
|
|
26
|
+
import { assertCurrentLayout, BACKEND_APP_FILE } from "./app-files.ts"
|
|
27
27
|
|
|
28
28
|
/** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
|
|
29
29
|
export interface SnapshotFile {
|
|
@@ -84,6 +84,7 @@ export function formatDiff(diff: RoutesDiff): string {
|
|
|
84
84
|
|
|
85
85
|
/** Import `backend/app.ts` from `cwd` and snapshot its routes. */
|
|
86
86
|
export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
87
|
+
assertCurrentLayout(cwd)
|
|
87
88
|
const backendPath = resolve(cwd, BACKEND_APP_FILE)
|
|
88
89
|
if (!existsSync(backendPath)) {
|
|
89
90
|
throw new Error(
|
|
@@ -97,35 +98,41 @@ export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapsh
|
|
|
97
98
|
return snapshotRoutes(backend)
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
/** `nifra snapshot`: write the current contract as the CI baseline. */
|
|
101
|
-
export async function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
/** `nifra snapshot`: write the current contract to `out` (from `cwd`) as the CI baseline. */
|
|
102
|
+
export async function writeSnapshot(
|
|
103
|
+
cwd: string,
|
|
104
|
+
out: string = DEFAULT_SNAPSHOT_FILE,
|
|
105
|
+
): Promise<SnapshotFile> {
|
|
106
|
+
const snapshot: SnapshotFile = { nifraSnapshot: 1, routes: await snapshotBackend(cwd) }
|
|
107
|
+
await Bun.write(resolve(cwd, out), `${JSON.stringify(snapshot, null, 2)}\n`)
|
|
108
|
+
return snapshot
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const outside = (root: string, target: string): boolean => {
|
|
112
|
+
const rel = relative(root, target)
|
|
113
|
+
return rel.startsWith("..") || isAbsolute(rel)
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
116
|
+
/**
|
|
117
|
+
* `nifra diff`: the current contract against the snapshot at `baselinePath` (from `cwd`). `contain`
|
|
118
|
+
* refuses a baseline outside `cwd`, symlinks followed, for a caller handed the path by someone else.
|
|
119
|
+
*/
|
|
120
|
+
export async function diffAgainstBaseline(
|
|
111
121
|
cwd: string,
|
|
112
|
-
baselinePath: string,
|
|
113
|
-
options: {
|
|
114
|
-
): Promise<
|
|
122
|
+
baselinePath: string = DEFAULT_SNAPSHOT_FILE,
|
|
123
|
+
options: { readonly contain?: boolean } = {},
|
|
124
|
+
): Promise<RoutesDiff> {
|
|
115
125
|
const resolved = resolve(cwd, baselinePath)
|
|
116
|
-
const
|
|
117
|
-
|
|
126
|
+
const refuse = (): never => {
|
|
127
|
+
throw new Error(`[nifra] the baseline ${baselinePath} is outside the project directory`)
|
|
128
|
+
}
|
|
129
|
+
if (options.contain === true && outside(resolve(cwd), resolved)) refuse()
|
|
130
|
+
if (!existsSync(resolved)) {
|
|
118
131
|
throw new Error(
|
|
119
132
|
`[nifra] baseline not found: ${resolved} - create it on your main branch with \`nifra snapshot\`.`,
|
|
120
133
|
)
|
|
121
134
|
}
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (options.json === true) {
|
|
126
|
-
console.log(JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2))
|
|
127
|
-
} else {
|
|
128
|
-
console.log(formatDiff(diff))
|
|
129
|
-
}
|
|
130
|
-
return !diff.hasBreaking
|
|
135
|
+
if (options.contain === true && outside(realpathSync(cwd), realpathSync(resolved))) refuse()
|
|
136
|
+
const baseline = parseSnapshotFile(await Bun.file(resolved).text(), baselinePath)
|
|
137
|
+
return diffRouteSnapshots(baseline.routes, await snapshotBackend(cwd))
|
|
131
138
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -29,11 +29,20 @@ import {
|
|
|
29
29
|
resolvedInstalledCopy,
|
|
30
30
|
} from "@nifrajs/web/internal/parity"
|
|
31
31
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
32
|
+
import {
|
|
33
|
+
type HandBuiltManifest,
|
|
34
|
+
handBuiltManifestMessage,
|
|
35
|
+
handBuiltManifests,
|
|
36
|
+
type ManifestList,
|
|
37
|
+
manifestListsIn,
|
|
38
|
+
} from "./hand-built-manifest.ts"
|
|
32
39
|
import { collectStaleMcpPins, type StaleMcpPin } from "./init-agents.ts"
|
|
33
40
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
41
|
+
import { createSpecifierResolver } from "./internal/project-specifiers.ts"
|
|
34
42
|
import { detectToolingDrift, syncMcpCommand, type ToolingDrift } from "./mcp-root.ts"
|
|
35
43
|
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
36
44
|
import { type ResolvedTarget, resolveTarget } from "./port.ts"
|
|
45
|
+
import { collectVersionDrift, type VersionDrift, versionDriftMessage } from "./version-drift.ts"
|
|
37
46
|
import { buildScriptName } from "./workspace-link.ts"
|
|
38
47
|
|
|
39
48
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
@@ -178,6 +187,17 @@ export interface DoctorResult {
|
|
|
178
187
|
* supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
|
|
179
188
|
*/
|
|
180
189
|
readonly toolingDrift?: ToolingDrift
|
|
190
|
+
/**
|
|
191
|
+
* More than one version of the fixed release group across pins, installs and the running CLI.
|
|
192
|
+
* Advisory (never folded into `ok`); its fix is `nifra upgrade <highest>`. Computed by
|
|
193
|
+
* {@link collectVersionDrift}.
|
|
194
|
+
*/
|
|
195
|
+
readonly versionDrift?: VersionDrift
|
|
196
|
+
/**
|
|
197
|
+
* `buildManifest(...)` calls whose spelled-out file list reaches a page but not its backend half, so
|
|
198
|
+
* that page runs without its loader, action and guards. Advisory (never folded into `ok`).
|
|
199
|
+
*/
|
|
200
|
+
readonly handBuiltManifests?: readonly HandBuiltManifest[]
|
|
181
201
|
/**
|
|
182
202
|
* Agent files whose MCP launch pins an `@nifrajs/cli` other than the one the project installs, so
|
|
183
203
|
* the agent's server answers for a release the code no longer builds with. Advisory (never folded
|
|
@@ -347,16 +367,6 @@ const declaredPackages = (pkg: Record<string, unknown>): ReadonlySet<string> =>
|
|
|
347
367
|
return declared
|
|
348
368
|
}
|
|
349
369
|
|
|
350
|
-
const tsconfigPaths = async (
|
|
351
|
-
root: string,
|
|
352
|
-
): Promise<Readonly<Record<string, unknown>> | undefined> => {
|
|
353
|
-
const tsconfig = await readJson(join(root, "tsconfig.json"))
|
|
354
|
-
const compilerOptions = tsconfig?.compilerOptions as
|
|
355
|
-
| { paths?: Record<string, unknown> }
|
|
356
|
-
| undefined
|
|
357
|
-
return compilerOptions?.paths
|
|
358
|
-
}
|
|
359
|
-
|
|
360
370
|
/** Build per-package declaration scopes for a workspace. Longest roots are first so nested packages
|
|
361
371
|
* own their source; files outside a workspace package remain owned by the root manifest. */
|
|
362
372
|
async function doctorPackageScopes(
|
|
@@ -365,16 +375,16 @@ async function doctorPackageScopes(
|
|
|
365
375
|
): Promise<readonly DoctorPackageScope[]> {
|
|
366
376
|
const importers = await workspaceImporters(cwd, rootPackage, true)
|
|
367
377
|
const packages = importers.length > 0 ? importers : [{ root: cwd, package: rootPackage }]
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
// The nearest tsconfig, read as TypeScript does: comments, trailing commas and `extends` included.
|
|
379
|
+
const resolver = createSpecifierResolver()
|
|
380
|
+
const scopes = packages.map(
|
|
381
|
+
(entry): DoctorPackageScope => ({
|
|
382
|
+
root: entry.root,
|
|
383
|
+
relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
|
|
384
|
+
declared: declaredPackages(entry.package),
|
|
385
|
+
isAlias: aliasMatcher(
|
|
386
|
+
Object.fromEntries(resolver.pathPatterns(entry.root).map((pattern) => [pattern, []])),
|
|
387
|
+
),
|
|
378
388
|
}),
|
|
379
389
|
)
|
|
380
390
|
return scopes.sort((a, b) => b.relativeRoot.length - a.relativeRoot.length)
|
|
@@ -690,7 +700,7 @@ async function collectDoctorReadiness(
|
|
|
690
700
|
: configured(
|
|
691
701
|
"graceful-lifecycle",
|
|
692
702
|
"graceful lifecycle",
|
|
693
|
-
/\bgracefulSignals\s*:\s*true\b|\bonStop\s*\(/,
|
|
703
|
+
/\bgracefulSignals\s*:\s*(?:true\b|\{)|\bonStop\s*\(/,
|
|
694
704
|
),
|
|
695
705
|
]
|
|
696
706
|
return {
|
|
@@ -783,6 +793,7 @@ export async function collectDoctorResult(
|
|
|
783
793
|
const scopes = await doctorPackageScopes(cwd, pkg)
|
|
784
794
|
|
|
785
795
|
const findings: DoctorFinding[] = []
|
|
796
|
+
const manifestLists: ManifestList[] = []
|
|
786
797
|
// A standalone app owns its tests, so they remain in the declaration check. A workspace root owns
|
|
787
798
|
// the published package source while its root devDependencies intentionally own shared tests,
|
|
788
799
|
// benchmarks, and scaffold copy-surfaces. Keep that boundary explicit instead of reporting a
|
|
@@ -813,6 +824,7 @@ export async function collectDoctorResult(
|
|
|
813
824
|
for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
|
|
814
825
|
findings.push({ file: f.file, line: f.line, package: f.snippet })
|
|
815
826
|
}
|
|
827
|
+
manifestLists.push(...manifestListsIn(rel, content))
|
|
816
828
|
},
|
|
817
829
|
{ includeTests: scanScope.includeTests, ignore: workspaceSurface },
|
|
818
830
|
)
|
|
@@ -829,6 +841,8 @@ export async function collectDoctorResult(
|
|
|
829
841
|
const toolingDrift =
|
|
830
842
|
opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
|
|
831
843
|
const staleMcpPins = await collectStaleMcpPins(cwd).catch(() => undefined)
|
|
844
|
+
const versionDrift = collectVersionDrift(cwd, opts.cliVersion)
|
|
845
|
+
const handBuilt = handBuiltManifests(cwd, manifestLists)
|
|
832
846
|
// `staleDists`, `toolingDrift` and `staleMcpPins` are advisory (see DoctorResult): they never fail `ok`.
|
|
833
847
|
return {
|
|
834
848
|
ok:
|
|
@@ -846,6 +860,8 @@ export async function collectDoctorResult(
|
|
|
846
860
|
readiness,
|
|
847
861
|
scanScope,
|
|
848
862
|
...(toolingDrift !== undefined ? { toolingDrift } : {}),
|
|
863
|
+
...(versionDrift !== undefined ? { versionDrift } : {}),
|
|
864
|
+
...(handBuilt.length > 0 ? { handBuiltManifests: handBuilt } : {}),
|
|
849
865
|
...(staleMcpPins !== undefined && staleMcpPins.files.length > 0 ? { staleMcpPins } : {}),
|
|
850
866
|
...(pipeline.ran ? { pipeline } : {}),
|
|
851
867
|
}
|
|
@@ -1031,6 +1047,12 @@ export async function runDoctor(
|
|
|
1031
1047
|
"or ./node_modules/.bin/nifra)\n",
|
|
1032
1048
|
)
|
|
1033
1049
|
}
|
|
1050
|
+
if (result.versionDrift !== undefined) {
|
|
1051
|
+
console.log(`⚠ ${versionDriftMessage(result.versionDrift)}\n`)
|
|
1052
|
+
}
|
|
1053
|
+
for (const finding of result.handBuiltManifests ?? []) {
|
|
1054
|
+
console.log(`⚠ ${handBuiltManifestMessage(finding)}\n`)
|
|
1055
|
+
}
|
|
1034
1056
|
// Advisory: the agent's MCP server is launched from these pins, so a stale one answers for an old
|
|
1035
1057
|
// release even when the CLI running doctor matches the project.
|
|
1036
1058
|
if (result.staleMcpPins !== undefined) {
|
package/src/env-file.ts
CHANGED
|
@@ -44,6 +44,13 @@ function unquote(raw: string): string {
|
|
|
44
44
|
return (comment === -1 ? value : value.slice(0, comment)).trim()
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Set to the command name while a CLI command imports a project only to read its declarations
|
|
49
|
+
* (`nifra check`, `routes`, the `assure` report), so app code can skip work a read never needs, such
|
|
50
|
+
* as connecting to Redis.
|
|
51
|
+
*/
|
|
52
|
+
export const REFLECT_ENV = "NIFRA_REFLECT"
|
|
53
|
+
|
|
47
54
|
/** Parse dotenv text into a plain record. Later assignments to the same key win, as in a shell. */
|
|
48
55
|
export function parseEnvFile(text: string): Record<string, string> {
|
|
49
56
|
const values: Record<string, string> = {}
|
package/src/fix-recipes.ts
CHANGED
|
@@ -42,10 +42,15 @@ registerFixRecipe({
|
|
|
42
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",
|
|
43
43
|
)
|
|
44
44
|
try {
|
|
45
|
-
const { rewriteSecretComparisons } = await import("./rules/security.ts")
|
|
45
|
+
const { provenBrowserFile, rewriteSecretComparisons } = await import("./rules/security.ts")
|
|
46
46
|
const source = await readFile(path, "utf8")
|
|
47
47
|
// Every site in the file at once, so the rest of that file's diagnostics find nothing left.
|
|
48
|
-
const rewritten = rewriteSecretComparisons(
|
|
48
|
+
const rewritten = rewriteSecretComparisons(
|
|
49
|
+
loaded.compiler,
|
|
50
|
+
diagnostic.file,
|
|
51
|
+
source,
|
|
52
|
+
provenBrowserFile(root, diagnostic.file),
|
|
53
|
+
)
|
|
49
54
|
if (rewritten === source) return []
|
|
50
55
|
await writeFile(path, rewritten, "utf8")
|
|
51
56
|
return [diagnostic.file]
|
|
@@ -62,7 +67,33 @@ registerFixRecipe({
|
|
|
62
67
|
async apply(root) {
|
|
63
68
|
const { syncServerManifests } = await import("./sync-manifest.ts")
|
|
64
69
|
const results = await syncServerManifests(root)
|
|
65
|
-
|
|
70
|
+
const changed = results.filter((result) => result.changed).map((result) => result.file)
|
|
71
|
+
const failed = results.filter((result) => result.error !== undefined)
|
|
72
|
+
if (failed.length > 0) {
|
|
73
|
+
const synced = changed.length === 0 ? "" : `synced ${changed.join(", ")}; `
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[nifra] ${synced}cannot sync ${failed.map((result) => `${result.file}: ${result.error}`).join("; ")}`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return changed
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
registerFixRecipe({
|
|
83
|
+
id: "framework.api-prefix",
|
|
84
|
+
description:
|
|
85
|
+
"Serve the backend over HTTP at /api by exporting apiPrefix from the framework config.",
|
|
86
|
+
verify: "nifra check",
|
|
87
|
+
async apply(root, diagnostic) {
|
|
88
|
+
if (diagnostic.file === undefined) return []
|
|
89
|
+
const path = await resolveInsideProject(root, diagnostic.file)
|
|
90
|
+
if (path === undefined) return []
|
|
91
|
+
const { withDefaultApiPrefix } = await import("./rules/pages.ts")
|
|
92
|
+
const file = Bun.file(path)
|
|
93
|
+
const next = withDefaultApiPrefix((await file.exists()) ? await file.text() : undefined)
|
|
94
|
+
if (next === undefined) return []
|
|
95
|
+
await writeFile(path, next, "utf8")
|
|
96
|
+
return [diagnostic.file]
|
|
66
97
|
},
|
|
67
98
|
})
|
|
68
99
|
|
|
@@ -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
|
+
}
|