@nifrajs/cli 2.10.0 → 2.11.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/check.d.ts +9 -1
  3. package/dist/check.d.ts.map +1 -1
  4. package/dist/check.js +38 -6
  5. package/dist/check.js.map +1 -1
  6. package/dist/cli.d.ts +2 -18
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +95 -64
  9. package/dist/cli.js.map +1 -1
  10. package/dist/dev-bun-config.d.ts +17 -6
  11. package/dist/dev-bun-config.d.ts.map +1 -1
  12. package/dist/dev-bun-config.js +51 -14
  13. package/dist/dev-bun-config.js.map +1 -1
  14. package/dist/doctor.d.ts +10 -0
  15. package/dist/doctor.d.ts.map +1 -1
  16. package/dist/doctor.js +17 -1
  17. package/dist/doctor.js.map +1 -1
  18. package/dist/introspect.d.ts.map +1 -1
  19. package/dist/introspect.js +7 -1
  20. package/dist/introspect.js.map +1 -1
  21. package/dist/load.d.ts +4 -0
  22. package/dist/load.d.ts.map +1 -1
  23. package/dist/load.js +1 -0
  24. package/dist/load.js.map +1 -1
  25. package/dist/mcp-http.js +1 -1
  26. package/dist/mcp.js +2 -2
  27. package/dist/mcp.js.map +1 -1
  28. package/dist/pipeline-guard.d.ts +25 -10
  29. package/dist/pipeline-guard.d.ts.map +1 -1
  30. package/dist/pipeline-guard.js +35 -15
  31. package/dist/pipeline-guard.js.map +1 -1
  32. package/dist/pipeline-report.d.ts +34 -0
  33. package/dist/pipeline-report.d.ts.map +1 -0
  34. package/dist/pipeline-report.js +282 -0
  35. package/dist/pipeline-report.js.map +1 -0
  36. package/docs/llms-full.txt +200 -13
  37. package/docs/types.json +88 -5
  38. package/package.json +9 -9
  39. package/src/check.ts +54 -5
  40. package/src/cli.ts +99 -65
  41. package/src/dev-bun-config.ts +57 -14
  42. package/src/doctor.ts +28 -1
  43. package/src/introspect.ts +6 -0
  44. package/src/load.ts +5 -0
  45. package/src/mcp-http.ts +1 -1
  46. package/src/mcp.ts +2 -2
  47. package/src/pipeline-guard.ts +44 -14
  48. package/src/pipeline-report.ts +341 -0
package/src/check.ts CHANGED
@@ -23,6 +23,9 @@ import { Glob } from "bun"
23
23
  import type * as TSApi from "typescript"
24
24
  import type { CapabilityProjectReport } from "./capabilities-tool.ts"
