@nifrajs/cli 2.1.0 → 2.3.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 (126) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +64 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +400 -83
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +37 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -53
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +8 -4
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +74 -14
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/levels-tool.d.ts +8 -8
  37. package/dist/levels-tool.d.ts.map +1 -1
  38. package/dist/levels-tool.js +33 -30
  39. package/dist/levels-tool.js.map +1 -1
  40. package/dist/load.d.ts +17 -6
  41. package/dist/load.d.ts.map +1 -1
  42. package/dist/load.js +45 -7
  43. package/dist/load.js.map +1 -1
  44. package/dist/manifest-tool.d.ts +1 -1
  45. package/dist/manifest-tool.d.ts.map +1 -1
  46. package/dist/manifest-tool.js +4 -2
  47. package/dist/manifest-tool.js.map +1 -1
  48. package/dist/mcp-docs-tools.d.ts +1 -1
  49. package/dist/mcp-docs-tools.d.ts.map +1 -1
  50. package/dist/mcp-docs-tools.js +14 -10
  51. package/dist/mcp-docs-tools.js.map +1 -1
  52. package/dist/mcp-http.d.ts +5 -5
  53. package/dist/mcp-http.js +7 -7
  54. package/dist/mcp-protocol.d.ts +2 -2
  55. package/dist/mcp-protocol.js +2 -2
  56. package/dist/mcp-render.d.ts +3 -3
  57. package/dist/mcp-render.js +15 -7
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-run.d.ts +2 -2
  60. package/dist/mcp-run.js +2 -2
  61. package/dist/mcp.d.ts +14 -14
  62. package/dist/mcp.d.ts.map +1 -1
  63. package/dist/mcp.js +119 -47
  64. package/dist/mcp.js.map +1 -1
  65. package/dist/openapi-tool.d.ts +1 -1
  66. package/dist/openapi-tool.js +2 -2
  67. package/dist/pipeline-guard.d.ts +83 -0
  68. package/dist/pipeline-guard.d.ts.map +1 -0
  69. package/dist/pipeline-guard.js +136 -0
  70. package/dist/pipeline-guard.js.map +1 -0
  71. package/dist/port.d.ts +9 -9
  72. package/dist/port.js +27 -27
  73. package/dist/recipes/1.8.0.d.ts +2 -2
  74. package/dist/recipes/1.8.0.js +5 -5
  75. package/dist/recipes/index.d.ts +2 -2
  76. package/dist/recipes/index.js +1 -1
  77. package/dist/scaffold.d.ts +3 -3
  78. package/dist/scaffold.js +8 -8
  79. package/dist/sync-routes.d.ts +28 -0
  80. package/dist/sync-routes.d.ts.map +1 -0
  81. package/dist/sync-routes.js +55 -0
  82. package/dist/sync-routes.js.map +1 -0
  83. package/dist/types-search.d.ts +6 -6
  84. package/dist/types-search.d.ts.map +1 -1
  85. package/dist/types-search.js +53 -8
  86. package/dist/types-search.js.map +1 -1
  87. package/dist/upgrade.d.ts +7 -7
  88. package/dist/upgrade.js +12 -12
  89. package/dist/verification.d.ts +51 -0
  90. package/dist/verification.d.ts.map +1 -0
  91. package/dist/verification.js +80 -0
  92. package/dist/verification.js.map +1 -0
  93. package/docs/examples.json +178 -88
  94. package/docs/llms-full.txt +1766 -766
  95. package/docs/types.json +2171 -2807
  96. package/package.json +22 -11
  97. package/src/assure.ts +17 -18
  98. package/src/capabilities-tool.ts +82 -16
  99. package/src/check.ts +493 -100
  100. package/src/cli.ts +306 -54
  101. package/src/diff-tool.ts +8 -8
  102. package/src/docs-search.ts +6 -6
  103. package/src/doctor.ts +81 -16
  104. package/src/examples.ts +7 -7
  105. package/src/init-agents.ts +17 -17
  106. package/src/internal/typescript-import.ts +33 -0
  107. package/src/introspect.ts +28 -28
  108. package/src/levels-tool.ts +37 -31
  109. package/src/load.ts +65 -12
  110. package/src/manifest-tool.ts +4 -2
  111. package/src/mcp-docs-tools.ts +20 -10
  112. package/src/mcp-http.ts +7 -7
  113. package/src/mcp-protocol.ts +2 -2
  114. package/src/mcp-render.ts +15 -7
  115. package/src/mcp-run.ts +2 -2
  116. package/src/mcp.ts +127 -47
  117. package/src/openapi-tool.ts +2 -2
  118. package/src/pipeline-guard.ts +186 -0
  119. package/src/port.ts +31 -31
  120. package/src/recipes/1.8.0.ts +5 -5
  121. package/src/recipes/index.ts +2 -2
  122. package/src/scaffold.ts +8 -8
  123. package/src/sync-routes.ts +69 -0
  124. package/src/types-search.ts +57 -8
  125. package/src/upgrade.ts +12 -12
  126. package/src/verification.ts +123 -0
