@nifrajs/cli 2.13.0 → 3.1.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 (114) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +260 -49
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +130 -414
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +250 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1032 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +55 -0
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +82 -9
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/env-file.d.ts +17 -0
  26. package/dist/env-file.d.ts.map +1 -0
  27. package/dist/env-file.js +113 -0
  28. package/dist/env-file.js.map +1 -0
  29. package/dist/fix-recipes.d.ts.map +1 -1
  30. package/dist/fix-recipes.js +91 -15
  31. package/dist/fix-recipes.js.map +1 -1
  32. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  33. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  34. package/dist/internal/reserved-segment-codemod.js +134 -0
  35. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  36. package/dist/internal/typescript-import.d.ts +1 -1
  37. package/dist/internal/typescript-import.d.ts.map +1 -1
  38. package/dist/internal/typescript-import.js +62 -11
  39. package/dist/internal/typescript-import.js.map +1 -1
  40. package/dist/introspect.d.ts +26 -1
  41. package/dist/introspect.d.ts.map +1 -1
  42. package/dist/introspect.js +74 -24
  43. package/dist/introspect.js.map +1 -1
  44. package/dist/load.d.ts.map +1 -1
  45. package/dist/load.js +22 -5
  46. package/dist/load.js.map +1 -1
  47. package/dist/manifest-tool.d.ts +15 -1
  48. package/dist/manifest-tool.d.ts.map +1 -1
  49. package/dist/manifest-tool.js +23 -9
  50. package/dist/manifest-tool.js.map +1 -1
  51. package/dist/mcp-http.js +1 -1
  52. package/dist/mcp-http.js.map +1 -1
  53. package/dist/mcp.d.ts +16 -0
  54. package/dist/mcp.d.ts.map +1 -1
  55. package/dist/mcp.js +127 -355
  56. package/dist/mcp.js.map +1 -1
  57. package/dist/project-facts.d.ts +40 -0
  58. package/dist/project-facts.d.ts.map +1 -0
  59. package/dist/project-facts.js +36 -0
  60. package/dist/project-facts.js.map +1 -0
  61. package/dist/recipes/3.0.0.d.ts +10 -0
  62. package/dist/recipes/3.0.0.d.ts.map +1 -0
  63. package/dist/recipes/3.0.0.js +23 -0
  64. package/dist/recipes/3.0.0.js.map +1 -0
  65. package/dist/recipes/index.d.ts.map +1 -1
  66. package/dist/recipes/index.js +2 -0
  67. package/dist/recipes/index.js.map +1 -1
  68. package/dist/release-verification.d.ts.map +1 -1
  69. package/dist/release-verification.js +15 -0
  70. package/dist/release-verification.js.map +1 -1
  71. package/dist/rules/comment-markers.d.ts +8 -0
  72. package/dist/rules/comment-markers.d.ts.map +1 -1
  73. package/dist/rules/comment-markers.js +36 -0
  74. package/dist/rules/comment-markers.js.map +1 -1
  75. package/dist/rules/index.d.ts +2 -3
  76. package/dist/rules/index.d.ts.map +1 -1
  77. package/dist/rules/index.js.map +1 -1
  78. package/dist/rules/routes.d.ts.map +1 -1
  79. package/dist/rules/routes.js +29 -44
  80. package/dist/rules/routes.js.map +1 -1
  81. package/dist/rules/security.js +14 -14
  82. package/dist/rules/security.js.map +1 -1
  83. package/dist/workspace-link.d.ts +37 -0
  84. package/dist/workspace-link.d.ts.map +1 -0
  85. package/dist/workspace-link.js +72 -0
  86. package/dist/workspace-link.js.map +1 -0
  87. package/docs/examples.json +1 -1
  88. package/docs/llms-full.txt +362 -20
  89. package/docs/types.json +956 -56
  90. package/package.json +9 -9
  91. package/src/capabilities-tool.ts +229 -21
  92. package/src/check.ts +312 -65
  93. package/src/cli.ts +140 -405
  94. package/src/command-catalog.ts +1435 -0
  95. package/src/contracts.ts +28 -7
  96. package/src/doctor.ts +134 -12
  97. package/src/env-file.ts +121 -0
  98. package/src/fix-recipes.ts +101 -15
  99. package/src/internal/reserved-segment-codemod.ts +150 -0
  100. package/src/internal/typescript-import.ts +61 -11
  101. package/src/introspect.ts +82 -21
  102. package/src/load.ts +25 -6
  103. package/src/manifest-tool.ts +43 -12
  104. package/src/mcp-http.ts +1 -1
  105. package/src/mcp.ts +163 -420
  106. package/src/project-facts.ts +86 -0
  107. package/src/recipes/3.0.0.ts +24 -0
  108. package/src/recipes/index.ts +2 -0
  109. package/src/release-verification.ts +17 -0
  110. package/src/rules/comment-markers.ts +36 -0
  111. package/src/rules/index.ts +3 -4
  112. package/src/rules/routes.ts +30 -44
  113. package/src/rules/security.ts +14 -14
  114. package/src/workspace-link.ts +80 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Codemod for reserved-segment collisions in typed-client call sites.
