@nifrajs/cli 1.13.0 → 2.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.
Files changed (47) hide show
  1. package/README.md +17 -2
  2. package/dist/check.d.ts +17 -6
  3. package/dist/check.d.ts.map +1 -1
  4. package/dist/check.js +256 -15
  5. package/dist/check.js.map +1 -1
  6. package/dist/cli.d.ts +2 -3
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +58 -71
  9. package/dist/cli.js.map +1 -1
  10. package/dist/doctor.d.ts.map +1 -1
  11. package/dist/doctor.js +108 -48
  12. package/dist/doctor.js.map +1 -1
  13. package/dist/mcp-http.js +1 -1
  14. package/dist/mcp-http.js.map +1 -1
  15. package/dist/mcp.d.ts +2 -0
  16. package/dist/mcp.d.ts.map +1 -1
  17. package/dist/mcp.js +42 -0
  18. package/dist/mcp.js.map +1 -1
  19. package/dist/recipes/2.0.0.d.ts +9 -0
  20. package/dist/recipes/2.0.0.d.ts.map +1 -0
  21. package/dist/recipes/2.0.0.js +25 -0
  22. package/dist/recipes/2.0.0.js.map +1 -0
  23. package/dist/recipes/index.d.ts +7 -0
  24. package/dist/recipes/index.d.ts.map +1 -1
  25. package/dist/recipes/index.js +2 -0
  26. package/dist/recipes/index.js.map +1 -1
  27. package/dist/sync-manifest.d.ts +33 -0
  28. package/dist/sync-manifest.d.ts.map +1 -0
  29. package/dist/sync-manifest.js +97 -0
  30. package/dist/sync-manifest.js.map +1 -0
  31. package/dist/upgrade.d.ts +23 -2
  32. package/dist/upgrade.d.ts.map +1 -1
  33. package/dist/upgrade.js +144 -13
  34. package/dist/upgrade.js.map +1 -1
  35. package/docs/examples.json +69 -21
  36. package/docs/llms-full.txt +1186 -142
  37. package/docs/types.json +1914 -213
  38. package/package.json +9 -9
  39. package/src/check.ts +251 -18
  40. package/src/cli.ts +57 -73
  41. package/src/doctor.ts +133 -49
  42. package/src/mcp-http.ts +1 -1
  43. package/src/mcp.ts +52 -0
  44. package/src/recipes/2.0.0.ts +26 -0
  45. package/src/recipes/index.ts +10 -0
  46. package/src/sync-manifest.ts +111 -0
  47. package/src/upgrade.ts +163 -11
package/src/cli.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
5
5
  *
6
6
  * nifra dev true-HMR dev server (Vite middleware + nifra SSR) — @nifrajs/web/vite
7
- * nifra build bundle the client (content-hashed) + write manifest.json — @nifrajs/web/build
8
- * nifra start serve the built client + SSR on Bun.serve — @nifrajs/web
7
+ * nifra build emit a complete target-specific deploy directory — @nifrajs/web/build
8
+ * nifra start run the default Bun build — dist/server.js
9
9
  *
10
10
  * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
11
11
  */
@@ -22,9 +22,8 @@ export interface Flags {
22
22
  readonly port: number
23
23
  readonly out: string
24
24
  readonly poll: boolean
25
- /** `nifra build --target <t>`: emit a full deploy dir for a target (bun/node/deno/cf-pages/vercel/
26
- * static) instead of just the client bundle. Undefined ⇒ the legacy client-only build. */
27
- readonly target?: string
25
+ /** `nifra build --target <t>`: emit a full deploy dir for this target. Defaults to `bun`. */
26
+ readonly target: string
28
27
  /** `nifra build --report`: print a per-chunk size + gzip table after the build. */
29
28
  readonly report: boolean
30
29
  }
