@nifrajs/cli 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
package/src/db-tool.ts CHANGED
@@ -34,8 +34,10 @@ import { readProjectEnv } from "./db-config.ts"
34
34
  import {
35
35
  CHILD_OUTPUT_MAX_BYTES,
36
36
  CHILD_TIMEOUT_MS,
37
+ OWN_PROCESS_GROUP,
37
38
  readBoundedLines,
38
39
  readBoundedStream,
40
+ signalProcessGroup,
39
41
  } from "./mcp-io.ts"
40
42
 
41
43
  /** Added to the declared `timeoutMs` before the subprocess is killed. */
@@ -134,12 +136,13 @@ export async function runDbChild(
134
136
  stdin: "pipe",
135
137
  stdout: "pipe",
136
138
  stderr: "pipe",
139
+ detached: OWN_PROCESS_GROUP,
137
140
  })
138
141
  let killedFor: "startup" | "deadline" | "cancel" | undefined
139
142
  const kill = (reason: NonNullable<typeof killedFor>): void => {
140
143
  if (killedFor !== undefined) return
141
144
  killedFor = reason
142
- proc.kill("SIGKILL")
145
+ signalProcessGroup(proc, "SIGKILL")
143
146
  }
144
147
  let deadlineMs: number | undefined
145
148
  let timer = setTimeout(() => kill("startup"), startupMs)