3
+ *
4
+ * A route segment spelling a reserved proxy key (`get`…`options`, `subscribe`, `ws`, `index`, `then`)
5
+ * cannot be reached by property access - the proxy resolves the reserved behavior first - so the client
6
+ * type resolves that child to `ReservedSegmentCollision<"seg">` and every `api.foo.delete.post()` in the
7
+ * project stops compiling at once. The fix is mechanical and identical at every site: call the parent
8
+ * node with the segment instead, `api.foo("delete").post()`. Doing that by hand across a codebase is
9
+ * pure toil, and it is toil the framework caused.
10
+ *
11
+ * The compiler is the site index. `tsc` already reports every broken access, and its message names the
12
+ * segment (`Property 'post' does not exist on type 'ReservedSegmentCollision<"delete">'`), so the
13
+ * rewrite needs no type graph of its own and cannot invent a site the compiler did not reject. That
14
+ * also disambiguates the one case a textual search could not: `.delete` as a real DELETE verb call is
15
+ * valid and never appears in this list.
16
+ *
17
+ * A site the scan cannot rewrite CONFIDENTLY is left alone and reported, never guessed at. Bracket
18
+ * access, a collision node stored in a variable and used elsewhere, and any shape where the text before
19
+ * the failing property is not `.<segment>` all fall into that bucket.
20
+ */
21
+ import { existsSync } from "node:fs"
22
+ import { dirname, join } from "node:path"
23
+
24
+ /** `path/to/file.ts(12,16): error TS2339: … 'ReservedSegmentCollision<"delete">'.` */
25
+ const TSC_POSITION = /^(.+?)\((\d+),(\d+)\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
26
+ const COLLISION_TYPE = /ReservedSegmentCollision<"([^"]+)">/
27
+
28
+ export interface CollisionSite {
29
+ readonly file: string
30
+ /** 1-based, as `tsc` reports them. */
31
+ readonly line: number
32
+ readonly column: number
33
+ /** The colliding path segment, read out of the type the compiler named. */
34
+ readonly segment: string
35
+ }
36
+
37
+ /** Every reserved-segment collision `tsc` reported, in the order it reported them. */
38
+ export function parseCollisionSites(tscOutput: string): readonly CollisionSite[] {
39
+ const sites: CollisionSite[] = []
40
+ for (const raw of tscOutput.split("\n")) {
41
+ const position = TSC_POSITION.exec(raw.trim())
42
+ if (position === null) continue
43
+ const segment = COLLISION_TYPE.exec(position[4] as string)
44
+ if (segment === null) continue
45
+ sites.push({
46
+ file: position[1] as string,
47
+ line: Number(position[2]),
48
+ column: Number(position[3]),
49
+ segment: segment[1] as string,
50
+ })
51
+ }
52
+ return sites
53
+ }
54
+
55
+ /** Byte offset of a 1-based (line, column) in `source`, or undefined when it is past the end. */
56
+ const offsetOf = (source: string, line: number, column: number): number | undefined => {
57
+ let offset = 0
58
+ for (let current = 1; current < line; current += 1) {
59
+ const next = source.indexOf("\n", offset)
60
+ if (next === -1) return undefined
61
+ offset = next + 1
62
+ }
63
+ const target = offset + column - 1
64
+ return target <= source.length ? target : undefined
65
+ }
66
+
67
+ const skipSpaceBack = (source: string, from: number): number => {
68
+ let index = from
69
+ while (index >= 0 && /\s/.test(source[index] as string)) index -= 1
70
+ return index
71
+ }
72
+
73
+ /**
74
+ * The span to replace for one site: the `.segment` immediately preceding the failing property access,
75
+ * which becomes `("segment")`. Undefined when the text does not have that shape - the caller reports
76
+ * those rather than rewriting something it did not recognise.
77
+ *
78
+ * The scan runs backwards over the whole source rather than one line, so a chain broken across lines
79
+ * (`api.foo\n .delete\n .post()`) rewrites the same as a chain on one line.
80
+ */
81
+ export function collisionSpan(
82
+ source: string,
83
+ site: CollisionSite,
84
+ ): { readonly start: number; readonly end: number } | undefined {
85
+ const offset = offsetOf(source, site.line, site.column)
86
+ if (offset === undefined) return undefined
87
+
88
+ // Back over the dot that introduces the FAILING property (`.post` in `…delete.post`).
89
+ let index = skipSpaceBack(source, offset - 1)
90
+ if (source[index] !== ".") return undefined
91
+
92
+ // Back over the colliding segment itself.
93
+ index = skipSpaceBack(source, index - 1)
94
+ const segmentEnd = index
95
+ const segmentStart = segmentEnd - site.segment.length + 1
96
+ if (segmentStart < 0) return undefined
97
+ if (source.slice(segmentStart, segmentEnd + 1) !== site.segment) return undefined
98
+
99
+ // Back over the dot that introduces the segment. Anything else (bracket access, a bare identifier
100
+ // holding the node) is a shape this rewrite does not understand.
101
+ const dot = skipSpaceBack(source, segmentStart - 1)
102
+ if (source[dot] !== ".") return undefined
103
+
104
+ // Take any whitespace before that dot into the span too, so a chain broken across lines closes up
105
+ // (`api.jobs\n .subscribe` becomes `api.jobs("subscribe")`) instead of leaving the call argument
106
+ // dangling on its own line.
107
+ return { start: skipSpaceBack(source, dot - 1) + 1, end: segmentEnd }
108
+ }
109
+
110
+ export interface FileRewrite {
111
+ readonly source: string
112
+ /** Sites in this file the scan declined to rewrite, so the caller can report them. */
113
+ readonly skipped: readonly CollisionSite[]
114
+ }
115
+
116
+ /**
117
+ * Apply every site belonging to one file. Spans are applied back to front so an earlier rewrite never
118
+ * shifts the offsets of one not yet applied, and a duplicate span (two failing properties resolving
119
+ * through the same access) is applied once.
120
+ */
121
+ export function rewriteFile(source: string, sites: readonly CollisionSite[]): FileRewrite {
122
+ const skipped: CollisionSite[] = []
123
+ const spans: { start: number; end: number; segment: string }[] = []
124
+ for (const site of sites) {
125
+ const span = collisionSpan(source, site)
126
+ if (span === undefined) {
127
+ skipped.push(site)
128
+ continue
129
+ }
130
+ if (spans.some((other) => other.start === span.start && other.end === span.end)) continue
131
+ spans.push({ ...span, segment: site.segment })
132
+ }
133
+ let out = source
134
+ for (const span of [...spans].sort((a, b) => b.start - a.start)) {
135
+ out = `${out.slice(0, span.start)}("${span.segment}")${out.slice(span.end + 1)}`
136
+ }
137
+ return { source: out, skipped }
138
+ }
139
+
140
+ /** The project's own `tsc` binary, found upward from `root`. Never installs one. */
141
+ export function resolveTscBin(root: string): string | undefined {
142
+ let dir = root
143
+ while (true) {
144
+ const candidate = join(dir, "node_modules", "typescript", "bin", "tsc")
145
+ if (existsSync(candidate)) return candidate
146
+ const parent = dirname(dir)
147
+ if (parent === dir) return undefined
148
+ dir = parent
149
+ }
150
+ }
@@ -12,11 +12,37 @@
12
12
  * install does not pay for it.