package/src/diff-tool.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `nifra snapshot` / `nifra diff` — the API breaking-change gate.
2
+ * `nifra snapshot` / `nifra diff` - the API breaking-change gate.
3
3
  *
4
4
  * nifra snapshot [--out api-snapshot.json] Write the backend's route contract as plain JSON.
5
5
  * nifra diff <baseline.json> [--json] Re-snapshot and compare against the committed
@@ -10,7 +10,7 @@
10
10
  * before it ships. Classification lives in `@nifrajs/core/diff` (direction-aware, fails closed);
11
11
  * this module only loads `backend.ts` and renders the result.
12
12
  *
13
- * Loads ONLY `backend.ts` — the API contract — so it works on an API-only project with no
13
+ * Loads ONLY `backend.ts` - the API contract - so it works on an API-only project with no
14
14
  * framework config or routes/ directory (unlike the eager `loadApp`).
15
15
  */
16
16
 
@@ -24,7 +24,7 @@ import {
24
24
  snapshotRoutes,
25
25
  } from "@nifrajs/core/diff"
26
26
 
27
- /** Snapshot file envelope — versioned so a future format change can migrate instead of misparse. */
27
+ /** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
28
28
  export interface SnapshotFile {
29
29
  readonly nifraSnapshot: 1
30
30
  readonly routes: readonly RouteSnapshot[]
@@ -39,7 +39,7 @@ export function parseSnapshotFile(content: string, sourcePath: string): Snapshot
39
39
  parsed = JSON.parse(content)
40
40
  } catch {
41
41
  throw new Error(
42
- `[nifra] ${sourcePath} is not valid JSON — regenerate it with \`nifra snapshot\`.`,
42
+ `[nifra] ${sourcePath} is not valid JSON - regenerate it with \`nifra snapshot\`.`,
43
43
  )
44
44
  }
45
45
  const record = parsed as Partial<SnapshotFile> | null
@@ -50,7 +50,7 @@ export function parseSnapshotFile(content: string, sourcePath: string): Snapshot
50
50
  !Array.isArray(record.routes)
51
51
  ) {
52
52
  throw new Error(
53
- `[nifra] ${sourcePath} is not a nifra API snapshot — expected { "nifraSnapshot": 1, "routes": [...] }.`,
53
+ `[nifra] ${sourcePath} is not a nifra API snapshot - expected { "nifraSnapshot": 1, "routes": [...] }.`,
54
54
  )
55
55
  }
56
56
  return record as SnapshotFile
@@ -76,7 +76,7 @@ export function formatDiff(diff: RoutesDiff): string {
76
76
  .map(describeChange)
77
77
  const breaking = diff.changes.filter((change) => change.severity === "breaking").length
78
78
  const summary = diff.hasBreaking
79
- ? `${breaking} breaking change${breaking === 1 ? "" : "s"} — existing clients will fail.`
79
+ ? `${breaking} breaking change${breaking === 1 ? "" : "s"} - existing clients will fail.`
80
80
  : "No breaking changes."
81
81
  return `${lines.join("\n")}\n\n${summary}`
82
82
  }
@@ -86,7 +86,7 @@ async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
86
86
  const backendPath = resolve(cwd, "backend.ts")
87
87
  if (!existsSync(backendPath)) {
88
88
  throw new Error(
89
- `[nifra] no backend.ts in ${cwd} — \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
89
+ `[nifra] no backend.ts in ${cwd} - \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
90
90
  )
91
91
  }
92
92
  const backend = ((await import(backendPath)) as { backend?: unknown }).backend
@@ -115,7 +115,7 @@ export async function runDiff(
115
115
  const baselineFile = Bun.file(resolved)
116
116
  if (!(await baselineFile.exists())) {
117
117
  throw new Error(
118
- `[nifra] baseline not found: ${resolved} — create it on your main branch with \`nifra snapshot\`.`,
118
+ `[nifra] baseline not found: ${resolved} - create it on your main branch with \`nifra snapshot\`.`,
119
119
  )
120
120
  }
121
121
  const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `nifra_docs` — searchable framework documentation for agents. The corpus is the generated
2
+ * `nifra_docs` - searchable framework documentation for agents. The corpus is the generated
3
3
  * `llms-full.txt` (written into this package by `bun run gen:llms`, so it can't drift from the
4
4
  * source it's generated from). Instead of dumping the whole ~150 KB into an agent's context, the
5
- * tool keyword-scores the document's `##` sections and returns only the top matches — or, with no
5
+ * tool keyword-scores the document's `##` sections and returns only the top matches - or, with no
6
6
  * query, just the section index (the cheap discovery call).
7
7
  *
8
8
  * Deliberately NOT a vector search: the corpus is one curated document with descriptive headings;
@@ -68,7 +68,7 @@ export function searchSections(
68
68
  return scored.sort((a, b) => b.score - a.score).slice(0, limit)
69
69
  }
70
70
 
71
- /** Cap a section body for the tool result — whole sections can be pages long; the agent can ask
71
+ /** Cap a section body for the tool result - whole sections can be pages long; the agent can ask
72
72
  * again with a narrower query if the trim cuts what it needed. */
