@nifrajs/cli 2.9.1 → 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 (55) hide show
  1. package/README.md +2 -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 +129 -66
  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 +26 -6
  19. package/dist/introspect.d.ts.map +1 -1
  20. package/dist/introspect.js +92 -2
  21. package/dist/introspect.js.map +1 -1
  22. package/dist/load.d.ts +4 -0
  23. package/dist/load.d.ts.map +1 -1
  24. package/dist/load.js +1 -0
  25. package/dist/load.js.map +1 -1
  26. package/dist/mcp-http.js +1 -1
  27. package/dist/mcp-http.js.map +1 -1
  28. package/dist/mcp.js +2 -2
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/pipeline-guard.d.ts +25 -10
  31. package/dist/pipeline-guard.d.ts.map +1 -1
  32. package/dist/pipeline-guard.js +35 -15
  33. package/dist/pipeline-guard.js.map +1 -1
  34. package/dist/pipeline-report.d.ts +34 -0
  35. package/dist/pipeline-report.d.ts.map +1 -0
  36. package/dist/pipeline-report.js +282 -0
  37. package/dist/pipeline-report.js.map +1 -0
  38. package/dist/sdk.d.ts +10 -0
  39. package/dist/sdk.d.ts.map +1 -0
  40. package/dist/sdk.js +229 -0
  41. package/dist/sdk.js.map +1 -0
  42. package/docs/llms-full.txt +246 -17
  43. package/docs/types.json +725 -16
  44. package/package.json +9 -9
  45. package/src/check.ts +54 -5
  46. package/src/cli.ts +131 -67
  47. package/src/dev-bun-config.ts +57 -14
  48. package/src/doctor.ts +28 -1
  49. package/src/introspect.ts +124 -4
  50. package/src/load.ts +5 -0
  51. package/src/mcp-http.ts +1 -1
  52. package/src/mcp.ts +2 -2
  53. package/src/pipeline-guard.ts +44 -14
  54. package/src/pipeline-report.ts +341 -0
  55. package/src/sdk.ts +260 -0
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") {
@@ -430,6 +436,106 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
430
436
  }
431
437
  }
432
438
 