@@ -565,7 +568,7 @@ export interface DbRoleInput {
565
568
  export const dbRoleSpec: CommandSpec<DbRoleInput, DbToolOutput> = {
566
569
  name: "db-role",
567
570
  summary:
568
- "Print the SQL that creates a read-only Postgres role for the declared development database (pg_read_all_data on 14+, else GRANT SELECT, with a REVOKE per excluded table). Nothing is run.",
571
+ "Print the SQL that creates a read-only Postgres role for the declared development database (GRANT SELECT on each allowed schema, with a REVOKE per excluded table). Nothing is run.",
569
572
  input: {
570
573
  jsonSchema: {
571
574
  type: "object",
@@ -42,10 +42,29 @@
42
42
  import { timingSafeEqual } from "node:crypto"
43
43
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
44
44
  import { relative, resolve } from "node:path"
45
+ import { readSingleCopyDeclaration } from "@nifrajs/core/single-copy"
45
46
 
46
- /** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
47
+ const TOML_ESCAPES: Readonly<Record<string, string>> = {
48
+ "\\": "\\\\",
49
+ '"': '\\"',
50
+ "\b": "\\b",
51
+ "\t": "\\t",
52
+ "\n": "\\n",
53
+ "\f": "\\f",
54
+ "\r": "\\r",
55
+ }
56
+
57
+ /** TOML string literal: basic (double-quoted) form. A basic string may hold no raw control
58
+ * character, so each one is escaped along with the quote and the backslash. */
47
59
  function tomlString(value: string): string {
48
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
60
+ let out = '"'
61
+ for (const char of value) {
62
+ const code = char.charCodeAt(0)
63
+ out +=
64
+ TOML_ESCAPES[char] ??
65
+ (code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, "0")}` : char)
66
+ }
67
+ return `${out}"`
49
68
  }
50
69
 
51
70
  /** A parsed bunfig: plain data from `Bun.TOML.parse`. */
@@ -198,24 +217,40 @@ function relativeSpecifier(fromDir: string, target: string): string {
198
217
  * `servePlugins` are the app's own bunfig `[serve.static] plugins`, composed last and in order rather
199
218
  * than listed beside this module, so that every plugin reaching the bundler registers through
200
219
  * `reserveDevSpecifiers` (see `@nifrajs/web/internal/dev-reserved`).
220
+ *
221
+ * `singleCopy` is whether `package.json` declares `"nifra": { "singleCopy": [...] }`. The declared
222
+ * resolver then runs where `buildClient` runs it, after the zone guard; an app that also lists
223
+ * `singleCopyPlugin()` in `clientPlugins` gets its own instance in that place, once.
201
224
  */
202
225
  export function renderBoundaryPluginModule(
203
226
  configPath?: string,
204
227
  pluginDir?: string,
205
228
  servePlugins: readonly string[] = [],
229
+ singleCopy = false,
206
230
  ): string {
207
231
  const appImport =
208
232
  configPath !== undefined && pluginDir !== undefined
209
233
  ? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
210
234
  : ""
211
- const appSetup =
235
+ const appPluginsSetup =
212
236
  appImport === ""
213
- ? ""
237
+ ? " const appPlugins = []\n"
214
238
  : ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
215
239
  const field = appConfig.clientPlugins
216
240
  const appPlugins = (typeof field === "function" ? await field() : field) ?? []
217
- for (const p of appPlugins) await p.setup(guarded)
218
241
  `
242
+ const singleCopySetup = singleCopy
243
+ ? ` const listedSingleCopy = appPlugins.find((p) => p?.name === "nifra-single-copy")
244
+ ;(listedSingleCopy ?? singleCopyPlugin({ cwd: appRoot })).setup(guarded)
245
+ `
246
+ : ""
247
+ // Without a declaration the app's plugins are read where they are set up, as before.
248
+ const appSetup =
249
+ appImport === ""
250
+ ? ""
251
+ : singleCopy
252
+ ? " for (const p of appPlugins) if (p !== listedSingleCopy) await p.setup(guarded)\n"
253
+ : `${appPluginsSetup} for (const p of appPlugins) await p.setup(guarded)\n`
219
254
  const serveSetup =
220
255
  servePlugins.length === 0
221
256
  ? ""
@@ -232,10 +267,11 @@ export function renderBoundaryPluginModule(
232
267
  import { serverFnStubPlugin, zoneGuardPlugin } from "@nifrajs/web/build"
233
268
  import { reserveDevSpecifiers } from "@nifrajs/web/internal/dev-reserved"
234
269
  import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
235
- ${appImport}
270
+ ${singleCopy ? 'import { singleCopyPlugin } from "@nifrajs/core/single-copy"\n' : ""}${appImport}
236
271
  // This module lives at <app>/.nifra/dev-bun/, so the app root is two directories up.
272
+ const appRoot = Bun.fileURLToPath(new URL("../../", import.meta.url))
237
273
  const zones = zoneGuardPlugin({
238
- appRoot: Bun.fileURLToPath(new URL("../../", import.meta.url)),${
274
+ appRoot,${
239
275
  appImport === ""
240
276
  ? ""
241
277
  : `
@@ -251,10 +287,10 @@ export default {
251
287
  // Every plugin below registers through this view: a resolve filter matching the dev entry script
252
288
  // or Bun's built-in React refresh runtime breaks the page even when its handler declines.
253
289
  const guarded = reserveDevSpecifiers(build)
254
- // Boundary first: an app plugin must never get to transform a module that the boundary is about
290
+ ${singleCopy ? appPluginsSetup : ""} // Boundary first: an app plugin must never get to transform a module that the boundary is about
255
291
  // to refuse or stub, or the transform decides what ships instead of the boundary.
256
292
  zones.setup(guarded)
257
- fn.setup(guarded)
293
+ ${singleCopySetup} fn.setup(guarded)
258
294
  cssModules.setup(guarded)
259
295
  ${appSetup}${serveSetup} },
260
296
  }
@@ -287,7 +323,12 @@ export async function writeBunDevConfig(
287
323
  const user = parseUserBunfig(userToml)
288
324
  writeFileSync(
289
325
  pluginPath,
290
- renderBoundaryPluginModule(configPath, dir, userServePlugins(user, appRoot)),
326
+ renderBoundaryPluginModule(
327
+ configPath,
328
+ dir,
329
+ userServePlugins(user, appRoot),
330
+ readSingleCopyDeclaration(appRoot) !== undefined,
331
+ ),
291
332
  )
292
333
  const bunfigPath = resolve(dir, "bunfig.toml")
293
334
  writeFileSync(bunfigPath, renderDevBunfig(pluginPath, user, appRoot, define))
@@ -21,6 +21,9 @@ export interface DiagnosticCompatibility {
21
21
  readonly suggestion?: DiagnosticSuggestion
22
22
  /** The import path evidence used by the legacy server-only-import view. */
23
23
  readonly chain?: readonly string[]
24
+ /** The project files a server-only chain runs through, so a diff review keeps a finding whose
25
+ * route is unchanged but whose chain a changed module made. */
26
+ readonly modules?: readonly string[]
24
27
  /** Preserve the old optional `CheckDiagnostic.code` field exactly where it existed. */
25
28
  readonly includeCode?: boolean
26
29
  }
package/src/diff-tool.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  * framework config or routes/ directory (unlike the eager `loadApp`).
15
15
  */
16
16
 
17
- import { existsSync } from "node:fs"
18
- import { resolve } from "node:path"
17
+ import { existsSync, realpathSync } from "node:fs"
18
+ import { isAbsolute, relative, resolve } from "node:path"
19
19
  import {
20
20
  diffRouteSnapshots,
21
21
  type RouteChange,
@@ -23,7 +23,7 @@ import {
23
23
  type RoutesDiff,
24
24
  snapshotRoutes,
25
25
  } from "@nifrajs/core/diff"
26
- import { BACKEND_APP_FILE } from "./app-files.ts"
26
+ import { assertCurrentLayout, BACKEND_APP_FILE } from "./app-files.ts"
27
27
 
28
28
  /** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
29
29
  export interface SnapshotFile {
@@ -84,6 +84,7 @@ export function formatDiff(diff: RoutesDiff): string {
84
84
 
85
85
  /** Import `backend/app.ts` from `cwd` and snapshot its routes. */
86
86
  export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
87
+ assertCurrentLayout(cwd)
87
88
  const backendPath = resolve(cwd, BACKEND_APP_FILE)
88
89
  if (!existsSync(backendPath)) {
89
90
  throw new Error(
@@ -97,35 +98,41 @@ export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapsh
97
98
  return snapshotRoutes(backend)
98
99
  }
99
100
 
100
- /** `nifra snapshot`: write the current contract as the CI baseline. */
101
- export async function runSnapshot(cwd: string, options: { out?: string }): Promise<void> {
102
- const routes = await snapshotBackend(cwd)
103
- const file: SnapshotFile = { nifraSnapshot: 1, routes }
104
- const outPath = resolve(cwd, options.out ?? DEFAULT_SNAPSHOT_FILE)
105
- await Bun.write(outPath, `${JSON.stringify(file, null, 2)}\n`)
106
- console.log(`[nifra] wrote ${routes.length} route${routes.length === 1 ? "" : "s"} to ${outPath}`)
101
+ /** `nifra snapshot`: write the current contract to `out` (from `cwd`) as the CI baseline. */
102
+ export async function writeSnapshot(
103
+ cwd: string,
104
+ out: string = DEFAULT_SNAPSHOT_FILE,
105
+ ): Promise<SnapshotFile> {
106
+ const snapshot: SnapshotFile = { nifraSnapshot: 1, routes: await snapshotBackend(cwd) }
107
+ await Bun.write(resolve(cwd, out), `${JSON.stringify(snapshot, null, 2)}\n`)
108
+ return snapshot
109
+ }
110
+
111
+ const outside = (root: string, target: string): boolean => {
112
+ const rel = relative(root, target)
113
+ return rel.startsWith("..") || isAbsolute(rel)
107
114
  }
108
115
 
109
- /** `nifra diff`: compare the current contract against a baseline. Returns false on breaking changes. */
110
- export async function runDiff(
116
+ /**
117
+ * `nifra diff`: the current contract against the snapshot at `baselinePath` (from `cwd`). `contain`
118
+ * refuses a baseline outside `cwd`, symlinks followed, for a caller handed the path by someone else.
119
+ */
120
+ export async function diffAgainstBaseline(
111
121
  cwd: string,
112
- baselinePath: string,
113
- options: { json?: boolean },
114
- ): Promise<boolean> {
122
+ baselinePath: string = DEFAULT_SNAPSHOT_FILE,
123
+ options: { readonly contain?: boolean } = {},
124
+ ): Promise<RoutesDiff> {
115
125
  const resolved = resolve(cwd, baselinePath)
116
- const baselineFile = Bun.file(resolved)
117
- if (!(await baselineFile.exists())) {
126
+ const refuse = (): never => {
127
+ throw new Error(`[nifra] the baseline ${baselinePath} is outside the project directory`)
128
+ }
129
+ if (options.contain === true && outside(resolve(cwd), resolved)) refuse()
130
+ if (!existsSync(resolved)) {
118
131
  throw new Error(
119
132
  `[nifra] baseline not found: ${resolved} - create it on your main branch with \`nifra snapshot\`.`,
120
133
  )
121
134
  }
122
- const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
123
- const current = await snapshotBackend(cwd)
124
- const diff = diffRouteSnapshots(baseline.routes, current)
125
- if (options.json === true) {
126
- console.log(JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2))
127
- } else {
128
- console.log(formatDiff(diff))
129
- }
130
- return !diff.hasBreaking
135
+ if (options.contain === true && outside(realpathSync(cwd), realpathSync(resolved))) refuse()
136
+ const baseline = parseSnapshotFile(await Bun.file(resolved).text(), baselinePath)
137
+ return diffRouteSnapshots(baseline.routes, await snapshotBackend(cwd))
131
138
  }
package/src/doctor.ts CHANGED
@@ -29,11 +29,20 @@ import {
29
29
  resolvedInstalledCopy,
30
30
  } from "@nifrajs/web/internal/parity"
31
31
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
32
+ import {
33
+ type HandBuiltManifest,
34
+ handBuiltManifestMessage,
35
+ handBuiltManifests,
36
+ type ManifestList,
37
+ manifestListsIn,
38
+ } from "./hand-built-manifest.ts"
32
39
  import { collectStaleMcpPins, type StaleMcpPin } from "./init-agents.ts"
33
40
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
41
+ import { createSpecifierResolver } from "./internal/project-specifiers.ts"
34
42
  import { detectToolingDrift, syncMcpCommand, type ToolingDrift } from "./mcp-root.ts"
35
43
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
36
44
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
45
+ import { collectVersionDrift, type VersionDrift, versionDriftMessage } from "./version-drift.ts"
37
46
  import { buildScriptName } from "./workspace-link.ts"
38
47
 
39
48
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
@@ -178,6 +187,17 @@ export interface DoctorResult {
178
187
  * supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
179
188
  */
180
189
  readonly toolingDrift?: ToolingDrift
190
+ /**
191
+ * More than one version of the fixed release group across pins, installs and the running CLI.
192
+ * Advisory (never folded into `ok`); its fix is `nifra upgrade <highest>`. Computed by
193
+ * {@link collectVersionDrift}.
194
+ */
195
+ readonly versionDrift?: VersionDrift
196
+ /**
197
+ * `buildManifest(...)` calls whose spelled-out file list reaches a page but not its backend half, so
198
+ * that page runs without its loader, action and guards. Advisory (never folded into `ok`).
199
+ */
200
+ readonly handBuiltManifests?: readonly HandBuiltManifest[]
181
201
  /**
182
202
  * Agent files whose MCP launch pins an `@nifrajs/cli` other than the one the project installs, so
183
203
  * the agent's server answers for a release the code no longer builds with. Advisory (never folded
@@ -347,16 +367,6 @@ const declaredPackages = (pkg: Record<string, unknown>): ReadonlySet<string> =>
347
367
  return declared
348
368
  }
349
369
 
350
- const tsconfigPaths = async (
351
- root: string,
352
- ): Promise<Readonly<Record<string, unknown>> | undefined> => {
353
- const tsconfig = await readJson(join(root, "tsconfig.json"))
354
- const compilerOptions = tsconfig?.compilerOptions as
355
- | { paths?: Record<string, unknown> }
356
- | undefined
357
- return compilerOptions?.paths
358
- }
359
-
360
370
  /** Build per-package declaration scopes for a workspace. Longest roots are first so nested packages
361
371
  * own their source; files outside a workspace package remain owned by the root manifest. */
362
372
  async function doctorPackageScopes(
@@ -365,16 +375,16 @@ async function doctorPackageScopes(
365
375
  ): Promise<readonly DoctorPackageScope[]> {
366
376
  const importers = await workspaceImporters(cwd, rootPackage, true)
367
377
  const packages = importers.length > 0 ? importers : [{ root: cwd, package: rootPackage }]
368
- const rootPaths = await tsconfigPaths(cwd)
369
- const scopes = await Promise.all(
370
- packages.map(async (entry): Promise<DoctorPackageScope> => {
371
- const paths = (await tsconfigPaths(entry.root)) ?? rootPaths
372
- return {
373
- root: entry.root,
374
- relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
375
- declared: declaredPackages(entry.package),
376
- isAlias: aliasMatcher(paths),
377
- }
378
+ // The nearest tsconfig, read as TypeScript does: comments, trailing commas and `extends` included.
379
+ const resolver = createSpecifierResolver()
380
+ const scopes = packages.map(
381
+ (entry): DoctorPackageScope => ({
382
+ root: entry.root,
383
+ relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
384
+ declared: declaredPackages(entry.package),
385
+ isAlias: aliasMatcher(
386
+ Object.fromEntries(resolver.pathPatterns(entry.root).map((pattern) => [pattern, []])),
387
+ ),
378
388
  }),
379
389
  )
380
390
  return scopes.sort((a, b) => b.relativeRoot.length - a.relativeRoot.length)
@@ -690,7 +700,7 @@ async function collectDoctorReadiness(
690
700
  : configured(
691
701
  "graceful-lifecycle",
692
702
  "graceful lifecycle",
693
- /\bgracefulSignals\s*:\s*true\b|\bonStop\s*\(/,
703
+ /\bgracefulSignals\s*:\s*(?:true\b|\{)|\bonStop\s*\(/,
694
704
  ),
695
705
  ]
696
706
  return {
@@ -783,6 +793,7 @@ export async function collectDoctorResult(
783
793
  const scopes = await doctorPackageScopes(cwd, pkg)
784
794
 
785
795
  const findings: DoctorFinding[] = []
796
+ const manifestLists: ManifestList[] = []
786
797
  // A standalone app owns its tests, so they remain in the declaration check. A workspace root owns
787
798
  // the published package source while its root devDependencies intentionally own shared tests,
788
799
  // benchmarks, and scaffold copy-surfaces. Keep that boundary explicit instead of reporting a
@@ -813,6 +824,7 @@ export async function collectDoctorResult(
813
824
  for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
814
825
  findings.push({ file: f.file, line: f.line, package: f.snippet })
815
826
  }
827
+ manifestLists.push(...manifestListsIn(rel, content))
816
828
  },
817
829
  { includeTests: scanScope.includeTests, ignore: workspaceSurface },
818
830
  )
@@ -829,6 +841,8 @@ export async function collectDoctorResult(
829
841
  const toolingDrift =
830
842
  opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
831
843
  const staleMcpPins = await collectStaleMcpPins(cwd).catch(() => undefined)
844
+ const versionDrift = collectVersionDrift(cwd, opts.cliVersion)
845
+ const handBuilt = handBuiltManifests(cwd, manifestLists)
832
846
  // `staleDists`, `toolingDrift` and `staleMcpPins` are advisory (see DoctorResult): they never fail `ok`.
833
847
  return {
834
848
  ok:
@@ -846,6 +860,8 @@ export async function collectDoctorResult(
846
860
  readiness,
847
861
  scanScope,
848
862
  ...(toolingDrift !== undefined ? { toolingDrift } : {}),
863
+ ...(versionDrift !== undefined ? { versionDrift } : {}),
864
+ ...(handBuilt.length > 0 ? { handBuiltManifests: handBuilt } : {}),
849
865
  ...(staleMcpPins !== undefined && staleMcpPins.files.length > 0 ? { staleMcpPins } : {}),
850
866
  ...(pipeline.ran ? { pipeline } : {}),
851
867
  }
@@ -1031,6 +1047,12 @@ export async function runDoctor(
1031
1047
  "or ./node_modules/.bin/nifra)\n",
1032
1048
  )
1033
1049
  }
1050
+ if (result.versionDrift !== undefined) {
1051
+ console.log(`⚠ ${versionDriftMessage(result.versionDrift)}\n`)
1052
+ }
1053
+ for (const finding of result.handBuiltManifests ?? []) {
1054
+ console.log(`⚠ ${handBuiltManifestMessage(finding)}\n`)
1055
+ }
1034
1056
  // Advisory: the agent's MCP server is launched from these pins, so a stale one answers for an old
1035
1057
  // release even when the CLI running doctor matches the project.
1036
1058
  if (result.staleMcpPins !== undefined) {
package/src/env-file.ts CHANGED
@@ -44,6 +44,13 @@ function unquote(raw: string): string {
44
44
  return (comment === -1 ? value : value.slice(0, comment)).trim()
45
45
  }
46
46
 
47
+ /**
48
+ * Set to the command name while a CLI command imports a project only to read its declarations
49
+ * (`nifra check`, `routes`, the `assure` report), so app code can skip work a read never needs, such
50
+ * as connecting to Redis.
51
+ */
52
+ export const REFLECT_ENV = "NIFRA_REFLECT"
53
+
47
54
  /** Parse dotenv text into a plain record. Later assignments to the same key win, as in a shell. */
48
55
  export function parseEnvFile(text: string): Record<string, string> {
49
56
  const values: Record<string, string> = {}
@@ -42,10 +42,15 @@ registerFixRecipe({
42
42
  "[nifra] the timing-safe fix finds each comparison in the syntax tree, so it needs TypeScript - run `bun add -d typescript`, then rerun the fix",
43
43
  )
44
44
  try {
45
- const { rewriteSecretComparisons } = await import("./rules/security.ts")
45
+ const { provenBrowserFile, rewriteSecretComparisons } = await import("./rules/security.ts")
46
46
  const source = await readFile(path, "utf8")
47
47
  // Every site in the file at once, so the rest of that file's diagnostics find nothing left.
48
- const rewritten = rewriteSecretComparisons(loaded.compiler, diagnostic.file, source)
48
+ const rewritten = rewriteSecretComparisons(
49
+ loaded.compiler,
50
+ diagnostic.file,
51
+ source,
52
+ provenBrowserFile(root, diagnostic.file),
53
+ )
49
54
  if (rewritten === source) return []
50
55
  await writeFile(path, rewritten, "utf8")
51
56
  return [diagnostic.file]
@@ -62,7 +67,33 @@ registerFixRecipe({
62
67
  async apply(root) {
63
68
  const { syncServerManifests } = await import("./sync-manifest.ts")
64
69
  const results = await syncServerManifests(root)
65
- return results.filter((result) => result.changed).map((result) => result.file)
70
+ const changed = results.filter((result) => result.changed).map((result) => result.file)
71
+ const failed = results.filter((result) => result.error !== undefined)
72
+ if (failed.length > 0) {
73
+ const synced = changed.length === 0 ? "" : `synced ${changed.join(", ")}; `
74
+ throw new Error(
75
+ `[nifra] ${synced}cannot sync ${failed.map((result) => `${result.file}: ${result.error}`).join("; ")}`,
76
+ )
77
+ }
78
+ return changed
79
+ },
80
+ })
81
+
82
+ registerFixRecipe({
83
+ id: "framework.api-prefix",
84
+ description:
85
+ "Serve the backend over HTTP at /api by exporting apiPrefix from the framework config.",
86
+ verify: "nifra check",
87
+ async apply(root, diagnostic) {
88
+ if (diagnostic.file === undefined) return []
89
+ const path = await resolveInsideProject(root, diagnostic.file)
90
+ if (path === undefined) return []
91
+ const { withDefaultApiPrefix } = await import("./rules/pages.ts")
92
+ const file = Bun.file(path)
93
+ const next = withDefaultApiPrefix((await file.exists()) ? await file.text() : undefined)
94
+ if (next === undefined) return []
95
+ await writeFile(path, next, "utf8")
96
+ return [diagnostic.file]
66
97
  },
67
98
  })
68
99
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * A route manifest built by hand from a list that names no backend half. `buildManifest(files, load)`
3
+ * pairs a page with its `.backend.*` half only when both are in `files`: it is pure, so it cannot know
4
+ * a half is missing. A test or server that spells the list out (an array literal, or `import.meta.glob`
5
+ * patterns) and leaves the halves out runs every route without its loader, action and guards.
6
+ */
7
+ import { existsSync, readdirSync, statSync } from "node:fs"
8
+ import { dirname, join } from "node:path"
9
+ import { Glob } from "bun"
10
+ import { codePositionMask } from "./check.ts"
11
+ import { expressionEnd } from "./rules/data-guard.ts"
12
+
13
+ /** A `buildManifest(...)` call whose file list reaches pages under `routes/` but not their halves. */
14
+ export interface HandBuiltManifest {
15
+ readonly file: string
16
+ readonly line: number
17
+ /** How the list was spelled out. */
18
+ readonly source: "array" | "glob"
19
+ /** The backend halves, cwd-relative, of pages the list reaches but whose half it does not. */
20
+ readonly missing: readonly string[]
21
+ }
22
+
23
+ /** A `buildManifest(...)` call and the literal entries or glob patterns of its file list. */
24
+ export interface ManifestList {
25
+ readonly file: string
26
+ readonly line: number
27
+ readonly source: "array" | "glob"
28
+ readonly entries: readonly string[]
29
+ }
30
+
31
+ // Mirrors `isRouteBackendFile` in @nifrajs/web's manifest.
32
+ const BACKEND_HALF = /\.backend\.(?:[cm]?[jt]s)$/
33
+ const LITERAL = /(["'`])((?:\\.|(?!\1)[^\\])*)\1/g
34
+
35
+ const lineAt = (text: string, index: number): number => text.slice(0, index).split("\n").length
36
+
37
+ /**
38
+ * The `buildManifest(` calls in `content` whose first argument is spelled out in source: an array of
39
+ * string literals, or an expression around `import.meta.glob(...)` with literal patterns. A list built
40
+ * any other way (a variable, a spread, a template with a substitution) is not read.
41
+ */
42
+ export function manifestListsIn(file: string, content: string): ManifestList[] {
43
+ if (!content.includes("buildManifest")) return []
44
+ const mask = codePositionMask(content)
45
+ const lists: ManifestList[] = []
46
+ for (const call of mask.matchAll(/\bbuildManifest\s*\(/g)) {
47
+ const start = call.index + call[0].length
48
+ const end = expressionEnd(mask, start)
49
+ const argument = mask.slice(start, end).trim()
50
+ const literals = [...content.slice(start, end).matchAll(LITERAL)].map((m) => m[2] ?? "")
51
+ if (literals.some((text) => text.includes("${"))) continue
52
+ let source: ManifestList["source"]
53
+ if (argument.startsWith("[") && argument.endsWith("]")) {
54
+ // Every element a literal: the mask, with literals blanked, holds only brackets and commas.
55
+ if (!/^\[[\s,]*\]$/.test(argument)) continue
56
+ source = "array"
57
+ } else if (argument.includes("import.meta.glob(")) {
58
+ source = "glob"
59
+ } else continue
60
+ lists.push({ file, line: lineAt(content, call.index), source, entries: literals })
61
+ }
62
+ return lists
63
+ }
64
+
65
+ function filesUnder(routesDir: string): readonly string[] {
66
+ try {
67
+ return readdirSync(routesDir, { recursive: true, encoding: "utf8" }).map((rel) =>
68
+ rel.replaceAll("\\", "/"),
69
+ )
70
+ } catch {
71
+ return []
72
+ }
73
+ }
74
+
75
+ const stem = (file: string): string => file.replace(/\.[^./]+$/, "")
76
+
77
+ /** A glob pattern from source, relative to `routes/` when it names that directory. */
78
+ function routesRelative(pattern: string): string {
79
+ const at = pattern.lastIndexOf("routes/")
80
+ return at === -1 ? pattern.replace(/^(?:\.{0,2}\/)+/, "") : pattern.slice(at + "routes/".length)
81
+ }
82
+
83
+ function reaches(list: ManifestList): (file: string) => boolean {
84
+ if (list.source === "array") {
85
+ const listed = new Set(list.entries.map((entry) => entry.replace(/^\.\//, "")))
86
+ return (file) => listed.has(file)
87
+ }
88
+ const glob = (pattern: string): ((file: string) => boolean) => {
89
+ const compiled = new Glob(routesRelative(pattern))
90
+ return (file) => compiled.match(file) || compiled.match(`routes/${file}`)
91
+ }
92
+ const include = list.entries.filter((p) => !p.startsWith("!")).map(glob)
93
+ const exclude = list.entries.filter((p) => p.startsWith("!")).map((p) => glob(p.slice(1)))
94
+ return (file) => include.some((match) => match(file)) && !exclude.some((match) => match(file))
95
+ }
96
+
97
+ /** The `routes/` directory nearest above `file`, up to and including `cwd`'s own, as a cwd-relative path. */
98
+ function routesDirFor(cwd: string, file: string): string | undefined {
99
+ for (let dir = dirname(file); ; dir = dirname(dir)) {
100
+ const candidate = dir === "." ? "routes" : `${dir}/routes`
101
+ const abs = join(cwd, candidate)
102
+ if (existsSync(abs) && statSync(abs).isDirectory()) return candidate
103
+ if (dir === "." || dir === "/" || dir === "") return undefined
104
+ }
105
+ }
106
+
107
+ /** The lists that reach a page under their app's `routes/` but not the backend half beside it. */
108
+ export function handBuiltManifests(
109
+ cwd: string,
110
+ lists: readonly ManifestList[],
111
+ ): HandBuiltManifest[] {
112
+ const found: HandBuiltManifest[] = []
113
+ const filesByDir = new Map<string, readonly string[]>()
114
+ for (const list of lists) {
115
+ const routesDir = routesDirFor(cwd, list.file)
116
+ if (routesDir === undefined) continue
117
+ let files = filesByDir.get(routesDir)
118
+ if (files === undefined) {
119
+ files = filesUnder(join(cwd, routesDir))
120
+ filesByDir.set(routesDir, files)
121
+ }
122
+ const halves = files.filter((file) => BACKEND_HALF.test(file))
123
+ if (halves.length === 0) continue
124
+ const listed = reaches(list)
125
+ const missing = halves.filter((half) => {
126
+ if (listed(half)) return false
127
+ const page = stem(half.replace(BACKEND_HALF, ""))
128
+ return files.some((file) => !BACKEND_HALF.test(file) && stem(file) === page && listed(file))
129
+ })
130
+ if (missing.length > 0) {
131
+ const shown = missing.map((half) => `${routesDir}/${half}`)
132
+ found.push({ file: list.file, line: list.line, source: list.source, missing: shown })
133
+ }
134
+ }
135
+ return found
136
+ }
137
+
138
+ /** The advisory `nifra doctor` prints for one finding. */
139
+ export function handBuiltManifestMessage(finding: HandBuiltManifest): string {
140
+ const list = finding.source === "array" ? "a file list" : "import.meta.glob patterns"
141
+ const shown = finding.missing.slice(0, 3)
142
+ const more = finding.missing.length > 3 ? ` and ${finding.missing.length - 3} more` : ""
143
+ return `${finding.file}:${finding.line} builds a route manifest from ${list} that leaves out ${finding.missing.length} backend half(s) of pages it lists (${shown.join(", ")}${more}). buildManifest pairs a page with its .backend.* half only when both are listed, so those pages run without their loaders, actions and guards. Use discoverRoutes("./routes") from @nifrajs/web/fs, or the generated server-manifest.`
144
+ }