@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.
- package/dist/check.d.ts +4 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +71 -0
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +165 -23
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +66 -6
- package/dist/doctor.js.map +1 -1
- package/dist/load.d.ts +11 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +30 -0
- package/dist/load.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +7 -3
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-render.js +9 -1
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +44 -2
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +83 -0
- package/dist/pipeline-guard.d.ts.map +1 -0
- package/dist/pipeline-guard.js +136 -0
- package/dist/pipeline-guard.js.map +1 -0
- package/dist/types-search.d.ts +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +47 -2
- package/dist/types-search.js.map +1 -1
- package/docs/examples.json +6 -0
- package/docs/llms-full.txt +169 -35
- package/docs/types.json +1618 -45
- package/package.json +9 -9
- package/src/check.ts +91 -0
- package/src/cli.ts +173 -24
- package/src/doctor.ts +71 -6
- package/src/load.ts +44 -0
- package/src/mcp-docs-tools.ts +13 -3
- package/src/mcp-http.ts +1 -1
- package/src/mcp-render.ts +9 -1
- package/src/mcp.ts +45 -2
- package/src/pipeline-guard.ts +186 -0
- 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
|
}
|
package/src/mcp-docs-tools.ts
CHANGED
|
@@ -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 {
|
|
103
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/types-search.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|