@nifrajs/cli 2.14.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2346
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +285 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +27 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +142 -2
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/doctor.d.ts +42 -0
  28. package/dist/doctor.d.ts.map +1 -1
  29. package/dist/doctor.js +64 -7
  30. package/dist/doctor.js.map +1 -1
  31. package/dist/env-file.d.ts +17 -0
  32. package/dist/env-file.d.ts.map +1 -0
  33. package/dist/env-file.js +113 -0
  34. package/dist/env-file.js.map +1 -0
  35. package/dist/fix-recipes.d.ts.map +1 -1
  36. package/dist/fix-recipes.js +91 -15
  37. package/dist/fix-recipes.js.map +1 -1
  38. package/dist/frontend-guidance.d.ts +57 -0
  39. package/dist/frontend-guidance.d.ts.map +1 -0
  40. package/dist/frontend-guidance.js +195 -0
  41. package/dist/frontend-guidance.js.map +1 -0
  42. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  43. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  44. package/dist/internal/reserved-segment-codemod.js +134 -0
  45. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  46. package/dist/internal/typescript-import.d.ts +1 -1
  47. package/dist/internal/typescript-import.d.ts.map +1 -1
  48. package/dist/internal/typescript-import.js +62 -11
  49. package/dist/internal/typescript-import.js.map +1 -1
  50. package/dist/introspect.d.ts +26 -1
  51. package/dist/introspect.d.ts.map +1 -1
  52. package/dist/introspect.js +74 -24
  53. package/dist/introspect.js.map +1 -1
  54. package/dist/levels-tool.d.ts.map +1 -1
  55. package/dist/levels-tool.js +6 -0
  56. package/dist/levels-tool.js.map +1 -1
  57. package/dist/load.d.ts.map +1 -1
  58. package/dist/load.js +4 -1
  59. package/dist/load.js.map +1 -1
  60. package/dist/mcp-context.d.ts +48 -0
  61. package/dist/mcp-context.d.ts.map +1 -0
  62. package/dist/mcp-context.js +215 -0
  63. package/dist/mcp-context.js.map +1 -0
  64. package/dist/mcp-docs-tools.d.ts.map +1 -1
  65. package/dist/mcp-docs-tools.js +31 -0
  66. package/dist/mcp-docs-tools.js.map +1 -1
  67. package/dist/mcp-exec.d.ts +74 -0
  68. package/dist/mcp-exec.d.ts.map +1 -0
  69. package/dist/mcp-exec.js +844 -0
  70. package/dist/mcp-exec.js.map +1 -0
  71. package/dist/mcp-http.js +1 -1
  72. package/dist/mcp-http.js.map +1 -1
  73. package/dist/mcp-io.d.ts +26 -0
  74. package/dist/mcp-io.d.ts.map +1 -0
  75. package/dist/mcp-io.js +100 -0
  76. package/dist/mcp-io.js.map +1 -0
  77. package/dist/mcp-reflect.d.ts +16 -0
  78. package/dist/mcp-reflect.d.ts.map +1 -0
  79. package/dist/mcp-reflect.js +101 -0
  80. package/dist/mcp-reflect.js.map +1 -0
  81. package/dist/mcp-root.d.ts +7 -0
  82. package/dist/mcp-root.d.ts.map +1 -1
  83. package/dist/mcp-root.js +51 -3
  84. package/dist/mcp-root.js.map +1 -1
  85. package/dist/mcp.d.ts +10 -84
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +13 -1170
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/openapi-tool.d.ts +5 -10
  90. package/dist/openapi-tool.d.ts.map +1 -1
  91. package/dist/openapi-tool.js +21 -2
  92. package/dist/openapi-tool.js.map +1 -1
  93. package/dist/openapi-types.d.ts +21 -0
  94. package/dist/openapi-types.d.ts.map +1 -0
  95. package/dist/openapi-types.js +214 -0
  96. package/dist/openapi-types.js.map +1 -0
  97. package/dist/port.d.ts.map +1 -1
  98. package/dist/port.js +2 -1
  99. package/dist/port.js.map +1 -1
  100. package/dist/project-facts.d.ts +2 -1
  101. package/dist/project-facts.d.ts.map +1 -1
  102. package/dist/project-facts.js.map +1 -1
  103. package/dist/recipes/3.0.0.d.ts +10 -0
  104. package/dist/recipes/3.0.0.d.ts.map +1 -0
  105. package/dist/recipes/3.0.0.js +23 -0
  106. package/dist/recipes/3.0.0.js.map +1 -0
  107. package/dist/recipes/index.d.ts.map +1 -1
  108. package/dist/recipes/index.js +2 -0
  109. package/dist/recipes/index.js.map +1 -1
  110. package/dist/release-verification.d.ts +4 -1
  111. package/dist/release-verification.d.ts.map +1 -1
  112. package/dist/release-verification.js +6 -123
  113. package/dist/release-verification.js.map +1 -1
  114. package/dist/rules/codes.d.ts +4 -0
  115. package/dist/rules/codes.d.ts.map +1 -1
  116. package/dist/rules/codes.js +4 -0
  117. package/dist/rules/codes.js.map +1 -1
  118. package/dist/rules/islands.d.ts +4 -0
  119. package/dist/rules/islands.d.ts.map +1 -0
  120. package/dist/rules/islands.js +155 -0
  121. package/dist/rules/islands.js.map +1 -0
  122. package/dist/rules/nano.d.ts +6 -0
  123. package/dist/rules/nano.d.ts.map +1 -0
  124. package/dist/rules/nano.js +264 -0
  125. package/dist/rules/nano.js.map +1 -0
  126. package/dist/rules/routes.d.ts.map +1 -1
  127. package/dist/rules/routes.js +17 -30
  128. package/dist/rules/routes.js.map +1 -1
  129. package/dist/rules/security.d.ts.map +1 -1
  130. package/dist/rules/security.js +38 -4
  131. package/dist/rules/security.js.map +1 -1
  132. package/dist/scaffold.d.ts +12 -6
  133. package/dist/scaffold.d.ts.map +1 -1
  134. package/dist/scaffold.js +149 -10
  135. package/dist/scaffold.js.map +1 -1
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -3
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/stylex-migrate.d.ts +38 -0
  140. package/dist/stylex-migrate.d.ts.map +1 -0
  141. package/dist/stylex-migrate.js +1052 -0
  142. package/dist/stylex-migrate.js.map +1 -0
  143. package/dist/sync-manifest.d.ts.map +1 -1
  144. package/dist/sync-manifest.js +2 -1
  145. package/dist/sync-manifest.js.map +1 -1
  146. package/dist/verification-plan.d.ts +24 -0
  147. package/dist/verification-plan.d.ts.map +1 -0
  148. package/dist/verification-plan.js +116 -0
  149. package/dist/verification-plan.js.map +1 -0
  150. package/dist/work-graph.d.ts.map +1 -1
  151. package/dist/work-graph.js +6 -3
  152. package/dist/work-graph.js.map +1 -1
  153. package/dist/workspace-link.d.ts +37 -0
  154. package/dist/workspace-link.d.ts.map +1 -0
  155. package/dist/workspace-link.js +72 -0
  156. package/dist/workspace-link.js.map +1 -0
  157. package/docs/examples.json +37 -1
  158. package/docs/llms-full.txt +1598 -44
  159. package/docs/types.json +5157 -172
  160. package/package.json +9 -9
  161. package/src/assure-hydration.ts +6 -1
  162. package/src/assure.ts +4 -2
  163. package/src/check-diagnostics.ts +1219 -0
  164. package/src/check-scan.ts +1825 -0
  165. package/src/check.ts +47 -2731
  166. package/src/cli.ts +291 -10
  167. package/src/command-catalog.ts +177 -2
  168. package/src/doctor.ts +99 -8
  169. package/src/env-file.ts +121 -0
  170. package/src/fix-recipes.ts +101 -15
  171. package/src/frontend-guidance.ts +259 -0
  172. package/src/internal/reserved-segment-codemod.ts +150 -0
  173. package/src/internal/typescript-import.ts +61 -11
  174. package/src/introspect.ts +82 -21
  175. package/src/levels-tool.ts +6 -0
  176. package/src/load.ts +4 -1
  177. package/src/mcp-context.ts +266 -0
  178. package/src/mcp-docs-tools.ts +37 -0
  179. package/src/mcp-exec.ts +1040 -0
  180. package/src/mcp-http.ts +1 -1
  181. package/src/mcp-io.ts +118 -0
  182. package/src/mcp-reflect.ts +140 -0
  183. package/src/mcp-root.ts +62 -4
  184. package/src/mcp.ts +61 -1436
  185. package/src/openapi-tool.ts +32 -2
  186. package/src/openapi-types.ts +261 -0
  187. package/src/port.ts +2 -1
  188. package/src/project-facts.ts +2 -4
  189. package/src/recipes/3.0.0.ts +24 -0
  190. package/src/recipes/index.ts +2 -0
  191. package/src/release-verification.ts +22 -147
  192. package/src/rules/codes.ts +4 -0
  193. package/src/rules/islands.ts +171 -0
  194. package/src/rules/nano.ts +295 -0
  195. package/src/rules/routes.ts +17 -30
  196. package/src/rules/security.ts +40 -4
  197. package/src/scaffold.ts +169 -9
  198. package/src/search-terms.ts +7 -3
  199. package/src/stylex-migrate.ts +1139 -0
  200. package/src/sync-manifest.ts +2 -1
  201. package/src/verification-plan.ts +242 -0
  202. package/src/work-graph.ts +6 -3
  203. package/src/workspace-link.ts +80 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * `nifra_frontend` - a symptom-indexed catalog of the mistakes an agent (or a human) hits on the client