13
13
  */
14
14
 
15
+ import { existsSync } from "node:fs"
16
+ import { dirname, join } from "node:path"
17
+
15
18
  /** The slice of the compiler the SQL scanner uses. Structural, so the peer stays optional. */
16
19
  export type TypeScriptApi = typeof import("typescript")
17
20
 
18
21
  /**
19
- * Import the project's TypeScript, or `undefined` when it is not installed.
22
+ * Is this module actually the compiler, rather than something else published under the same name?
23
+ *
24
+ * The `typescript` package name also carries small non-compiler entry points (a version stub, for
25
+ * one), and a resolver that falls back to a global download cache can hand one of those back. They
26
+ * import cleanly and then explode on first use - `undefined is not an object (evaluating
27
+ * 'ts.ScriptKind.TSX')` deep inside a scan - which reads as a Nifra bug rather than as "the compiler
28
+ * we loaded is not a compiler". Checking the two members every caller needs turns that into a
29
+ * resolution miss, which the fallbacks below already know how to handle.
30
+ */
31
+ function isCompiler(module: unknown): module is TypeScriptApi {
32
+ if (typeof module !== "object" || module === null) return false
33
+ const api = module as Partial<TypeScriptApi>
34
+ return typeof api.createSourceFile === "function" && api.ScriptKind !== undefined
35
+ }
36
+
37
+ /** Unwrap the CJS default interop wrapper, so both `import * as ts` shapes look the same. */
38
+ function compilerOf(module: unknown): TypeScriptApi | undefined {
39
+ if (isCompiler(module)) return module
40
+ const wrapped = (module as { default?: unknown } | undefined)?.default
41
+ return isCompiler(wrapped) ? wrapped : undefined
42
+ }
43
+
44
+ /**
45
+ * Import TypeScript from the CLI's own dependency tree, or `undefined` when it is not installed.
20
46
  *
21
47
  * Only a RESOLUTION failure is absence. A compiler that resolves and then fails while evaluating is a
22
48
  * broken install, not a missing one, and telling that user to install what they already have sends
@@ -24,7 +50,7 @@ export type TypeScriptApi = typeof import("typescript")
24
50
  */
