@nifrajs/cli 2.0.0 → 2.2.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/dist/check.d.ts +4 -1
  2. package/dist/check.d.ts.map +1 -1
  3. package/dist/check.js +71 -0
  4. package/dist/check.js.map +1 -1
  5. package/dist/cli.d.ts +9 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +165 -23
  8. package/dist/cli.js.map +1 -1
  9. package/dist/doctor.d.ts +5 -1
  10. package/dist/doctor.d.ts.map +1 -1
  11. package/dist/doctor.js +66 -6
  12. package/dist/doctor.js.map +1 -1
  13. package/dist/load.d.ts +11 -0
  14. package/dist/load.d.ts.map +1 -1
  15. package/dist/load.js +30 -0
  16. package/dist/load.js.map +1 -1
  17. package/dist/mcp-docs-tools.d.ts.map +1 -1
  18. package/dist/mcp-docs-tools.js +7 -3
  19. package/dist/mcp-docs-tools.js.map +1 -1
  20. package/dist/mcp-http.js +1 -1
  21. package/dist/mcp-render.js +9 -1
  22. package/dist/mcp-render.js.map +1 -1
  23. package/dist/mcp.d.ts.map +1 -1
  24. package/dist/mcp.js +44 -2
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/pipeline-guard.d.ts +83 -0
  27. package/dist/pipeline-guard.d.ts.map +1 -0
  28. package/dist/pipeline-guard.js +136 -0
  29. package/dist/pipeline-guard.js.map +1 -0
  30. package/dist/types-search.d.ts +1 -1
  31. package/dist/types-search.d.ts.map +1 -1
  32. package/dist/types-search.js +47 -2
  33. package/dist/types-search.js.map +1 -1
  34. package/docs/examples.json +6 -0
  35. package/docs/llms-full.txt +169 -35
  36. package/docs/types.json +1618 -45
  37. package/package.json +9 -9
  38. package/src/check.ts +91 -0
  39. package/src/cli.ts +173 -24
  40. package/src/doctor.ts +71 -6
  41. package/src/load.ts +44 -0
  42. package/src/mcp-docs-tools.ts +13 -3
  43. package/src/mcp-http.ts +1 -1
  44. package/src/mcp-render.ts +9 -1
  45. package/src/mcp.ts +45 -2
  46. package/src/pipeline-guard.ts +186 -0
  47. package/src/types-search.ts +50 -1
package/src/load.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { existsSync } from "node:fs"
16
16
  import { resolve } from "node:path"
17
+ import { checkPipelineSeparation } from "./pipeline-guard.ts"
17
18
 
18
19
  /**
19
20
  * A plugin list — either an array, or a **thunk** that returns one (optionally async). Both forms are
@@ -49,6 +50,33 @@ export interface NifraFramework {
49
50
  readonly conditions?: readonly string[]
50
51
  /** Compile-time `define` replacements, e.g. Vue's `__VUE_*` flags. */
51
52
  readonly define?: Readonly<Record<string, string>>
53
+ /** Static files directory. Defaults to `<app>/public`; `false` disables it. */
54
+ readonly publicDir?: string | false
55
+ /** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
56
+ readonly publicEnvPrefix?: string
57
+ }
58
+
59
+ /**
60
+ * Refuse a config that puts one pipeline's plugins into the other's slot.
61
+ *
62
+ * Checked here rather than in `nifra check`, which holds a deliberate pre-`loadApp` invariant - reading
63
+ * plugins means importing the app's config, which is code execution. This runs at the moment the app
64
+ * IS loaded, so `dev`, `build` and `start` are all covered from one place, immediately before the
65
+ * plugins are handed to a bundler.
66
+ *
67
+ * Throws rather than warns. A misplaced plugin does not fail the build: Bun.build has no `transform`
68
+ * hook and Vite never calls `setup`, so the wrong-slot plugin is accepted and silently never invoked -
69
+ * the transform just does not happen, and the output looks plausible. A loud stop with the exact move
70
+ * is strictly better than a build that succeeds and omits the work. The classifier only fires on a
71
+ * decisive hook shape, so a plugin it cannot place is left alone.
72
+ */
73
+ function assertPipelineSeparation(plugins: ResolvedPlugins, configFile: string): void {
74
+ const mismatches = checkPipelineSeparation(plugins)
75
+ if (mismatches.length === 0) return
76
+ throw new Error(
77
+ `[nifra] ${configFile}: plugins are in the wrong pipeline slot. nifra supports Vite and Bun, but a phase is owned by ONE of them - a plugin in the wrong slot is silently never called.\n\n` +
78
+ mismatches.map((m) => ` - ${m.fix}`).join("\n"),
79
+ )
52
80
  }