25
25
  import { importTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
26
+ // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
27
+ // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
28
+ import type { PipelineReport } from "./pipeline-report.ts"
26
29
 
27
30
  export interface SourceFinding {
28
31
  readonly file: string
@@ -1396,6 +1399,7 @@ export interface CheckDiagnostic {
1396
1399
  | "undeclared-dependency"
1397
1400
  | "duplicate-install"
1398
1401
  | "stale-workspace-dist"
1402
+ | "pipeline"
1399
1403
  | "server-manifest-drift"
1400
1404
  | "manifest-drift"
1401
1405
  | "capability-assurance"
@@ -1443,6 +1447,13 @@ export interface CheckResult {
1443
1447
  readonly ok: boolean
1444
1448
  readonly typecheck: "pass" | "fail" | "skipped"
1445
1449
  readonly diagnostics: readonly CheckDiagnostic[]
1450
+ /**
1451
+ * Which bundler this app's phases run on, read statically from the config, and how nifra concluded
1452
+ * it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
1453
+ * slot is live and which toolchain compiles a component before its next edit, and every `pipeline`
1454
+ * diagnostic below is only interpretable against it. Absent when the directory is not a nifra app.
1455
+ */
1456
+ readonly pipeline?: PipelineReport
1446
1457
  /** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
1447
1458
  * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
1448
1459
  * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
@@ -1507,6 +1518,8 @@ const SERVER_IMPORT_HINT =
1507
1518
  "server-only import in a route module (bundled for the browser) - reach it via c.db / ctx.api inside a loader, never a top-level import"
1508
1519
  const RESPONSE_ROUTE_HINT =
1509
1520
  "route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
1521
+ const PIPELINE_DOC_HINT =
1522
+ "nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
1510
1523
  const UNDECLARED_DEP_HINT =
1511
1524
  "imported package is not declared in package.json dependencies - run bun add to declare it"
1512
1525
  const SQL_COMPILER_MISSING_HINT =
@@ -1944,6 +1957,21 @@ export async function collectCheckResult(
1944
1957
  })
1945
1958
  }
1946
1959
  }
1960
+ // The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
1961
+ // `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
1962
+ // never executes project code, and CI that never starts a dev server, would otherwise meet these for
1963
+ // the first time as a production server that built cleanly and died at startup.
1964
+ for (const f of dr.pipeline?.findings ?? []) {
1965
+ diagnostics.push({
1966
+ rule: "pipeline",
1967
+ severity: f.severity,
1968
+ file: f.file,
1969
+ ...(f.line !== undefined ? { line: f.line } : {}),
1970
+ message: f.message,
1971
+ fix: f.fix,
1972
+ suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
1973
+ })
1974
+ }
1947
1975
  // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
1948
1976
  for (const f of manifestDrift) {
1949
1977
  const parts: string[] = []
@@ -2117,6 +2145,7 @@ export async function collectCheckResult(
2117
2145
  ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
2118
2146
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
2119
2147
  diagnostics: shown,
2148
+ ...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
2120
2149
  ...(checkConfig.externalMounts.length > 0
2121
2150
  ? { externalMounts: checkConfig.externalMounts }
2122
2151
  : {}),
@@ -2146,6 +2175,16 @@ export async function runCheck(
2146
2175
  ? "✗ typecheck failed - the frontend/backend contract is broken"
2147
2176
  : "• typecheck skipped (no tsconfig / typescript not installed)",
2148
2177
  )
2178
+ // Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
2179
+ // live and which toolchain compiles a component, so it belongs in the report rather than only in the
2180
+ // dev server's banner - where CI never sees it.
2181
+ if (result.pipeline !== undefined) {
2182
+ console.log(
2183
+ result.pipeline.pipeline === "unknown"
2184
+ ? `• bundler: not readable from ${result.pipeline.configFile} - ${result.pipeline.reason}`
2185
+ : `• bundler: ${result.pipeline.pipeline} (${result.pipeline.reason})`,
2186
+ )
2187
+ }
2149
2188
  if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
2150
2189
  console.log(
2151
2190
  `• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
@@ -2163,6 +2202,7 @@ export async function runCheck(
2163
2202
  ["undeclared-dependency", "undeclared dependency in package.json"],
2164
2203
  ["duplicate-install", "duplicate identity-sensitive dependency install"],
2165
2204
  ["stale-workspace-dist", "workspace-linked dist older than its source"],
2205
+ ["pipeline", "bundler pipeline (Vite/Bun) config"],
2166
2206
  ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
2167
2207
  ["manifest-drift", "versioned trust manifest drift"],
2168
2208
  ["capability-assurance", "effect/capability assurance"],
@@ -2170,11 +2210,20 @@ export async function runCheck(
2170
2210
  ["check-config", "nifra.check.json"],
2171
2211
  ] as const) {
2172
2212
  const ds = counts(rule)
2173
- if (rule === "response-route" || rule === "stale-workspace-dist") {
2174
- // Advisory: surfaced with ⚠, never folded into pass/fail.
2175
- console.log(ds.length === 0 ? `✓ ${label}: none` : `⚠ ${label}: ${ds.length} (advisory)`)
2176
- } else if (rule !== "typecheck") {
2177
- console.log(ds.length === 0 ? `✓ ${label}: none` : `✗ ${label}: ${ds.length}`)
2213
+ if (rule !== "typecheck") {
2214
+ // Marked by SEVERITY, not by rule name. `response-route` and `stale-workspace-dist` are advisory
2215
+ // in whole; `pipeline` is the first rule that is advisory in part (a misplaced plugin fails, a
2216
+ // resolve condition the Bun dev bundler can't take does not), so the counts are split rather than
2217
+ // rounded up to the worse of the two.
2218
+ const errors = ds.filter((d) => d.severity === "error").length
2219
+ const advisory = ds.length - errors
2220
+ console.log(
2221
+ ds.length === 0
2222
+ ? `✓ ${label}: none`
2223
+ : errors === 0
2224
+ ? `⚠ ${label}: ${advisory} (advisory)`
2225
+ : `✗ ${label}: ${errors}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
2226
+ )
2178
2227
  }
2179
2228
  for (const d of ds) {
2180
2229
  console.log(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * `nifra` - the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
4
4
  * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
5
5
  *
6
- * nifra dev true-HMR dev server (Vite middleware + nifra SSR) - @nifrajs/web/vite
6
+ * nifra dev true-HMR dev server (Bun native HMR + nifra SSR) - @nifrajs/web/dev
7
7
  * nifra build emit a complete target-specific deploy directory - @nifrajs/web/build
8
8
  * nifra start run the default Bun build - dist/server.js
9
9
  *
@@ -17,7 +17,7 @@ import { discoverRoutes } from "@nifrajs/web/fs"
17
17
  import type { BunPlugin } from "bun"
18
18
  import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
19
19
  import { type LoadedApp, loadApp } from "./load.ts"
20
- import { chooseBuildPipeline } from "./pipeline-guard.ts"
20
+ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
21
21
 
22
22
  export interface Flags {
23
23
  readonly port: number
@@ -30,9 +30,8 @@ export interface Flags {
30
30
  /** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
31
31
  * chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
32
32
  readonly vite: boolean
33
- /** `nifra dev --bun`: run the Bun-pipeline dev server (Bun.serve native HMR - including React Fast
34
- * Refresh with state preserved - and no Vite in the process). Default stays Vite for its plugin
35
- * ecosystem; `--bun` trades that for one bundler across dev and prod. Refuses CSS-Modules apps.
33
+ /** `nifra dev --bun`: force the Bun-pipeline dev server. Rarely needed - `dev` and `build` share one
34
+ * rule, so Bun is already the default unless `vitePlugins` are the app's ONLY transforms.
36
35
  *
37
36
  * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
38
37
  readonly bun: boolean
@@ -41,11 +40,14 @@ export interface Flags {
41
40
  const HELP = `nifra - zero-config dev/build/start for a nifra app
42
41
 
43
42
  Usage:
44
- nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
45
- [--bun] Run the BUN pipeline instead: Bun.serve native HMR (React Fast
46
- Refresh included, state preserved), no Vite in the process - one
47
- bundler across dev and prod. Plain CSS/Tailwind work; refuses an
48
- app using *.module.css (Bun's dev bundler can't transform it).
43
+ nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Bun). Default port ${DEFAULT_DEV_PORT}.
44
+ An app whose ONLY transforms are \`vitePlugins\` uses Vite (the
45
+ Bun pipeline cannot run them); everything else uses Bun.
46
+ [--vite] Force the Vite middleware/HMR pipeline.
47
+ [--bun] Force Bun.serve native HMR (React Fast Refresh included, state
48
+ preserved), no Vite in the process. CSS Modules, plain CSS,
49
+ Tailwind and the app's own \`clientPlugins\`/\`serverPlugins\` run
50
+ through the same Bun transforms production uses.
49
51
  nifra build [--out <dir>] [--report] Emit a complete deploy directory.
50
52
  [--target <t>] Target a FULL deploy dir for <t>:
51
53
  bun | node | deno | cf-pages | vercel | static. Packages
@@ -152,7 +154,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
152
154
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
153
155
 
154
156
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
155
- const CLI_VERSION = "2.10.0"
157
+ const CLI_VERSION = "2.11.0"
156
158
 
157
159
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
158
160
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -194,52 +196,26 @@ export function formatCliError(err: unknown): string {
194
196
  return String(err)
195
197
  }
196
198
 
197
- /**
198
- * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
199
- *
200
- * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
201
- * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
202
- * bundler does not: the import compiles to a dangling reference and the browser throws
203
- * `ReferenceError: import_X_module is not defined` from inside the component - a message that names
204
- * neither CSS Modules nor the dev server, so the cause is invisible.
205
- *
206
- * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
207
- * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
208
- * an app without CSS Modules still gets the Bun dev loop.
209
- */
210
- export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
211
- // Server functions and `*.server` modules are handled: the generated bunfig (dev-bun-config.ts)
212
- // delivers the SAME serverFnStubPlugin/serverOnlyEmptyPlugin the production build uses to Bun's
213
- // dev-server bundler via `[serve.static] plugins` - the one channel that bundler accepts plugins
214
- // through (a runtime `Bun.plugin` never reaches it; programmatic ask: oven-sh/bun#36830). The old
215
- // fail-closed refusal for those modules is gone because the leak it guarded is gone.
216
- //
217
- // CSS Modules remain gated: the `*.module.css` plugin rests on an `onResolve` virtual-module
218
- // stylesheet emitter that is not yet verified under `[serve.static]`, and its failure mode is a
219
- // broken client rather than a leak - refuse loudly until it is proven there.
220
- const { Glob } = await import("bun")
221
- const offenders: string[] = []
222
- for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
223
- if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
224
- offenders.push(file)
225
- if (offenders.length >= 5) break
199
+ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
200
+ if (flags.vite && flags.bun) {
201
+ throw new Error("[nifra] `nifra dev` takes `--vite` or `--bun`, not both.")
226
202
  }
227
- if (offenders.length === 0) return
228
- throw new Error(
229
- "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
230
- "transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
231
- "with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
232
- ` CSS Modules found: ${offenders.join(", ")}\n` +
233
- " Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.",
203
+ // Default to Bun for the dev phase too. An app whose only transforms are Vite plugins stays on Vite so
204
+ // its transform is never silently dropped; `--vite`/`--bun` make that choice explicit.
205
+ // ONE rule for both phases - `chooseBuildPipeline` decides `dev` exactly as it decides `build`, so a
206
+ // project cannot end up bundled by one toolchain in dev and the other in production. An earlier
207
+ // version of this kept apps declaring `vitePlugins` on Vite for dev even when they also shipped Bun
208
+ // equivalents, on the theory that the framework plugins were the safer HMR path. Measurement killed
209
+ // it: Vue, Solid and Svelte all render correctly on the Bun pipeline, so the hedge bought nothing
210
+ // and cost an app the guarantee that dev and production bundle the same way. (Svelte used to render
211
+ // ONLY on Bun, which made the hedge actively harmful; it now renders on either - see the note at the
212
+ // Vite branch for what the adapter had to be given to make that true.)
213
+ const decision = chooseBuildPipeline(
214
+ app.resolvedPlugins,
215
+ flags.vite ? "vite" : flags.bun ? "bun" : undefined,
216
+ "dev",
234
217
  )
235
- }
236
-
237
- async function dev(app: LoadedApp, flags: Flags): Promise<void> {
238
- // `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
239
- // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
240
- // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
241
- if (flags.bun) {
242
- await assertBunDevSupportsApp(app)
218
+ if (decision.pipeline === "bun") {
243
219
  // Bun's dev-server bundler takes plugins only via bunfig `[serve.static]`, read at process
244
220
  // start - so the boundary plugins (server-fn stubs, server-only emptying) are delivered by
245
221
  // generating a config and re-execing this same command once with `--config=`. The child proves
@@ -259,15 +235,36 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
259
235
  "launches; refusing to serve without the client-boundary plugins.",
260
236
  )
261
237
  }
262
- const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd)
238
+ const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd, app.configPath)
263
239
  const child = Bun.spawn(
264
- [process.execPath, `--config=${bunfigPath}`, ...process.argv.slice(1)],
240
+ [
241
+ process.execPath,
242
+ `--config=${bunfigPath}`,
243
+ // The app's `conditions` reach SSR here or nowhere: bunfig has no key for them (a top-level,
244
+ // `[run]`, `[serve.static]` or `[bundle]` `conditions` is parsed and ignored), and the re-exec
245
+ // is the only point where this process's resolver is still configurable. Without it a package
246
+ // with an `exports` map resolves to a DIFFERENT file on the server than in the client bundle,
247
+ // which is the two-toolchains-one-module failure the pipeline guard exists to prevent.
248
+ //
249
+ // One flag per condition. `--conditions=a,b` is accepted and matches nothing: Bun takes the
250
+ // whole string as a single condition name, so the comma form fails silently.
251
+ ...(app.framework.conditions ?? []).map((condition) => `--conditions=${condition}`),
252
+ ...process.argv.slice(1),
253
+ ],
265
254
  {
266
255
  stdio: ["inherit", "inherit", "inherit"],
267
256
  env: {
268
257
  ...process.env,
269
258
  NIFRA_BUN_DEV_TOKEN: launchToken,
270
259
  NIFRA_BUN_DEV_DEPTH: String(depth + 1),
260
+ // The child is the process that renders, so it is the one that needs dev-shaped runtimes.
261
+ // A library ships its dev build behind the `development` export condition with a `NODE_ENV`
262
+ // fallback for resolvers that set no conditions - which is Bun's runtime, where SSR runs,
263
+ // so on the server that fallback is the only signal there is. It has to be in place before
264
+ // the app's config is read, because reading it imports the adapter and through it the
265
+ // framework runtime; setting it inside the dev server would already be a step too late.
266
+ // Preserved if the caller pinned one.
267
+ NODE_ENV: process.env.NODE_ENV ?? "development",
271
268
  },
272
269
  },
273
270
  )
@@ -276,6 +273,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
276
273
  process.on("SIGTERM", forward)
277
274
  process.exit(await child.exited)
278
275
  }
276
+ // SSR runs in THIS process, on Bun's runtime - a different loader from the dev-server bundler the
277
+ // bunfig above configures. Both halves have to agree, so the runtime gets the same transforms:
278
+ //
279
+ // - CSS Modules. The scoped name is a pure function of file path + class name, so the `"ssr"` form
280
+ // produces the identical map the client bundle emits (and no stylesheet - that ships from the
281
+ // client build). Without it `styles.box` is `undefined` on the server: the SSR markup carries no
282
+ // class, the page paints unstyled, and hydration then reports a className mismatch.
283
+ // - the app's `serverPlugins` - the SSR counterpart of the `clientPlugins` the generated bunfig
284
+ // delivers, exactly as `nifra build`'s prerender pass registers them.
285
+ //
286
+ // Registered BEFORE `createDevServer`, because a Bun runtime plugin only affects modules loaded
287
+ // after it - and `createApp` imports the route modules.
288
+ const { plugin } = await import("bun")
289
+ const { cssModulesBunPlugin } = await import("@nifrajs/web/plugins/css-modules")
290
+ plugin(cssModulesBunPlugin("ssr"))
291
+ for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins)) plugin(p)
292
+ // "After" has one consequence an adapter has to respect. Reading the app's config is what PRODUCES
293
+ // these plugins, so the config's import graph - which includes the adapter - is already loaded when
294
+ // they register. An adapter must therefore not import an asset its own plugin compiles at module
295
+ // scope; it has to defer to first render, which happens after this point. `@nifrajs/web-svelte`
296
+ // loads its `Chain.svelte` that way for exactly this reason, and says so at the import.
279
297
  const { createDevServer } = await import("@nifrajs/web/dev")
