@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/contracts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
|
+
import { NIFRA_ASSURANCE } from "@nifrajs/core/assurance"
|
|
3
4
|
import {
|
|
4
5
|
type ProjectEvidenceSnapshot,
|
|
5
6
|
reflectedRoutesFromEvidence,
|
|
@@ -16,9 +17,18 @@ export interface ContractDigest {
|
|
|
16
17
|
|
|
17
18
|
export interface ContractsLock {
|
|
18
19
|
readonly version: 1
|
|
20
|
+
/** The {@link CONTRACT_DIGEST_FORMAT} that produced `routes`. Absent in a lock written before it was recorded. */
|
|
21
|
+
readonly digest?: number
|
|
19
22
|
readonly routes: Readonly<Record<string, ContractDigest>>
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Bumped whenever {@link digestRoute} hashes the same schema differently. The lock records it, so a
|
|
27
|
+
* format change reads as "re-snapshot" instead of as drift on every route. 2: properties named like
|
|
28
|
+
* annotation keywords are hashed (4.0.1).
|
|
29
|
+
*/
|
|
30
|
+
export const CONTRACT_DIGEST_FORMAT = 2
|
|
31
|
+
|
|
22
32
|
export const DEFAULT_CONTRACTS_LOCK = "contracts.lock.json"
|
|
23
33
|
const DIGEST = /^[a-f0-9]{64}$/
|
|
24
34
|
|
|
@@ -146,6 +156,7 @@ export async function buildContractsLock(
|
|
|
146
156
|
)
|
|
147
157
|
return {
|
|
148
158
|
version: 1,
|
|
159
|
+
digest: CONTRACT_DIGEST_FORMAT,
|
|
149
160
|
routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => codeUnitOrder(a, b))),
|
|
150
161
|
}
|
|
151
162
|
}
|
|
@@ -160,6 +171,27 @@ async function loadBackend(cwd: string): Promise<unknown> {
|
|
|
160
171
|
return loaded.backend
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
const INPUT_PARTS = ["body", "query", "headers", "cookies", "params"] as const
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A route that in-process auth guards, but through a hook that runs after its declared input is
|
|
178
|
+
* validated: an unauthenticated caller reaches the body parser and reads the validation issues.
|
|
179
|
+
* Gateway auth (declared provenance) never lets that caller in, and an explicit `validationOrder`
|
|
180
|
+
* is the author's choice.
|
|
181
|
+
*/
|
|
182
|
+
export function validatesBeforeAuthentication(route: ReflectedRoute): boolean {
|
|
183
|
+
const schema = route.schema
|
|
184
|
+
if (schema === undefined || schema.validationOrder !== undefined) return false
|
|
185
|
+
if (!INPUT_PARTS.some((part) => schema[part] !== undefined)) return false
|
|
186
|
+
const auth = (route.assurance ?? []).filter(
|
|
187
|
+
(evidence) =>
|
|
188
|
+
evidence.id === NIFRA_ASSURANCE.AUTHENTICATED &&
|
|
189
|
+
(evidence.provenance ?? (evidence.source === "declared" ? "declared" : "runtime")) ===
|
|
190
|
+
"runtime",
|
|
191
|
+
)
|
|
192
|
+
return auth.length > 0 && auth.every((evidence) => evidence.source !== "nifra.authenticate")
|
|
193
|
+
}
|
|
194
|
+
|
|
163
195
|
export async function snapshotContracts(
|
|
164
196
|
cwd: string,
|
|
165
197
|
out = DEFAULT_CONTRACTS_LOCK,
|
|
@@ -176,6 +208,9 @@ export function parseContractsLock(value: unknown): ContractsLock {
|
|
|
176
208
|
const routesRecord = recordOf(routesValue)
|
|
177
209
|
if (record.version !== 1 || routesRecord === undefined)
|
|
178
210
|
throw new TypeError("contracts lock must have version 1 and routes")
|
|
211
|
+
const digest = record.digest
|
|
212
|
+
if (digest !== undefined && !(Number.isSafeInteger(digest) && Number(digest) > 0))
|
|
213
|
+
throw new TypeError("contracts lock digest must be a positive integer")
|
|
179
214
|
const routes: Record<string, ContractDigest> = {}
|
|
180
215
|
for (const [key, raw] of Object.entries(routesRecord)) {
|
|
181
216
|
const item = recordOf(raw)
|
|
@@ -192,7 +227,7 @@ export function parseContractsLock(value: unknown): ContractsLock {
|
|
|
192
227
|
throw new TypeError(`invalid contract ${key}`)
|
|
193
228
|
routes[key] = { request, response }
|
|
194
229
|
}
|
|
195
|
-
return { version: 1, routes }
|
|
230
|
+
return typeof digest === "number" ? { version: 1, digest, routes } : { version: 1, routes }
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
export async function checkContractsLock(
|
|
@@ -202,13 +237,30 @@ export async function checkContractsLock(
|
|
|
202
237
|
present: boolean
|
|
203
238
|
vacuous: boolean
|
|
204
239
|
diagnostics: Array<{ route?: string; message: string }>
|
|
240
|
+
/** `METHOD /path` of every route the backend serves, read when the lock made the check load it. */
|
|
241
|
+
served?: readonly string[]
|
|
242
|
+
/** `METHOD /path` of each route {@link validatesBeforeAuthentication} names, read alongside `served`. */
|
|
243
|
+
inputBeforeAuth?: readonly string[]
|
|
205
244
|
}> {
|
|
206
245
|
const path = resolve(cwd, file)
|
|
207
246
|
if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
|
|
208
247
|
const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
|
|
209
|
-
const
|
|
248
|
+
const evidence = snapshotProjectEvidence(await loadBackend(cwd))
|
|
249
|
+
const current = await buildContractsLock(undefined, evidence)
|
|
210
250
|
const vacuous = await isVacuousLock(current)
|
|
251
|
+
const served = Object.keys(current.routes)
|
|
252
|
+
const inputBeforeAuth = reflectedRoutesFromEvidence(evidence)
|
|
253
|
+
.filter(validatesBeforeAuthentication)
|
|
254
|
+
.map((route) => `${route.method} ${route.path}`)
|
|
211
255
|
const diagnostics: Array<{ route?: string; message: string }> = []
|
|
256
|
+
// Digests of two formats never compare, so this still fails; it just names the real cause.
|
|
257
|
+
if (parsed.digest !== undefined && parsed.digest !== CONTRACT_DIGEST_FORMAT) {
|
|
258
|
+
const newer = parsed.digest > CONTRACT_DIGEST_FORMAT
|
|
259
|
+
diagnostics.push({
|
|
260
|
+
message: `${file} was written with contract digest format ${parsed.digest}, but this nifra computes format ${CONTRACT_DIGEST_FORMAT}${newer ? " (a newer @nifrajs/cli wrote it; upgrade this one)" : ""}, so no route could be compared. Review the route schema changes since the lock was written, then run \`nifra contracts snapshot\` and commit the lock.`,
|
|
261
|
+
})
|
|
262
|
+
return { present: true, vacuous, diagnostics, served, inputBeforeAuth }
|
|
263
|
+
}
|
|
212
264
|
const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
|
|
213
265
|
for (const key of [...keys].sort()) {
|
|
214
266
|
const before = parsed.routes[key]
|
|
@@ -225,5 +277,10 @@ export async function checkContractsLock(
|
|
|
225
277
|
})
|
|
226
278
|
}
|
|
227
279
|
}
|
|
228
|
-
|
|
280
|
+
if (diagnostics.length > 0 && parsed.digest === undefined) {
|
|
281
|
+
diagnostics.push({
|
|
282
|
+
message: `${file} records no contract digest format. A lock written before nifra 4.0.1 hashed schemas differently, so a route reported above may not have changed. Review them, then run \`nifra contracts snapshot\` to record the format.`,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
return { present: true, vacuous, diagnostics, served, inputBeforeAuth }
|
|
229
286
|
}
|
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/dev-feed-tool.ts
CHANGED
|
@@ -793,6 +793,117 @@ export const logsSpec: CommandSpec<LogsInput, LogsOutput> = {
|
|
|
793
793
|
json: (out) => out,
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
+
export interface ExplainInput {
|
|
797
|
+
readonly error?: string | undefined
|
|
798
|
+
readonly stack?: string | undefined
|
|
799
|
+
readonly name?: string | undefined
|
|
800
|
+
readonly port?: number | undefined
|
|
801
|
+
readonly dir?: string | undefined
|
|
802
|
+
readonly json?: boolean | undefined
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** A diagnostic, or `NIFRA_NONE` with why there was nothing to explain. */
|
|
806
|
+
export type ExplainOutput =
|
|
807
|
+
| (Diagnostic & { readonly entry?: object; readonly note?: string })
|
|
808
|
+
| { code: "NIFRA_NONE"; message: string }
|
|
809
|
+
|
|
810
|
+
const explained = (out: ExplainOutput): out is Diagnostic => out.code !== "NIFRA_NONE"
|
|
811
|
+
|
|
812
|
+
export const explainSpec: CommandSpec<ExplainInput, ExplainOutput> = {
|
|
813
|
+
name: "explain",
|
|
814
|
+
summary:
|
|
815
|
+
"Turn an error into a structured diagnostic: its code, the frame in your source with a codeframe, and the cause and fix when nifra recognises it. With no message, the running dev server's latest error.",
|
|
816
|
+
input: {
|
|
817
|
+
jsonSchema: {
|
|
818
|
+
type: "object",
|
|
819
|
+
properties: {
|
|
820
|
+
error: { type: "string", description: "The error message to explain." },
|
|
821
|
+
stack: {
|
|
822
|
+
type: "string",
|
|
823
|
+
description: "Its stack trace, for the codeframe and source frame.",
|
|
824
|
+
},
|
|
825
|
+
name: { type: "string", description: "Its class name, such as TypeError." },
|
|
826
|
+
port: SHARED_PROPERTIES.port,
|
|
827
|
+
dir: { type: "string", description: "The app directory." },
|
|
828
|
+
json: { type: "boolean" },
|
|
829
|
+
},
|
|
830
|
+
additionalProperties: false,
|
|
831
|
+
},
|
|
832
|
+
parse: (value) => {
|
|
833
|
+
const raw = fields(value)
|
|
834
|
+
return {
|
|
835
|
+
error: text(raw, "error"),
|
|
836
|
+
stack: text(raw, "stack"),
|
|
837
|
+
name: text(raw, "name"),
|
|
838
|
+
port: count(raw, "port"),
|
|
839
|
+
dir: text(raw, "dir"),
|
|
840
|
+
json: flag(raw, "json"),
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
output: {
|
|
845
|
+
version: 1,
|
|
846
|
+
jsonSchema: {
|
|
847
|
+
type: "object",
|
|
848
|
+
properties: { code: { type: "string" }, message: { type: "string" } },
|
|
849
|
+
required: ["code", "message"],
|
|
850
|
+
},
|
|
851
|
+
parse: (value) => {
|
|
852
|
+
if (isDiagnostic(value)) return value
|
|
853
|
+
if (
|
|
854
|
+
typeof value === "object" &&
|
|
855
|
+
value !== null &&
|
|
856
|
+
"code" in value &&
|
|
857
|
+
value.code === "NIFRA_NONE" &&
|
|
858
|
+
"message" in value &&
|
|
859
|
+
typeof value.message === "string"
|
|
860
|
+
)
|
|
861
|
+
return { code: "NIFRA_NONE", message: value.message }
|
|
862
|
+
throw new TypeError("explain output must be a diagnostic or NIFRA_NONE")
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
transports: ["cli"],
|
|
866
|
+
stability: "experimental",
|
|
867
|
+
argv: {
|
|
868
|
+
positionals: ["error"],
|
|
869
|
+
flags: [
|
|
870
|
+
{ name: "stack", field: "stack", type: "string" },
|
|
871
|
+
{ name: "name", field: "name", type: "string" },
|
|
872
|
+
{ name: "port", field: "port", type: "number" },
|
|
873
|
+
{ name: "dir", field: "dir", type: "string" },
|
|
874
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
875
|
+
],
|
|
876
|
+
},
|
|
877
|
+
async run(input, ctx) {
|
|
878
|
+
const root = resolve(ctx.cwd, input.dir ?? ".")
|
|
879
|
+
if (input.error === undefined && input.stack === undefined) {
|
|
880
|
+
return explainLatest(root, input.port, ctx.signal)
|
|
881
|
+
}
|
|
882
|
+
const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
|
|
883
|
+
const { explainSourceGate } = await import("./mcp-exec.ts")
|
|
884
|
+
const error = new Error(input.error ?? "")
|
|
885
|
+
if (input.name !== undefined) error.name = input.name
|
|
886
|
+
// Without --stack the frames would be this command's own, not the app's.
|
|
887
|
+
error.stack = input.stack ?? `${error.name}: ${error.message}`
|
|
888
|
+
return buildDiagnostic(error, { root, showSource: explainSourceGate(root) })
|
|
889
|
+
},
|
|
890
|
+
render: (out) => {
|
|
891
|
+
if (!explained(out)) return [out.message]
|
|
892
|
+
const at = topFrame(out)
|
|
893
|
+
return [
|
|
894
|
+
`${out.code} ${headline(out)}`,
|
|
895
|
+
...(at === undefined ? [] : [` at ${at}`]),
|
|
896
|
+
...(out.codeframe?.lines ?? []).map(
|
|
897
|
+
(line) => ` ${line.caret ? ">" : " "} ${String(line.number).padStart(4)} | ${line.text}`,
|
|
898
|
+
),
|
|
899
|
+
...(out.cause === undefined ? [] : [` cause: ${out.cause}`]),
|
|
900
|
+
...(out.fix === undefined ? [] : [` fix: ${out.fix}`]),
|
|
901
|
+
]
|
|
902
|
+
},
|
|
903
|
+
exitCode: (out) => (explained(out) ? 0 : 2),
|
|
904
|
+
json: (out) => out,
|
|
905
|
+
}
|
|
906
|
+
|
|
796
907
|
// ---------------------------------------------------------------------------------------------------
|
|
797
908
|
// nifra_explain / nifra_inspect reads
|
|
798
909
|
// ---------------------------------------------------------------------------------------------------
|
|
@@ -802,7 +913,7 @@ export async function explainLatest(
|
|
|
802
913
|
cwd: string,
|
|
803
914
|
port: number | undefined,
|
|
804
915
|
signal?: AbortSignal,
|
|
805
|
-
): Promise<
|
|
916
|
+
): Promise<ExplainOutput> {
|
|
806
917
|
const { findDevServer, describeLookup, readDevFeed } = await import("./dev-server-client.ts")
|
|
807
918
|
const lookup = await findDevServer(cwd, { port }, signal)
|
|
808
919
|
if (lookup.status === "live") {
|
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> = {}
|