25
51
  export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
26
52
  try {
27
- return (await import("typescript")) as TypeScriptApi
53
+ return compilerOf(await import("typescript"))
28
54
  } catch (cause) {
29
55
  const message = cause instanceof Error ? cause.message : String(cause)
30
56
  if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)) return undefined
@@ -32,6 +58,33 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
32
58
  }
33
59
  }
34
60
 
61
+ /**
62
+ * Find the project's compiler the way module resolution would: `node_modules/typescript` in `root`,
63
+ * then each parent directory up to the filesystem root - the same walk the typecheck gate uses to
64
+ * find `tsc`, so both gates agree on which compiler the project has.
65
+ *
66
+ * The walk is deliberate, not a fallback for a missing resolver. `Bun.resolveSync` memoizes a
67
+ * specifier for the life of the process AND auto-installs from the global download cache when the
68
+ * project has none, so a long-lived process (the MCP server) that first resolved `typescript` before
69
+ * `bun install` kept handing back that cache entry afterwards - the typecheck went phantom until the
70
+ * server was restarted. A filesystem probe is re-answered every call, so an install that lands
71
+ * mid-session is picked up on the next scan.
72
+ */
73
+ function resolveProjectTypeScript(root: string): string | undefined {
74
+ let dir = root
75
+ while (true) {
76
+ const pkg = join(dir, "node_modules", "typescript")
77
+ // `lib/typescript.js` is the compiler entry across every published major; probing it directly
78
+ // (rather than importing the directory) keeps a package whose main entry is a stub out of play.
79
+ const entry = join(pkg, "lib", "typescript.js")
80
+ if (existsSync(entry)) return entry
81
+ if (existsSync(join(pkg, "package.json"))) return pkg
82
+ const parent = dirname(dir)
83
+ if (parent === dir) return undefined
84
+ dir = parent
85
+ }
86
+ }
87
+
35
88
  /**
36
89
  * Import TypeScript resolved from the PROJECT root first, falling back to the CLI's own dependency
37
90
  * tree. A bare `import("typescript")` resolves relative to this file - the CLI's install - so a
@@ -41,16 +94,13 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
41
94
  * the CLI was installed - the same cwd-invariance the typecheck gate's tsc resolution has.
42
95
  */