280
298
  const { framework: fw, routesDir, outDir, backend } = app
281
299
  const server = await createDevServer({
@@ -283,8 +301,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
283
301
  outDir,
284
302
  clientModule: fw.clientModule,
285
303
  port: flags.port,
286
- // The Bun pipeline's own plugins (SFC compilers etc.) - never `vitePlugins`, which belong to the
287
- // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
304
+ // For the background client-leak guard, which re-runs `buildClient` on change - the SERVED client
305
+ // bundle gets these same plugins through the generated bunfig, since Bun's dev-server bundler takes
306
+ // plugins only as module paths. Never `vitePlugins`, which belong to the other pipeline;
307
+ // `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
288
308
  plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
289
309
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
290
310
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -298,9 +318,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
298
318
  }),
299
319
  })
300
320
  console.log(`nifra dev (bun) → http://localhost:${server.port}`)
321
+ console.log(` ${describePipeline(decision)}`)
301
322
  return
302
323
  }
303
- // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
324
+ // Preflight: the Vite fallback needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
304
325
  // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
305
326
  // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
306
327
  try {
@@ -321,6 +342,18 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
321
342
  // same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
322
343
  // made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
323
344
  // not a redundancy worth keeping "just in case".
345
+ //
346
+ // Svelte is the case that proves the rule, and the discarded attempts are recorded so they are not
347
+ // retried. The adapter package stays `ssr.external` (its context must be the ONE instance the
348
+ // Bun-imported adapter provides), so BUN loads it - and `@nifrajs/web-svelte` renders through its own
349
+ // `Chain.svelte`, which needs a compiler that this pipeline does not put in the runtime. Registering
350
+ // the app's `serverPlugins` here, scoped to that package's directory, compiles it against a SECOND
351
+ // Svelte runtime and the first `setContext` dies (`context.function` null); externalizing the
352
+ // adapter's framework peer as well gets further and then dies on `context.function[FILENAME]`,
353
+ // because `svelteBunPlugin` and `vite-plugin-svelte` do not emit the same dev-mode component shape.
354
+ // Both attempts add a compiler. The fix removes one instead: `createViteDevServer` publishes its
355
+ // `ssrLoadModule` via `setSsrModuleLoader`, and the adapter loads `Chain.svelte` AND `svelte/server`
356
+ // through it - one toolchain, one runtime, context intact across the whole tree.
324
357
  const server = await createViteDevServer({
325
358
  root: cwd,
326
359
  routesDir,
@@ -343,7 +376,8 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
343
376
  ...apiOf(backend),
344
377
  }),
345
378
  })