53
81
 
54
82
  /** Normalize a {@link PluginsField} (array | thunk | undefined) to a fresh array, awaiting a thunk. */
@@ -57,12 +85,20 @@ export async function resolvePlugins(field: PluginsField | undefined): Promise<u
57
85
  return value ? [...value] : []
58
86
  }
59
87
 
88
+ export interface ResolvedPlugins {
89
+ readonly vitePlugins: readonly unknown[]
90
+ readonly clientPlugins: readonly unknown[]
91
+ readonly serverPlugins: readonly unknown[]
92
+ }
93
+
60
94
  export interface LoadedApp {
61
95
  readonly cwd: string
62
96
  readonly routesDir: string
63
97
  /** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
64
98
  readonly outDir: string
65
99
  readonly framework: NifraFramework
100
+ /** Plugin thunks resolved exactly once during app loading and reused by every command phase. */
101
+ readonly resolvedPlugins: ResolvedPlugins
66
102
  /** The `backend` export from `backend.ts`, or `undefined` if there's no `backend.ts`. */
67
103
  readonly backend: unknown
68
104
  }
@@ -160,6 +196,13 @@ export async function loadApp(
160
196
  `[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
161
197
  )
162
198
  }
199
+ const [vitePlugins, clientPlugins, serverPlugins] = await Promise.all([
200
+ resolvePlugins(fw.vitePlugins),
201
+ resolvePlugins(fw.clientPlugins),
202
+ resolvePlugins(fw.serverPlugins),
203
+ ])
204
+ const resolvedPlugins = { vitePlugins, clientPlugins, serverPlugins }
205
+ assertPipelineSeparation(resolvedPlugins, configFile)
163
206
 
164
207
  let backend: unknown
165
208
  const backendPath = resolve(cwd, "backend.ts")
@@ -173,6 +216,7 @@ export async function loadApp(
173
216
  routesDir: resolve(cwd, "routes"),
174
217
  outDir: resolve(cwd, outDirName),
175
218
  framework: fw as NifraFramework,
219
+ resolvedPlugins,
176
220
  backend,
177
221
  }
178
222
  }
@@ -73,7 +73,7 @@ export function docsTools(
73
73
  {
74
74
  name: "nifra_types",
75
75
  description:
76
- 'Get the EXACT TypeScript declaration of any exported @nifrajs/* symbol — interface, type, class, function, const. Each signature is generated from the package\'s built .d.ts, so it is the LITERAL declaration: complete, authoritative, never prose and never truncated. THIS IS THE SOURCE OF TRUTH for nifra\'s types — do NOT read node_modules/@nifrajs/**/*.d.ts; call this instead. Pass `name` for an exact symbol (e.g. "RateLimitStore", "RouteSchema", "Context", "rateLimit"); pass `query` to search by keyword; omit both for the per-package index of names.',
76
+ 'Get the EXACT TypeScript declaration of any exported @nifrajs/* symbol — interface, type, class, function, const. Each signature is generated from the package\'s built .d.ts, so it is the LITERAL declaration: complete, authoritative, never prose and never truncated. THIS IS THE SOURCE OF TRUTH for nifra\'s types — do NOT read node_modules/@nifrajs/**/*.d.ts; call this instead. Pass `name` for an exact symbol (e.g. "RateLimitStore", "RouteSchema", "Context", "rateLimit"); pass `query` to search by keyword; omit both for the per-package index of names. A `name` lookup is always the complete declaration; a `query` returns a one-line summary plus the signature, collapsing an oversized body — pass `full: true` to override.',
77
77
  inputSchema: {
78
78
  type: "object",
79
79
  properties: {
@@ -91,6 +91,11 @@ export function docsTools(
91
91
  type: "number",
92
92
  description: "Max results for a query/search (default 5, max 8).",
93
93
  },
94
+ full: {
95
+ type: "boolean",
96
+ description:
97
+ "Query mode only: return whole declarations instead of collapsed ones. Off by default — a search is for picking a symbol, and one match can be tens of thousands of characters.",
98
+ },
94
99
  },
95
100
  additionalProperties: false,
96
101
  },
@@ -99,8 +104,13 @@ export function docsTools(
99
104
  if (corpus === undefined) {
100
105
  return "types corpus missing — run `bun run build && bun run gen:llms` in the nifra repo (published builds ship it)."
101
106
  }
102
- const { name, query, limit } = args as { name?: string; query?: string; limit?: number }
103
- return renderTypesResult(corpus, name, query, clamp(limit, 1, 8, 5))
107
+ const { name, query, limit, full } = args as {
108
+ name?: string
109
+ query?: string
110
+ limit?: number
111
+ full?: boolean
112
+ }
113
+ return renderTypesResult(corpus, name, query, clamp(limit, 1, 8, 5), full === true)
104
114
  },
105
115
  },
106
116
  ]
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 = "2.0.0"
29
+ const VERSION = "2.2.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-render.ts CHANGED
@@ -169,5 +169,13 @@ if (import.meta.main) {
169
169
  } catch {
170
170
  output = { error: "invalid input: expected JSON { requests: [...] }" }
171
171
  }
172
- process.stdout.write(JSON.stringify(output, null, 2))
172
+ await Bun.write(Bun.stdout, JSON.stringify(output, null, 2))
173
+ // Exit explicitly, exactly as the `--worker` branch above already does.
174
+ //
175
+ // Loading the app runs its module side effects, and a real app opens things: a database pool, a
176
+ // Redis client, a metrics interval. Any one of those keeps the event loop alive, so falling off
177
+ // the end of this module never terminates the process - and the parent is sitting in
178
+ // `await proc.exited`. The render itself finished; there is nothing left to wait for, and this
179
+ // process is single-use. `Bun.write` above is awaited so the output is flushed before we exit.
180
+ process.exit(0)
173
181
  }
package/src/mcp.ts CHANGED
@@ -61,6 +61,26 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
61
61
  }
62
62
 
63
63
  /** Spawn `bun <child> <cwd>`, pipe `input` to its stdin, return its stdout (or a stderr-backed error). */
64
+ /**
65
+ * How long a child gets before it is killed.
66
+ *
67
+ * A child loads the app, which runs its module side effects - a database pool, a Redis client, a
68
+ * metrics interval. Any of those keeps the child's event loop alive after the work is done, and the
69
+ * parent is sitting in `await proc.exited`. The children now exit explicitly, so this is the backstop
70
+ * rather than the fix: without it a single wedged child blocks an agent indefinitely, and the caller
71
+ * has no way to tell "still working" from "never returning".
72
+ *
73
+ * Generous enough for a cold start that compiles route modules, short enough that a hang is reported
74
+ * within one attention span rather than after minutes of polling.
75
+ */
76
+ const CHILD_TIMEOUT_MS = 30_000
77
+
78
+ const timeoutMessage = (label: string, ms: number): string =>
79
+ `${label} timed out after ${ms / 1000}s and was terminated.\n` +
80
+ `The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
81
+ `that keeps the event loop alive (a database pool, a Redis client, an interval) opened during import ` +
82
+ `rather than lazily. Check for top-level connections in the app entry or anything it imports.`
83
+
64
84
  async function spawnChild(
65
85
  child: "mcp-run" | "mcp-render" | "mcp-ws",
66
86
  cwd: string,
@@ -76,6 +96,11 @@ async function spawnChild(
76
96
  })
77
97
  const abort = (): void => proc.kill()
78
98
  signal?.addEventListener("abort", abort, { once: true })
99
+ let timedOut = false
100
+ const timer = setTimeout(() => {
101
+ timedOut = true
102
+ proc.kill()
103
+ }, CHILD_TIMEOUT_MS)
79
104
  try {
80
105
  proc.stdin.write(JSON.stringify(input))
81
106
  await proc.stdin.end()
@@ -88,8 +113,13 @@ async function spawnChild(
88
113
  const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
89
114
  return `${label} cancelled${reason}.`
90
115
  }
91
- return out.trim() || `${label} failed:\n${err.trim() || "(no output)"}`
116
+ // A killed child may still have flushed a complete result before the timer fired; prefer real
117
+ // output over the timeout message so a slow-but-successful render is not reported as a failure.
118
+ const text = out.trim()
119
+ if (timedOut && text === "") return timeoutMessage(label, CHILD_TIMEOUT_MS)
120
+ return text || `${label} failed:\n${err.trim() || "(no output)"}`
92
121
  } finally {
122
+ clearTimeout(timer)
93
123
  signal?.removeEventListener("abort", abort)
94
124
  }
95
125
  }