43
96
  export async function importProjectTypeScript(root: string): Promise<TypeScriptApi | undefined> {
44
- let resolved: string
45
- try {
46
- resolved = Bun.resolveSync("typescript", root)
47
- } catch {
48
- // Unresolvable from the project (not installed there, or a non-Bun host without resolveSync):
49
- // fall back to the CLI's own tree, which keeps the previous behavior as the floor.
50
- return importTypeScript()
51
- }
97
+ const resolved = resolveProjectTypeScript(root)
98
+ // Not installed in the project (or installed without a compiler entry): fall back to the CLI's own
99
+ // tree, which keeps the previous behavior as the floor.
100
+ if (resolved === undefined) return importTypeScript()
52
101
  try {
53
- return (await import(resolved)) as TypeScriptApi
102
+ const compiler = compilerOf(await import(resolved))
103
+ return compiler ?? (await importTypeScript())
54
104
  } catch (cause) {
55
105
  // Same contract as importTypeScript: a compiler that RESOLVES and then fails evaluating is a
56
106
  // broken install, not a missing one - rethrow rather than silently checking with another copy.
package/src/introspect.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * *this* project's actual surface, not just generic docs.
6
6
  */
7
7
 
8
+ import { reservedKeyFor } from "@nifrajs/client"
8
9
  import {
9
10
  type ProjectEvidenceSchema,
10
11
  type ProjectEvidenceSchemaPart,
@@ -153,6 +154,24 @@ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
153
154
 
154
155
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
155
156
 
157
+ /**
158
+ * The typed-client proxy chain for a path, without the terminal verb call. Shared by `clientCall`
159
+ * and the `nifra routes` collision annotation so both teach exactly one spelling.
160
+ */
161
+ function clientChain(path: string): string {
162
+ const segs = path.split("/").filter((seg) => seg !== "")
163
+ if (segs.length === 0) return "api.index"
164
+ let chain = "api"
165
+ for (const seg of segs) {
166
+ if (seg.startsWith(":") || seg.startsWith("*")) {
167
+ const name = seg.replace(/^[:*]/, "") || "value"
168
+ chain += `({ ${name} })`
169
+ } else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
170
+ else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
171
+ }
172
+ return chain
173
+ }
174
+
156
175
  /**
157
176
  * The typed-client call form for a route - the exact `client<typeof app>` proxy chain an agent should
158
177
  * write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
@@ -160,20 +179,16 @@ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
160
179
  * that appends the value (`({ id })`), the root path is `.index`, and the HTTP verb is the terminal call.
161
180
  * Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first -
162
181
  * so the `{ query }` argument lands in the right slot for each.
182
+ *
183
+ * A segment spelling a reserved proxy key (`delete`, `subscribe`, `then`, ...) is emitted as a call on
184
+ * the parent node instead - and NOT as bracket access, which does not help: the proxy intercepts the
185
+ * key however it is spelled. Emitting `.delete` here would hand an agent a chain that resolves the
186
+ * DELETE verb and silently requests the wrong path.
163
187
  */
164
188
  export function clientCall(method: string, path: string, schema: unknown): string {
165
189
  const s = schema as { body?: unknown; query?: unknown } | undefined
166
190
  const verb = method.toLowerCase()
167
- const segs = path.split("/").filter((seg) => seg !== "")
168
- let chain = "api"
169
- if (segs.length === 0) chain += ".index"
170
- else
171
- for (const seg of segs) {
172
- if (seg.startsWith(":") || seg.startsWith("*")) {
173
- const name = seg.replace(/^[:*]/, "") || "value"
174
- chain += `({ ${name} })`
175
- } else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
176
- }
191
+ const chain = clientChain(path)
177
192
  const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
178
193
  let call: string
179
194
  if (isBodyVerb) {
@@ -460,6 +475,27 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
460
475
  )
461
476
  }
462
477
 
478
+ /**
479
+ * The typed-client spelling for a route path, and the reserved key it collides with when one of its
480
+ * static segments cannot be reached by property access.
481
+ *
482
+ * This is where a dev meets the reserved set EARLIEST: `nifra routes` is what you run while the
483
+ * route is being written, long before a typed client call site exists to break. The reserved names
484
+ * are deliberately unguessable from the outside (`then` and `index` especially - they exist so the
485
+ * proxy stays await-safe and can address `/`), so the route table names the collision and shows the
486
+ * spelling that reaches the route, rather than leaving it to be discovered by a failing build.
487
+ */
488
+ export function clientSpellingFor(
489
+ path: string,
490
+ ): { readonly spelling: string; readonly reserved: string } | undefined {
491
+ for (const segment of path.split("/")) {
492
+ if (segment === "") continue
493
+ const reserved = reservedKeyFor(segment)
494
+ if (reserved !== undefined) return { spelling: clientChain(path), reserved }
495
+ }
496
+ return undefined
497
+ }
498
+
463
499
  /** Render the route table as a terse aligned text table (the `nifra routes` default output). Pure. */
464
500
  export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
465
501
  if (rows.length === 0)
@@ -468,16 +504,29 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
468
504
  methods: r.methods.join(", "),
469
505
  kind: r.kind,
470
506
  path: r.kind === "api" && r.autoMounted ? `${r.path} (auto-mounted)` : r.path,
507
+ client: clientSpellingFor(r.path),
471
508
  }))