346
- console.log(`nifra dev → http://localhost:${server.port}`)
379
+ console.log(`nifra dev (vite) → http://localhost:${server.port}`)
380
+ console.log(` ${describePipeline(decision)}`)
347
381
  }
348
382
 
349
383
  /**
@@ -411,10 +445,10 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
411
445
  // The static target needs a built app to drive prerendering - only build it when targeting static.
412
446
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
413
447
  })
414
- console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
415
- // An auto-selected pipeline is stated, never silent: the user asked for the default and got something
416
- // else, and the reason is the thing that makes that legible instead of surprising.
417
- if (decision.reason !== undefined) console.log(` ${decision.reason}`)
448
+ console.log(`nifra build (${target}, ${decision.pipeline}) → ${result.run}`)
449
+ // The pipeline is stated on every build, not only the surprising ones - an auto-selected Vite build
450
+ // must never look like the default, and a default Bun build must not leave the question open either.
451
+ console.log(` ${describePipeline(decision)}`)
418
452
  if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
419
453
  }
420
454
 
@@ -7,10 +7,15 @@
7
7
  * would ship to the browser WHOLE (body, DB handles, secrets) instead of as the RPC stub the
8
8
  * production client build emits, and a `*.server` module would ship instead of being emptied.
9
9
  *
10
+ * The same channel is the only way an app's OWN `clientPlugins` (SFC compilers and the like) can reach
11
+ * that bundler: the CLI holds them as plugin OBJECTS, and bunfig accepts only module paths - so the
12
+ * generated module re-imports the app's config and composes them in.
13
+ *
10
14
  * So the CLI generates, under `.nifra/dev-bun/`:
11
15
  * - `boundary-plugin.ts` - default-exports one plugin composing the SAME `serverFnStubPlugin` +
12
- * `serverOnlyEmptyPlugin` the production build uses (imported from `@nifrajs/web/build`, resolved
13
- * from the app - identical stubs, not a re-implementation);
16
+ * `serverOnlyEmptyPlugin` + CSS Modules transform the production build uses (imported from
17
+ * `@nifrajs/web`, resolved from the app - identical transforms, not re-implementations), plus the
18
+ * app's own `clientPlugins`;
14
19
  * - `bunfig.toml` - the app's ENTIRE bunfig carried over verbatim (jsx, loaders, defines, install
15
20
  * settings - dropping any of them would give dev a different Bun than the app configured), with
16
21
  * `[serve.static] plugins` merged to put the boundary plugin first and path-bearing entries
@@ -34,7 +39,7 @@
34
39
  */