@@ -33,15 +32,15 @@ const HELP = `nifra — zero-config dev/build/start for a nifra app
33
32
 
34
33
  Usage:
35
34
  nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
36
- nifra build [--out <dir>] Bundle the client + write manifest.json.
37
- [--target <t>] [--report] With --target, emit a FULL deploy dir for <t>:
35
+ nifra build [--out <dir>] [--report] Emit a complete Bun deploy directory by default.
36
+ [--target <t>] Target a FULL deploy dir for <t>:
38
37
  bun | node | deno | cf-pages | vercel | static. Packages
39
38
  buildClient + buildServer (+ prerender for static) so an app
40
39
  no longer hand-writes build-<target>.ts + _worker.ts +
41
40
  _routes.json. The server entry is generated from your
42
41
  framework.ts (adapter) + backend.ts + routes/. --report prints
43
42
  a per-chunk size + gzip table (biggest first).
44
- nifra start [--port <n>] [--out <dir>] Serve the built app (client + SSR) on Bun. Default port ${DEFAULT_DEV_PORT}.
43
+ nifra start [--port <n>] [--out <dir>] Run the Bun build at <out>/server.js. Default port ${DEFAULT_DEV_PORT}.
45
44
  nifra context Print this project's route INDEX (API + page routes) + conventions
46
45
  for an AI agent's prompt. Per-route schemas: nifra mcp's
47
46
  nifra_context (path/kind slice) or nifra routes --json.
@@ -59,12 +58,16 @@ Usage:
59
58
  agent: nifra_context, nifra_routes (routes+schemas as JSON),
60
59
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
61
60
  nifra_example (verified snippets), nifra_scaffold (route→file),
62
- nifra_check (drift gate + fixes), nifra_doctor (deps + duplicate installs).
61
+ nifra_check (drift gate + fixes), nifra_levels (verification
62
+ ladder), nifra_doctor (deps + duplicate installs).
63
63
  nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
64
64
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
65
65
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
66
66
  server-only imports in routes/). Run as "done"; --json for agents;
67
67
  --lints-only skips tsc for a near-instant inner-loop pass.
68
+ nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
69
+ a full build - clears server-manifest drift after a route add/rename
70
+ (a new hydrating component still needs a full build).
68
71
  nifra snapshot [--out <file>] Write the backend's API contract (routes + schemas) as plain
69
72
  JSON — the baseline for \`nifra diff\`. Default api-snapshot.json.
70
73
  nifra diff [<baseline>] [--json] Breaking-change gate: re-snapshot the contract and compare
@@ -95,8 +98,8 @@ Usage:
95
98
  @nifrajs/core/React identity-sensitive dependencies;
96
99
  --auto-fix writes safe local-version dependency fixes.
97
100
  nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
98
- [--write] [--no-verify] matching dependency pin (preserving ^/~/exact) + apply exact
99
- [--list] [--json] import moves across the workspace, then verify with nifra check.
101
+ [--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
102
+ [--list] [--json] packages, apply exact imports, then verify with nifra check.
100
103
  Dry-run by default; --write applies then verifies (--no-verify
101
104
  to skip). --list shows available targets. Fail-closed on an
102
105
  unknown version. Deterministic + idempotent.
@@ -114,7 +117,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
114
117
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
115
118
 
116
119
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
117
- const CLI_VERSION = "1.13.0"
120
+ const CLI_VERSION = "2.1.0"
118
121
 
119
122
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
120
123
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -123,6 +126,17 @@ const apiOf = (backend: unknown): { api?: unknown } =>
123
126
  backend === undefined ? {} : { api: inProcessClient(backend as never) }
124
127
 
125
128
  async function dev(app: LoadedApp, flags: Flags): Promise<void> {
129
+ // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
130
+ // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
131
+ // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
132
+ try {
133
+ Bun.resolveSync("vite", app.cwd)
134
+ } catch {
135
+ throw new Error(
136
+ "[nifra] `nifra dev` needs `vite` installed in this project. Run it via your workspace-local " +
137
+ "script (`bun run dev`), not `bunx @nifrajs/cli dev` (which can't resolve the project's peers).",
138
+ )
139
+ }
126
140
  const { plugin } = await import("bun")
127
141
  const { createViteDevServer } = await import("@nifrajs/web/vite")
128
142
  const { framework: fw, routesDir, cwd, backend } = app
@@ -150,20 +164,6 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
150
164
  console.log(`nifra dev → http://localhost:${server.port}`)
151
165
  }
152
166
 