@@ -189,10 +219,23 @@ export class WarmWorker {
189
219
  // process here would reject every OTHER in-flight request via the `exited` handler and force a
190
220
  // cold rebuild. Leave it hot — `createWarmHandler` already replaces it on file change.
191
221
  this.pending.delete(id)
222
+ clearTimeout(timer)
192
223
  const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
193
224
  resolve(`${this.label} cancelled${reason}.`)
194
225
  }
195
- const cleanup = (): void => signal?.removeEventListener("abort", abort)
226
+ // Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
227
+ // promise unsettled forever. Drop the id and replace the worker - unlike a per-request cancel,
228
+ // a timeout means the worker itself is suspect, so the next call should start from a fresh one.
229
+ const timer = setTimeout(() => {
230
+ if (!this.pending.delete(id)) return
231
+ signal?.removeEventListener("abort", abort)
232
+ this.stop()
233
+ resolve(timeoutMessage(this.label, CHILD_TIMEOUT_MS))
234
+ }, CHILD_TIMEOUT_MS)
235
+ const cleanup = (): void => {
236
+ clearTimeout(timer)
237
+ signal?.removeEventListener("abort", abort)
238
+ }
196
239
  signal?.addEventListener("abort", abort, { once: true })
197
240
  this.pending.set(id, { resolve, reject, cleanup })