439
+ export interface RouteGraphNode {
440
+ readonly id: string
441
+ readonly kind: "root" | RouteKind
442
+ readonly path: string
443
+ readonly methods?: readonly string[]
444
+ readonly file?: string
445
+ readonly autoMounted?: boolean
446
+ }
447
+
448
+ export interface RouteGraphEdge {
449
+ readonly from: string
450
+ readonly to: string
451
+ }
452
+
453
+ export interface RouteGraph {
454
+ readonly nodes: readonly RouteGraphNode[]
455
+ readonly edges: readonly RouteGraphEdge[]
456
+ }
457
+
458
+ function parentPath(path: string): string | undefined {
459
+ if (path === "/") return undefined
460
+ const trimmed = path.replace(/\/$/, "")
461
+ const slash = trimmed.lastIndexOf("/")
462
+ return slash <= 0 ? "/" : trimmed.slice(0, slash)
463
+ }
464
+
465
+ /** Build a stable parent/child graph from the public route table. Pure and topology-neutral. */
466
+ export function buildRouteGraph(rows: readonly RouteTableEntry[]): RouteGraph {
467
+ const sorted = [...rows].sort(
468
+ (a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind),
469
+ )
470
+ const nodes: RouteGraphNode[] = [
471
+ { id: "root", kind: "root", path: "/" },
472
+ ...sorted.map((row) => ({
473
+ id: `${row.kind}:${row.path}`,
474
+ kind: row.kind,
475
+ path: row.path,
476
+ methods: row.methods,
477
+ ...(row.file === undefined ? {} : { file: row.file }),
478
+ ...(row.kind === "api" ? { autoMounted: row.autoMounted === true } : {}),
479
+ })),
480
+ ]
481
+ const nodeIds = new Set(nodes.map((node) => node.id))
482
+ const edges: RouteGraphEdge[] = []
483
+ for (const row of sorted) {
484
+ let parent = parentPath(row.path)
485
+ let from = "root"
486
+ while (parent !== undefined) {
487
+ const candidate = `${row.kind}:${parent}`
488
+ if (nodeIds.has(candidate)) {
489
+ from = candidate
490
+ break
491
+ }
492
+ parent = parentPath(parent)
493
+ }
494
+ edges.push({ from, to: `${row.kind}:${row.path}` })
495
+ }
496
+ return { nodes, edges }
497
+ }
498
+
499
+ /** Stable JSON shape for agents and contract explorers. */
500
+ export function routeGraphToJson(graph: RouteGraph): RouteGraph {
501
+ return {
502
+ nodes: graph.nodes.map((node) => ({
503
+ ...node,
504
+ ...(node.methods === undefined ? {} : { methods: [...node.methods] }),
505
+ })),
506
+ edges: graph.edges.map((edge) => ({ ...edge })),
507
+ }
508
+ }
509
+
510
+ function mermaidText(value: string): string {
511
+ return value
512
+ .replaceAll("&", "&amp;")
513
+ .replaceAll('"', "&quot;")
514
+ .replaceAll("<", "&lt;")
515
+ .replaceAll(">", "&gt;")
516
+ .replaceAll(/[\r\n]/g, " ")
517
+ }
518
+
519
+ function mermaidLabel(node: RouteGraphNode): string {
520
+ if (node.kind === "root") return "routes"
521
+ const methods = node.methods?.join(", ") ?? ""
522
+ const mounted = node.autoMounted === true ? " (auto-mounted)" : ""
523
+ return mermaidText(`${node.kind}: ${methods} ${node.path}${mounted}`)
524
+ }
525
+
526
+ /** Render a route graph as Mermaid for terminals, docs, and agent-facing previews. */
527
+ export function renderRouteGraph(graph: RouteGraph): string {
528
+ const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]))
529
+ const lines = ["flowchart TD"]
530
+ for (const node of graph.nodes) {
531
+ lines.push(` ${ids.get(node.id)}["${mermaidLabel(node)}"]`)
532
+ }
533
+ for (const edge of graph.edges) {
534
+ lines.push(` ${ids.get(edge.from)} --> ${ids.get(edge.to)}`)
535
+ }
536
+ return lines.join("\n")
537
+ }
538
+
433
539
  /** A route module that may export an `action` (→ the page serves POST). */