153
- async function build(app: LoadedApp): Promise<void> {
154
- const { buildClient } = await import("@nifrajs/web/build")
155
- const { framework: fw, routesDir, outDir } = app
156
- const manifest = await buildClient({
157
- routesDir,
158
- outDir,
159
- clientModule: fw.clientModule,
160
- plugins: asBunPlugins(await resolvePlugins(fw.clientPlugins)),
161
- define: { "process.env.NODE_ENV": '"production"', ...(fw.define ?? {}) },
162
- ...(fw.conditions ? { conditions: fw.conditions } : {}),
163
- })
164
- console.log(`nifra build → ${outDir} (entry ${manifest.entry}${manifest.css ? ", + css" : ""})`)
165
- }
166
-
167
167
  /**
168
168
  * `nifra build --target <t>` — package the engine (buildClient + buildServer + prerender) into one
169
169
  * command that emits a full deploy dir, so an app no longer hand-writes build-bun.ts + _worker.ts +
@@ -206,12 +206,12 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
206
206
  if (report) console.log(`\n${renderSizeReport(result.size)}`)
207
207
  }
208
208
 
209
- /** Build the app FACTORY for the `static` target's prerender pass. Mirrors `nifra start`: register the
209
+ /** Build the app FACTORY for the `static` target's prerender pass. Registers the
210
210
  * framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import) once, then return a factory that
211
211
  * `createWebApp`s the app for the client build's manifest. The client entry MUST be the real content-hashed
212
212
  * bundle (`client.entry`) — it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
213
213
  * would 404 and the pages would render but never hydrate (inert controls). Styles/route-preload are wired
214
- * from the same manifest so the static HTML matches what `nifra start` serves. */
214
+ * from the same manifest so the static HTML matches the deployed app. */
215
215
  async function buildPrerenderApp(
216
216
  app: LoadedApp,
217
217
  ): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
@@ -238,43 +238,23 @@ interface BuiltManifest {
238
238
  }
239
239
 
240
240
  async function start(app: LoadedApp, flags: Flags): Promise<void> {
241
- const { plugin } = await import("bun")
242
- const { framework: fw, routesDir, outDir, backend } = app
243
- // Register the framework's SSR Bun plugins so `.vue`/`.svelte`/Solid route files compile on the
244
- // server's runtime import (React/Preact JSX is Bun-native → none).
245
- for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
246
-
247
- const manifestFile = Bun.file(`${outDir}/manifest.json`)
248
- if (!(await manifestFile.exists())) {
249
- throw new Error(`[nifra] no ${outDir}/manifest.json — run \`nifra build\` first.`)
241
+ const serverFile = resolve(app.outDir, "server.js")
242
+ if (!existsSync(serverFile)) {
243
+ // A Cloudflare Pages build emits `_worker.js` (a Workers bundle), not a self-hosting `server.js` - so
244
+ // `nifra start` on a dir built for cf-pages would otherwise fail with a bare "no server.js". Name the
245
+ // actual mismatch and the fix.
246
+ if (existsSync(resolve(app.outDir, "_worker.js"))) {
247
+ throw new Error(
248
+ `[nifra] ${app.outDir} holds a Cloudflare Pages worker bundle (_worker.js), not a self-hosting ` +
249
+ "server. Run `nifra build --target bun` then `nifra start`, or serve this dir with `wrangler pages`.",
250
+ )
251
+ }
252
+ throw new Error(
253
+ `[nifra] no ${serverFile} — run \`nifra build\` or \`nifra build --target bun\` first.`,
254
+ )
250
255
  }
251
- const manifest = JSON.parse(await manifestFile.text()) as BuiltManifest
252
-
253
- const server = createWebApp({
254
- adapter: asAdapter(fw.adapter),
255
- manifest: discoverRoutes(routesDir),
256
- clientEntry: manifest.entry,
257
- ...(manifest.routes ? { routePreload: manifest.routes } : {}),
258
- ...(manifest.css ? { styles: manifest.css } : {}),
259
- ...(manifest.routeStyles ? { routeStyles: manifest.routeStyles } : {}),
260
- ...apiOf(backend),
261
- })
262
- // Serve the content-hashed bundle (immutable). `.css` → text/css, else JS. Name is path-validated.
263
- server.get("/assets/*", async (c) => {
264
- const name = new URL(c.req.url).pathname.slice("/assets/".length)
265
- if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })
266
- const file = Bun.file(`${outDir}/${name}`)
267
- if (!(await file.exists())) return new Response("Not Found", { status: 404 })
268
- const type = name.endsWith(".css") ? "text/css" : "text/javascript"
269
- return new Response(file, {
270
- headers: {
271
- "content-type": `${type}; charset=utf-8`,
272
- "cache-control": "public, max-age=31536000, immutable",
273
- },
274
- })
275
- })
276
- const running = server.listen(flags.port)
277
- console.log(`nifra start → http://localhost:${running.port}`)
256
+ Bun.env.PORT = String(flags.port)
257
+ await import(serverFile)
278
258
  }
