@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
@@ -5,7 +5,7 @@ import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
5
5
  import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
6
6
  import { SINGLE_COPY_REGISTER_SPECIFIER } from "@nifrajs/core/single-copy"
7
7
  import type { CapabilityProjectReport } from "../capabilities-tool.ts"
8
- import type { SourceFinding, StaticRouteFinding } from "../check-scan.ts"
8
+ import type { SourceFinding, StaticRouteFinding, TypedClientFinding } from "../check-scan.ts"
9
9
  import {
10
10
  IDENT,
11
11
  movedExportSites,
@@ -22,7 +22,11 @@ import {
22
22
  } from "../diagnostics.ts"
23
23
  import type { DuplicateInstallFinding } from "../doctor.ts"
24
24
  import { codeUnitOrder } from "../internal/code-unit-order.ts"
25
+ import { createWebAppProbe, withApiKindHint } from "../internal/nifra-project.ts"
25
26
  import { unsupportedTypeScriptDiagnostic } from "../internal/typescript-import.ts"
27
+ import { zoneFolderOf } from "../typecheck-coverage.ts"
28
+ import { versionDriftMessage } from "../version-drift.ts"
29
+ import { MISSING_EXPECTATION_REASON } from "./comment-markers.ts"
26
30
  import type { CheckRule, RuleContext } from "./index.ts"
27
31
 
28
32
  /** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
@@ -89,13 +93,13 @@ export const LEGACY_RULE_ORDER: readonly string[] = Object.freeze([
89
93
  ])
90
94
 
91
95
  const FETCH_HINT =
92
- "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
96
+ "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift; for a call no typed client covers (a foreign handler, a static file), add a `// nifra-expect NF-C002: <reason>` comment"
93
97
  const STREAM_HINT =
94
- "hand-rolled EventSource/WebSocket to your own API - subscribe through client<typeof app> (`.subscribe()` for `app.sse()` routes, `.ws()` for `app.ws()` routes) so the compiler catches drift"
98
+ "hand-rolled EventSource/WebSocket to your own API - subscribe through client<typeof app> (`.subscribe()` for `app.sse()` routes, `.ws()` for `app.ws()` routes) so the compiler catches drift; for a stream no typed client covers, add a `// nifra-expect NF-C002: <reason>` comment"
95
99
  const SERVER_IMPORT_HINT =
96
100
  "server-only code in browser code (a route's frontend half, frontend/ or shared/) - reach it from the route's backend half (x.backend.ts) or a *.fn.ts server function; a dynamic import() is bundled too"
97
101
  const RESPONSE_ROUTE_HINT =
98
- "route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
102
+ "route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect NF-C007: <reason>` comment to mark it and silence this"
99
103
  const PIPELINE_DOC_HINT =
100
104
  "nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
101
105
  const UNDECLARED_DEP_HINT =
@@ -124,6 +128,7 @@ interface LegacyFields {
124
128
  readonly message: string
125
129
  readonly evidence?: readonly string[]
126
130
  readonly chain?: readonly string[]
131
+ readonly modules?: readonly string[]
127
132
  readonly fix?: string
128
133
  /** A recipe for this one finding, when the rule's findings are not all mechanically fixable. */
129
134
  readonly recipe?: Diagnostic["fix"]
@@ -163,6 +168,7 @@ function legacyDiagnostic(rule: string, fields: LegacyFields): Diagnostic {
163
168
  ...(fields.fix === undefined ? {} : { fix: fields.fix }),
164
169
  ...(fields.suggestion === undefined ? {} : { suggestion: fields.suggestion }),
165
170
  ...(fields.chain === undefined ? {} : { chain: fields.chain }),
171
+ ...(fields.modules === undefined ? {} : { modules: fields.modules }),
166
172
  ...(fields.includeCode === true ? { includeCode: true } : {}),
167
173
  }
168
174
  return diagnosticWithCompatibility(canonical, compatibility)
@@ -312,7 +318,7 @@ function responseRouteSuggestion(): DiagnosticSuggestion {
312
318
  steps: [
313
319
  "Prefer returning a plain object from JSON routes; nifra serializes it for you.",
314
320
  "For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
315
- "If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect raw-response` comment above the return to mark it intentional and silence this advisory.",
321
+ "If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect NF-C007: <reason>` comment above the return to mark it intentional and silence this advisory.",
316
322
  ],
317
323
  }
318
324
  }
