@nifrajs/cli 4.0.2 → 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 (323) 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 +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  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 +21 -2
  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 +778 -64
  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/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
@@ -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]
@@ -74,6 +79,24 @@ registerFixRecipe({
74
79
  },
75
80
  })
76
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]
97
+ },
98
+ })
99
+
77
100
  registerFixRecipe({
78
101
  id: "contracts.snapshot",
79
102
  description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
@@ -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
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `nifra routes --hooks`: which hooks run for each route and for each prefix's unmatched paths, and the
3
+ * diff between two such reports (`--since <file>`, and `nifra upgrade`, which captures one before it
4
+ * writes). The report itself is `reflectHooks` from `@nifrajs/core/reflection`.
5
+ */
6
+ import type { ReflectedHook, ReflectedHookReport } from "@nifrajs/core/reflection"
7
+
8
+ /** Where `nifra upgrade --write` keeps the report it captured before editing anything. */
9
+ export const HOOKS_BEFORE_UPGRADE_FILE = ".nifra/hooks-before-upgrade.json"
10
+
11
+ export async function collectHookReport(backend: unknown): Promise<ReflectedHookReport> {
12
+ const { reflectHooks } = await import("@nifrajs/core/reflection")
13
+ return reflectHooks(backend)
14
+ }
15
+
16
+ const entryText = (hook: ReflectedHook): string =>
17
+ `${hook.kind} ${hook.name}${hook.headers === undefined ? "" : ` [${hook.headers.join(", ")}]`} (${hook.site})`
18
+
19
+ /** One line per hook, keyed by `METHOD /path` for routes and `unmatched /prefix` for the rest. */
20
+ export function hookReportTargets(report: ReflectedHookReport): Map<string, readonly string[]> {
21
+ const targets = new Map<string, readonly string[]>()
22
+ for (const route of report.routes) {
23
+ targets.set(`${route.method} ${route.path}`, route.hooks.map(entryText))
24
+ }
25
+ for (const entry of report.unmatched) {
26
+ targets.set(`unmatched ${entry.prefix}`, entry.hooks.map(entryText))
27
+ }
28
+ return targets
29
+ }
30
+
31
+ export function renderHookReport(report: ReflectedHookReport): string {
32
+ const targets = hookReportTargets(report)
33
+ if (targets.size === 0) return "no backend routes to report hooks for"
34
+ const lines: string[] = []
35
+ for (const [target, hooks] of targets) {
36
+ lines.push(target)
37
+ if (hooks.length === 0) lines.push(" (no hooks)")
38
+ for (const hook of hooks) lines.push(` ${hook}`)
39
+ }
40
+ if (!report.complete) {
41
+ lines.push(
42
+ "",
43
+ "Some hooks have no recorded origin (site `unknown`): origins are recorded only outside production builds.",
44
+ )
45
+ }
46
+ return lines.join("\n")
47
+ }
48
+
49
+ export interface HookReportChange {
50
+ readonly target: string
51
+ readonly added: readonly string[]
52
+ readonly removed: readonly string[]
53
+ }
54
+
55
+ /** What runs differently: per route or unmatched prefix, the hooks it gained and lost. */
56
+ export function diffHookReports(
57
+ before: ReflectedHookReport,
58
+ after: ReflectedHookReport,
59
+ ): readonly HookReportChange[] {
60
+ const old = hookReportTargets(before)
61
+ const now = hookReportTargets(after)
62
+ const changes: HookReportChange[] = []
63
+ const minus = (from: readonly string[], take: readonly string[]): string[] => {
64
+ const left = [...take]
65
+ return from.filter((entry) => {
66
+ const index = left.indexOf(entry)
67
+ if (index === -1) return true
68
+ left.splice(index, 1)
69
+ return false
70
+ })
71
+ }
72
+ const targets = [...new Set([...old.keys(), ...now.keys()])].sort()
73
+ for (const target of targets) {
74
+ const was = old.get(target) ?? []
75
+ const is = now.get(target) ?? []
76
+ const added = minus(is, was)
77
+ const removed = minus(was, is)
78
+ if (added.length > 0 || removed.length > 0) changes.push({ target, added, removed })
79
+ }
80
+ return changes
81
+ }
82
+
83
+ export function renderHookDiff(changes: readonly HookReportChange[]): string {
84
+ if (changes.length === 0) return "hook scope unchanged"
85
+ const lines: string[] = []
86
+ for (const change of changes) {
87
+ lines.push(change.target)
88
+ for (const entry of change.added) lines.push(` + ${entry}`)
89
+ for (const entry of change.removed) lines.push(` - ${entry}`)
90
+ }
91
+ return lines.join("\n")
92
+ }
93
+
94
+ const field = (value: unknown, key: string): unknown =>
95
+ typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined
96
+
97
+ const hooksValid = (row: unknown): boolean => {
98
+ const hooks = field(row, "hooks")
99
+ return (
100
+ Array.isArray(hooks) &&
101
+ hooks.every(
102
+ (hook) =>
103
+ typeof field(hook, "kind") === "string" &&
104
+ typeof field(hook, "name") === "string" &&
105
+ typeof field(hook, "site") === "string",
106
+ )
107
+ )
108
+ }
109
+
110
+ function isHookReport(value: unknown): value is ReflectedHookReport {
111
+ const routes = field(value, "routes")
112
+ const unmatched = field(value, "unmatched")
113
+ return (
114
+ typeof field(value, "complete") === "boolean" &&
115
+ Array.isArray(routes) &&
116
+ Array.isArray(unmatched) &&
117
+ routes.every(
118
+ (row) =>
119
+ typeof field(row, "method") === "string" &&
120
+ typeof field(row, "path") === "string" &&
121
+ hooksValid(row),
122
+ ) &&
123
+ unmatched.every((row) => typeof field(row, "prefix") === "string" && hooksValid(row))
124
+ )
125
+ }
126
+
127
+ /** A saved report, parsed; `undefined` when the text is not one. */
128
+ export function parseHookReport(text: string): ReflectedHookReport | undefined {
129
+ try {
130
+ const value: unknown = JSON.parse(text)
131
+ return isHookReport(value) ? value : undefined
132
+ } catch {
133
+ return undefined
134
+ }
135
+ }
@@ -9,8 +9,8 @@
9
9
  * `## Project structure` section, appended when missing (or a minimal
10
10
  * file if none exists)
11
11
  *
12
- * The generators are imported from `create-nifra/agent-files` - the SAME source of truth `create-nifra`
13
- * uses at scaffold time - so a retrofitted app and a freshly scaffolded one get byte-identical configs.
12
+ * The generators come from `./agent-files.ts`, a byte-identical copy of the module `create-nifra` uses at
13
+ * scaffold time, so a retrofitted app and a freshly scaffolded one get byte-identical configs.
14
14
  *
15
15
  * Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
16
16
  * customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
@@ -44,7 +44,7 @@ import {
44
44
  MCP_SERVER_COMMAND,
45
45
  mcpJson,
46
46
  STRUCTURE_HEADING,
47
- } from "create-nifra/agent-files"
47
+ } from "./agent-files.ts"
48
48
  import { installedNifraVersion, resolveRootState } from "./mcp-root.ts"
49
49
 
50
50
  /** What happened to one file during the retrofit, for the printed report + the `--json` shape. */
@@ -121,13 +121,17 @@ async function assertSafeParents(cwd: string, abs: string): Promise<void> {
121
121
  }
122
122
  }