35
40
  import { timingSafeEqual } from "node:crypto"
36
41
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
37
- import { resolve } from "node:path"
42
+ import { relative, resolve } from "node:path"
38
43
 
39
44
  /** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
40
45
  function tomlString(value: string): string {
@@ -132,22 +137,56 @@ export function renderDevBunfig(
132
137
  )
133
138
  }
134
139
 
135
- /** The generated plugin module: compose the two production boundary plugins into one default
136
- * export. Import specifiers are bare so they resolve from the APP's install (the module lives
137
- * under the app's `.nifra/`), keeping dev stubs byte-identical to production's. */
138
- export function renderBoundaryPluginModule(): string {
139
- return `// Generated by \`nifra dev --bun\` - do not edit. Regenerated on every dev start.
140
- import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
140
+ /** A TS/JS import specifier for `target` as seen from `fromDir`, always `./`-prefixed and posix-slashed
141
+ * so it reads as a FILE path to the module loader rather than a package name. */
142
+ function relativeSpecifier(fromDir: string, target: string): string {
143
+ const rel = relative(fromDir, target).replaceAll("\\", "/")
144
+ return rel.startsWith(".") ? rel : `./${rel}`
145
+ }
141
146
 
147
+ /**
148
+ * The generated plugin module: compose the production boundary plugins, the production CSS Modules
149
+ * transform, and the APP's own `clientPlugins` into one default export.
150
+ *
151
+ * The `@nifrajs/*` specifiers are bare so they resolve from the APP's install (the module lives under
152
+ * the app's `.nifra/`), keeping dev transforms byte-identical to production's.
153
+ *
154
+ * `configPath` is the app's `nifra.config.ts` (or `framework.ts`). Importing it here is the only way an
155
+ * app's `clientPlugins` can reach Bun's dev-server bundler at all: that bundler takes plugins as module
156
+ * PATHS via bunfig, never as the plugin objects the CLI already holds in memory. Without this an app
157
+ * whose transforms live in `clientPlugins` had no working Bun dev loop - its client would be bundled
158
+ * with the transform silently missing. `setup` is async because `clientPlugins` may be a thunk.
159
+ */
160
+ export function renderBoundaryPluginModule(configPath?: string, pluginDir?: string): string {
161
+ const appImport =
162
+ configPath !== undefined && pluginDir !== undefined
163
+ ? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
164
+ : ""
165
+ const appSetup =
166
+ appImport === ""
167
+ ? ""
168
+ : ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
169
+ const field = appConfig.clientPlugins
170
+ const appPlugins = (typeof field === "function" ? await field() : field) ?? []
171
+ for (const p of appPlugins) await p.setup(build)
172
+ `
173
+ return `// Generated by \`nifra dev\` (Bun pipeline) - do not edit. Regenerated on every dev start.
174
+ import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
175
+ import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
176
+ ${appImport}
142
177
  const fn = serverFnStubPlugin()
143
178
  const serverOnly = serverOnlyEmptyPlugin()
179
+ const cssModules = cssModulesBunPlugin("dom")
144
180
 
145
181
  export default {
146
182
  name: "nifra-dev-boundary",
147
- setup(build) {
183
+ async setup(build) {
184
+ // Boundary first: an app plugin must never get to transform a module that the boundary is about to
185
+ // empty or stub, or the transform decides what ships instead of the boundary.
148
186
  serverOnly.setup(build)
149
187
  fn.setup(build)
150
- },
188
+ cssModules.setup(build)
189
+ ${appSetup} },
151
190
  }
152
191
  `
153
192
  }
@@ -161,12 +200,16 @@ export interface BunDevConfig {
161
200
  const TOKEN_FILE = "launch-token"
162
201
 
163
202
  /** Write the generated config for an app; returns the bunfig path to re-exec with and the fresh
164
- * launch token to pass in the child's environment. */
165
- export async function writeBunDevConfig(appRoot: string): Promise<BunDevConfig> {
203
+ * launch token to pass in the child's environment. `configPath` (the app's `nifra.config.ts` /
204
+ * `framework.ts`) is what lets the app's own `clientPlugins` reach Bun's dev-server bundler. */
205
+ export async function writeBunDevConfig(
206
+ appRoot: string,
207
+ configPath?: string,
208
+ ): Promise<BunDevConfig> {
166
209
  const dir = resolve(appRoot, ".nifra", "dev-bun")
167
210
  mkdirSync(dir, { recursive: true })
168
211
  const pluginPath = resolve(dir, "boundary-plugin.ts")
169
- writeFileSync(pluginPath, renderBoundaryPluginModule())
212
+ writeFileSync(pluginPath, renderBoundaryPluginModule(configPath, dir))
170
213
  const userToml = await Bun.file(resolve(appRoot, "bunfig.toml"))
171
214
  .text()
172
215
  .catch(() => undefined)
package/src/doctor.ts CHANGED
@@ -21,6 +21,7 @@ import { readdir, realpath, stat } from "node:fs/promises"
21
21
  import { builtinModules } from "node:module"
22
22
  import { dirname, join, relative, sep } from "node:path"
23
23
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
24
+ import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
24
25
 
25
26
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
26
27
  // own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
@@ -135,6 +136,15 @@ export interface DoctorResult {
135
136
  * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
136
137
  * momentarily behind - the finding matters when a dev server starts against it. */
137
138
  readonly staleDists: readonly StaleDistFinding[]
139
+ /**
140
+ * Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
141
+ * that exist only because there are two. Absent when the directory is not a nifra app.
142
+ *
143
+ * Reported by doctor rather than only by `dev`/`build` because the answer governs how the rest of a
144
+ * project is read - which plugin slot is live, whether `conditions` reach the client bundle, which
145
+ * toolchain compiles a component - and asking for it should not require starting a server.
146
+ */
147
+ readonly pipeline?: PipelineReport
138
148
  /** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
139
149
  readonly fixed?: readonly DoctorAppliedFix[]
140
150
  /** Findings that were safe to report but not safe to write automatically. */
@@ -701,13 +711,16 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
701
711
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
702
712
  const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
703
713
  const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
714
+ const pipeline = await collectPipelineReport(cwd)
715
+ const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
704
716
  // `staleDists` is advisory (see DoctorResult): it never fails `ok`.
705
717
  return {
706
- ok: findings.length === 0 && duplicateInstalls.length === 0,
718
+ ok: findings.length === 0 && duplicateInstalls.length === 0 && pipelineErrors.length === 0,
707
719
  ran: true,
708
720
  findings,
709
721
  duplicateInstalls,
710
722
  staleDists,
723
+ ...(pipeline.ran ? { pipeline } : {}),
711
724
  }
712
725
  }
713
726
 
@@ -811,6 +824,20 @@ export async function runDoctor(
811
824
  }
812
825
  console.log("")
813
826
  }
827
+ // Printed before the findings and regardless of ok: which bundler owns this app is the frame the
828
+ // rest of the report is read in, not a footnote to it.
829
+ if (result.pipeline !== undefined) {
830
+ const p = result.pipeline
831
+ console.log(
832
+ p.pipeline === "unknown"
833
+ ? `• bundler: could not be read from ${p.configFile} - ${p.reason}\n`
834
+ : `• bundler: ${p.pipeline} (${p.reason})\n`,
835
+ )
836
+ for (const f of p.findings) {
837
+ console.log(`${f.severity === "error" ? "✗" : "⚠"} ${f.file}:${f.line ?? 0} ${f.message}`)
838
+ console.log(` fix: ${f.fix}\n`)
839
+ }
840
+ }
814
841
  // Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
815
842
  // it costs an hour of misdiagnosis when the dev server 500s inside the package.
816
843
  if (result.staleDists.length > 0) {
package/src/introspect.ts CHANGED
@@ -9,6 +9,7 @@ import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "@nifr
9
9
  import type { Manifest } from "@nifrajs/web"
10
10
  import { discoverRoutes } from "@nifrajs/web/fs"
11
11
  import type { LoadedApp } from "./load.ts"
12
+ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
12
13
 
13
14
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
14
15
  export function backendRoutes(backend: unknown): ReflectedRoute[] {
@@ -232,6 +233,11 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
232
233
  // Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
233
234
  // wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
234
235
  "> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
236
+ // The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
237
+ // the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
238
+ // index because it governs where a plugin goes and which toolchain compiles a component - an edit
239
+ // put in the other pipeline's slot is accepted and then silently never runs.
240
+ `**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
235
241
  )
236
242
  }
237
243
  if (filter?.kind !== "pages") {
package/src/load.ts CHANGED
@@ -93,6 +93,10 @@ export interface ResolvedPlugins {
93
93
 
94
94
  export interface LoadedApp {
95
95
  readonly cwd: string
96
+ /** Absolute path of the config this app was loaded from - `nifra.config.ts`, or `framework.ts` as the
97
+ * fallback. `nifra dev`'s Bun pipeline re-imports it from a GENERATED module so the app's own
98
+ * `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
99
+ readonly configPath: string
96
100
  readonly routesDir: string
97
101
  /** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
98
102
  readonly outDir: string
@@ -222,6 +226,7 @@ export async function loadApp(
222
226
 
223
227
  return {
224
228
  cwd,
229
+ configPath,
225
230
  routesDir: resolve(cwd, "routes"),
226
231
  outDir: resolve(cwd, outDirName),
227
232
  framework: { ...fw, clientModule } as NifraFramework,
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "2.10.0"
32
+ const VERSION = "2.11.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>