279
259
 
280
260
  export function parseFlags(args: readonly string[]): Flags {
@@ -284,20 +264,20 @@ export function parseFlags(args: readonly string[]): Flags {
284
264
  let port = Number(Bun.env.PORT ?? DEFAULT_DEV_PORT)
285
265
  let out = "dist"
286
266
  let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
287
- let target: string | undefined
267
+ let target = "bun"
288
268
  let report = false
289
269
  for (let i = 0; i < args.length; i++) {
290
270
  const a = args[i]
291
271
  if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
292
272
  else if (a === "--out" && args[i + 1]) out = args[++i] as string
293
273
  else if (a === "--poll") poll = true
294
- else if (a === "--target" && args[i + 1]) target = args[++i]
274
+ else if (a === "--target" && args[i + 1]) target = args[++i] as string
295
275
  else if (a === "--report") report = true
296
276
  }
297
277
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
298
278
  throw new Error(`[nifra] invalid --port: ${port}`)
299
279
  }
300
- return { port, out, poll, report, ...(target !== undefined ? { target } : {}) }
280
+ return { port, out, poll, target, report }
301
281
  }
302
282
 
303
283
  async function main(): Promise<void> {
@@ -355,6 +335,14 @@ async function main(): Promise<void> {
355
335
  })
356
336
  return
357
337
  }
338
+ // `sync-manifest` rewrites a committed web `server-manifest.ts` from the current `routes/` tree, without
339
+ // a full build. Pure fs/text (re-scan routes, preserve baked client assets), so - like `check` - dispatch
340
+ // it before the eager `loadApp`.
341
+ if (command === "sync-manifest") {
342
+ const { runSyncManifest } = await import("./sync-manifest.ts")
343
+ if (!(await runSyncManifest(process.cwd()))) process.exitCode = 1
344
+ return
345
+ }
358
346
  // `doctor` is a pure cwd check (imports vs declared deps) — like `check`, dispatch before `loadApp`
359
347
  // so it runs on an API-only / not-yet-built project.