472
509
  const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
473
510
  const kindW = Math.max("KIND".length, ...display.map((d) => d.kind.length))
474
511
  const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length))
475
512
  const line = (methods: string, kind: string, path: string): string =>
476
513
  `${padEnd(methods, methodW)} ${padEnd(kind, kindW)} ${path}`
477
- return [
514
+ const table = [
478
515
  line("METHOD", "KIND", "PATH"),
479
- ...display.map((d) => line(d.methods, d.kind, d.path)),
480
- ].join("\n")
516
+ ...display.flatMap((d) =>
517
+ d.client === undefined
518
+ ? [line(d.methods, d.kind, d.path)]
519
+ : [
520
+ line(d.methods, d.kind, d.path),
521
+ line(
522
+ "",
523
+ "",
524
+ ` -> '${d.client.reserved}' is a reserved typed-client key; reach this route as \`${d.client.spelling}\``,
525
+ ),
526
+ ],
527
+ ),
528
+ ]
529
+ return table.join("\n")
481
530
  }
482
531
 
483
532
  /** One JSON row for `nifra routes --json` (the agent-facing, stable shape). */
@@ -487,21 +536,33 @@ export interface RouteJsonRow {
487
536
  readonly methods: readonly string[]
488
537
  readonly file?: string
489
538
  readonly autoMounted?: boolean
539
+ /** The reserved typed-client key a static segment of this path collides with, when one does. */
540
+ readonly reservedKey?: string
541
+ /** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
542
+ readonly clientSpelling?: string
490
543
  }
491
544
 
492
545
  /** The route table as a JSON document for `--json` (agents). Stable keys; `file`/`autoMounted` present
493
- * only where meaningful (file on pages, autoMounted on API routes). */
546
+ * only where meaningful (file on pages, autoMounted on API routes). A colliding row additionally
547
+ * carries `reservedKey`/`clientSpelling` so an agent writing client code from this table never has to
548
+ * infer the closed set of reserved names - the one thing about it nobody can guess. */
494
549
  export function routeTableToJson(rows: readonly RouteTableEntry[]): {
495
550
  readonly routes: readonly RouteJsonRow[]
496
551
  } {
497
552
  return {
498
- routes: rows.map((r) => ({
499
- kind: r.kind,
500
- path: r.path,
501
- methods: r.methods,
502
- ...(r.file !== undefined ? { file: r.file } : {}),
503
- ...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
504
- })),
553
+ routes: rows.map((r) => {
554
+ const client = clientSpellingFor(r.path)
555
+ return {
556
+ kind: r.kind,
557
+ path: r.path,
558
+ methods: r.methods,
559
+ ...(r.file !== undefined ? { file: r.file } : {}),
560
+ ...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
561
+ ...(client === undefined
562
+ ? {}
563
+ : { reservedKey: client.reserved, clientSpelling: client.spelling }),
564
+ }
565
+ }),
505
566
  }
506
567
  }
507
568
 
package/src/load.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * reach the edge bundle. A simple single-target app with no edge build can put it all in `framework.ts`.
14
14
  */
15
15
  import { existsSync } from "node:fs"
16
- import { resolve } from "node:path"
16
+ import { isAbsolute, resolve } from "node:path"
17
17
  import { checkPipelineSeparation } from "./pipeline-guard.ts"
18
18
 
19
19
  /**
@@ -210,11 +210,30 @@ export async function loadApp(
210
210
  // resolved to absolute here. It is embedded verbatim as an import specifier in the generated client
211
211
  // entry, and `nifra dev` and `nifra build` write that entry into DIFFERENT directories - so a relative
212
212
  // specifier would resolve against different bases and load in one phase but not the other. A bare or
213
- // package specifier (`@nifrajs/web-react/client`) is location-independent and passes through unchanged.
214
- const clientModule =
215
- fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")
216
- ? resolve(cwd, fw.clientModule)
217
- : fw.clientModule
213
+ // package specifier (`@nifrajs/web-react/client`) or an already-absolute path is location-independent
214
+ // and passes through unchanged.
215
+ let clientModule: string
216
+ if (fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")) {
217
+ clientModule = resolve(cwd, fw.clientModule)
218
+ } else {
219
+ // A specifier that is neither `./`-relative, scoped (`@…`), nor absolute is read as a BARE PACKAGE
220
+ // specifier - resolved against `node_modules`, not `cwd`. If a real local file sits at that path
221
+ // (a `./` was forgotten: `src/client.tsx` instead of `./src/client.tsx`), the bundler would ignore
222
+ // the file, fail to find a package, and surface an opaque "cannot resolve" deep in the build. Reject
223
+ // it here with the one-character fix rather than letting it fail silently.
224
+ if (
225
+ !isAbsolute(fw.clientModule) &&
226
+ !fw.clientModule.startsWith("@") &&
227
+ existsSync(resolve(cwd, fw.clientModule))
228
+ ) {
229
+ throw new Error(
230
+ `[nifra] clientModule "${fw.clientModule}" has no "./" prefix, so it is read as a bare package ` +
231
+ `specifier - but a local file exists at that path. Prefix it with "./" (e.g. ` +
232
+ `"./${fw.clientModule}") so \`nifra dev\` and \`nifra build\` resolve the same local entry.`,
233
+ )
234
+ }
235
+ clientModule = fw.clientModule
236
+ }
218
237
  const [vitePlugins, clientPlugins, serverPlugins] = await Promise.all([
219
238
  resolvePlugins(fw.vitePlugins),
220
239
  resolvePlugins(fw.clientPlugins),
@@ -5,6 +5,7 @@ import { resolve } from "node:path"
5
5
  import {
6
6
  buildNifraManifest,
7
7
  diffNifraManifests,
8
+ type NifraManifest,
8
9
  type NifraManifestDiff,
9
10
  parseNifraManifest,
10
11
  serializeNifraManifest,
@@ -38,11 +39,18 @@ export function formatManifestDiff(diff: NifraManifestDiff): string {
38
39
  return lines.join("\n")
39
40
  }
40
41
 
41
- /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
42
- export async function runManifestEmit(
42
+ export interface ManifestEmitCommandResult {
43
+ readonly ok: boolean
44
+ readonly path: string
45
+ readonly manifest?: NifraManifest
46
+ readonly signaturePath?: string
47
+ }
48
+
49
+ /** Emit the manifest without printing. This is the execution seam used by all adapters. */
50
+ export async function collectManifestEmit(
43
51
  cwd: string,
44
52
  options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
45
- ): Promise<boolean> {
53
+ ): Promise<ManifestEmitCommandResult> {
46
54
  const verification = await collectProjectVerification(cwd, {
47
55
  ...(options.config !== undefined ? { config: options.config } : {}),
48
56
  })
@@ -58,9 +66,9 @@ export async function runManifestEmit(
58
66
  if (assurance === undefined) {
59
67
  throw new Error("[nifra] assurance evaluation was unavailable for manifest emission")
60
68
  }
69
+ const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
61
70
  if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
62
- console.error("[nifra] refusing to emit a manifest from failing assurance")
63
- return false
71
+ return { ok: false, path }
64
72
  }
65
73
  const manifest = await buildNifraManifest({
66
74
  source: config.source,
@@ -68,9 +76,9 @@ export async function runManifestEmit(
68
76
  assurance,
69
77
  ...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
70
78
  })
71
- const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
72
79
  await Bun.write(path, `${serializeNifraManifest(manifest)}\n`)
73
80
 
81
+ let signaturePath: string | undefined
74
82
  if (options.sign !== undefined) {
75
83
  if (config.manifest?.signer === undefined) {
76
84
  throw new Error(
@@ -79,29 +87,52 @@ export async function runManifestEmit(
79
87
  }
80
88
  const signer = await config.manifest.signer(options.sign)
81
89
  const signature = await signNifraManifest(manifest, signer)
82
- await Bun.write(`${path}.sig`, `${serializeNifraManifestSignature(signature)}\n`)
90
+ signaturePath = `${path}.sig`
91
+ await Bun.write(signaturePath, `${serializeNifraManifestSignature(signature)}\n`)
92
+ }
93
+ return { ok: true, path, manifest, ...(signaturePath === undefined ? {} : { signaturePath }) }
94
+ }
95
+
96
+ /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
97
+ export async function runManifestEmit(
98
+ cwd: string,
99
+ options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
100
+ ): Promise<boolean> {
101
+ const result = await collectManifestEmit(cwd, options)
102
+ if (!result.ok) {
103
+ console.error("[nifra] refusing to emit a manifest from failing assurance")
104
+ return false
83
105
  }
84
106
  console.log(
85
- `[nifra] wrote manifest to ${path}${options.sign === undefined ? "" : ` and ${path}.sig`}`,
107
+ `[nifra] wrote manifest to ${result.path}${result.signaturePath === undefined ? "" : ` and ${result.signaturePath}`}`,
86
108
  )
87
109
  return true
88
110
  }
89
111
 
90
112
  /** Diff two already-emitted, hash-verified artifacts. Suitable for deploy promotion gates. */
91
- export async function runManifestDiff(
113
+ export async function collectManifestDiff(
92
114
  cwd: string,
93
115
  beforePath: string,
94
116
  afterPath: string,
95
- options: { readonly json?: boolean } = {},
96
- ): Promise<boolean> {
117
+ ): Promise<NifraManifestDiff> {
97
118
  const before = resolve(cwd, beforePath)
98
119
  const after = resolve(cwd, afterPath)
99
120
  if (!existsSync(before)) throw new Error(`[nifra] manifest not found: ${before}`)
100
121
  if (!existsSync(after)) throw new Error(`[nifra] manifest not found: ${after}`)
101
- const diff = diffNifraManifests(
122
+ return diffNifraManifests(
102
123
  await parseNifraManifest(await Bun.file(before).text(), before),
103
124
  await parseNifraManifest(await Bun.file(after).text(), after),
104
125
  )
126
+ }
127
+
128
+ /** Diff two already-emitted, hash-verified artifacts and print the result. */
129
+ export async function runManifestDiff(
130
+ cwd: string,
131
+ beforePath: string,
132
+ afterPath: string,
133
+ options: { readonly json?: boolean } = {},
134
+ ): Promise<boolean> {
135
+ const diff = await collectManifestDiff(cwd, beforePath, afterPath)
105
136
  console.log(
106
137
  options.json === true
107
138
  ? JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2)
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "2.13.0"
32
+ const VERSION = "3.1.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>