@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.
Files changed (177) hide show
  1. package/README.md +6 -0
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +125 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +706 -145
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +23 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +194 -10
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/diagnostics.d.ts +23 -0
  31. package/dist/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics.js +9 -0
  33. package/dist/diagnostics.js.map +1 -0
  34. package/dist/diff-tool.d.ts +2 -0
  35. package/dist/diff-tool.d.ts.map +1 -1
  36. package/dist/diff-tool.js +1 -1
  37. package/dist/diff-tool.js.map +1 -1
  38. package/dist/docs-search.d.ts +8 -3
  39. package/dist/docs-search.d.ts.map +1 -1
  40. package/dist/docs-search.js +36 -15
  41. package/dist/docs-search.js.map +1 -1
  42. package/dist/doctor.d.ts +28 -2
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +173 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/examples.d.ts +1 -2
  47. package/dist/examples.d.ts.map +1 -1
  48. package/dist/examples.js +37 -16
  49. package/dist/examples.js.map +1 -1
  50. package/dist/fix-recipes.d.ts +12 -0
  51. package/dist/fix-recipes.d.ts.map +1 -0
  52. package/dist/fix-recipes.js +106 -0
  53. package/dist/fix-recipes.js.map +1 -0
  54. package/dist/init-agents.d.ts.map +1 -1
  55. package/dist/init-agents.js +60 -4
  56. package/dist/init-agents.js.map +1 -1
  57. package/dist/internal/source-facts.d.ts +15 -0
  58. package/dist/internal/source-facts.d.ts.map +1 -0
  59. package/dist/internal/source-facts.js +82 -0
  60. package/dist/internal/source-facts.js.map +1 -0
  61. package/dist/internal/typescript-import.d.ts +9 -0
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +38 -0
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +6 -2
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +53 -4
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/load.d.ts +6 -0
  70. package/dist/load.d.ts.map +1 -1
  71. package/dist/load.js.map +1 -1
  72. package/dist/manifest-tool.d.ts.map +1 -1
  73. package/dist/manifest-tool.js +14 -9
  74. package/dist/manifest-tool.js.map +1 -1
  75. package/dist/mcp-docs-tools.d.ts.map +1 -1
  76. package/dist/mcp-docs-tools.js +3 -0
  77. package/dist/mcp-docs-tools.js.map +1 -1
  78. package/dist/mcp-http.js +1 -1
  79. package/dist/mcp-root.d.ts +83 -0
  80. package/dist/mcp-root.d.ts.map +1 -0
  81. package/dist/mcp-root.js +221 -0
  82. package/dist/mcp-root.js.map +1 -0
  83. package/dist/mcp-run.d.ts.map +1 -1
  84. package/dist/mcp-run.js +18 -4
  85. package/dist/mcp-run.js.map +1 -1
  86. package/dist/mcp.d.ts +27 -4
  87. package/dist/mcp.d.ts.map +1 -1
  88. package/dist/mcp.js +358 -25
  89. package/dist/mcp.js.map +1 -1
  90. package/dist/project-path.d.ts +3 -0
  91. package/dist/project-path.d.ts.map +1 -0
  92. package/dist/project-path.js +36 -0
  93. package/dist/project-path.js.map +1 -0
  94. package/dist/replay.d.ts +12 -0
  95. package/dist/replay.d.ts.map +1 -0
  96. package/dist/replay.js +36 -0
  97. package/dist/replay.js.map +1 -0
  98. package/dist/rules/codes.d.ts +42 -0
  99. package/dist/rules/codes.d.ts.map +1 -0
  100. package/dist/rules/codes.js +52 -0
  101. package/dist/rules/codes.js.map +1 -0
  102. package/dist/rules/comment-markers.d.ts +10 -0
  103. package/dist/rules/comment-markers.d.ts.map +1 -0
  104. package/dist/rules/comment-markers.js +24 -0
  105. package/dist/rules/comment-markers.js.map +1 -0
  106. package/dist/rules/index.d.ts +31 -0
  107. package/dist/rules/index.d.ts.map +1 -0
  108. package/dist/rules/index.js +133 -0
  109. package/dist/rules/index.js.map +1 -0
  110. package/dist/rules/legacy.d.ts +7 -0
  111. package/dist/rules/legacy.d.ts.map +1 -0
  112. package/dist/rules/legacy.js +85 -0
  113. package/dist/rules/legacy.js.map +1 -0
  114. package/dist/rules/routes.d.ts +12 -0
  115. package/dist/rules/routes.d.ts.map +1 -0
  116. package/dist/rules/routes.js +142 -0
  117. package/dist/rules/routes.js.map +1 -0
  118. package/dist/rules/security.d.ts +10 -0
  119. package/dist/rules/security.d.ts.map +1 -0
  120. package/dist/rules/security.js +471 -0
  121. package/dist/rules/security.js.map +1 -0
  122. package/dist/search-terms.d.ts +2 -0
  123. package/dist/search-terms.d.ts.map +1 -1
  124. package/dist/search-terms.js +5 -1
  125. package/dist/search-terms.js.map +1 -1
  126. package/dist/types-search.d.ts +1 -2
  127. package/dist/types-search.d.ts.map +1 -1
  128. package/dist/types-search.js +33 -16
  129. package/dist/types-search.js.map +1 -1
  130. package/dist/verification.d.ts +9 -0
  131. package/dist/verification.d.ts.map +1 -1
  132. package/dist/verification.js +17 -1
  133. package/dist/verification.js.map +1 -1
  134. package/dist/work-graph.d.ts +123 -0
  135. package/dist/work-graph.d.ts.map +1 -0
  136. package/dist/work-graph.js +465 -0
  137. package/dist/work-graph.js.map +1 -0
  138. package/docs/examples.json +32 -2
  139. package/docs/llms-full.txt +906 -15
  140. package/docs/types.json +1682 -130
  141. package/package.json +34 -9
  142. package/src/assure-hydration.ts +606 -0
  143. package/src/assure.ts +316 -2
  144. package/src/capabilities-tool.ts +80 -1
  145. package/src/check.ts +873 -168
  146. package/src/cli.ts +198 -11
  147. package/src/contract-proof.ts +194 -0
  148. package/src/contracts.ts +157 -0
  149. package/src/diagnostics.ts +32 -0
  150. package/src/diff-tool.ts +1 -1
  151. package/src/docs-search.ts +45 -15
  152. package/src/doctor.ts +259 -8
  153. package/src/examples.ts +43 -15
  154. package/src/fix-recipes.ts +126 -0
  155. package/src/init-agents.ts +61 -4
  156. package/src/internal/source-facts.ts +133 -0
  157. package/src/internal/typescript-import.ts +29 -0
  158. package/src/introspect.ts +75 -6
  159. package/src/load.ts +6 -0
  160. package/src/manifest-tool.ts +16 -10
  161. package/src/mcp-docs-tools.ts +3 -0
  162. package/src/mcp-http.ts +1 -1
  163. package/src/mcp-root.ts +271 -0
  164. package/src/mcp-run.ts +19 -4
  165. package/src/mcp.ts +438 -32
  166. package/src/project-path.ts +36 -0
  167. package/src/replay.ts +44 -0
  168. package/src/rules/codes.ts +57 -0
  169. package/src/rules/comment-markers.ts +26 -0
  170. package/src/rules/index.ts +175 -0
  171. package/src/rules/legacy.ts +93 -0
  172. package/src/rules/routes.ts +157 -0
  173. package/src/rules/security.ts +505 -0
  174. package/src/search-terms.ts +9 -4
  175. package/src/types-search.ts +39 -15
  176. package/src/verification.ts +18 -1
  177. 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 { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
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 Start an MCP server (stdio) exposing this project to a coding
86
- agent: nifra_context, nifra_routes (routes+schemas as JSON),
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] Route-assurance gate: load nifra.assurance.ts, classify every
115
- reflected backend route, and fail when required enforcement
116
- evidence is missing/forbidden or a route is unclassified.
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
- @nifrajs/core/React identity-sensitive dependencies;
135
- --auto-fix writes safe local-version dependency fixes.
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.11.0"
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
- await runMcpServer(process.cwd(), CLI_VERSION)
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
+ }