360
348
  if (command === "doctor") {
@@ -600,12 +588,8 @@ async function main(): Promise<void> {
600
588
  const flags = parseFlags(argv.slice(1))
601
589
  const app = await loadApp(process.cwd(), flags.out)
602
590
  if (command === "dev") await dev(app, flags)
603
- else if (command === "build") {
604
- // `--target` (or `--report`) → the full per-target deploy build; otherwise the legacy client build.
605
- if (flags.target !== undefined || flags.report) {
606
- await buildForTarget(app, flags.target ?? "cf-pages", flags.report)
607
- } else await build(app)
608
- } else if (command === "context") console.log(describeProject(app))
591
+ else if (command === "build") await buildForTarget(app, flags.target, flags.report)
592
+ else if (command === "context") console.log(describeProject(app))
609
593
  else if (command === "routes") {
610
594
  console.log(await describeRoutes(app, { json: argv.includes("--json") }))
611
595
  } else await start(app, flags)
package/src/doctor.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { realpath } from "node:fs/promises"
14
14
  import { builtinModules } from "node:module"
15
15
  import { dirname, join, relative } from "node:path"
16
- import { type SourceFinding, stripComments, walkSource } from "./check.ts"
16
+ import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
17
17
 
18
18
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
19
19
  // own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
@@ -89,9 +89,13 @@ export function scanUndeclaredImports(
89
89
  const out: SourceFinding[] = []
90
90
  const seen = new Set<string>()
91
91
  const code = stripComments(content) // so doc-comment usage examples aren't read as real imports
92
+ const positions = codePositionMask(content)
92
93
  for (const rx of IMPORT_PATTERNS) {
93
94
  rx.lastIndex = 0
94
95
  for (let m = rx.exec(code); m !== null; m = rx.exec(code)) {
96
+ // The specifier regex must run over quoted literals, but its import/require token must begin in
97
+ // executable code. Otherwise documentation and code-generator strings look like real imports.
98
+ if (positions[m.index] === " ") continue
95
99
  const spec = m[1] ?? ""
96
100
  if (isAlias(spec)) continue
97
101
  const pkg = packageOf(spec)
@@ -191,11 +195,13 @@ function workspacePatterns(pkg: Record<string, unknown>): string[] {
191
195
  .sort()
192
196
  }
193
197
 
194
- /** Discover package roots declared by npm/Bun's two workspace manifest shapes. Bounded so a hostile
195
- * `**` pattern cannot turn a diagnostic into an unbounded filesystem crawl. */
198
+ /** Discover package roots declared by npm/Bun's workspace manifest shapes plus nested package
199
+ * boundaries (scaffold templates and benchmark fixtures are commonly real standalone packages even
200
+ * when they are not workspace members). Bounded so a hostile tree cannot make doctor unbounded. */
196
201
  async function workspaceImporters(
197
202
  cwd: string,
198
203
  rootPackage: Record<string, unknown>,
204
+ includeNestedPackages = false,
199
205
  ): Promise<Array<{ root: string; package: Record<string, unknown> }>> {
200
206
  const manifests = new Set<string>([join(cwd, "package.json")])
201
207
  for (const pattern of workspacePatterns(rootPackage)) {
@@ -208,6 +214,22 @@ async function workspaceImporters(
208
214
  if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
209
215
  }
210
216
  }
217
+ if (includeNestedPackages) {
218
+ for await (const rel of new Bun.Glob("**/package.json").scan({ cwd, dot: false })) {
219
+ const segments = rel.split(/[\\/]/)
220
+ if (
221
+ segments.some((segment) =>
222
+ ["node_modules", "dist", "build", ".git", ".nifra", ".next", "coverage"].includes(
223
+ segment,
224
+ ),
225
+ )
226
+ ) {
227
+ continue
228
+ }
229
+ manifests.add(join(cwd, rel))
230
+ if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
231
+ }
232
+ }
211
233
 
212
234
  const out: Array<{ root: string; package: Record<string, unknown> }> = []
213
235
  for (const manifest of [...manifests].sort()) {
@@ -217,6 +239,62 @@ async function workspaceImporters(
217
239
  return out
218
240
  }
219
241
 
242
+ interface DoctorPackageScope {
243
+ readonly root: string
244
+ readonly relativeRoot: string
245
+ readonly declared: ReadonlySet<string>
246
+ readonly isAlias: (specifier: string) => boolean
247
+ }
248
+
249
+ const declaredPackages = (pkg: Record<string, unknown>): ReadonlySet<string> => {
250
+ const declared = new Set<string>()
251
+ if (typeof pkg.name === "string") declared.add(pkg.name)
252
+ for (const field of DEPENDENCY_FIELDS) {
253
+ for (const name of depNames(pkg, field)) declared.add(name)
254
+ }
255
+ return declared
256
+ }
257
+
258
+ const tsconfigPaths = async (
259
+ root: string,
260
+ ): Promise<Readonly<Record<string, unknown>> | undefined> => {
261
+ const tsconfig = await readJson(join(root, "tsconfig.json"))
262
+ const compilerOptions = tsconfig?.compilerOptions as
263
+ | { paths?: Record<string, unknown> }
264
+ | undefined
265
+ return compilerOptions?.paths
266
+ }
267
+
268
+ /** Build per-package declaration scopes for a workspace. Longest roots are first so nested packages
269
+ * own their source; files outside a workspace package remain owned by the root manifest. */
270
+ async function doctorPackageScopes(
271
+ cwd: string,
272
+ rootPackage: Record<string, unknown>,
273
+ ): Promise<readonly DoctorPackageScope[]> {
274
+ const importers = await workspaceImporters(cwd, rootPackage, true)
275
+ const packages = importers.length > 0 ? importers : [{ root: cwd, package: rootPackage }]
276
+ const rootPaths = await tsconfigPaths(cwd)
277
+ const scopes = await Promise.all(
278
+ packages.map(async (entry): Promise<DoctorPackageScope> => {
279
+ const paths = (await tsconfigPaths(entry.root)) ?? rootPaths
280
+ return {
281
+ root: entry.root,
282
+ relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
283
+ declared: declaredPackages(entry.package),
284
+ isAlias: aliasMatcher(paths),
285
+ }
286
+ }),
287
+ )
288
+ return scopes.sort((a, b) => b.relativeRoot.length - a.relativeRoot.length)
289
+ }
290
+
291
+ const scopeForFile = (scopes: readonly DoctorPackageScope[], file: string): DoctorPackageScope =>
292
+ scopes.find(
293
+ (scope) =>
294
+ scope.relativeRoot !== "" &&
295
+ (file === scope.relativeRoot || file.startsWith(`${scope.relativeRoot}/`)),
296
+ ) ?? (scopes.find((scope) => scope.relativeRoot === "") as DoctorPackageScope)
297
+
220
298
  function duplicateTargets(pkg: Record<string, unknown>): string[] {
221
299
  const targets = new Set<string>()
222
300
  for (const field of DEPENDENCY_FIELDS) {
@@ -377,21 +455,12 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
377
455
  const pkg = await readJson(join(cwd, "package.json"))
378
456
  if (pkg === undefined) return { ok: true, ran: false, findings: [], duplicateInstalls: [] }
379
457
 
380
- const declared = new Set<string>()
381
- if (typeof pkg.name === "string") declared.add(pkg.name) // a package may import its own name (exports map)
382
- for (const field of DEPENDENCY_FIELDS) {
383
- for (const name of depNames(pkg, field)) declared.add(name)
384
- }
385
-
386
- const tsconfig = await readJson(join(cwd, "tsconfig.json"))
387
- const compilerOptions = tsconfig?.compilerOptions as
388
- | { paths?: Record<string, unknown> }
389
- | undefined
390
- const isAlias = aliasMatcher(compilerOptions?.paths)
458
+ const scopes = await doctorPackageScopes(cwd, pkg)
391
459
 
392
460
  const findings: DoctorFinding[] = []
393
461
  await walkSource(cwd, (rel, content) => {
394
- for (const f of scanUndeclaredImports(rel, content, declared, isAlias)) {
462
+ const scope = scopeForFile(scopes, rel)
463
+ for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
395
464
  findings.push({ file: f.file, line: f.line, package: f.snippet })
396
465
  }
397
466
  })
@@ -410,49 +479,64 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
410
479
  const before = await collectDoctorResult(cwd)
411
480
  if (!before.ran || before.findings.length === 0) return before
412
481
 
413
- const pkgPath = join(cwd, "package.json")
414
- const pkg = await readJson(pkgPath)
415
- if (pkg === undefined) return before
416
- const dependencies = pkg.dependencies
417
- if (dependencies !== undefined && !isRecord(dependencies)) {
418
- const skippedFixes = [...new Set(before.findings.map((f) => f.package))].sort().map(
419
- (name): DoctorSkippedFix => ({
420
- package: name,
421
- reason: "`dependencies` exists but is not an object; refusing to rewrite it automatically",
422
- command: ["bun", "add", name],
423
- }),
424
- )
425
- return { ...before, skippedFixes }
482
+ const rootPackage = await readJson(join(cwd, "package.json"))
483
+ if (rootPackage === undefined) return before
484
+ const scopes = await doctorPackageScopes(cwd, rootPackage)
485
+ const byRoot = new Map<string, Set<string>>()
486
+ for (const finding of before.findings) {
487
+ const root = scopeForFile(scopes, finding.file).root
488
+ const names = byRoot.get(root) ?? new Set<string>()
489
+ names.add(finding.package)
490
+ byRoot.set(root, names)
426
491
  }
427
492
 
428
- const deps = (dependencies ?? {}) as Record<string, unknown>
429
- if (dependencies === undefined) pkg.dependencies = deps
430
-
431
493
  const fixed: DoctorAppliedFix[] = []
432
494
  const skippedFixes: DoctorSkippedFix[] = []
433
- for (const name of [...new Set(before.findings.map((f) => f.package))].sort()) {
434
- if (!isNpmPackageName(name)) {
435
- skippedFixes.push({
436
- package: name,
437
- reason: "package name did not match npm package-name syntax",
438
- command: ["bun", "add", name],
439
- })
495
+ for (const [root, names] of [...byRoot.entries()].sort(([a], [b]) => a.localeCompare(b))) {
496
+ const pkgPath = join(root, "package.json")
497
+ const pkg = await readJson(pkgPath)
498
+ if (pkg === undefined) continue
499
+ const dependencies = pkg.dependencies
500
+ if (dependencies !== undefined && !isRecord(dependencies)) {
501
+ for (const name of [...names].sort()) {
502
+ skippedFixes.push({
503
+ package: name,
504
+ reason:
505
+ "`dependencies` exists but is not an object; refusing to rewrite it automatically",
506
+ command: ["bun", "add", name],
507
+ })
508
+ }
440
509
  continue
441
510
  }
442
- const inferred = await inferDependencyFix(cwd, name)
443
- if (inferred === undefined) {
444
- skippedFixes.push({
445
- package: name,
446
- reason: "no declared ancestor version or installed package metadata was found locally",
447
- command: ["bun", "add", name],
448
- })
449
- continue
511
+
512
+ const deps = (dependencies ?? {}) as Record<string, unknown>
513
+ if (dependencies === undefined) pkg.dependencies = deps
514
+ let changed = false
515
+ for (const name of [...names].sort()) {
516
+ if (!isNpmPackageName(name)) {
517
+ skippedFixes.push({
518
+ package: name,
519
+ reason: "package name did not match npm package-name syntax",
520
+ command: ["bun", "add", name],
521
+ })
522
+ continue
523
+ }
524
+ const inferred = await inferDependencyFix(root, name)
525
+ if (inferred === undefined) {
526
+ skippedFixes.push({
527
+ package: name,
528
+ reason: "no declared ancestor version or installed package metadata was found locally",
529
+ command: ["bun", "add", name],
530
+ })
531
+ continue
532
+ }
533
+ deps[name] = inferred.version
534
+ changed = true
535
+ fixed.push({ package: name, field: "dependencies", ...inferred })
450
536
  }
451
- deps[name] = inferred.version
452
- fixed.push({ package: name, field: "dependencies", ...inferred })
537
+ if (changed) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
453
538
  }
454
539
 
455
- if (fixed.length > 0) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
456
540
  const after = fixed.length > 0 ? await collectDoctorResult(cwd) : before
457
541
  return {
458
542
  ...after,
package/src/mcp-http.ts CHANGED
@@ -26,7 +26,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
26
26
  export type { TypeEntry } from "./types-search.ts"
27
27
 
28
28
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
29
- const VERSION = "1.13.0"
29
+ const VERSION = "2.1.0"
30
30
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
31
31
  const DOCS_HEALTH =
32
32
  "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
package/src/mcp.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
16
16
  * - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
17
  * - `nifra_assure` — route classification + enforcement-evidence gate.
18
+ * - `nifra_levels` - the cumulative verification ladder (L0 contract → L4 invariants): what the
19
+ * project actually proves, and why each level it misses does not hold.
18
20
  * - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
19
21
  *
20
22
  * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
@@ -988,6 +990,56 @@ export function projectTools(
988
990
  return JSON.stringify(await collectAssuranceReport(target, config), null, 2)
989
991
  },
990
992
  },
993
+ {
994
+ name: "nifra_levels",
995
+ description:
996
+ "Compute this project's verification ladder and return { achieved, levels[] }, where each level carries { level, name, ok, reasons[] }. The ladder is cumulative: L0 typed contract (nifra check), L1 route assurance, L2 reviewed capability lockfile, L3 route trust manifest in sync, L4 contract invariants, so a level only holds when every level below it holds, and `achieved` is -1 when even L0 fails. This is the single gate that reports what a change actually proves: run it after finishing work, and treat any `reasons` on a level the project already claimed as a regression to fix.",
997
+ inputSchema: {
998
+ type: "object",
999
+ properties: {
1000
+ config: {
1001
+ type: "string",
1002
+ description:
1003
+ "Assurance config path relative to the selected project directory. Default: nifra.assurance.ts.",
1004
+ },
1005
+ seed: {
1006
+ type: "number",
1007
+ description: "Deterministic seed for the L4 invariant run. Default 1.",
1008
+ },
1009
+ dir: {
1010
+ type: "string",
1011
+ description:
1012
+ 'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
1013
+ },
1014
+ },
1015
+ additionalProperties: false,
1016
+ },
1017
+ handler: async (args) => {
1018
+ const opts = args as { config?: string; seed?: number; dir?: string }
1019
+ const target = resolveProjectDir(cwd, opts.dir)
1020
+ if (target === null) return dirError(opts.dir)
1021
+ if (opts.seed !== undefined && !Number.isSafeInteger(opts.seed)) {
1022
+ return JSON.stringify({ ok: false, error: "seed must be a safe integer" }, null, 2)
1023
+ }
1024
+ const config = opts.config === undefined ? undefined : resolve(target, opts.config)
1025
+ if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
1026
+ return JSON.stringify(
1027
+ { ok: false, error: "config must stay inside the selected project directory" },
1028
+ null,
1029
+ 2,
1030
+ )
1031
+ }
1032
+ const { collectVerificationLevels } = await import("./levels-tool.ts")
1033
+ return JSON.stringify(
1034
+ await collectVerificationLevels(target, {
1035
+ ...(opts.config === undefined ? {} : { config: opts.config }),
1036
+ ...(opts.seed === undefined ? {} : { seed: opts.seed }),
1037
+ }),
1038
+ null,
1039
+ 2,
1040
+ )
1041
+ },
1042
+ },
991
1043
  {
992
1044
  name: "nifra_doctor",
993
1045
  description:
@@ -0,0 +1,26 @@
1
+ import type { UpgradeRecipe } from "./index.ts"
2
+
3
+ /**
4
+ * Upgrade recipe for the Nifra 2.0 fixed-group cutover.
5
+ *
6
+ * The runner handles deterministic package pins and the removed budget import. Structural API changes
7
+ * stay as explicit notes so the tool never guesses at application intent.
8
+ */
9
+ export const recipe: UpgradeRecipe = {
10
+ version: "2.0.0",
11
+ pins: [
12
+ { match: "@nifrajs/", to: "2.0.0" },
13
+ { match: "create-nifra", to: "2.0.0" },
14
+ { match: "nifra", to: "2.0.0" },
15
+ ],
16
+ dependencyMoves: [{ from: "@nifrajs/budget", to: "@nifrajs/core", toVersion: "2.0.0" }],
17
+ importMoves: [{ from: "@nifrajs/budget", to: "@nifrajs/core/budget" }],
18
+ notes: [
19
+ "Read the 1.x → 2.0 migration guide: https://nifra.dev/docs/migrate-2.",
20
+ "Optional server systems are instance-scoped plugins in 2.0. Add `.use(idempotency())`, `.use(effectLedger())`, `.use(mcp())`, `.use(streaming())`, `.use(websocket())`, or `.use(nodeDirect())` where used.",
21
+ "The old `@nifrajs/budget` package is removed. This recipe moves its dependency to `@nifrajs/core` and its imports to `@nifrajs/core/budget`.",
22
+ "Custom web backend mounts must implement the symbol-keyed BackendMount interface. `inProcessClient(app)` and `testClient(app)` already do.",
23
+ 'If Better Auth or another library owns a non-Nifra route prefix, declare it in `nifra.check.json`, for example `{ "externalMounts": ["/auth"] }`.',
24
+ "Client failures with declared error schemas now narrow by `status`; narrow `res.status` before reading failure data.",
25
+ ],
26
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { recipe as v1_8_0 } from "./1.8.0.ts"
8
+ import { recipe as v2_0_0 } from "./2.0.0.ts"
8
9
 
9
10
  /** A pin rule: every dependency whose name starts with `match` is set to version `to`. */
10
11
  export interface PinRule {
@@ -14,6 +15,13 @@ export interface PinRule {
14
15
  readonly to: string
15
16
  }
16
17
 
18
+ /** Move a removed package dependency to its replacement, preserving the consumer's range style. */
19
+ export interface DependencyMove {
20
+ readonly from: string
21
+ readonly to: string
22
+ readonly toVersion: string
23
+ }
24
+
17
25
  /** An exact import-specifier rewrite (string-level, not AST): `from` → `to`. */
18
26
  export interface ImportMove {
19
27
  readonly from: string
@@ -23,6 +31,7 @@ export interface ImportMove {
23
31
  export interface UpgradeRecipe {
24
32
  readonly version: string
25
33
  readonly pins: readonly PinRule[]
34
+ readonly dependencyMoves?: readonly DependencyMove[]
26
35
  readonly importMoves: readonly ImportMove[]
27
36
  /** Human notes printed after the plan — e.g. structural changes the runner deliberately can't do. */
28
37
  readonly notes?: readonly string[]
@@ -30,6 +39,7 @@ export interface UpgradeRecipe {
30
39
 
31
40
  const RECIPES: Record<string, UpgradeRecipe> = {
32
41
  [v1_8_0.version]: v1_8_0,
42
+ [v2_0_0.version]: v2_0_0,
33
43
  }
34
44
 
35
45
  export function getRecipe(version: string): UpgradeRecipe | undefined {