@@ -378,35 +384,93 @@ const typecheckRule: CheckRule = {
378
384
  )
379
385
  }
380
386
  }
387
+ findings.push(...outsideProgramFindings(tc.outsideProgram ?? []))
381
388
  return findings
382
389
  },
383
390
  }
384
391
 
392
+ /** One finding per zone folder: the fix (tsconfig `include`) is per folder, not per file. */
393
+ function outsideProgramFindings(files: readonly string[]): Diagnostic[] {
394
+ const byFolder = new Map<string, { readonly first: string; readonly group: string[] }>()
395
+ for (const file of files) {
396
+ const folder = zoneFolderOf(file) ?? file
397
+ const entry = byFolder.get(folder)
398
+ if (entry === undefined) byFolder.set(folder, { first: file, group: [file] })
399
+ else entry.group.push(file)
400
+ }
401
+ return [...byFolder].map(([folder, { first, group }]) => {
402
+ const shown = group.slice(0, 3).join(", ")
403
+ const more = group.length > 3 ? ` and ${group.length - 3} more` : ""
404
+ return legacyDiagnostic("typecheck", {
405
+ severity: "error",
406
+ file: first,
407
+ message: `${group.length} file(s) under ${folder}/ are outside tsc's program, so the typecheck passed without reading them: ${shown}${more}`,
408
+ evidence: group,
409
+ fix: `Add "${folder}" to tsconfig.json "include" (or list the files), then run \`nifra check\` again`,
410
+ suggestion: {
411
+ kind: "manual",
412
+ title: "Bring the folder into the TypeScript program",
413
+ steps: [
414
+ `Add "${folder}" to the "include" array in tsconfig.json.`,
415
+ 'Files kept out on purpose: name them in the tsconfig "exclude", or under "typecheck": { "ignore": [...] } in nifra.check.json.',
416
+ "Run `nifra check` again; the newly checked files may report type errors.",
417
+ ],
418
+ },
419
+ })
420
+ })
421
+ }
422
+
423
+ /**
424
+ * One NF-C002 finding. A `// nifra-expect NF-C002: <reason>` comment keeps it in the report as `info`,
425
+ * carrying the comment as evidence; the same comment without a reason silences nothing.
426
+ */
427
+ function typedClientDiagnostic(
428
+ finding: TypedClientFinding,
429
+ hint: string,
430
+ kind: string,
431
+ suggestion: DiagnosticSuggestion,
432
+ ): Diagnostic {
433
+ const reason = finding.expected?.reason
434
+ if (reason !== undefined) {
435
+ return legacyDiagnostic("typed-client", {
436
+ severity: "info",
437
+ file: finding.file,
438
+ line: finding.line,
439
+ message: `${finding.snippet} - ${kind} is expected here`,
440
+ evidence: [`nifra-expect NF-C002: ${reason}`],
441
+ })
442
+ }
443
+ const unreasoned = finding.expected === undefined ? "" : `; ${MISSING_EXPECTATION_REASON}`
444
+ return legacyDiagnostic("typed-client", {
445
+ severity: "error",
446
+ file: finding.file,
447
+ line: finding.line,
448
+ message: `${finding.snippet} - ${hint}${unreasoned}`,
449
+ fix: hint,
450
+ suggestion,
451
+ })
452
+ }
453
+
385
454
  const typedClientRule: CheckRule = {
386
455
  code: LEGACY_RULE_CODES["typed-client"]!,
387
456
  title: TITLES["typed-client"]!,
388
457
  async scan(ctx) {
389
458
  const routes = staticRouteMap(ctx.project.routes)
390
- const fetchFindings = [...ctx.project.sourceFindings.fetches].sort(bySite).map((finding) =>
391
- legacyDiagnostic("typed-client", {
392
- severity: "error",
393
- file: finding.file,
394
- line: finding.line,
395
- message: `${finding.snippet} - ${FETCH_HINT}`,
396
- fix: FETCH_HINT,
397
- suggestion: ownFetchSuggestion(finding, routes),
398
- }),
399
- )
400
- const streamFindings = [...ctx.project.sourceFindings.streams].sort(bySite).map((finding) =>
401
- legacyDiagnostic("typed-client", {
402
- severity: "error",
403
- file: finding.file,
404
- line: finding.line,
405
- message: `${finding.snippet} - ${STREAM_HINT}`,
406
- fix: STREAM_HINT,
407
- suggestion: ownStreamSuggestion(),
408
- }),
409
- )
459
+ const fetchFindings = [...ctx.project.sourceFindings.fetches]
460
+ .sort(bySite)
461
+ .map((finding) =>
462
+ typedClientDiagnostic(
463
+ finding,
464
+ FETCH_HINT,
465
+ "own-API fetch",
466
+ ownFetchSuggestion(finding, routes),
467
+ ),
468
+ )
469
+ const streamFindings = [...ctx.project.sourceFindings.streams]
470
+ .sort(bySite)
471
+ .map((finding) =>
472
+ typedClientDiagnostic(finding, STREAM_HINT, "own-API stream", ownStreamSuggestion()),
473
+ )
410
474
  return [...fetchFindings, ...streamFindings].sort(
411
475
  (a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
412
476
  )
@@ -468,19 +532,31 @@ const serverOnlyImportRule: CheckRule = {
468
532
  code: LEGACY_RULE_CODES["server-only-import"]!,
469
533
  title: TITLES["server-only-import"]!,
470
534
  async scan(ctx) {
471
- return [...ctx.project.importGraph].sort(bySite).map((finding) => {
535
+ const inWebApp = createWebAppProbe(ctx.root)
536
+ const findings: Diagnostic[] = []
537
+ // No component module got a receipt, so each is judged as an ordinary import: say why.
538
+ const components = ctx.project.check.componentCheckUnsupported
539
+ if (components !== undefined)
540
+ findings.push(unsupportedTypeScriptDiagnostic(components.version, components.message))
541
+ for (const finding of [...ctx.project.importGraph].sort(bySite)) {
472
542
  const chain = finding.chain
473
543
  const sink = chain[chain.length - 1] ?? finding.specifier
474
- return legacyDiagnostic("server-only-import", {
544
+ const dir = finding.file.slice(0, Math.max(finding.file.lastIndexOf("/"), 0))
545
+ const found = legacyDiagnostic("server-only-import", {
475
546
  severity: "error",
476
547
  file: finding.file,
477
548
  line: finding.line,
478
- message: `${finding.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${finding.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
549
+ message: (inWebApp(dir) ? (text: string) => text : withApiKindHint)(
550
+ `${finding.snippet} - server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${finding.fallback ? " (direct edge - couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
551
+ ),
479
552
  fix: SERVER_IMPORT_HINT,
480
553
  chain,
554
+ modules: finding.modules,
481
555
  suggestion: serverImportSuggestion(finding.specifier, chain, finding.fallback),
482
556
  })
483
- })
557
+ findings.push(found)
558
+ }
559
+ return findings
484
560
  },
485
561
  }
486
562
 
@@ -507,6 +583,18 @@ const interpolatedSqlRule: CheckRule = {
507
583
  )
508
584
  }
509
585
  for (const finding of [...ctx.project.sourceFindings.interpolatedSql].sort(bySite)) {
586
+ if (finding.expected !== undefined) {
587
+ findings.push(
588
+ legacyDiagnostic("interpolated-sql", {
589
+ severity: "info",
590
+ file: finding.file,
591
+ line: finding.line,
592
+ message: `${finding.snippet} - interpolated SQL is expected here`,
593
+ evidence: [`nifra-expect NF-C006: ${finding.expected}`],
594
+ }),
595
+ )
596
+ continue
597
+ }
510
598
  findings.push(
511
599
  legacyDiagnostic("interpolated-sql", {
512
600
  severity: "error",
@@ -522,7 +610,7 @@ const interpolatedSqlRule: CheckRule = {
522
610
  "Pass the value as an argument alongside the statement, so the driver binds it.",
523
611
  `Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
524
612
  "An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
525
- "If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect sql-dynamic: <reason>` comment on the line above - the reason is required.",
613
+ "If the statement is dynamic but every value is already bound (generating `($1),($2),…` for a batch insert), mark it with a `// nifra-expect NF-C006: <reason>` comment on the line above - the reason is required.",
526
614
  ],
527
615
  },
528
616
  }),
@@ -556,31 +644,61 @@ function copyLines(finding: DuplicateInstallFinding): string[] {
556
644
  )
557
645
  }
558
646
 
559
- function duplicateSuggestion(finding: DuplicateInstallFinding): DiagnosticSuggestion {
560
- const lines = copyLines(finding)
647
+ /** One fix for duplicates that share a cause and topology, so a linked sibling repository that
648
+ * duplicates every package reads as one finding instead of one paragraph per package. */
649
+ function duplicateSuggestion(group: readonly DuplicateInstallFinding[]): DiagnosticSuggestion {
650
+ const names = group.map((finding) => `"${finding.package}"`).join(", ")
561
651
  return {
562
652
  kind: "manual",
563
- title: `Resolve ${finding.package} to one loaded copy`,
653
+ title:
654
+ group.length === 1
655
+ ? `Resolve ${group[0]?.package} to one loaded copy`
656
+ : `Resolve each of ${group.length} packages to one loaded copy`,
564
657
  diff: [
565
658
  "package.json:",
566
- ` "nifra": { "singleCopy": ["${finding.package}"] }`,
659
+ ` "nifra": { "singleCopy": [${names}] }`,
567
660
  "bunfig.toml:",
568
661
  ` preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`,
569
662
  " [test]",
570
663
  ` preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`,
571
664
  ].join("\n"),
572
665
  steps: [
573
- ...lines.map((line) => `Copy: ${line}`),
574
- ...(finding.provenance === undefined
575
- ? []
576
- : [`Planted links: ${finding.provenance} Try this before the fixes below.`]),
666
+ ...group.flatMap(copyLines).map((line) => `Copy: ${line}`),
667
+ ...group.flatMap((finding) =>
668
+ finding.provenance === undefined
669
+ ? []
670
+ : [
671
+ `Planted links${group.length === 1 ? "" : ` (${finding.package})`}: ${finding.provenance} Try this before the fixes below.`,
672
+ ],
673
+ ),
577
674
  "Fix 1 - deduplicate: align workspace dependency and peer ranges on one compatible version, remove stale nested installs, and reinstall from the workspace root so every importer resolves one physical copy.",
578
- `Fix 2 - declare single-copy: apply the package.json and bunfig.toml config printed above; nifra then rewrites every duplicate to this app's copy.${finding.cause === "version-skew" ? " A declaration only covers same-version duplicates, so fix 1's range alignment must land first." : ""}`,
675
+ `Fix 2 - declare single-copy: apply the package.json and bunfig.toml config printed above; nifra then rewrites every duplicate to this app's copy.${group.some((finding) => finding.cause === "version-skew") ? " A declaration only covers same-version duplicates, so fix 1's range alignment must land first." : ""}`,
579
676
  "Re-run `nifra check`; the gate stays failing until one fix lands.",
580
677
  ],
581
678
  }
582
679
  }
583
680
 
681
+ function duplicateDiagnostic(group: readonly DuplicateInstallFinding[]): Diagnostic | undefined {
682
+ const first = group[0]
683
+ if (first === undefined) return undefined
684
+ const context = `${first.topology === undefined ? "" : `. Topology: ${first.topology}`}${first.scope === undefined ? "" : `. Scope: ${first.scope}`}`
685
+ const skew = first.cause === "version-skew"
686
+ const names = group.map((finding) => finding.package)
687
+ return legacyDiagnostic("duplicate-install", {
688
+ severity: "error",
689
+ message:
690
+ group.length === 1
691
+ ? `${first.package} identity preflight found ${first.cause} - ${first.explanation}${context}`
692
+ : `${group.length} packages share one identity preflight finding, ${first.cause} - ${names.join(", ")} ${skew ? "each resolve to multiple versions and physical paths" : "are each loaded from more than one physical path"}, so module state and symbols are not shared${context}`,
693
+ fix:
694
+ group.length === 1 || skew
695
+ ? first.remediation
696
+ : `Deduplicate so each resolves to a single physical path, or declare them single-copy: add "nifra": { "singleCopy": [${names.map((name) => `"${name}"`).join(", ")}] } to package.json and preload "${SINGLE_COPY_REGISTER_SPECIFIER}" from bunfig.toml.`,
697
+ evidence: group.flatMap(copyLines),
698
+ suggestion: duplicateSuggestion(group),
699
+ })
700
+ }
701
+
584
702
  const doctorRules: readonly CheckRule[] = Object.freeze([
585
703
  {
586
704
  code: LEGACY_RULE_CODES["undeclared-dependency"]!,
@@ -610,20 +728,14 @@ const doctorRules: readonly CheckRule[] = Object.freeze([
610
728
  async scan(ctx: RuleContext) {
611
729
  const doctor = ctx.project.packages.doctor
612
730
  if (!doctor.ran) return []
613
- const findings: Diagnostic[] = []
731
+ // Duplicates with one cause and one layout (every package a linked sibling repo installs again)
732
+ // have one fix, so they are one finding; `identityPreflight` in --json keeps each package.
733
+ const groups = new Map<string, DuplicateInstallFinding[]>()
614
734
  for (const finding of doctor.duplicateInstalls) {
615
- const copies = copyLines(finding)
616
- findings.push(
617
- legacyDiagnostic("duplicate-install", {
618
- severity: "error",
619
- message: `${finding.package} identity preflight found ${finding.cause} - ${finding.explanation}${finding.topology === undefined ? "" : `. Topology: ${finding.topology}`}${finding.scope === undefined ? "" : `. Scope: ${finding.scope}`}`,
620
- fix: finding.remediation,
621
- evidence: copies,
622
- suggestion: duplicateSuggestion(finding),
623
- }),
624
- )
735
+ const key = [finding.cause, finding.topology ?? "", finding.scope ?? ""].join("\0")
736
+ groups.set(key, [...(groups.get(key) ?? []), finding])
625
737
  }
626
- return findings
738
+ return [...groups.values()].flatMap((group) => duplicateDiagnostic(group) ?? [])
627
739
  },
628
740
  },
629
741
  {
@@ -658,6 +770,26 @@ const doctorRules: readonly CheckRule[] = Object.freeze([
658
770
  },
659
771
  ])
660
772
 
773
+ /** Read from the doctor result check already collected; it spawns nothing and edits nothing. */
774
+ const versionDriftRule: CheckRule = {
775
+ code: "NF-D002",
776
+ title: "Mixed nifra versions",
777
+ async scan(ctx) {
778
+ const drift = ctx.project.packages.doctor.versionDrift
779
+ if (drift === undefined) return []
780
+ return [
781
+ diagnostic({
782
+ code: "NF-D002",
783
+ severity: "info",
784
+ message: versionDriftMessage(drift),
785
+ evidence: drift.versions.map(({ version }) => version),
786
+ fix: { recipe: "upgrade.align-versions", command: `nifra upgrade ${drift.highest}` },
787
+ verify: "nifra doctor",
788
+ }),
789
+ ]
790
+ },
791
+ }
792
+
661
793
  const pipelineRule: CheckRule = {
662
794
  code: LEGACY_RULE_CODES.pipeline!,
663
795
  title: TITLES.pipeline!,
@@ -995,6 +1127,7 @@ export const legacyRules: readonly CheckRule[] = Object.freeze([
995
1127
  interpolatedSqlRule,
996
1128
  responseRouteRule,
997
1129
  ...doctorRules,
1130
+ versionDriftRule,
998
1131
  pipelineRule,
999
1132
  serverManifestRule,
1000
1133
  manifestRule,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * NF-C027: a page file whose URL sits under the backend's mount path.
3
3
  *
4
- * `backend/app.ts`'s backend is mounted at `apiPrefix` (default `/api`) ahead of page routing, and
4
+ * `backend/app.ts`'s backend is mounted at `apiPrefix`, when one is set, ahead of page routing, and
5
5
  * its 404 is final, so `routes/api/report.tsx` never renders - every request for it gets the
6
6
  * backend's 404. `createWebApp` refuses such an app at startup and `nifra build` refuses to build
7
7
  * it; this reports it earlier, without running app code (check's pre-`loadApp` invariant): the
@@ -15,10 +15,15 @@ import { existsSync, readFileSync } from "node:fs"
15
15
  import { join } from "node:path"
16
16
  import { discoverRoutes } from "@nifrajs/web/fs"
17
17
  import { shadowedPages } from "@nifrajs/web/route-manifest"
18
+ import type * as TSApi from "typescript"
18
19
  import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
19
20
  import { stripComments } from "../check-scan.ts"
20
21
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
22
+ import { codeUnitOrder } from "../internal/code-unit-order.ts"
23
+ import { hasWebConfig } from "../internal/nifra-project.ts"
24
+ import { scriptKindOf } from "../internal/script-kind.ts"
21
25
  import type { CheckRule } from "./index.ts"
26
+ import { loadRuleTypeScript } from "./typescript.ts"
22
27
 
23
28
  /** What a static read of a framework config says about `apiPrefix`. */
24
29
  export type StaticApiPrefix =
@@ -51,7 +56,7 @@ export const shadowedPageRule: CheckRule = {
51
56
  if (!existsSync(backendFile) || !existsSync(routesDir)) return []
52
57
  if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
53
58
 
54
- let apiPrefix = "/api"
59
+ let apiPrefix = ""
55
60
  // backend/framework.ts first: the generated server entry imports from it, and the build refuses
56
61
  // a nifra.config.ts whose forwarded fields differ from it.
57
62
  const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
@@ -96,4 +101,234 @@ export const shadowedPageRule: CheckRule = {
96
101
  },
97
102
  }
98
103
 
99
- export const pageRules = Object.freeze([shadowedPageRule])
104
+ const IN_LAYOUTS = /\binLayouts\b/
105
+
106
+ /** An import of the root layout from a page beside it, statically or dynamically. */
107
+ const importsRootLayout = (code: string, layoutFile: string): boolean => {
108
+ const stem = layoutFile.replace(/\.[^./]+$/, "")
109
+ return [`"./${stem}`, `'./${stem}`].some((quoted) =>
110
+ [`${quoted}"`, `${quoted}'`, `${quoted}.`].some((opening) => {
111
+ for (let at = code.indexOf(opening); at !== -1; at = code.indexOf(opening, at + 1)) {
112
+ const before = code.slice(Math.max(0, at - 16), at)
113
+ if (/(?:\bfrom|\bimport)\s*\(?\s*$/.test(before)) return true
114
+ }
115
+ return false
116
+ }),
117
+ )
118
+ }
119
+
120
+ /**
121
+ * NF-C034: a root status page (`_404`, `_<status>`) beside a root `_layout` that imports that layout
122
+ * to wrap itself. Status pages render inside their layouts, so such a page renders the layout twice.
123
+ * A page that names `inLayouts` at all has decided, so it is not reported.
124
+ */
125
+ export const statusLayoutRule: CheckRule = {
126
+ code: "NF-C034",
127
+ title: "Root status page wraps the root layout itself",
128
+ async scan(ctx) {
129
+ const routesDir = join(ctx.root, "routes")
130
+ if (!existsSync(routesDir)) return []
131
+ let manifest: ReturnType<typeof discoverRoutes>
132
+ try {
133
+ manifest = discoverRoutes(routesDir)
134
+ } catch {
135
+ return []
136
+ }
137
+ const layout = manifest.layouts._layout
138
+ if (layout === undefined) return []
139
+ const pages = [
140
+ ...(manifest.notFound === undefined ? [] : [manifest.notFound.file]),
141
+ ...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
142
+ ]
143
+ const findings: Diagnostic[] = []
144
+ for (const file of pages.sort()) {
145
+ const source = stripComments(readFileSync(join(routesDir, file), "utf8"))
146
+ if (IN_LAYOUTS.test(source) || !importsRootLayout(source, layout.file)) continue
147
+ findings.push(
148
+ diagnostic({
149
+ code: "NF-C034",
150
+ severity: "warn",
151
+ file: `routes/${file}`,
152
+ message: `routes/${file} imports routes/${layout.file} to wrap itself, and status pages render inside their layouts, so it renders routes/${layout.file} twice. Remove the wrap, or add \`export const inLayouts = false\` to keep rendering the page alone.`,
153
+ evidence: [`layout: routes/${layout.file}`],
154
+ verify: "nifra check",
155
+ }),
156
+ )
157
+ }
158
+ return findings
159
+ },
160
+ }
161
+
162
+ /**
163
+ * NF-C035: `backend/app.ts` exports a backend and the framework config sets no `apiPrefix`, so the
164
+ * backend is not served over HTTP - a browser call to `/api` reaches page routing. Loader-only is a
165
+ * valid choice; the finding asks for it to be stated. An unreadable `apiPrefix` is NF-C027's to report.
166
+ */
167
+ export const implicitApiMountRule: CheckRule = {
168
+ code: "NF-C035",
169
+ title: "Backend not served over HTTP",
170
+ async scan(ctx) {
171
+ const backendFile = join(ctx.root, BACKEND_APP_FILE)
172
+ // An API-only app serves its backend itself; there is no page routing to fall into.
173
+ if (!existsSync(backendFile) || !hasWebConfig(ctx.root, ".")) return []
174
+ if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
175
+ const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
176
+ if (
177
+ configFile !== undefined &&
178
+ readStaticApiPrefix(readFileSync(join(ctx.root, configFile), "utf8")).kind !== "default"
179
+ ) {
180
+ return []
181
+ }
182
+ const file = configFile ?? FRAMEWORK_FILE
183
+ return [
184
+ diagnostic({
185
+ code: "NF-C035",
186
+ severity: "info",
187
+ file,
188
+ message: `${BACKEND_APP_FILE}'s backend is not served over HTTP, because ${file} sets no apiPrefix: a browser call to /api reaches page routing instead. Add \`export const apiPrefix = "/api"\` to ${file} to serve it there, as 4.x did by default, or \`export const apiPrefix = ""\` to keep it loader-only.`,
189
+ fix: { recipe: "framework.api-prefix", command: "nifra fix --code NF-C035" },
190
+ verify: "nifra check",
191
+ }),
192
+ ]
193
+ },
194
+ }
195
+
196
+ /** Append `export const apiPrefix = "/api"` to a framework config that sets none, creating it if needed. */
197
+ export function withDefaultApiPrefix(source: string | undefined): string | undefined {
198
+ if (source !== undefined && readStaticApiPrefix(source).kind !== "default") return undefined
199
+ const line = 'export const apiPrefix = "/api"\n'
200
+ if (source === undefined || source === "") return line
201
+ return `${source}${source.endsWith("\n") ? "" : "\n"}${line}`
202
+ }
203
+
204
+ const LOADER_EXPORT =
205
+ /\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+loader\b|\bexport\s*\{[^}]*\bloader\b[^}]*\}/
206
+ const SCRIPT_LAYOUT = /\.[cm]?[jt]sx?$/
207
+
208
+ const hasModifier = (ts: typeof TSApi, node: TSApi.Node, kind: TSApi.SyntaxKind): boolean =>
209
+ ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === kind)
210
+
211
+ /** The function an exported `meta` names: a declaration, or a const initialized with one. */
212
+ function exportedMetaFunction(
213
+ ts: typeof TSApi,
214
+ tree: TSApi.SourceFile,
215
+ ): TSApi.FunctionDeclaration | TSApi.ArrowFunction | TSApi.FunctionExpression | undefined {
216
+ for (const statement of tree.statements) {
217
+ if (!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword)) continue
218
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === "meta") return statement
219
+ if (!ts.isVariableStatement(statement)) continue
220
+ for (const declaration of statement.declarationList.declarations) {
221
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "meta") continue
222
+ let init = declaration.initializer
223
+ while (
224
+ init !== undefined &&
225
+ (ts.isParenthesizedExpression(init) ||
226
+ ts.isAsExpression(init) ||
227
+ ts.isSatisfiesExpression(init))
228
+ ) {
229
+ init = init.expression
230
+ }
231
+ if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
232
+ return init
233
+ }
234
+ }
235
+ return undefined
236
+ }
237
+
238
+ /** Where an exported function `meta` reads its args' `data`: a destructured `data`, or `args.data`. */
239
+ export function metaDataReads(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
240
+ const meta = exportedMetaFunction(ts, tree)
241
+ const param = meta?.parameters[0]
242
+ if (param === undefined) return []
243
+ const lineOf = (node: TSApi.Node) =>
244
+ tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
245
+ if (ts.isObjectBindingPattern(param.name)) {
246
+ return param.name.elements
247
+ .filter((element) => (element.propertyName ?? element.name).getText(tree) === "data")
248
+ .map(lineOf)
249
+ }
250
+ if (!ts.isIdentifier(param.name) || meta?.body === undefined) return []
251
+ const args = param.name.text
252
+ const reads: number[] = []
253
+ const visit = (node: TSApi.Node): void => {
254
+ if (
255
+ ts.isPropertyAccessExpression(node) &&
256
+ ts.isIdentifier(node.expression) &&
257
+ node.expression.text === args &&
258
+ node.name.text === "data"
259
+ ) {
260
+ reads.push(lineOf(node))
261
+ }
262
+ ts.forEachChild(node, visit)
263
+ }
264
+ visit(meta.body)
265
+ return reads
266
+ }
267
+
268
+ /**
269
+ * NF-C040: a layout whose function `meta` reads `data` and which has no loader. A layout's `meta`
270
+ * receives its own loader data, `null` without a loader, so `data` there is always `null`; the page's
271
+ * data is `pageData`. Until 5.0 a layout's `meta` received the page's data as `data`, which is what such
272
+ * a `meta` was written against. A layout with a loader reading `data` reads its own, so it is not reported.
273
+ */
274
+ export const layoutMetaDataRule: CheckRule = {
275
+ code: "NF-C040",
276
+ title: "Layout meta reads data its layout never loads",
277
+ async scan(ctx) {
278
+ const routesDir = join(ctx.root, "routes")
279
+ if (!existsSync(routesDir)) return []
280
+ let manifest: ReturnType<typeof discoverRoutes>
281
+ try {
282
+ manifest = discoverRoutes(routesDir)
283
+ } catch {
284
+ return []
285
+ }
286
+ const layouts = Object.values(manifest.layouts)
287
+ .filter((layout) => SCRIPT_LAYOUT.test(layout.file))
288
+ .sort((a, b) => codeUnitOrder(a.file, b.file))
289
+ if (layouts.length === 0) return []
290
+ const loaded = await loadRuleTypeScript(ctx)
291
+ const ts = loaded.compiler
292
+ if (ts === undefined) return []
293
+ const findings: Diagnostic[] = []
294
+ for (const layout of layouts) {
295
+ const halves = [layout.file, ...(layout.backend === undefined ? [] : [layout.backend])]
296
+ const loads = halves.some((file) =>
297
+ LOADER_EXPORT.test(stripComments(readFileSync(join(routesDir, file), "utf8"))),
298
+ )
299
+ if (loads) continue
300
+ const text = readFileSync(join(routesDir, layout.file), "utf8")
301
+ const tree = ts.createSourceFile(
302
+ layout.file,
303
+ text,
304
+ ts.ScriptTarget.Latest,
305
+ true,
306
+ scriptKindOf(ts, layout.file),
307
+ )
308
+ for (const line of metaDataReads(ts, tree)) {
309
+ findings.push(
310
+ diagnostic({
311
+ code: "NF-C040",
312
+ severity: "warn",
313
+ file: `routes/${layout.file}`,
314
+ line,
315
+ message: `routes/${layout.file}'s meta reads \`data\`, which is this layout's own loader data, and the layout has no loader, so it is always null. Read the page's data from \`pageData\` instead.`,
316
+ evidence: [
317
+ "data: the layout's loader data (null without a loader)",
318
+ "pageData: the page's loader data",
319
+ ],
320
+ verify: "nifra check",
321
+ }),
322
+ )
323
+ }
324
+ }
325
+ return findings
326
+ },
327
+ }
328
+
329
+ export const pageRules = Object.freeze([
330
+ shadowedPageRule,
331
+ statusLayoutRule,
332
+ implicitApiMountRule,
333
+ layoutMetaDataRule,
334
+ ])