198
241
  try {
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pipeline separation guard — one bundler owns a phase, never two.
3
+ *
4
+ * nifra supports both Vite and Bun, and the config keeps them in separate slots: `vitePlugins` for
5
+ * the Vite pipeline, `clientPlugins`/`serverPlugins` for the Bun one. Nothing enforces the split, so
6
+ * a plugin lands in the wrong slot and is silently ignored - a Vite plugin in `clientPlugins` is
7
+ * handed to `Bun.build`, which has no `transform` hook to call, so the transform simply never runs
8
+ * and the build succeeds.
9
+ *
10
+ * That failure mode is the whole reason for this file. nifra's expensive bugs are not caused by Vite
11
+ * or by Bun; they are caused by two toolchains disagreeing about one module. Dual-React is that, the
12
+ * dedupe plugins are patches for that, and a `resolve.alias` that never reaches SSR is that. Mixing
13
+ * is the defect - choice is not - so the separation has to be checked rather than documented.
14
+ *
15
+ * Detection is by hook shape, which is what each bundler actually dispatches on:
16
+ * - a Bun plugin has `setup(build)`
17
+ * - a Rollup/Vite plugin has `resolveId` / `load` / `transform` / `config` / `configResolved` /
18
+ * `transformIndexHtml` and no `setup`
19
+ *
20
+ * Deliberately conservative: a plugin that matches neither shape is left alone rather than guessed
21
+ * at. A guard that fires on correct config is a guard people turn off.
22
+ */
23
+
24
+ /** Which pipeline a slot belongs to. */
25
+ export type PipelineSlot = "vitePlugins" | "clientPlugins" | "serverPlugins"
26
+
27
+ export interface PipelineMismatch {
28
+ readonly slot: PipelineSlot
29
+ /** The plugin's `name`, or a positional label when it has none. */
30
+ readonly plugin: string
31
+ /** The pipeline the slot feeds. */
32
+ readonly expected: "vite" | "bun"
33
+ /** The pipeline the plugin's hooks say it belongs to. */
34
+ readonly actual: "vite" | "bun"
35
+ readonly fix: string
36
+ }
37
+
38
+ const ROLLUP_HOOKS = [
39
+ "resolveId",
40
+ "load",
41
+ "transform",
42
+ "config",
43
+ "configResolved",
44
+ "configureServer",
45
+ "transformIndexHtml",
46
+ "generateBundle",
47
+ "renderChunk",
48
+ ] as const
49
+
50
+ /** Classify a plugin by the hooks it exposes, or `undefined` when its shape says nothing. */
51
+ function classify(plugin: unknown): "vite" | "bun" | undefined {
52
+ if (typeof plugin !== "object" || plugin === null) return undefined
53
+ const record = plugin as Record<string, unknown>
54
+ // `setup` is Bun's only entry point, so its presence is decisive even alongside other keys.
55
+ if (typeof record.setup === "function") return "bun"
56
+ if (ROLLUP_HOOKS.some((hook) => typeof record[hook] === "function")) return "vite"
57
+ return undefined
58
+ }
59
+
60
+ const SLOT_PIPELINE: Readonly<Record<PipelineSlot, "vite" | "bun">> = {
61
+ vitePlugins: "vite",
62
+ clientPlugins: "bun",
63
+ serverPlugins: "bun",
64
+ }
65
+
66
+ const MOVE_TO: Readonly<Record<"vite" | "bun", string>> = {
67
+ vite: "`vitePlugins`",
68
+ bun: "`clientPlugins` (client bundle) or `serverPlugins` (SSR)",
69
+ }
70
+
71
+ /**
72
+ * Check one slot's resolved plugin list. Pure: takes the already-resolved array so a thunk-valued
73
+ * field is awaited by the caller, and so this stays testable without loading a project.
74
+ */
75
+ export function checkPipelineSlot(
76
+ slot: PipelineSlot,
77
+ plugins: readonly unknown[],
78
+ ): PipelineMismatch[] {
79
+ const expected = SLOT_PIPELINE[slot]
80
+ const out: PipelineMismatch[] = []
81
+ for (let i = 0; i < plugins.length; i++) {
82
+ const plugin = plugins[i]
83
+ const actual = classify(plugin)
84
+ if (actual === undefined || actual === expected) continue
85
+ const named = (plugin as { name?: unknown }).name
86
+ const label = typeof named === "string" && named !== "" ? named : `${slot}[${i}]`
87
+ out.push({
88
+ slot,
89
+ plugin: label,
90
+ expected,
91
+ actual,
92
+ fix:
93
+ actual === "vite"
94
+ ? `"${label}" is a Vite/Rollup plugin (it defines Rollup hooks, not \`setup\`) but sits in \`${slot}\`, which feeds Bun.build. Bun never calls those hooks, so the transform silently does not run. Move it to ${MOVE_TO.vite}.`
95
+ : `"${label}" is a Bun plugin (it defines \`setup\`) but sits in \`${slot}\`, which feeds Vite. Vite never calls \`setup\`, so the transform silently does not run. Move it to ${MOVE_TO.bun}.`,
96
+ })
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** Check every slot of a resolved framework config. */
102
+ export function checkPipelineSeparation(slots: {
103
+ readonly vitePlugins?: readonly unknown[]
104
+ readonly clientPlugins?: readonly unknown[]
105
+ readonly serverPlugins?: readonly unknown[]
106
+ }): PipelineMismatch[] {
107
+ return [
108
+ ...checkPipelineSlot("vitePlugins", slots.vitePlugins ?? []),
109
+ ...checkPipelineSlot("clientPlugins", slots.clientPlugins ?? []),
110
+ ...checkPipelineSlot("serverPlugins", slots.serverPlugins ?? []),
111
+ ]
112
+ }
113
+
114
+ /** Which bundler a build runs on. */
115
+ export type BuildPipeline = "bun" | "vite"
116
+
117
+ export interface PipelineDecision {
118
+ readonly pipeline: BuildPipeline
119
+ /**
120
+ * Why this pipeline, for the `nifra build` log line - so an auto-selected Vite build never looks
121
+ * like the user got the default. `undefined` when the choice was the plain default or was forced.
122
+ */
123
+ readonly reason?: string
124
+ }
125
+
126
+ /** The plugin slots a decision reads, already resolved. */
127
+ export interface PipelineSlots {
128
+ readonly vitePlugins?: readonly unknown[]
129
+ readonly clientPlugins?: readonly unknown[]
130
+ readonly serverPlugins?: readonly unknown[]
131
+ }
132
+
133
+ const pluginNames = (plugins: readonly unknown[]): string =>
134
+ plugins
135
+ .map((plugin, i) => {
136
+ const named = (plugin as { name?: unknown }).name
137
+ return typeof named === "string" && named !== "" ? named : `vitePlugins[${i}]`
138
+ })
139
+ .join(", ")
140
+
141
+ /**
142
+ * Pick the bundler for `nifra build`, per app rather than per global default.
143
+ *
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.
152
+ *
153
+ * So the default follows the app: Vite plugins with no Bun counterpart means the app has exactly one
154
+ * pipeline that can build it, and that is the one used. An app declaring BOTH has supplied the Bun
155
+ * equivalent on purpose - nothing is dropped - so the fast Bun default stands.
156
+ *
157
+ * `forced` is `--vite` / `--bun` and always wins, except for the one combination that silently discards
158
+ * work, which throws instead. Refusing rather than warning matches the slot guard: a build that succeeds
159
+ * and omits the transform is worse than one that stops and says so.
160
+ */
161
+ export function chooseBuildPipeline(
162
+ slots: PipelineSlots,
163
+ forced?: BuildPipeline,
164
+ ): PipelineDecision {
165
+ const vitePlugins = slots.vitePlugins ?? []
166
+ const bunPlugins = [...(slots.clientPlugins ?? []), ...(slots.serverPlugins ?? [])]
167
+ const viteOnly = vitePlugins.length > 0 && bunPlugins.length === 0
168
+
169
+ if (forced === "bun" && viteOnly) {
170
+ 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 " +
173
+ "never run and the output would look plausible without them.\n" +
174
+ ` Would be dropped: ${pluginNames(vitePlugins)}\n` +
175
+ " Drop the flag to build with Vite, or add the Bun equivalents to `clientPlugins`/`serverPlugins`.",
176
+ )
177
+ }
178
+ if (forced !== undefined) return { pipeline: forced }
179
+ if (viteOnly) {
180
+ return {
181
+ pipeline: "vite",
182
+ reason: `auto: this app's only transforms are \`vitePlugins\` (${pluginNames(vitePlugins)}), which the Bun build cannot run`,
183
+ }
184
+ }
185
+ return { pipeline: "bun" }
186
+ }
@@ -60,6 +60,53 @@ function renderEntry(t: TypeEntry): string {
60
60
  return `${head}\n\n\`\`\`ts\n${doc}${t.signature}\n\`\`\``
61
61
  }
62
62
 
63
+ /**
64
+ * Longest signature rendered whole in a SEARCH result.
65
+ *
66
+ * Search is "which symbol did I mean?", not "show me everything about it" - and the corpus is wildly
67
+ * uneven: the median declaration is small, while `Server` alone is ~32,000 characters, five times the
68
+ * next largest. One broad query that happened to match it returned the entire class for near-zero
69
+ * value. Above this, a search result shows the head plus a member count and says how to get the rest;
70
+ * an exact `name` lookup is never collapsed, because there the caller asked for that symbol.
71
+ */
72
+ const SEARCH_SIGNATURE_MAX = 600
73
+
74
+ /** First sentence of the doc block, flattened to one line - enough to pick between candidates. */
75
+ function summarize(doc: string | undefined): string {
76
+ if (doc === undefined) return ""
77
+ const text = doc
78
+ .replace(/\/\*\*|\*\/|^\s*\*/gm, " ")
79
+ .replace(/\s+/g, " ")
80
+ .trim()
81
+ if (text === "") return ""
82
+ const end = text.search(/\.\s|\.$/)
83
+ const sentence = end === -1 ? text : text.slice(0, end + 1)
84
+ return sentence.length > 200 ? `${sentence.slice(0, 197)}...` : sentence
85
+ }
86
+
87
+ /** Keep the declaration head; replace a long body with a member count. */
88
+ function collapseSignature(signature: string): string {
89
+ const open = signature.indexOf("{")
90
+ if (open === -1) return `${signature.slice(0, SEARCH_SIGNATURE_MAX)}…`
91
+ const body = signature.slice(open)
92
+ // Rough but honest: count top-level-looking member lines rather than parsing TypeScript here.
93
+ const members = body.split("\n").filter((line) => /^\s{2,4}\S/.test(line)).length
94
+ return `${signature.slice(0, open).trim()} { … ${members} members … }`
95
+ }
96
+
97
+ /** A search hit: enough to choose, not the whole declaration. */
98
+ function renderSearchHit(t: TypeEntry): string {
99
+ const head = `## ${t.name} — ${t.kind}, \`${t.package}\``
100
+ const summary = summarize(t.doc)
101
+ const signature =
102
+ t.signature.length <= SEARCH_SIGNATURE_MAX ? t.signature : collapseSignature(t.signature)
103
+ const note =
104
+ t.signature.length <= SEARCH_SIGNATURE_MAX
105
+ ? ""
106
+ : `\n_Collapsed (${t.signature.length} chars). Call \`nifra_types({ name: "${t.name}" })\` for the full declaration._`
107
+ return `${head}\n${summary ? `\n${summary}\n` : ""}\n\`\`\`ts\n${signature}\n\`\`\`${note}`
108
+ }
109
+
63
110
  /**
64
111
  * Render the tool result. `name` → the exact declaration(s); else `query` → the top matches; else a
65
112
  * per-package index of type names. The signatures are authoritative (generated from the built `.d.ts`),
@@ -70,6 +117,7 @@ export function renderTypesResult(
70
117
  name: string | undefined,
71
118
  query: string | undefined,
72
119
  limit: number,
120
+ full = false,
73
121
  ): string {
74
122
  if (name !== undefined && name.trim() !== "") {
75
123
  const hits = lookupType(types, name)
@@ -83,7 +131,8 @@ export function renderTypesResult(
83
131
  if (matches.length === 0) {
84
132
  return `No types matched ${JSON.stringify(query)}. Try a broader term, pass an exact \`name\`, or call with no args for the index.`
85
133
  }
86
- return matches.map(renderEntry).join("\n\n---\n\n")
134
+ // Search results are collapsed by default; `full` opts back into whole declarations.
135
+ return matches.map(full ? renderEntry : renderSearchHit).join("\n\n---\n\n")
87
136
  }
88
137
  const byPkg = new Map<string, string[]>()
89
138
  for (const t of types) {