73
73
  const MAX_SECTION_CHARS = 2800
74
74
 
@@ -81,7 +81,7 @@ export function renderDocsResult(
81
81
  const sections = splitSections(markdown)
82
82
  if (query === undefined || query.trim() === "") {
83
83
  const index = sections.map((s) => `- ${s.heading}`).join("\n")
84
- return `# nifra docs — section index (pass \`query\` to fetch matching sections)\n\n${index}`
84
+ return `# nifra docs - section index (pass \`query\` to fetch matching sections)\n\n${index}`
85
85
  }
86
86
  const matches = searchSections(sections, query, limit)
87
87
  if (matches.length === 0) {
@@ -91,7 +91,7 @@ export function renderDocsResult(
91
91
  .map((m) => {
92
92
  const body =
93
93
  m.body.length > MAX_SECTION_CHARS
94
- ? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed — narrow the query for the rest)`
94
+ ? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed - narrow the query for the rest)`
95
95
  : m.body
96
96
  return `## ${m.heading}\n\n${body}`
97
97
  })
@@ -99,7 +99,7 @@ export function renderDocsResult(
99
99
  }
100
100
 
101
101
  /** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
102
- * and `dist/` (published) alike — `docs/llms-full.txt` sits at the package root next to both. */
102
+ * and `dist/` (published) alike - `docs/llms-full.txt` sits at the package root next to both. */
103
103
  export async function loadDocsCorpus(): Promise<string | undefined> {
104
104
  const url = new URL("../docs/llms-full.txt", import.meta.url)
105
105
  try {
package/src/doctor.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
- * `nifra doctor` — catches the Bun-workspace footgun the typed-client lint can't: a package that's
2
+ * `nifra doctor` - catches the Bun-workspace footgun the typed-client lint can't: a package that's
3
3
  * IMPORTED in source but NOT declared in `package.json`. Bun resolves it at runtime (via hoisting or a
4
- * workspace), so tests pass and `bun install` reports "no changes" — false confidence — yet `tsc` fails
4
+ * workspace), so tests pass and `bun install` reports "no changes" - false confidence - yet `tsc` fails
5
5
  * and a fresh or standalone install can't resolve it. doctor diffs every bare import specifier against
6
6
  * the package's declared dependencies and flags the gap, with a stable `--json` shape for agents/CI.
7
7
  *
8
8
  * Scope is intentionally per-package: it checks the `package.json` at `cwd`, because a dependency must be
9
9
  * declared by the package that imports it (for `tsc` and for that package to install on its own) even
10
10
  * when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
11
- * the package's own name, and tsconfig `paths` aliases are excluded — none of them are npm deps.
11
+ * the package's own name, and tsconfig `paths` aliases are excluded - none of them are npm deps.
12
12
  */
13
- import { realpath } from "node:fs/promises"
13
+ import { realpath, stat } from "node:fs/promises"
14
14
  import { builtinModules } from "node:module"
15
15
  import { dirname, join, relative } from "node:path"
16
16
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
@@ -25,7 +25,7 @@ const BUILTINS: ReadonlySet<string> = new Set<string>([
25
25
 
26
26
  // The four specifier-bearing forms: static import/re-export with a source, side-effect import, dynamic
27
27
  // import, and CJS require. Anchored with `(?<![.\w$])` so `myimport`/`.import`/`foorequire` never match.
28
- // Comments are stripped before these run (see stripComments) — else a doc-comment usage example would be
28
+ // Comments are stripped before these run (see stripComments) - else a doc-comment usage example would be
29
29
  // flagged as a real import.
30
30
  const IMPORT_PATTERNS: readonly RegExp[] = [
31
31
  /(?<![.\w$])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g,
@@ -56,7 +56,7 @@ export function packageOf(spec: string): string | undefined {
56
56
  if (spec.startsWith("node:") || spec.startsWith("bun:")) return undefined
57
57
  if (spec.startsWith("@")) {
58
58
  const [scope, name] = spec.split("/")
59
- if (!scope || scope === "@" || !name) return undefined // `@/alias`, `@foo` — not a real package
59
+ if (!scope || scope === "@" || !name) return undefined // `@/alias`, `@foo` - not a real package
60
60
  const pkg = `${scope}/${name}`
61
61
  return isNpmPackageName(pkg) ? pkg : undefined
62
62
  }
@@ -66,7 +66,7 @@ export function packageOf(spec: string): string | undefined {
66
66
  }
67
67
 
68
68
  /** Build a predicate matching tsconfig `paths` aliases (e.g. `@/*`, `~/utils`), which resolve to local
69
- * source, not npm packages — so doctor must not flag them. */
69
+ * source, not npm packages - so doctor must not flag them. */
70
70
  export function aliasMatcher(
71
71
  paths: Readonly<Record<string, unknown>> | undefined,
72
72
  ): (spec: string) => boolean {
@@ -113,13 +113,13 @@ export function scanUndeclaredImports(
113
113
  export interface DoctorFinding {
114
114
  readonly file: string
115
115
  readonly line: number
116
- /** The undeclared package name — add it to `package.json` dependencies. */
116
+ /** The undeclared package name - add it to `package.json` dependencies. */
117
117
  readonly package: string
118
118
  }
119
119
 
120
120
  export interface DoctorResult {
121
121
  readonly ok: boolean
122
- /** `false` when no `package.json` was found at cwd — doctor can't run (reported, not a crash). */
122
+ /** `false` when no `package.json` was found at cwd - doctor can't run (reported, not a crash). */
123
123
  readonly ran: boolean
124
124
  readonly findings: readonly DoctorFinding[]
125
125
  /** Packages that resolve to more than one physical install across this workspace. */
@@ -336,13 +336,75 @@ const displayPath = (cwd: string, path: string): string => {
336
336
  return rel === "" ? "." : rel
337
337
  }
338
338
 
339
+ /**
340
+ * Resolve the workspace root that actually governs `cwd`, or `undefined` when there is none.
341
+ *
342
+ * Everything below is anchored to a package that declares `workspaces`. In a monorepo you run
343
+ * `nifra check` from the app - `apps/web` - and THAT manifest declares nothing, so the scan collapses
344
+ * to the app itself and the sibling package holding the second copy is never probed. Running from an
345
+ * app subdirectory is the normal case, which made this the configuration the check was blind in.
346
+ *
347
+ * An ancestor is adopted only when its `workspaces` patterns genuinely match `cwd`. A parent
348
+ * directory that happens to contain a manifest is not automatically this project's root, and adopting
349
+ * one would silently widen the scan to an unrelated tree. The walk also stops at a `.git` boundary
350
+ * (checked after the manifest, so a repo root that IS the workspace root still counts) and at the
351
+ * filesystem root.
352
+ */
353
+ /** Whether `path` exists at all. `stat`, not a file read: a `.git` is a directory in a normal
354
+ * checkout and a FILE in a worktree or submodule, and both mark the same boundary. */
355
+ const pathExists = (path: string): Promise<boolean> =>
356
+ stat(path).then(
357
+ () => true,
358
+ () => false,
359
+ )
360
+
361
+ async function findWorkspaceRoot(
362
+ cwd: string,
363
+ ): Promise<{ root: string; package: Record<string, unknown> } | undefined> {
364
+ let dir = dirname(cwd)
365
+ for (;;) {
366
+ const manifestPath = join(dir, "package.json")
367
+ const manifest = await readJson(manifestPath)
368
+ if (manifest !== undefined) {
369
+ const patterns = workspacePatterns(manifest)
370
+ if (patterns.length > 0 && (await patternsCover(dir, patterns, cwd))) {
371
+ return { root: dir, package: manifest }
372
+ }
373
+ }
374
+ // A `.git` here means we have reached this project's boundary; anything above belongs to a
375
+ // different repo (or a parent checkout) and must not be adopted.
376
+ if (await pathExists(join(dir, ".git"))) return undefined
377
+ const parent = dirname(dir)
378
+ if (parent === dir) return undefined
379
+ dir = parent
380
+ }
381
+ }
382
+
383
+ /** Whether any of `root`'s workspace patterns resolves to a manifest inside `target`'s package. */
384
+ async function patternsCover(root: string, patterns: string[], target: string): Promise<boolean> {
385
+ for (const pattern of patterns) {
386
+ const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
387
+ for await (const rel of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
388
+ if (rel.split(/[\\/]/).includes("node_modules")) continue
389
+ if (dirname(join(root, rel)) === target) return true
390
+ }
391
+ }
392
+ return false
393
+ }
394
+
339
395
  /** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
340
- * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based. */
396
+ * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
397
+ *
398
+ * `cwd` is where the user ran the check; the scan is anchored at the workspace root above it (see
399
+ * {@link findWorkspaceRoot}) so sibling packages are probed, while paths are still REPORTED relative
400
+ * to `cwd` so the findings read the way the user's terminal does. */
341
401
  export async function collectDuplicateInstalls(
342
402
  cwd: string,
343
403
  rootPackage: Record<string, unknown>,
344
404
  ): Promise<DuplicateInstallFinding[]> {
345
- const importers = await workspaceImporters(cwd, rootPackage)
405
+ const workspace = await findWorkspaceRoot(cwd)
406
+ const scanRoot = workspace?.root ?? cwd
407
+ const importers = await workspaceImporters(scanRoot, workspace?.package ?? rootPackage)
346
408
  const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
347
409
  const record = (
348
410
  name: string,
@@ -364,7 +426,10 @@ export async function collectDuplicateInstalls(
364
426
  for (const name of duplicateTargets(importer.package)) {
365
427
  if (importer.package.name === name) continue
366
428
  targets.add(name)
367
- const copy = await resolvedInstalledCopy(importer.root, cwd, name)
429
+ // Boundary is the SCAN root, not `cwd`: an importer can now sit beside or above the directory
430
+ // the user ran from, and stopping the upward walk at `cwd` would abandon it before reaching the
431
+ // hoisted copy it actually resolves.
432
+ const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
368
433
  if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
369
434
  }
370
435
  }
@@ -374,8 +439,8 @@ export async function collectDuplicateInstalls(
374
439
  // out hides the very split it should catch: every declaring package nested onto one copy while the
375
440
  // root resolved a different one, which reads as a single copy when only declarers are consulted.
376
441
  for (const name of targets) {
377
- const copy = await resolvedInstalledCopy(cwd, cwd, name)
378
- if (copy !== undefined) record(name, copy, ".")
442
+ const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
443
+ if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
379
444
  }
380
445
 
381
446
  const findings: DuplicateInstallFinding[] = []
@@ -557,7 +622,7 @@ export async function runDoctor(
557
622
  }
558
623
  console.log("nifra doctor\n")
559
624
  if (!result.ran) {
560
- console.log("• no package.json at this directory — nothing to check")
625
+ console.log("• no package.json at this directory - nothing to check")
561
626
  return true
562
627
  }
563
628
  if (result.fixed && result.fixed.length > 0) {
@@ -570,7 +635,7 @@ export async function runDoctor(
570
635
  if (result.skippedFixes && result.skippedFixes.length > 0) {
571
636
  console.log("• not auto-fixed:")
572
637
  for (const f of result.skippedFixes) {
573
- console.log(` ${f.package} — ${f.reason}; run \`${f.command.join(" ")}\``)
638
+ console.log(` ${f.package} - ${f.reason}; run \`${f.command.join(" ")}\``)
574
639
  }
575
640
  console.log("")
576
641
  }
package/src/examples.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `nifra_example` — verified, copy-pasteable code examples for agents. The corpus is `examples.json`
2
+ * `nifra_example` - verified, copy-pasteable code examples for agents. The corpus is `examples.json`
3
3
  * (written into this package by `bun run gen:llms`): the same doc snippets `check:docs` typechecks
4
4
  * against the live `@nifrajs/*` API. So every example this returns is GUARANTEED to compile against the
5
- * installed version — the antidote to an agent hallucinating a framework API from stale training.
5
+ * installed version - the antidote to an agent hallucinating a framework API from stale training.
6
6
  *
7
7
  * Keyword-scored over each example's name/topic/slug (curated identifiers, heavily weighted) and its
8
8
  * code body. No query → a grouped index of what's available (the cheap discovery call).
@@ -12,9 +12,9 @@ import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-
12
12
 
13
13
  export interface Example {
14
14
  readonly name: string
15
- /** The doc page's title, e.g. "Nifra — Auth & sessions". */
15
+ /** The doc page's title, e.g. "Nifra - Auth & sessions". */
16
16
  readonly topic: string
17
- /** The doc page slug, e.g. "auth" — a short, matchable tag. */
17
+ /** The doc page slug, e.g. "auth" - a short, matchable tag. */
18
18
  readonly slug: string
19
19
  readonly code: string
20
20
  }
@@ -63,19 +63,19 @@ export function renderExamplesResult(
63
63
  const index = [...byTopic.entries()]
64
64
  .map(([topic, names]) => `- **${topic}**: ${names.join(", ")}`)
65
65
  .join("\n")
66
- return `# nifra examples — index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version — prefer these over recalling an API.\n\n${index}`
66
+ return `# nifra examples - index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version - prefer these over recalling an API.\n\n${index}`
67
67
  }
68
68
  const matches = searchExamples(examples, query, limit)
69
69
  if (matches.length === 0) {
70
70
  return `No examples matched ${JSON.stringify(query)}. Call without a query for the index, or try a broader term (e.g. "auth", "upload", "isr", "client").`
71
71
  }
72
72
  return matches
73
- .map((m) => `## ${m.name} — ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
73
+ .map((m) => `## ${m.name} - ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
74
74
  .join("\n\n---\n\n")
75
75
  }
76
76
 
77
77
  /** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
78
- * and `dist/` (published) alike — `docs/examples.json` sits at the package root next to both. */
78
+ * and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
79
79
  export async function loadExamplesCorpus(): Promise<Example[] | undefined> {
80
80
  const url = new URL("../docs/examples.json", import.meta.url)
81
81
  try {
@@ -1,18 +1,18 @@
1
1
  /**
2
- * `nifra init-agents` — retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
2
+ * `nifra init-agents` - retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
3
3
  * app ships, so an already-built project adopts the nifra MCP in one command:
4
4
  *
5
- * .mcp.json — Claude Code's project MCP registry (launches `bunx @nifrajs/cli mcp`)
6
- * .cursor/mcp.json — Cursor's MCP registry (same server config)
7
- * CLAUDE.md — Claude's MCP-first preamble + `@AGENTS.md` import
8
- * AGENTS.md — a `## MCP server` section appended (or a minimal file if none exists)
5
+ * .mcp.json - Claude Code's project MCP registry (launches `bunx @nifrajs/cli mcp`)
6
+ * .cursor/mcp.json - Cursor's MCP registry (same server config)
7
+ * CLAUDE.md - Claude's MCP-first preamble + `@AGENTS.md` import
8
+ * AGENTS.md - a `## MCP server` section appended (or a minimal file if none exists)
9
9
  *
10
- * The generators are imported from `create-nifra/agent-files` — the SAME source of truth `create-nifra`
11
- * uses at scaffold time — so a retrofitted app and a freshly scaffolded one get byte-identical configs.
10
+ * The generators are imported from `create-nifra/agent-files` - the SAME source of truth `create-nifra`
11
+ * uses at scaffold time - so a retrofitted app and a freshly scaffolded one get byte-identical configs.
12
12
  *
13
13
  * Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
14
14
  * customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
15
- * notice; `--force` overwrites. `AGENTS.md` is special-cased — if it already has the MCP section it's
15
+ * notice; `--force` overwrites. `AGENTS.md` is special-cased - if it already has the MCP section it's
16
16
  * left alone, otherwise the section is APPENDED (never overwriting the user's conventions), and `--force`
17
17
  * is not needed for that append since it's additive. Every write path is resolved + confined under the
18
18
  * cwd (no `..` traversal escaping the project root).
@@ -34,10 +34,10 @@ import {
34
34
  export interface InitAgentsFileResult {
35
35
  /** Project-root-relative POSIX path. */
36
36
  readonly path: string
37
- /** `wrote` — created or (with --force) overwrote; `appended` — added the MCP section to an existing
38
- * AGENTS.md; `skipped` — already present and not forced; `present` — MCP section already there. */
37
+ /** `wrote` - created or (with --force) overwrote; `appended` - added the MCP section to an existing
38
+ * AGENTS.md; `skipped` - already present and not forced; `present` - MCP section already there. */
39
39
  readonly action: "wrote" | "appended" | "skipped" | "present"
40
- /** Why it was skipped/left, for the notice (e.g. "exists — pass --force to overwrite"). */
40
+ /** Why it was skipped/left, for the notice (e.g. "exists - pass --force to overwrite"). */
41
41
  readonly note?: string
42
42
  }
43
43
 
@@ -52,7 +52,7 @@ export interface InitAgentsOptions {
52
52
  }
53
53
 
54
54
  /** Confine a project-relative path under `cwd` and return the absolute path. Rejects a spec that escapes
55
- * the root (defense-in-depth — these specs are constants, but the cwd-confinement invariant is enforced
55
+ * the root (defense-in-depth - these specs are constants, but the cwd-confinement invariant is enforced
56
56
  * at the seam, not assumed). Exported so the invariant is directly testable. */
57
57
  export function safeJoin(cwd: string, rel: string): string {
58
58
  const abs = resolve(cwd, rel)
@@ -80,7 +80,7 @@ async function writeOwned(
80
80
  ): Promise<InitAgentsFileResult> {
81
81
  const abs = safeJoin(cwd, rel)
82
82
  if (!force && (await fileExists(abs))) {
83
- return { path: rel, action: "skipped", note: "exists — pass --force to overwrite" }
83
+ return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
84
84
  }
85
85
  // `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
86
86
  await mkdir(resolve(abs, ".."), { recursive: true })
@@ -91,7 +91,7 @@ async function writeOwned(
91
91
  /**
92
92
  * AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
93
93
  * without the section, append the section (preserving the user's conventions); if it's absent, write a
94
- * minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here — we never
94
+ * minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here - we never
95
95
  * overwrite the user's existing guidance.
96
96
  */
97
97
  async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
@@ -121,7 +121,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
121
121
 
122
122
  /**
123
123
  * Retrofit `cwd` with the four agent-discovery files. Pure enough to unit-test (no argv, no process.exit,
124
- * no console) — the CLI wrapper handles printing + the exit code.
124
+ * no console) - the CLI wrapper handles printing + the exit code.
125
125
  */
126
126
  export async function initAgents(
127
127
  cwd: string,
@@ -153,13 +153,13 @@ export function renderInitAgents(result: InitAgentsResult): string {
153
153
  const wroteAny = result.files.some((f) => f.action === "wrote" || f.action === "appended")
154
154
  const footer = wroteAny
155
155
  ? "\nThe nifra MCP is now registered. Restart your agent so it picks up .mcp.json, then prefer nifra_docs / nifra_example and gate on nifra check."
156
- : "\nNothing to do — every file was already present (use --force to overwrite the owned ones)."
156
+ : "\nNothing to do - every file was already present (use --force to overwrite the owned ones)."
157
157
  return `nifra init-agents\n\n${lines.join("\n")}\n${footer}`
158
158
  }
159
159
 
160
160
  /**
161
161
  * CLI entry: run the retrofit at `cwd` and print the result. Returns `true` (the command always succeeds
162
- * unless a write throws — which propagates as a non-zero exit via the dispatcher's catch). `--json`
162
+ * unless a write throws - which propagates as a non-zero exit via the dispatcher's catch). `--json`
163
163
  * emits the structured result for agents/CI.
164
164
  */
165
165
  export async function runInitAgents(
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The one place the CLI imports TypeScript from.
3
+ *
4
+ * The interpolated-SQL rule parses source with the TypeScript compiler, because the regex it replaced
5
+ * could not tell `sql\`… ${id} …\`` (bound) from a plain template (injectable) without guessing at
6
+ * string and comment boundaries. That is the right tool, but it is a ~25 MB dependency, and the CLI's
7
+ * own typecheck step already treats `tsc` as something the PROJECT provides rather than something the
8
+ * CLI ships. Making it a hard dependency would have contradicted that for every install.
9
+ *
10
+ * So it is an optional peer, resolved at the moment the rule runs. Every Nifra project has TypeScript
11
+ * - the templates all ship a `typecheck` script - so this resolves in practice; the point is that an
12
+ * install does not pay for it.
13
+ */
14
+
15
+ /** The slice of the compiler the SQL scanner uses. Structural, so the peer stays optional. */
16
+ export type TypeScriptApi = typeof import("typescript")
17
+
18
+ /**
19
+ * Import the project's TypeScript, or `undefined` when it is not installed.
20
+ *
21
+ * Only a RESOLUTION failure is absence. A compiler that resolves and then fails while evaluating is a
22
+ * broken install, not a missing one, and telling that user to install what they already have sends
23
+ * them the wrong way - so that error is rethrown for the caller to report.
24
+ */
25
+ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
26
+ try {
27
+ return (await import("typescript")) as TypeScriptApi
28
+ } catch (cause) {
29
+ const message = cause instanceof Error ? cause.message : String(cause)
30
+ if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)) return undefined
31
+ throw cause
32
+ }
33
+ }