3
+ * side of a nifra app, across every adapter. It is the FRONTEND companion to `nifra_check`: where the
4
+ * check statically flags the footguns nifra's own seam owns (server-only imports, the nano lane), this
5
+ * catalog covers the ones a static rule can't own cheaply - hydration nondeterminism, a duplicated
6
+ * framework runtime, and the per-framework reactivity-loss idioms that each ecosystem's own linter is
7
+ * the real authority on.
8
+ *
9
+ * Deliberately a compact reference, not an accumulated fault corpus: two kinds of entry only.
10
+ * - Seam entries (`adapters: ["all"]`) - the boundary nifra defines: server/client split, the loader
11
+ * data contract, hydration determinism, one framework copy. These point at a `nifra_*` tool to fix
12
+ * and verify, because nifra owns that surface.
13
+ * - Framework entries - the classic reactivity-loss shape for one adapter (Vue ref, Solid props,
14
+ * Svelte runes, React/Preact deps). These point at that framework's own ESLint plugin, which is the
15
+ * authority; nifra does not re-implement it, it routes you to it.
16
+ *
17
+ * One source, two heads: `nifra_frontend` (MCP) serves it to an agent; `nifra frontend` (CLI) prints it
18
+ * for a human. Both call {@link renderFrontendResult}, so they can never drift.
19
+ */
20
+
21
+ import type { Framework } from "./scaffold.ts"
22
+
23
+ /** An adapter an entry applies to, or `all` for a seam issue that is adapter-independent. */
24
+ export type GuidanceAdapter = Framework | "all"
25
+
26
+ export interface GuidanceEntry {
27
+ /** Stable slug, e.g. `server-import`. */
28
+ readonly id: string
29
+ /** Which adapters this applies to. `["all"]` is a seam issue every adapter shares. */
30
+ readonly adapters: readonly GuidanceAdapter[]
31
+ /** What you observe - the searchable symptom, phrased as the failure, not the cause. */
32
+ readonly symptom: string
33
+ /** Why it happens. */
34
+ readonly cause: string
35
+ /** The concrete change that fixes it. */
36
+ readonly fix: string
37
+ /** How to confirm it is fixed - a `nifra_*` tool for a seam issue, the framework's linter otherwise. */
38
+ readonly verify: string
39
+ /** Where to go deeper - a `nifra_docs`/`nifra_example` query, or the ecosystem linter that owns it. */
40
+ readonly seeAlso?: string | undefined
41
+ }
42
+
43
+ /**
44
+ * The catalog. Seam entries first (they are the nifra-specific value and apply everywhere), then the
45
+ * per-framework reactivity-loss entries. Keep it compact and high-signal - a curated fault corpus mined
46
+ * from real failures is operated depth and lives elsewhere, not in the public package.
47
+ */
48
+ export const FRONTEND_GUIDANCE: readonly GuidanceEntry[] = [
49
+ {
50
+ id: "server-import",
51
+ adapters: ["all"],
52
+ symptom:
53
+ "A client component crashes at build or runtime with a missing Node built-in, a leaked secret, or 'module not found in the browser'.",
54
+ cause:
55
+ "A server-only module (the DB, a secret, `node:*`, the backend file) is imported at the top level of a component, so the bundler tries to ship it to the browser.",
56
+ fix: "Move the access into a server-only `loader`/`action` and read the result in the component. Reach the backend through the typed `api` argument, never a top-level server-only import.",
57
+ verify:
58
+ "nifra_check - its transitive server-import scan flags the exact import chain into a client module.",
59
+ seeAlso: "loader action server-only import boundary",
60
+ },
61
+ {
62
+ id: "hydration-mismatch",
63
+ adapters: ["all"],
64
+ symptom:
65
+ "A hydration warning, text that flips on load, or 'server HTML did not match client' the first time the page becomes interactive.",
66
+ cause:
67
+ "The first client render is not identical to the SSR HTML - usually a non-deterministic read during render: `Date.now()`, `Math.random()`, `window`/`localStorage`, or a locale/timezone the server did not have.",
68
+ fix: "Render deterministic markup, then apply anything client-only in an effect / `onMount` after the first paint. Pass anything the server knew (time, locale, flags) down through loader data so both renders agree.",
69
+ verify:
70
+ "nifra_render the route and diff its HTML against the first client render; nifra_check's hydration assurance reports nondeterminism it can see.",
71
+ seeAlso: "hydration ssr determinism loader data",
72
+ },
73
+ {
74
+ id: "duplicate-runtime",
75
+ adapters: ["react", "preact", "solid", "vue", "svelte"],
76
+ symptom:
77
+ "'Invalid hook call', a null context/provider, signals that never update, or a component that mounts twice.",
78
+ cause:
79
+ "Two copies of the framework are in the bundle - the app's own dependency and a second one resolved through the adapter - so the two runtimes do not share the same module-level state.",
80
+ fix: "Dedupe the framework to a single version across the workspace (one entry in the lockfile). The adapter must resolve the app's copy, not bundle its own.",
81
+ verify: "nifra_doctor - it detects a duplicated framework runtime (NF-H002).",
82
+ seeAlso: "duplicate framework runtime dedupe NF-H002",
83
+ },
84
+ {
85
+ id: "loader-typing",
86
+ adapters: ["all"],
87
+ symptom:
88
+ "Loader data is typed `any` in the page, or a hand-written interface silently drifts from what the loader actually returns.",
89
+ cause:
90
+ "The component types its props by hand instead of inferring them from the loader, so a change to the loader is not a type error at the use site.",
91
+ fix: "Type the page from the loader: `props: { data: LoaderData<typeof loader> }`. Call the backend through the typed `api` so the request/response shape is inferred, not restated.",
92
+ verify:
93
+ "nifra_context prints the exact loader shape and typed call form; nifra_check fails on drift.",
94
+ seeAlso: "LoaderData typed client loader inference",
95
+ },
96
+ {
97
+ id: "list-key",
98
+ adapters: ["all"],
99
+ symptom:
100
+ "List rows keep the wrong state on reorder/insert - a checked box, focus, or an input value jumps to a different row.",
101
+ cause:
102
+ "The list is keyed by the array index (or not keyed at all), so add/remove/reorder maps the wrong item to an existing node.",
103
+ fix: "Key each row by a stable, unique id from the item, never its position in the array.",
104
+ verify:
105
+ "The framework's own linter for JSX keys; on the vanilla nano lane, nifra_check flags an index key as NF-C022.",
106
+ seeAlso: "list key stable id reconcile nano",
107
+ },
108
+ {
109
+ id: "vue-lost-reactivity",
110
+ adapters: ["vue"],
111
+ symptom:
112
+ "A value stops updating the template after you pull it out of a ref, reactive object, or props.",
113
+ cause:
114
+ "Destructuring a `reactive()` object or `props` copies the current value and drops the reactive link; a `ref` read in script forgets `.value`.",
115
+ fix: "Keep the reactive source intact: use `toRefs`/`storeToRefs` when you must destructure, access `props.x` directly, and remember `.value` on a ref in script (templates unwrap it for you).",
116
+ verify:
117
+ "eslint-plugin-vue - it is the authority on Vue reactivity; nifra does not re-implement it.",
118
+ seeAlso: "eslint-plugin-vue toRefs reactivity",
119
+ },
120
+ {
121
+ id: "solid-lost-reactivity",
122
+ adapters: ["solid"],
123
+ symptom: "A prop or store value renders once and never tracks updates in a Solid component.",
124
+ cause:
125
+ "Solid props are getters, evaluated where you read them. Destructuring props (or reading them outside JSX/an effect) reads once and loses tracking.",
126
+ fix: "Access `props.x` at the point of use inside JSX, or `splitProps`/`mergeProps` when you need to pass a subset on. Never destructure props in the function signature.",
127
+ verify:
128
+ "eslint-plugin-solid - the authority on Solid reactivity; nifra routes you to it, not around it.",
129
+ seeAlso: "eslint-plugin-solid splitProps props getters",
130
+ },
131
+ {
132
+ id: "svelte-runes",
133
+ adapters: ["svelte"],
134
+ symptom:
135
+ "In a Svelte 5 component, `export let` props or a `$:` statement do not behave as expected, or the compiler rejects them in runes mode.",
136
+ cause:
137
+ "Runes mode replaces the Svelte 4 idioms: props come from `$props()`, derived values from `$derived`, side effects from `$effect`. Mixing the two models is the mismatch.",
138
+ fix: "In runes mode use `$props()` for inputs, `$state` for local state, `$derived` for computed values, and `$effect` for side effects - not `export let` or `$:`.",
139
+ verify:
140
+ "eslint-plugin-svelte and the Svelte compiler warnings own this; nifra points you at them.",
141
+ seeAlso: "eslint-plugin-svelte runes $props $derived",
142
+ },
143
+ {
144
+ id: "react-stale-deps",
145
+ adapters: ["react", "preact"],
146
+ symptom:
147
+ "An effect or callback uses a stale value, fires too often, or never re-runs when a value it reads changes.",
148
+ cause:
149
+ "The dependency array omits a value the effect/callback closes over (or the effect has no array), so the closure captures an old render's value.",
150
+ fix: "List every reactive value the effect reads in its deps, or hold the mutable value in a ref when it must not trigger a re-run. Do not silence the rule by emptying the array.",
151
+ verify:
152
+ "eslint-plugin-react-hooks (`exhaustive-deps`) is the authority on effect deps; nifra does not duplicate it.",
153
+ seeAlso: "eslint-plugin-react-hooks exhaustive-deps effect closure",
154
+ },
155
+ {
156
+ id: "prefer-nano-or-island",
157
+ adapters: ["vanilla"],
158
+ symptom:
159
+ "A vanilla page needs local state or a list a human edits, and hand-written DOM updates are getting fragile.",
160
+ cause:
161
+ "The islands lane is imperative and has no state primitive, so each update is hand-wired and a list means rebuilding the container.",
162
+ fix: "Use the nano lane: `signal` + `computed(fn, [deps])` for state, `bindList` for a keyed list, `resource` for async. Every reactive edge is a visible call, so its mistakes are static lints.",
163
+ verify:
164
+ "nifra_check runs NF-C021 (discarded disposer), NF-C022 (index key), NF-C023 (missing dep) over the nano code.",
165
+ seeAlso: "nano signal computed bindList resource islands",
166
+ },
167
+ ]
168
+
169
+ const ADAPTER_ALIASES: Record<string, GuidanceAdapter> = {
170
+ all: "all",
171
+ react: "react",
172
+ preact: "preact",
173
+ solid: "solid",
174
+ vue: "vue",
175
+ svelte: "svelte",
176
+ vanilla: "vanilla",
177
+ }
178
+
179
+ /** Normalize a free-text adapter argument to a known adapter, or `undefined` if it is not one. */
180
+ export function parseAdapter(value: string | undefined): GuidanceAdapter | undefined {
181
+ if (value === undefined) return undefined
182
+ return ADAPTER_ALIASES[value.trim().toLowerCase()]
183
+ }
184
+
185
+ const appliesTo = (entry: GuidanceEntry, adapter: GuidanceAdapter): boolean =>
186
+ adapter === "all" || entry.adapters.includes("all") || entry.adapters.includes(adapter)
187
+
188
+ /** Keyword score of an entry against a query - counts distinct query words found in its text. */
189
+ function score(entry: GuidanceEntry, words: readonly string[]): number {
190
+ const hay =
191
+ `${entry.id} ${entry.symptom} ${entry.cause} ${entry.fix} ${entry.seeAlso ?? ""}`.toLowerCase()
192
+ let hits = 0
193
+ for (const w of words) if (hay.includes(w)) hits++
194
+ return hits
195
+ }
196
+
197
+ function renderEntry(entry: GuidanceEntry): string {
198
+ const scope = entry.adapters.includes("all") ? "all adapters" : entry.adapters.join(", ")
199
+ const parts = [
200
+ `[${entry.id}] (${scope})`,
201
+ `Symptom: ${entry.symptom}`,
202
+ `Cause: ${entry.cause}`,
203
+ `Fix: ${entry.fix}`,
204
+ `Verify: ${entry.verify}`,
205
+ ]
206
+ if (entry.seeAlso !== undefined) parts.push(`See: ${entry.seeAlso}`)
207
+ return parts.join("\n")
208
+ }
209
+
210
+ function renderIndex(
211
+ entries: readonly GuidanceEntry[],
212
+ adapter: GuidanceAdapter | undefined,
213
+ ): string {
214
+ const scope = adapter === undefined ? "all adapters" : `adapter: ${adapter}`
215
+ const lines = entries.map((e) => {
216
+ const s = e.adapters.includes("all") ? "all" : e.adapters.join("/")
217
+ return `- ${e.id} (${s}) - ${e.symptom}`
218
+ })
219
+ return [
220
+ `nifra frontend guidance (${entries.length} of ${FRONTEND_GUIDANCE.length}, ${scope}). Pass a symptom to get the cause + fix + how to verify; pass an adapter to filter.`,
221
+ "",
222
+ ...lines,
223
+ ].join("\n")
224
+ }
225
+
226
+ /**
227
+ * Render the catalog. Filters by `adapter` (when it names a known one), then by a `symptom` keyword
228
+ * query. With no query it returns the (filtered) index; with a query it returns the best-matching
229
+ * entries in full, or the index plus a "no match" note when nothing scores. Pure, so both heads agree.
230
+ */
231
+ export function renderFrontendResult(args: {
232
+ adapter?: string | undefined
233
+ symptom?: string | undefined
234
+ limit?: number | undefined
235
+ }): string {
236
+ const adapter = parseAdapter(args.adapter)
237
+ const scoped =
238
+ adapter === undefined
239
+ ? FRONTEND_GUIDANCE
240
+ : FRONTEND_GUIDANCE.filter((e) => appliesTo(e, adapter))
241
+ const query = args.symptom?.trim()
242
+ if (query === undefined || query.length === 0) return renderIndex(scoped, adapter)
243
+
244
+ const words = query
245
+ .toLowerCase()
246
+ .split(/\s+/)
247
+ .filter((w) => w.length > 1)
248
+ const limit = Math.min(Math.max(args.limit ?? 3, 1), 5)
249
+ const ranked = scoped
250
+ .map((entry) => ({ entry, s: score(entry, words) }))
251
+ .filter((r) => r.s > 0)
252
+ .sort((a, b) => b.s - a.s)
253
+ .slice(0, limit)
254
+
255
+ if (ranked.length === 0) {
256
+ return `No frontend-guidance entry matched "${query}"${adapter ? ` for ${adapter}` : ""}.\n\n${renderIndex(scoped, adapter)}`
257
+ }
258
+ return ranked.map((r) => renderEntry(r.entry)).join("\n\n")
259
+ }
@@ -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