123
123
 
124
- /** Refuse to write anything that is not a plain absent-or-regular file: a symlink here would
125
- * redirect the write, and a directory or device node would make it fail in a confusing way. */
126
- async function assertRegularTarget(abs: string, rel: string): Promise<void> {
124
+ /** Never write anything that is not a plain absent-or-regular file: a symlink here would redirect
125
+ * the write, and a directory or device node would make it fail in a confusing way. Such a file is
126
+ * reported and left alone rather than stopping the run - a symlinked CLAUDE.md is a common alias
127
+ * for AGENTS.md. */
128
+ async function nonRegularTarget(
129
+ abs: string,
130
+ rel: string,
131
+ ): Promise<InitAgentsFileResult | undefined> {
127
132
  const stat = await lstatOrMissing(abs)
128
- if (stat !== undefined && (stat.isSymbolicLink() || !stat.isFile())) {
129
- throw new Error(`[nifra] refusing to write non-regular file: ${rel}`)
130
- }
133
+ if (stat === undefined || (stat.isFile() && !stat.isSymbolicLink())) return undefined
134
+ return { path: rel, action: "skipped", note: "not a regular file - left alone" }
131
135
  }
132
136
 
133
137
  /**
@@ -142,7 +146,8 @@ async function writeOwned(
142
146
  ): Promise<InitAgentsFileResult> {
143
147
  const abs = safeJoin(cwd, rel)
144
148
  await assertSafeParents(cwd, abs)
145
- await assertRegularTarget(abs, rel)
149
+ const irregular = await nonRegularTarget(abs, rel)
150
+ if (irregular !== undefined) return irregular
146
151
  if (!force && (await fileExists(abs))) {
147
152
  return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
148
153
  }
@@ -180,7 +185,8 @@ async function atomicWrite(
180
185
  async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
181
186
  const abs = safeJoin(cwd, AGENTS_MD_PATH)
182
187
  await assertSafeParents(cwd, abs)
183
- await assertRegularTarget(abs, AGENTS_MD_PATH)
188
+ const irregular = await nonRegularTarget(abs, AGENTS_MD_PATH)
189
+ if (irregular !== undefined) return irregular
184
190
  const sections = [{ heading: "## MCP server", text: agentsMcpSection() }]
185
191
  if (await isDirectory(safeJoin(cwd, "routes"))) {
186
192
  sections.push({ heading: STRUCTURE_HEADING, text: agentsStructureSection() })
@@ -0,0 +1,191 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join, relative } from "node:path"
3
+ import { CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
4
+
5
+ const NIFRA_PACKAGE = /^(?:@nifrajs\/|nifra$)/
6
+ const DEPENDENCY_FIELDS = [
7
+ "dependencies",
8
+ "devDependencies",
9
+ "peerDependencies",
10
+ "optionalDependencies",
11
+ ]
12
+
13
+ /** Whether a package.json text names `nifra` or a `@nifrajs/` package, or carries a `nifra` setting. */
14
+ function declaresNifra(manifest: string): boolean {
15
+ let parsed: unknown
16
+ try {
17
+ parsed = JSON.parse(manifest)
18
+ } catch {
19
+ // A manifest nobody can read proves nothing either way, so the directory stays checked.
20
+ return true
21
+ }
22
+ if (typeof parsed !== "object" || parsed === null) return true
23
+ if ("nifra" in parsed) return true
24
+ return DEPENDENCY_FIELDS.some((field) => {
25
+ const deps: unknown = Reflect.get(parsed, field)
26
+ return (
27
+ typeof deps === "object" &&
28
+ deps !== null &&
29
+ Object.keys(deps).some((name) => NIFRA_PACKAGE.test(name))
30
+ )
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Whether a project-relative directory ("" for the root) belongs to a nifra project, read statically.
36
+ * The checked root always does: running the check there says so. Below it, walking up, the first
37
+ * directory with a nifra config decides yes, and the first package.json decides by naming `nifra` or
38
+ * a `@nifrajs/` package, or carrying a `nifra` setting; a nested package that does none of that is
39
+ * another project, such as another framework's app kept beside this one.
40
+ */
41
+ export function createNifraProjectProbe(root: string): (dir: string) => boolean {
42
+ const cache = new Map<string, boolean>()
43
+ const probe = (dir: string): boolean => {
44
+ const cached = cache.get(dir)
45
+ if (cached !== undefined) return cached
46
+ const at = join(root, dir)
47
+ let nifra: boolean
48
+ if (dir === "" || existsSync(join(at, CONFIG_FILE)) || existsSync(join(at, FRAMEWORK_FILE)))
49
+ nifra = true
50
+ else if (existsSync(join(at, "package.json"))) {
51
+ let manifest: string
52
+ try {
53
+ manifest = readFileSync(join(at, "package.json"), "utf8")
54
+ } catch {
55
+ manifest = ""
56
+ }
57
+ nifra = declaresNifra(manifest)
58
+ } else nifra = probe(dir.slice(0, Math.max(dir.lastIndexOf("/"), 0)))
59
+ cache.set(dir, nifra)
60
+ return nifra
61
+ }
62
+ return probe
63
+ }
64
+
65
+ export type ProjectKind = "api" | "web"
66
+
67
+ export interface KindDeclaration {
68
+ /** `"api"` or `"web"` from `"nifra": { "kind" }`, or `undefined` when the package declares none. */
69
+ readonly kind: ProjectKind | undefined
70
+ /** The package.json that decides, relative to the checked root (`../package.json` above it). */
71
+ readonly manifest: string | undefined
72
+ /** The problem with a declared value that is neither `"api"` nor `"web"`. */
73
+ readonly invalid?: string
74
+ }
75
+
76
+ function readJson(path: string): unknown {
77
+ try {
78
+ return JSON.parse(readFileSync(path, "utf8"))
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
85
+ typeof value === "object" && value !== null && !Array.isArray(value)
86
+ ? Object.fromEntries(Object.entries(value))
87
+ : undefined
88
+
89
+ /**
90
+ * The `"nifra": { "kind" }` that governs a project-relative directory: read from the nearest
91
+ * package.json at or above it, the way the zone setting is read, with no app code run.
92
+ */
93
+ export function createKindProbe(root: string): (dir: string) => KindDeclaration {
94
+ const cache = new Map<string, KindDeclaration>()
95
+ const probe = (absolute: string): KindDeclaration => {
96
+ const cached = cache.get(absolute)
97
+ if (cached !== undefined) return cached
98
+ let declaration: KindDeclaration
99
+ const manifestPath = join(absolute, "package.json")
100
+ if (existsSync(manifestPath)) {
101
+ const manifest = relative(root, manifestPath).replaceAll("\\", "/")
102
+ const kind = recordOf(recordOf(readJson(manifestPath))?.nifra)?.kind
103
+ if (kind === undefined) declaration = { kind: undefined, manifest }
104
+ else if (kind === "api" || kind === "web") declaration = { kind, manifest }
105
+ else
106
+ declaration = {
107
+ kind: undefined,
108
+ manifest,
109
+ invalid: `[nifra] ${manifest} "nifra.kind" must be "api" or "web", not ${JSON.stringify(kind)}`,
110
+ }
111
+ } else {
112
+ const parent = dirname(absolute)
113
+ declaration = parent === absolute ? { kind: undefined, manifest: undefined } : probe(parent)
114
+ }
115
+ cache.set(absolute, declaration)
116
+ return declaration
117
+ }
118
+ return (dir) => probe(join(root, dir))
119
+ }
120
+
121
+ /** Whether a project-relative directory holds web config: a `nifra.config.ts` exporting `adapter`, or
122
+ * `backend/framework.ts`. */
123
+ export function hasWebConfig(root: string, dir: string): boolean {
124
+ if (existsSync(join(root, dir, FRAMEWORK_FILE))) return true
125
+ const config = join(root, dir, CONFIG_FILE)
126
+ if (!existsSync(config)) return false
127
+ let text: string
128
+ try {
129
+ text = readFileSync(config, "utf8")
130
+ } catch {
131
+ return true
132
+ }
133
+ return /\bexport\s+(?:const|let|var|function)\s+adapter\b|\bexport\s*\{[^}]*\badapter\b/.test(
134
+ text,
135
+ )
136
+ }
137
+
138
+ const WEB_PACKAGE = /^@nifrajs\/web(?:-[a-z0-9-]+)?$/
139
+
140
+ /**
141
+ * Whether a project-relative directory ("" for the root) is inside a nifra web app. Walking up, the
142
+ * first directory with `nifra.config.ts` or `backend/framework.ts` makes it one; the first package.json
143
+ * decides instead: `"nifra": { "kind": "api" }` says no, otherwise naming `@nifrajs/web` or an adapter
144
+ * says yes. Nothing up to the root means no.
145
+ */
146
+ export function createWebAppProbe(root: string): (dir: string) => boolean {
147
+ const cache = new Map<string, boolean>()
148
+ const probe = (dir: string): boolean => {
149
+ const cached = cache.get(dir)
150
+ if (cached !== undefined) return cached
151
+ const at = join(root, dir)
152
+ let web: boolean
153
+ if (existsSync(join(at, CONFIG_FILE)) || existsSync(join(at, FRAMEWORK_FILE))) web = true
154
+ else if (existsSync(join(at, "package.json"))) {
155
+ const manifest = recordOf(readJson(join(at, "package.json")))
156
+ web =
157
+ recordOf(manifest?.nifra)?.kind !== "api" &&
158
+ DEPENDENCY_FIELDS.some((field) => {
159
+ const deps = recordOf(manifest?.[field])
160
+ return deps !== undefined && Object.keys(deps).some((name) => WEB_PACKAGE.test(name))
161
+ })
162
+ } else web = dir !== "" && probe(dir.slice(0, Math.max(dir.lastIndexOf("/"), 0)))
163
+ cache.set(dir, web)
164
+ return web
165
+ }
166
+ return probe
167
+ }
168
+
169
+ /** A zone or server-only finding outside any web app, with the API-only setting named. */
170
+ export const withApiKindHint = (message: string): string =>
171
+ `${message}${message.endsWith(".") ? "" : "."} If this project serves no pages, declare it in package.json: "nifra": { "kind": "api" }`
172
+
173
+ /**
174
+ * Whether `"nifra": { "kind": "api" }` takes a project-relative directory out of the zone checks. It
175
+ * does only where no web config sits at or above the directory, up to the checked root: an app that
176
+ * serves pages keeps its zone checks whatever its package.json says (NF-C037 reports the clash).
177
+ */
178
+ export function createApiOnlyProbe(root: string): (dir: string) => boolean {
179
+ const kindOf = createKindProbe(root)
180
+ const web = new Map<string, boolean>()
181
+ const webConfigUp = (dir: string): boolean => {
182
+ const cached = web.get(dir)
183
+ if (cached !== undefined) return cached
184
+ const found =
185
+ hasWebConfig(root, dir) ||
186
+ (dir !== "" && webConfigUp(dir.slice(0, Math.max(dir.lastIndexOf("/"), 0))))
187
+ web.set(dir, found)
188
+ return found
189
+ }
190
+ return (dir) => kindOf(dir).kind === "api" && !webConfigUp(dir)
191
+ }