434
540
  interface MaybeActionModule {
435
541
  readonly action?: unknown
@@ -441,10 +547,7 @@ interface MaybeActionModule {
441
547
  * text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
442
548
  * degrades to GET-only for that route (never throws the whole command).
443
549
  */
444
- export async function describeRoutes(
445
- app: LoadedApp,
446
- opts: { readonly json?: boolean } = {},
447
- ): Promise<string> {
550
+ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
448
551
  let manifest: Manifest | undefined
449
552
  try {
450
553
  manifest = discoverRoutes(app.routesDir)
@@ -463,5 +566,22 @@ export async function describeRoutes(
463
566
  pages.push({ pattern: route.pattern, file: route.file, hasAction })
464
567
  }
465
568
  const rows = buildRouteTable({ pages, api: backendRoutes(app.backend) })
569
+ return rows
570
+ }
571
+
572
+ export async function describeRoutes(
573
+ app: LoadedApp,
574
+ opts: { readonly json?: boolean } = {},
575
+ ): Promise<string> {
576
+ const rows = await collectRouteTable(app)
466
577
  return opts.json ? JSON.stringify(routeTableToJson(rows), null, 2) : renderRouteTable(rows)
467
578
  }
579
+
580
+ /** Gather and render the public route graph for `nifra routes --graph`. */
581
+ export async function describeRouteGraph(
582
+ app: LoadedApp,
583
+ opts: { readonly json?: boolean } = {},
584
+ ): Promise<string> {
585
+ const graph = buildRouteGraph(await collectRouteTable(app))
586
+ return opts.json ? JSON.stringify(routeGraphToJson(graph), null, 2) : renderRouteGraph(graph)
587
+ }
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.9.1"
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 =>
package/src/mcp.ts CHANGED
@@ -1124,7 +1124,7 @@ export function projectTools(
1124
1124
  {
1125
1125
  name: "nifra_check",
1126
1126
  description:
1127
- 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
1127
+ 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[], pipeline? }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. `pipeline` answers "which bundler does this app run on" (bun|vite, for dev AND build alike) without starting a server, and its `pipeline` diagnostics catch the hazards of having two: a plugin in the slot the other bundler reads (accepted, then never called), a dev toolchain imported by the file `nifra build` bundles into the production server, and `conditions` that cannot reach Bun\'s dev client bundle. Read it before adding a plugin or a compiler. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
1128
1128
  inputSchema: {
1129
1129
  type: "object",
1130
1130
  properties: {
@@ -1323,7 +1323,7 @@ export function projectTools(
1323
1323
  {
1324
1324
  name: "nifra_doctor",
1325
1325
  description:
1326
- "Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
1326
+ "Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Also reports `pipeline`: which bundler (bun|vite) this app's dev AND build phases run on and why, plus config hazards that only exist because there are two - a plugin sitting in the slot the other bundler reads (accepted, then never called), a dev toolchain imported by the file `nifra build` bundles into the production server (builds clean, dies at startup), and `conditions` that cannot reach the client bundle Bun's dev server serves. Returns { ok, ran, findings[], pipeline?, fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
1327
1327
  inputSchema: {
1328
1328
  type: "object",
1329
1329
  properties: {
@@ -116,6 +116,13 @@ export type BuildPipeline = "bun" | "vite"
116
116
 
117
117
  export interface PipelineDecision {
118
118
  readonly pipeline: BuildPipeline
119
+ /**
120
+ * How the pipeline was arrived at. Always set, because "which bundler am I on" is a question every
121
+ * `dev`/`build` run should answer without the user reading this source: `default` is the plain Bun
122
+ * default, `auto` means the config forced nifra's hand (see {@link reason}), `forced` is `--vite` /
123
+ * `--bun`.
124
+ */
125
+ readonly why: "default" | "auto" | "forced"
119
126
  /**
120
127
  * Why this pipeline, for the `nifra build` log line - so an auto-selected Vite build never looks
121
128
  * like the user got the default. `undefined` when the choice was the plain default or was forced.
@@ -139,16 +146,14 @@ const pluginNames = (plugins: readonly unknown[]): string =>
139
146
  .join(", ")
140
147
 
141
148
  /**
142
- * Pick the bundler for `nifra build`, per app rather than per global default.
149
+ * Pick the bundler for `nifra build` and `nifra dev`, per app rather than per global default.
143
150
  *
144
- * The phase defaults are deliberately asymmetric - `nifra dev` is Vite (for the plugin ecosystem, and
145
- * because Bun's DEV-server bundler cannot compile CSS Modules), `nifra build` is Bun (faster and
146
- * Bun-native). For an app with no transforms that asymmetry is free: there is nothing to disagree about.
147
- * For an app whose ONLY transforms are Vite plugins it is not. Those plugins run in dev and are then
148
- * dropped by the Bun build - `buildForTarget` reads `clientPlugins`/`serverPlugins`, never `vitePlugins`
149
- * - so the build succeeds, the output looks plausible, and the transform simply did not happen. That is
150
- * the same failure {@link checkPipelineSeparation} exists to prevent, reached by crossing PHASES instead
151
- * of slots, and the slot check cannot see it because the plugins are correctly placed.
151
+ * The phase defaults are Bun (faster and Bun-native). For an app with no transforms that is direct and
152
+ * predictable. For an app whose ONLY transforms are Vite plugins it is not: those plugins are required in
153
+ * both dev and production, while the Bun build reads `clientPlugins`/`serverPlugins`, never `vitePlugins`.
154
+ * Selecting Bun would make the build succeed with the transform omitted. That is the same failure
155
+ * {@link checkPipelineSeparation} exists to prevent, reached by crossing PHASES instead of slots, and the
156
+ * slot check cannot see it because the plugins are correctly placed.
152
157
  *
153
158
  * So the default follows the app: Vite plugins with no Bun counterpart means the app has exactly one
154
159
  * pipeline that can build it, and that is the one used. An app declaring BOTH has supplied the Bun
@@ -161,26 +166,51 @@ const pluginNames = (plugins: readonly unknown[]): string =>
161
166
  export function chooseBuildPipeline(
162
167
  slots: PipelineSlots,
163
168
  forced?: BuildPipeline,
169
+ command: "build" | "dev" = "build",
164
170
  ): PipelineDecision {
165
171
  const vitePlugins = slots.vitePlugins ?? []
166
172
  const bunPlugins = [...(slots.clientPlugins ?? []), ...(slots.serverPlugins ?? [])]
167
173
  const viteOnly = vitePlugins.length > 0 && bunPlugins.length === 0
168
174
 
169
175
  if (forced === "bun" && viteOnly) {
176
+ // Named for the command the user actually typed - a `nifra dev --bun` failure that talks about
177
+ // `nifra build` sends them looking at the wrong command.
178
+ const phase = command === "dev" ? "dev server" : "build"
170
179
  throw new Error(
171
- "[nifra] `nifra build --bun` would silently drop this app's only transforms. `vitePlugins` are " +
172
- "read by the Vite build; the Bun build reads `clientPlugins`/`serverPlugins`, so these would " +
180
+ `[nifra] \`nifra ${command} --bun\` would silently drop this app's only transforms. \`vitePlugins\` are ` +
181
+ `read by the Vite ${phase}; the Bun ${phase} reads \`clientPlugins\`/\`serverPlugins\`, so these would ` +
173
182
  "never run and the output would look plausible without them.\n" +
174
183
  ` Would be dropped: ${pluginNames(vitePlugins)}\n` +
175
- " Drop the flag to build with Vite, or add the Bun equivalents to `clientPlugins`/`serverPlugins`.",
184
+ ` Drop the flag to use Vite, or add the Bun equivalents to \`clientPlugins\`/\`serverPlugins\`.`,
176
185
  )
177
186
  }
178
- if (forced !== undefined) return { pipeline: forced }
187
+ if (forced !== undefined) return { pipeline: forced, why: "forced" }
179
188
  if (viteOnly) {
180
189
  return {
181
190
  pipeline: "vite",
191
+ why: "auto",
182
192
  reason: `auto: this app's only transforms are \`vitePlugins\` (${pluginNames(vitePlugins)}), which the Bun build cannot run`,
183
193
  }
184
194
  }
185
- return { pipeline: "bun" }
195
+ return { pipeline: "bun", why: "default" }
196
+ }
197
+
198
+ /**
199
+ * The one-line "which bundler, and why" note `nifra dev` and `nifra build` print under their banner.
200
+ *
201
+ * Printed on EVERY run, not only the surprising ones. A pipeline that announces itself only when it
202
+ * deviates teaches nobody which pipeline they are normally on, and every gotcha in the dev docs -
203
+ * plugin slots, resolve conditions, which compiler owns a `.svelte` file - is answerable only once you
204
+ * know that. The line also names the flag that switches, so the answer to "can I try the other one"
205
+ * is on screen rather than in the docs.
206
+ */
207
+ export function describePipeline(decision: PipelineDecision): string {
208
+ const other = decision.pipeline === "bun" ? "--vite" : "--bun"
209
+ const detail =
210
+ decision.why === "forced"
211
+ ? `forced by --${decision.pipeline}`
212
+ : decision.why === "auto"
213
+ ? (decision.reason ?? "auto")
214
+ : `default; ${other} to switch`
215
+ return `bundler: ${decision.pipeline} (${detail})`
186
216
  }