@nifrajs/cli 1.0.0-beta.3 → 1.0.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/src/mcp.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  type McpToolContext,
47
47
  rpcError,
48
48
  } from "./mcp-protocol.ts"
49
+ import { loadTypesCorpus } from "./types-search.ts"
49
50
 
50
51
  /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
51
52
  * dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
@@ -124,8 +125,9 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
124
125
  * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
125
126
  * powers both `nifra_run warm` and `nifra_render warm` — `child` selects which engine, `label` shapes the
126
127
  * cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
127
- * replaces the worker when a file changes, so warm reuse never serves a stale result. */
128
- class WarmWorker {
128
+ * replaces the worker when a file changes, so warm reuse never serves a stale result. Exported for the
129
+ * concurrency test that proves a single per-request cancel doesn't tear down the shared worker. */
130
+ export class WarmWorker {
129
131
  private readonly proc: PipeSubprocess
130
132
  private readonly pending = new Map<
131
133
  number,
@@ -176,8 +178,11 @@ class WarmWorker {
176
178
  const id = ++this.nextId
177
179
  return new Promise((resolve, reject) => {
178
180
  const abort = (): void => {
181
+ // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
182
+ // across concurrent requests (`pending` is id-keyed for exactly this reason), so killing the
183
+ // process here would reject every OTHER in-flight request via the `exited` handler and force a
184
+ // cold rebuild. Leave it hot — `createWarmHandler` already replaces it on file change.
179
185
  this.pending.delete(id)
180
- this.stop()
181
186
  const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
182
187
  resolve(`${this.label} cancelled${reason}.`)
183
188
  }
@@ -764,7 +769,7 @@ export function projectTools(
764
769
  },
765
770
  // nifra_docs + nifra_example are project-independent (corpus-backed); the shared factory keeps their
766
771
  // definitions identical to the CLI HTTP server and the site's edge worker.
767
- ...docsTools(loadDocsCorpus, loadExamplesCorpus),
772
+ ...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
768
773
  {
769
774
  name: "nifra_scaffold",
770
775
  description:
@@ -900,7 +905,7 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
900
905
  allResources.push(...(ns.features.resources ?? []))
901
906
  allPrompts.push(...(ns.features.prompts ?? []))
902
907
  }
903
- tools = [...docsTools(loadDocsCorpus, loadExamplesCorpus), ...allTools]
908
+ tools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
904
909
  features = { resources: allResources, prompts: allPrompts }
905
910
  } else {
906
911
  const loadAppCached = createCachedAppLoader(cwd)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `nifra_types` — the EXACT TypeScript of every exported `@nifrajs/*` symbol. The corpus is `types.json`
3
+ * (written into this package by `bun run gen:llms`): each entry's `signature` is the literal declaration
4
+ * parsed from the package's BUILT `.d.ts` with the TS compiler — a clean signature, never prose, never
5
+ * truncated. So an agent that needs the precise shape of a type (`RateLimitStore`, `RouteSchema`, a
6
+ * function signature) gets the authoritative source here and never has to read a `.d.ts` file.
7
+ *
8
+ * Lookup by `name` is exact (case-insensitive); `query` keyword-scores over the name (heavy) + the
9
+ * signature/doc body; no args → a per-package index of the available type names.
10
+ */
11
+
12
+ import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-terms.ts"
13
+
14
+ export interface TypeEntry {
15
+ readonly name: string
16
+ readonly kind: "interface" | "type" | "class" | "function" | "enum" | "const"
17
+ readonly package: string
18
+ /** The literal declaration text from the `.d.ts` — a clean signature, no implementation. */
19
+ readonly signature: string
20
+ /** The declaration's JSDoc block, if any. */
21
+ readonly doc?: string
22
+ }
23
+
24
+ export interface ScoredType extends TypeEntry {
25
+ readonly score: number
26
+ }
27
+
28
+ /** Exact (case-insensitive) name lookup — returns every declaration with that name (a name can exist in
29
+ * more than one package). */
30
+ export function lookupType(types: readonly TypeEntry[], name: string): TypeEntry[] {
31
+ const wanted = name.trim().toLowerCase()
32
+ return types.filter((t) => t.name.toLowerCase() === wanted)
33
+ }
34
+
35
+ /** Keyword-score types: a term in the name is worth far more than one in the signature/doc body. */
36
+ export function searchTypes(
37
+ types: readonly TypeEntry[],
38
+ query: string,
39
+ limit: number,
40
+ ): ScoredType[] {
41
+ const terms = queryTermGroups(query)
42
+ if (terms.length === 0) return []
43
+ const scored: ScoredType[] = []
44
+ for (const t of types) {
45
+ const nameTokens = new Set(tokenize(t.name))
46
+ const body = `${t.signature} ${t.doc ?? ""}`.toLowerCase()
47
+ let score = 0
48
+ for (const term of terms) {
49
+ if (tokenSetHas(nameTokens, term)) score += 8
50
+ score += countBodyHits(body, term, 4)
51
+ }
52
+ if (score > 0) scored.push({ ...t, score })
53
+ }
54
+ return scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit)
55
+ }
56
+
57
+ function renderEntry(t: TypeEntry): string {
58
+ const head = `## ${t.name} — ${t.kind}, \`${t.package}\``
59
+ const doc = t.doc ? `${t.doc}\n` : ""
60
+ return `${head}\n\n\`\`\`ts\n${doc}${t.signature}\n\`\`\``
61
+ }
62
+
63
+ /**
64
+ * Render the tool result. `name` → the exact declaration(s); else `query` → the top matches; else a
65
+ * per-package index of type names. The signatures are authoritative (generated from the built `.d.ts`),
66
+ * so this is the complete answer — there is no need to read a `.d.ts` file.
67
+ */
68
+ export function renderTypesResult(
69
+ types: readonly TypeEntry[],
70
+ name: string | undefined,
71
+ query: string | undefined,
72
+ limit: number,
73
+ ): string {
74
+ if (name !== undefined && name.trim() !== "") {
75
+ const hits = lookupType(types, name)
76
+ if (hits.length === 0) {
77
+ return `No exported type named ${JSON.stringify(name)} in @nifrajs/*. Call nifra_types with a \`query\` (e.g. "rate limit") or no args for the per-package index of names.`
78
+ }
79
+ return hits.map(renderEntry).join("\n\n---\n\n")
80
+ }
81
+ if (query !== undefined && query.trim() !== "") {
82
+ const matches = searchTypes(types, query, limit)
83
+ if (matches.length === 0) {
84
+ return `No types matched ${JSON.stringify(query)}. Try a broader term, pass an exact \`name\`, or call with no args for the index.`
85
+ }
86
+ return matches.map(renderEntry).join("\n\n---\n\n")
87
+ }
88
+ const byPkg = new Map<string, string[]>()
89
+ for (const t of types) {
90
+ const list = byPkg.get(t.package) ?? []
91
+ list.push(t.name)
92
+ byPkg.set(t.package, list)
93
+ }
94
+ const index = [...byPkg.entries()]
95
+ .sort(([a], [b]) => a.localeCompare(b))
96
+ .map(([pkg, names]) => `- **${pkg}**: ${names.sort().join(", ")}`)
97
+ .join("\n")
98
+ return `# nifra types — index (${types.length} exported symbols; pass \`name\` for the exact declaration, or \`query\` to search)\n\nSignatures are generated from the built \`.d.ts\` — authoritative + complete. Use this instead of reading any \`.d.ts\` file.\n\n${index}`
99
+ }
100
+
101
+ /** Load the bundled type corpus (`docs/types.json`), resolved relative to this module so it works from
102
+ * `src/` (dev) and `dist/` (published) alike. */
103
+ export async function loadTypesCorpus(): Promise<TypeEntry[] | undefined> {
104
+ const url = new URL("../docs/types.json", import.meta.url)
105
+ try {
106
+ const text = await Bun.file(url).text()
107
+ if (text.length === 0) return undefined
108
+ const parsed = JSON.parse(text) as TypeEntry[]
109
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
110
+ } catch {
111
+ return undefined
112
+ }
113
+ }