@nifrajs/cli 2.11.0 → 2.12.1
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 +6 -0
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +358 -25
- package/dist/mcp.js.map +1 -1
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +906 -15
- package/docs/types.json +1682 -130
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +75 -6
- package/src/load.ts +6 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +438 -32
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
package/src/cli.ts
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
import { existsSync } from "node:fs"
|
|
13
13
|
import { resolve } from "node:path"
|
|
14
14
|
import { inProcessClient } from "@nifrajs/client"
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type CreateWebAppOptions,
|
|
17
|
+
createWebApp,
|
|
18
|
+
DEFAULT_DEV_PORT,
|
|
19
|
+
type RenderAdapter,
|
|
20
|
+
} from "@nifrajs/web"
|
|
16
21
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
22
|
import type { BunPlugin } from "bun"
|
|
18
23
|
import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
|
|
19
|
-
import { type LoadedApp, loadApp } from "./load.ts"
|
|
24
|
+
import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
|
|
20
25
|
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
21
26
|
|
|
22
27
|
export interface Flags {
|
|
@@ -82,8 +87,11 @@ Usage:
|
|
|
82
87
|
and a "## MCP server" section in AGENTS.md. No-clobber by
|
|
83
88
|
default (skips a file you've customized); --force overwrites the
|
|
84
89
|
owned files. AGENTS.md is only appended to, never overwritten.
|
|
85
|
-
nifra mcp
|
|
86
|
-
agent
|
|
90
|
+
nifra mcp [dir] Start an MCP server (stdio) exposing this project to a coding
|
|
91
|
+
agent. The project root is [dir] when given, else resolved from
|
|
92
|
+
cwd (marker walk-up + the client's MCP roots); tools refuse
|
|
93
|
+
with a fix when no nifra project is found - never a silent
|
|
94
|
+
wrong-project answer. Tools: nifra_context, nifra_routes,
|
|
87
95
|
nifra_run (backend), nifra_render (SSR a page), nifra_docs,
|
|
88
96
|
nifra_example (verified snippets), nifra_scaffold (route→file),
|
|
89
97
|
nifra_check (drift gate + fixes), nifra_levels (verification
|
|
@@ -96,6 +104,8 @@ Usage:
|
|
|
96
104
|
nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
|
|
97
105
|
server-only imports in routes/). Run as "done"; --json for agents;
|
|
98
106
|
--lints-only skips tsc for a near-instant inner-loop pass.
|
|
107
|
+
nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
|
|
108
|
+
structured diagnostics.
|
|
99
109
|
nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
|
|
100
110
|
a full build - clears server-manifest drift after a route add/rename
|
|
101
111
|
(a new hydrating component still needs a full build).
|
|
@@ -111,9 +121,14 @@ Usage:
|
|
|
111
121
|
closed. Exits non-zero on any breaking change - run it in CI.
|
|
112
122
|
nifra sdk --lang <python|go> [--out <file>]
|
|
113
123
|
Generate a deterministic non-TypeScript SDK from backend.ts.
|
|
114
|
-
nifra assure [--config <file>] [--json]
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
nifra assure [--config <file>] [--json] [--strict] [--out <file>] [--hydration] [--interact]
|
|
125
|
+
Emit one structured assurance bundle. The bundle records every
|
|
126
|
+
applicable gate as pass, fail, or skip and exits non-zero unless
|
|
127
|
+
its verdict is green. Without these bundle flags, the legacy
|
|
128
|
+
route-assurance report remains available.
|
|
129
|
+
nifra contracts snapshot [--out <file>] Write the deterministic route contract lock.
|
|
130
|
+
nifra contracts check [--json] Check the current routes against contracts.lock.json.
|
|
131
|
+
nifra replay <file> Replay a token-only verification metadata file.
|
|
117
132
|
nifra capabilities snapshot [--out <file>] [--config <file>]
|
|
118
133
|
Write the deterministic, token-only capability lockfile, but
|
|
119
134
|
only after provenance + idempotency assurance passes.
|
|
@@ -130,9 +145,15 @@ Usage:
|
|
|
130
145
|
Compute the verification ladder (L0 typed contract → L1 route
|
|
131
146
|
assurance → L2 capability lockfile → L3 route manifest → L4
|
|
132
147
|
invariant-tested); --min <n> fails the exit code below level n.
|
|
148
|
+
nifra prove [--json] [--file <path>] [--min <n>]
|
|
149
|
+
Build the static verification work graph, plan the cheapest
|
|
150
|
+
proofs for changed files, and report a machine-checkable stop
|
|
151
|
+
condition. Requires a fresh build; never probes a running app.
|
|
133
152
|
nifra doctor [--json] [--auto-fix] Flag undeclared imports and multiple physical copies of
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
[--strict] [--target] @nifrajs/core/React identity-sensitive dependencies;
|
|
154
|
+
print production readiness, with --strict making absent
|
|
155
|
+
applicable guarantees fail. --auto-fix writes safe local-
|
|
156
|
+
version dependency fixes.
|
|
136
157
|
nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
|
|
137
158
|
[--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
|
|
138
159
|
[--list] [--json] packages, apply exact imports, then verify with nifra check.
|
|
@@ -154,11 +175,13 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
154
175
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
155
176
|
|
|
156
177
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
157
|
-
const CLI_VERSION = "2.
|
|
178
|
+
const CLI_VERSION = "2.12.1"
|
|
158
179
|
|
|
159
180
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
160
181
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
161
182
|
const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
|
|
183
|
+
const asUse = (v: (app: never) => void): NonNullable<CreateWebAppOptions["use"]> =>
|
|
184
|
+
v as NonNullable<CreateWebAppOptions["use"]>
|
|
162
185
|
const apiOf = (backend: unknown): { api?: unknown } =>
|
|
163
186
|
backend === undefined ? {} : { api: inProcessClient(backend as never) }
|
|
164
187
|
|
|
@@ -314,6 +337,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
314
337
|
adapter: asAdapter(fw.adapter),
|
|
315
338
|
manifest: discoverRoutes(routesDir, { importQuery }),
|
|
316
339
|
clientEntry,
|
|
340
|
+
...(fw.use ? { use: asUse(fw.use) } : {}),
|
|
317
341
|
...apiOf(backend),
|
|
318
342
|
}),
|
|
319
343
|
})
|
|
@@ -373,6 +397,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
373
397
|
adapter: asAdapter(fw.adapter),
|
|
374
398
|
manifest: discoverRoutes(routesDir, { load }),
|
|
375
399
|
clientEntry,
|
|
400
|
+
...(fw.use ? { use: asUse(fw.use) } : {}),
|
|
376
401
|
...apiOf(backend),
|
|
377
402
|
}),
|
|
378
403
|
})
|
|
@@ -387,6 +412,37 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
387
412
|
* `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
|
|
388
413
|
* `buildTarget` generates the per-target server entry from those + the app's `routes/`.
|
|
389
414
|
*/
|
|
415
|
+
/**
|
|
416
|
+
* Refuse a `use` the generated server entry cannot import.
|
|
417
|
+
*
|
|
418
|
+
* `nifra build` emits `import { use } from <frameworkFile>` (framework.ts, the edge-bundlable file),
|
|
419
|
+
* but `loadApp` prefers `nifra.config.ts` - so an app with BOTH files whose `use` lives only in
|
|
420
|
+
* `nifra.config.ts` makes `fw.use` defined while `framework.ts` exports nothing of that name, and the
|
|
421
|
+
* build dies later with an opaque bundler error pointing at generated code. Refuse it here with the
|
|
422
|
+
* exact move instead, in the spirit of `assertPipelineSeparation` (load.ts).
|
|
423
|
+
*
|
|
424
|
+
* Detected by importing `frameworkFile` and checking the named export, not by comparing paths alone:
|
|
425
|
+
* a split app legitimately DEFINES `use` in framework.ts and re-exports it from nifra.config.ts
|
|
426
|
+
* (exactly how `adapter` reaches both readers), and a path compare would refuse that correct layout.
|
|
427
|
+
* The import is cheap - framework.ts is already in the loaded config's module graph in that layout.
|
|
428
|
+
*/
|
|
429
|
+
export async function assertUseIsEdgeExported(
|
|
430
|
+
use: NifraFramework["use"],
|
|
431
|
+
configPath: string,
|
|
432
|
+
frameworkFile: string,
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
if (use === undefined || configPath === frameworkFile) return
|
|
435
|
+
const mod = (await import(frameworkFile).catch(() => ({}))) as { use?: unknown }
|
|
436
|
+
if (typeof mod.use === "function") return
|
|
437
|
+
throw new Error(
|
|
438
|
+
`[nifra] \`use\` is exported from ${configPath} but not from ${frameworkFile}. ` +
|
|
439
|
+
"`nifra build` generates a server entry that imports `use` from framework.ts (the edge-bundled " +
|
|
440
|
+
"file), so this build would fail later with an opaque bundler error inside generated code.\n\n" +
|
|
441
|
+
" - Define `use` in framework.ts and re-export it from nifra.config.ts " +
|
|
442
|
+
'(`export { use } from "./framework.ts"`) so `nifra dev` sees it too.',
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
390
446
|
async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
|
|
391
447
|
const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
|
|
392
448
|
if (!isBuildTarget(target)) {
|
|
@@ -418,6 +474,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
|
|
|
418
474
|
? resolve(cwd, "nifra.config.ts")
|
|
419
475
|
: resolve(cwd, "framework.ts")
|
|
420
476
|
const backendFile = resolve(cwd, "backend.ts")
|
|
477
|
+
await assertUseIsEdgeExported(fw.use, app.configPath, frameworkFile)
|
|
421
478
|
// Plugin FORMAT differs by pipeline: the Bun build takes Bun plugins (clientPlugins/serverPlugins); the
|
|
422
479
|
// Vite build takes the app's Vite plugins (fw.vitePlugins) for BOTH halves. buildTargetWith forwards
|
|
423
480
|
// whatever it's given straight to the chosen bundler, which casts to its own plugin type.
|
|
@@ -436,6 +493,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
|
|
|
436
493
|
workDir: resolve(cwd, ".nifra-build"),
|
|
437
494
|
clientModule: fw.clientModule,
|
|
438
495
|
adapterImport: frameworkFile,
|
|
496
|
+
...(fw.use ? { useImport: frameworkFile } : {}),
|
|
439
497
|
...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
|
|
440
498
|
...plugins,
|
|
441
499
|
...(fw.conditions ? { conditions: fw.conditions } : {}),
|
|
@@ -469,6 +527,7 @@ async function buildPrerenderApp(
|
|
|
469
527
|
adapter: asAdapter(fw.adapter),
|
|
470
528
|
manifest: discoverRoutes(routesDir),
|
|
471
529
|
clientEntry: client.entry,
|
|
530
|
+
...(fw.use ? { use: asUse(fw.use) } : {}),
|
|
472
531
|
...(client.routes ? { routePreload: client.routes } : {}),
|
|
473
532
|
...(client.css ? { styles: client.css } : {}),
|
|
474
533
|
...(client.routeStyles ? { routeStyles: client.routeStyles } : {}),
|
|
@@ -545,7 +604,10 @@ async function main(): Promise<void> {
|
|
|
545
604
|
// the eager `loadApp` below (which would fail fast on a project that's API-only / not yet built).
|
|
546
605
|
if (command === "mcp") {
|
|
547
606
|
const { runMcpServer } = await import("./mcp.ts")
|
|
548
|
-
|
|
607
|
+
// `nifra mcp [dir]` - an explicit project directory pins the root (for clients configured outside
|
|
608
|
+
// the project); otherwise the server resolves it from cwd + the client's MCP roots.
|
|
609
|
+
const dirArg = argv[1] !== undefined && !argv[1].startsWith("-") ? argv[1] : undefined
|
|
610
|
+
await runMcpServer(process.cwd(), CLI_VERSION, dirArg)
|
|
549
611
|
return
|
|
550
612
|
}
|
|
551
613
|
// `docs-mcp` runs the PUBLIC docs MCP over HTTP - project-independent (serves the bundled corpus), so
|
|
@@ -580,12 +642,36 @@ async function main(): Promise<void> {
|
|
|
580
642
|
if (
|
|
581
643
|
!(await runCheck(process.cwd(), {
|
|
582
644
|
json: argv.includes("--json"),
|
|
645
|
+
structured: argv.includes("--json"),
|
|
583
646
|
lintsOnly: argv.includes("--lints-only"),
|
|
584
647
|
}))
|
|
585
648
|
)
|
|
586
649
|
process.exitCode = 1
|
|
587
650
|
return
|
|
588
651
|
}
|
|
652
|
+
if (command === "fix") {
|
|
653
|
+
const codeIndex = argv.indexOf("--code")
|
|
654
|
+
const code = codeIndex === -1 ? undefined : argv[codeIndex + 1]
|
|
655
|
+
try {
|
|
656
|
+
const { collectCheckResult } = await import("./check.ts")
|
|
657
|
+
const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
|
|
658
|
+
const result = await collectCheckResult(process.cwd(), { lintsOnly: true })
|
|
659
|
+
const changed: string[] = []
|
|
660
|
+
for (const diagnostic of result.structuredDiagnostics ?? []) {
|
|
661
|
+
if (code !== undefined && diagnostic.code !== code) continue
|
|
662
|
+
changed.push(...(await applyDiagnosticRecipe(process.cwd(), diagnostic)))
|
|
663
|
+
}
|
|
664
|
+
const final = await collectCheckResult(process.cwd(), { lintsOnly: true })
|
|
665
|
+
console.log(
|
|
666
|
+
JSON.stringify({ changed, diagnostics: final.structuredDiagnostics ?? [] }, null, 2),
|
|
667
|
+
)
|
|
668
|
+
if (final.ok === false) process.exitCode = 1
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.error(formatCliError(err))
|
|
671
|
+
process.exitCode = 1
|
|
672
|
+
}
|
|
673
|
+
return
|
|
674
|
+
}
|
|
589
675
|
// `init-agents` retrofits the agent-discovery files (.mcp.json, CLAUDE.md, …) into the cwd. It's a
|
|
590
676
|
// pure file-writing command independent of the app loading, so dispatch it before the eager `loadApp`
|
|
591
677
|
// (an existing app might be API-only or not yet built). It always succeeds unless a write throws.
|
|
@@ -616,10 +702,19 @@ async function main(): Promise<void> {
|
|
|
616
702
|
// so it runs on an API-only / not-yet-built project.
|
|
617
703
|
if (command === "doctor") {
|
|
618
704
|
const { runDoctor } = await import("./doctor.ts")
|
|
705
|
+
const targetIdx = argv.indexOf("--target")
|
|
706
|
+
const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
|
|
707
|
+
if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
|
|
708
|
+
console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
|
|
709
|
+
process.exitCode = 1
|
|
710
|
+
return
|
|
711
|
+
}
|
|
619
712
|
if (
|
|
620
713
|
!(await runDoctor(process.cwd(), {
|
|
621
714
|
json: argv.includes("--json"),
|
|
622
715
|
autoFix: argv.includes("--auto-fix") || argv.includes("--fix"),
|
|
716
|
+
strict: argv.includes("--strict"),
|
|
717
|
+
...(target === undefined ? {} : { target }),
|
|
623
718
|
}))
|
|
624
719
|
)
|
|
625
720
|
process.exitCode = 1
|
|
@@ -696,6 +791,16 @@ async function main(): Promise<void> {
|
|
|
696
791
|
if (
|
|
697
792
|
!(await runAssurance(process.cwd(), {
|
|
698
793
|
json: argv.includes("--json"),
|
|
794
|
+
bundle:
|
|
795
|
+
argv.includes("--json") ||
|
|
796
|
+
argv.includes("--strict") ||
|
|
797
|
+
argv.includes("--hydration") ||
|
|
798
|
+
argv.includes("--interact") ||
|
|
799
|
+
argv.includes("--out"),
|
|
800
|
+
strict: argv.includes("--strict"),
|
|
801
|
+
hydration: argv.includes("--hydration"),
|
|
802
|
+
interact: argv.includes("--interact"),
|
|
803
|
+
...(argv.includes("--out") ? { out: argv[argv.indexOf("--out") + 1] } : {}),
|
|
699
804
|
...(config !== undefined ? { config } : {}),
|
|
700
805
|
}))
|
|
701
806
|
)
|
|
@@ -706,6 +811,49 @@ async function main(): Promise<void> {
|
|
|
706
811
|
}
|
|
707
812
|
return
|
|
708
813
|
}
|
|
814
|
+
if (command === "contracts") {
|
|
815
|
+
const action = argv[1]
|
|
816
|
+
const outIndex = argv.indexOf("--out")
|
|
817
|
+
const out = outIndex === -1 ? undefined : argv[outIndex + 1]
|
|
818
|
+
try {
|
|
819
|
+
const { checkContractsLock, snapshotContracts } = await import("./contracts.ts")
|
|
820
|
+
if (action === "snapshot") {
|
|
821
|
+
const lock = await snapshotContracts(process.cwd(), out)
|
|
822
|
+
if (argv.includes("--json")) console.log(JSON.stringify(lock, null, 2))
|
|
823
|
+
else console.log(`[nifra] wrote ${Object.keys(lock.routes).length} contract digests`)
|
|
824
|
+
} else if (action === "check") {
|
|
825
|
+
const result = await checkContractsLock(process.cwd())
|
|
826
|
+
const output = { ok: result.present && result.diagnostics.length === 0, ...result }
|
|
827
|
+
console.log(JSON.stringify(output, null, 2))
|
|
828
|
+
if (!output.ok) process.exitCode = 1
|
|
829
|
+
} else {
|
|
830
|
+
console.error("[nifra] contracts needs snapshot or check")
|
|
831
|
+
process.exitCode = 1
|
|
832
|
+
}
|
|
833
|
+
} catch (err) {
|
|
834
|
+
console.error(formatCliError(err))
|
|
835
|
+
process.exitCode = 1
|
|
836
|
+
}
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
if (command === "replay") {
|
|
840
|
+
const file = argv[1]
|
|
841
|
+
if (file === undefined || file.startsWith("-")) {
|
|
842
|
+
console.error("[nifra] replay needs a file path")
|
|
843
|
+
process.exitCode = 1
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
const { runReplay } = await import("./replay.ts")
|
|
848
|
+
const result = await runReplay(process.cwd(), file)
|
|
849
|
+
console.log(JSON.stringify(result, null, 2))
|
|
850
|
+
if (!result.ok) process.exitCode = 1
|
|
851
|
+
} catch (err) {
|
|
852
|
+
console.error(formatCliError(err))
|
|
853
|
+
process.exitCode = 1
|
|
854
|
+
}
|
|
855
|
+
return
|
|
856
|
+
}
|
|
709
857
|
if (command === "capabilities") {
|
|
710
858
|
const action = argv[1]
|
|
711
859
|
const valueAfter = (flag: string): string | undefined => {
|
|
@@ -820,6 +968,45 @@ async function main(): Promise<void> {
|
|
|
820
968
|
}
|
|
821
969
|
return
|
|
822
970
|
}
|
|
971
|
+
if (command === "prove") {
|
|
972
|
+
const valueAfter = (flag: string): string | undefined => {
|
|
973
|
+
const index = argv.indexOf(flag)
|
|
974
|
+
const value = index === -1 ? undefined : argv[index + 1]
|
|
975
|
+
if (index !== -1 && (value === undefined || value.startsWith("-"))) {
|
|
976
|
+
throw new Error(`[nifra] ${flag} needs a value`)
|
|
977
|
+
}
|
|
978
|
+
return value
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const minRaw = valueAfter("--min")
|
|
982
|
+
const min = minRaw === undefined ? undefined : Number(minRaw)
|
|
983
|
+
if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
|
|
984
|
+
throw new Error("[nifra] --min must be an integer level between 0 and 4")
|
|
985
|
+
}
|
|
986
|
+
const changedFiles: string[] = []
|
|
987
|
+
for (let index = 0; index < argv.length; index++) {
|
|
988
|
+
if (argv[index] !== "--file") continue
|
|
989
|
+
const file = argv[index + 1]
|
|
990
|
+
if (file === undefined || file.startsWith("-"))
|
|
991
|
+
throw new Error("[nifra] --file needs a path")
|
|
992
|
+
changedFiles.push(file)
|
|
993
|
+
index++
|
|
994
|
+
}
|
|
995
|
+
const { runWorkGraph } = await import("./work-graph.ts")
|
|
996
|
+
if (
|
|
997
|
+
!(await runWorkGraph(process.cwd(), {
|
|
998
|
+
json: argv.includes("--json"),
|
|
999
|
+
...(changedFiles.length === 0 ? {} : { changedFiles }),
|
|
1000
|
+
...(min === undefined ? {} : { minLevel: min }),
|
|
1001
|
+
}))
|
|
1002
|
+
)
|
|
1003
|
+
process.exitCode = 1
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
console.error(formatCliError(err))
|
|
1006
|
+
process.exitCode = 1
|
|
1007
|
+
}
|
|
1008
|
+
return
|
|
1009
|
+
}
|
|
823
1010
|
// `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
|
|
824
1011
|
// recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
|
|
825
1012
|
// repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
2
|
+
import { resolve, sep } from "node:path"
|
|
3
|
+
import type { AssuranceFinding, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
|
|
4
|
+
import type {
|
|
5
|
+
AssuredCapabilityRoute,
|
|
6
|
+
CapabilityAssuranceReport,
|
|
7
|
+
CapabilityFinding,
|
|
8
|
+
} from "@nifrajs/core/capabilities"
|
|
9
|
+
import { diffRouteSnapshots, type RouteChange, type RoutesDiff } from "@nifrajs/core/diff"
|
|
10
|
+
import type { CheckResult } from "./check.ts"
|
|
11
|
+
import { DEFAULT_SNAPSHOT_FILE, parseSnapshotFile, snapshotBackend } from "./diff-tool.ts"
|
|
12
|
+
import { collectProjectVerification } from "./verification.ts"
|
|
13
|
+
|
|
14
|
+
const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
|
|
15
|
+
|
|
16
|
+
export interface ContractProofRoute {
|
|
17
|
+
readonly method: string
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly changes: readonly RouteChange[]
|
|
20
|
+
readonly assurance?: {
|
|
21
|
+
readonly route?: AssuredRoute
|
|
22
|
+
readonly findings: readonly AssuranceFinding[]
|
|
23
|
+
}
|
|
24
|
+
readonly capability?: {
|
|
25
|
+
readonly route?: AssuredCapabilityRoute
|
|
26
|
+
readonly findings: readonly CapabilityFinding[]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContractProofVerification {
|
|
31
|
+
readonly assuranceConfigPresent: boolean
|
|
32
|
+
readonly configError?: string
|
|
33
|
+
readonly assurance?: {
|
|
34
|
+
readonly ok: boolean
|
|
35
|
+
readonly routeCount: number
|
|
36
|
+
readonly findings: readonly AssuranceFinding[]
|
|
37
|
+
}
|
|
38
|
+
readonly capability?: {
|
|
39
|
+
readonly ok: boolean
|
|
40
|
+
readonly routeCount: number
|
|
41
|
+
readonly findings: readonly CapabilityFinding[]
|
|
42
|
+
}
|
|
43
|
+
readonly check?: CheckResult
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ContractProofReport {
|
|
47
|
+
readonly baseline: string
|
|
48
|
+
readonly hasBreaking: boolean
|
|
49
|
+
readonly changes: readonly RouteChange[]
|
|
50
|
+
readonly routes: readonly ContractProofRoute[]
|
|
51
|
+
readonly verification: ContractProofVerification
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ContractProofOptions {
|
|
55
|
+
/** Baseline path relative to the project directory. Defaults to api-snapshot.json. */
|
|
56
|
+
readonly baselinePath?: string
|
|
57
|
+
/** Run the lazy typed-contract check only when explicitly requested. */
|
|
58
|
+
readonly check?: boolean
|
|
59
|
+
readonly signal?: AbortSignal
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeBaselinePath(cwd: string, baselinePath: string): string {
|
|
63
|
+
const root = resolve(cwd)
|
|
64
|
+
const target = resolve(root, baselinePath)
|
|
65
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
66
|
+
throw new Error("contract proof baseline must stay inside the project directory")
|
|
67
|
+
}
|
|
68
|
+
return target
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorMessage(error: unknown): string {
|
|
72
|
+
return error instanceof Error ? error.message : String(error)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function changedRoutes(
|
|
76
|
+
diff: RoutesDiff,
|
|
77
|
+
assurance: AssuranceReport | undefined,
|
|
78
|
+
capability: CapabilityAssuranceReport | undefined,
|
|
79
|
+
): readonly ContractProofRoute[] {
|
|
80
|
+
const changes = new Map<string, RouteChange[]>()
|
|
81
|
+
for (const change of diff.changes) {
|
|
82
|
+
const key = routeKey(change.method, change.path)
|
|
83
|
+
const current = changes.get(key)
|
|
84
|
+
if (current === undefined) changes.set(key, [change])
|
|
85
|
+
else current.push(change)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const assured = new Map(
|
|
89
|
+
(assurance?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
|
|
90
|
+
)
|
|
91
|
+
const assuranceFindings = new Map<string, AssuranceFinding[]>()
|
|
92
|
+
for (const finding of assurance?.findings ?? []) {
|
|
93
|
+
const key = routeKey(finding.method, finding.path)
|
|
94
|
+
const current = assuranceFindings.get(key)
|
|
95
|
+
if (current === undefined) assuranceFindings.set(key, [finding])
|
|
96
|
+
else current.push(finding)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const capable = new Map(
|
|
100
|
+
(capability?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
|
|
101
|
+
)
|
|
102
|
+
const capabilityFindings = new Map<string, CapabilityFinding[]>()
|
|
103
|
+
for (const finding of capability?.findings ?? []) {
|
|
104
|
+
const key = routeKey(finding.method, finding.path)
|
|
105
|
+
const current = capabilityFindings.get(key)
|
|
106
|
+
if (current === undefined) capabilityFindings.set(key, [finding])
|
|
107
|
+
else current.push(finding)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.freeze(
|
|
111
|
+
[...changes].map(([key, routeChanges]) => {
|
|
112
|
+
const first = routeChanges[0]
|
|
113
|
+
if (first === undefined) throw new Error("contract proof: missing route change")
|
|
114
|
+
const assuredRoute = assured.get(key)
|
|
115
|
+
const capableRoute = capable.get(key)
|
|
116
|
+
return {
|
|
117
|
+
method: first.method,
|
|
118
|
+
path: first.path,
|
|
119
|
+
changes: Object.freeze([...routeChanges]),
|
|
120
|
+
...(assurance === undefined
|
|
121
|
+
? {}
|
|
122
|
+
: {
|
|
123
|
+
assurance: {
|
|
124
|
+
...(assuredRoute === undefined ? {} : { route: assuredRoute }),
|
|
125
|
+
findings: Object.freeze([...(assuranceFindings.get(key) ?? [])]),
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
...(capability === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: {
|
|
131
|
+
capability: {
|
|
132
|
+
...(capableRoute === undefined ? {} : { route: capableRoute }),
|
|
133
|
+
findings: Object.freeze([...(capabilityFindings.get(key) ?? [])]),
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Compose route diff, route assurance, capability evidence, and an optional check result. */
|
|
142
|
+
export async function collectContractProof(
|
|
143
|
+
cwd: string,
|
|
144
|
+
options: ContractProofOptions = {},
|
|
145
|
+
): Promise<ContractProofReport> {
|
|
146
|
+
const baselinePath = options.baselinePath ?? DEFAULT_SNAPSHOT_FILE
|
|
147
|
+
const resolvedBaseline = safeBaselinePath(cwd, baselinePath)
|
|
148
|
+
if (!existsSync(resolvedBaseline)) {
|
|
149
|
+
throw new Error(`contract proof baseline not found: ${resolvedBaseline}`)
|
|
150
|
+
}
|
|
151
|
+
const realRoot = realpathSync(resolve(cwd))
|
|
152
|
+
const realBaseline = realpathSync(resolvedBaseline)
|
|
153
|
+
if (realBaseline !== realRoot && !realBaseline.startsWith(`${realRoot}${sep}`)) {
|
|
154
|
+
throw new Error("contract proof baseline must stay inside the project directory")
|
|
155
|
+
}
|
|
156
|
+
const baseline = parseSnapshotFile(await Bun.file(resolvedBaseline).text(), baselinePath)
|
|
157
|
+
const current = await snapshotBackend(cwd)
|
|
158
|
+
const diff = diffRouteSnapshots(baseline.routes, current)
|
|
159
|
+
const verification = await collectProjectVerification(cwd, {
|
|
160
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
161
|
+
})
|
|
162
|
+
const proof: ContractProofVerification = {
|
|
163
|
+
assuranceConfigPresent: verification.assuranceConfigPresent,
|
|
164
|
+
...(verification.configError === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { configError: errorMessage(verification.configError) }),
|
|
167
|
+
...(verification.routeAssurance === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: {
|
|
170
|
+
assurance: {
|
|
171
|
+
ok: verification.routeAssurance.ok,
|
|
172
|
+
routeCount: verification.routeAssurance.routes.length,
|
|
173
|
+
findings: verification.routeAssurance.findings,
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
...(verification.capability === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: {
|
|
179
|
+
capability: {
|
|
180
|
+
ok: verification.capability.report.ok,
|
|
181
|
+
routeCount: verification.capability.report.routes.length,
|
|
182
|
+
findings: verification.capability.report.findings,
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
...(options.check === true ? { check: await verification.check() } : {}),
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
baseline: baselinePath,
|
|
189
|
+
hasBreaking: diff.hasBreaking,
|
|
190
|
+
changes: diff.changes,
|
|
191
|
+
routes: changedRoutes(diff, verification.routeAssurance, verification.capability?.report),
|
|
192
|
+
verification: proof,
|
|
193
|
+
})
|
|
194
|
+
}
|