@nifrajs/cli 3.3.0 → 4.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 (425) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +7 -15
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +162 -713
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +58 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +323 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts +1 -0
  30. package/dist/check.d.ts.map +1 -1
  31. package/dist/check.js +145 -54
  32. package/dist/check.js.map +1 -1
  33. package/dist/cli.d.ts +42 -16
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +306 -87
  36. package/dist/cli.js.map +1 -1
  37. package/dist/command-catalog.d.ts +60 -5
  38. package/dist/command-catalog.d.ts.map +1 -1
  39. package/dist/command-catalog.js +298 -33
  40. package/dist/command-catalog.js.map +1 -1
  41. package/dist/config-target.d.ts +8 -0
  42. package/dist/config-target.d.ts.map +1 -0
  43. package/dist/config-target.js +45 -0
  44. package/dist/config-target.js.map +1 -0
  45. package/dist/contracts.d.ts +3 -2
  46. package/dist/contracts.d.ts.map +1 -1
  47. package/dist/contracts.js +56 -16
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db-audit.d.ts +37 -0
  50. package/dist/db-audit.d.ts.map +1 -0
  51. package/dist/db-audit.js +99 -0
  52. package/dist/db-audit.js.map +1 -0
  53. package/dist/db-child.d.ts +64 -0
  54. package/dist/db-child.d.ts.map +1 -0
  55. package/dist/db-child.js +213 -0
  56. package/dist/db-child.js.map +1 -0
  57. package/dist/db-config.d.ts +60 -0
  58. package/dist/db-config.d.ts.map +1 -0
  59. package/dist/db-config.js +176 -0
  60. package/dist/db-config.js.map +1 -0
  61. package/dist/db-tool.d.ts +84 -0
  62. package/dist/db-tool.d.ts.map +1 -0
  63. package/dist/db-tool.js +537 -0
  64. package/dist/db-tool.js.map +1 -0
  65. package/dist/dev-bun-config.d.ts +20 -13
  66. package/dist/dev-bun-config.d.ts.map +1 -1
  67. package/dist/dev-bun-config.js +98 -41
  68. package/dist/dev-bun-config.js.map +1 -1
  69. package/dist/dev-feed-tool.d.ts +106 -0
  70. package/dist/dev-feed-tool.d.ts.map +1 -0
  71. package/dist/dev-feed-tool.js +713 -0
  72. package/dist/dev-feed-tool.js.map +1 -0
  73. package/dist/dev-server-client.d.ts +67 -0
  74. package/dist/dev-server-client.d.ts.map +1 -0
  75. package/dist/dev-server-client.js +195 -0
  76. package/dist/dev-server-client.js.map +1 -0
  77. package/dist/diagnostics.d.ts +86 -0
  78. package/dist/diagnostics.d.ts.map +1 -1
  79. package/dist/diagnostics.js +85 -0
  80. package/dist/diagnostics.js.map +1 -1
  81. package/dist/diff-tool.d.ts +3 -3
  82. package/dist/diff-tool.d.ts.map +1 -1
  83. package/dist/diff-tool.js +6 -5
  84. package/dist/diff-tool.js.map +1 -1
  85. package/dist/docs-search.d.ts.map +1 -1
  86. package/dist/docs-search.js +12 -4
  87. package/dist/docs-search.js.map +1 -1
  88. package/dist/doctor.d.ts +17 -0
  89. package/dist/doctor.d.ts.map +1 -1
  90. package/dist/doctor.js +76 -15
  91. package/dist/doctor.js.map +1 -1
  92. package/dist/env-file.d.ts +15 -0
  93. package/dist/env-file.d.ts.map +1 -1
  94. package/dist/env-file.js +73 -1
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/fix-recipes.d.ts.map +1 -1
  97. package/dist/fix-recipes.js +41 -21
  98. package/dist/fix-recipes.js.map +1 -1
  99. package/dist/frontend-guidance.js +5 -5
  100. package/dist/frontend-guidance.js.map +1 -1
  101. package/dist/i18n-check.d.ts +16 -0
  102. package/dist/i18n-check.d.ts.map +1 -0
  103. package/dist/i18n-check.js +154 -0
  104. package/dist/i18n-check.js.map +1 -0
  105. package/dist/init-agents.d.ts +49 -13
  106. package/dist/init-agents.d.ts.map +1 -1
  107. package/dist/init-agents.js +239 -34
  108. package/dist/init-agents.js.map +1 -1
  109. package/dist/internal/code-unit-order.d.ts +4 -0
  110. package/dist/internal/code-unit-order.d.ts.map +1 -0
  111. package/dist/internal/code-unit-order.js +6 -0
  112. package/dist/internal/code-unit-order.js.map +1 -0
  113. package/dist/internal/script-kind.d.ts +7 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -0
  115. package/dist/internal/script-kind.js +12 -0
  116. package/dist/internal/script-kind.js.map +1 -0
  117. package/dist/internal/source-facts.d.ts +5 -0
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +61 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +49 -24
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +507 -67
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +6 -4
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +93 -74
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/learn.js +3 -3
  130. package/dist/learn.js.map +1 -1
  131. package/dist/levels-tool.d.ts +10 -0
  132. package/dist/levels-tool.d.ts.map +1 -1
  133. package/dist/levels-tool.js +33 -3
  134. package/dist/levels-tool.js.map +1 -1
  135. package/dist/load.d.ts +71 -16
  136. package/dist/load.d.ts.map +1 -1
  137. package/dist/load.js +78 -18
  138. package/dist/load.js.map +1 -1
  139. package/dist/manifest-tool.d.ts.map +1 -1
  140. package/dist/manifest-tool.js +3 -2
  141. package/dist/manifest-tool.js.map +1 -1
  142. package/dist/mcp-app-tool.d.ts.map +1 -1
  143. package/dist/mcp-app-tool.js +2 -0
  144. package/dist/mcp-app-tool.js.map +1 -1
  145. package/dist/mcp-context.d.ts.map +1 -1
  146. package/dist/mcp-context.js +5 -6
  147. package/dist/mcp-context.js.map +1 -1
  148. package/dist/mcp-delegate.d.ts +57 -0
  149. package/dist/mcp-delegate.d.ts.map +1 -0
  150. package/dist/mcp-delegate.js +130 -0
  151. package/dist/mcp-delegate.js.map +1 -0
  152. package/dist/mcp-docs-tools.d.ts.map +1 -1
  153. package/dist/mcp-docs-tools.js +23 -5
  154. package/dist/mcp-docs-tools.js.map +1 -1
  155. package/dist/mcp-exec.d.ts +3 -0
  156. package/dist/mcp-exec.d.ts.map +1 -1
  157. package/dist/mcp-exec.js +263 -131
  158. package/dist/mcp-exec.js.map +1 -1
  159. package/dist/mcp-http.d.ts.map +1 -1
  160. package/dist/mcp-http.js +20 -5
  161. package/dist/mcp-http.js.map +1 -1
  162. package/dist/mcp-io.d.ts +26 -0
  163. package/dist/mcp-io.d.ts.map +1 -1
  164. package/dist/mcp-io.js +113 -0
  165. package/dist/mcp-io.js.map +1 -1
  166. package/dist/mcp-isolate.d.ts +48 -0
  167. package/dist/mcp-isolate.d.ts.map +1 -0
  168. package/dist/mcp-isolate.js +222 -0
  169. package/dist/mcp-isolate.js.map +1 -0
  170. package/dist/mcp-path.d.ts +10 -0
  171. package/dist/mcp-path.d.ts.map +1 -0
  172. package/dist/mcp-path.js +51 -0
  173. package/dist/mcp-path.js.map +1 -0
  174. package/dist/mcp-project-child.d.ts +74 -0
  175. package/dist/mcp-project-child.d.ts.map +1 -0
  176. package/dist/mcp-project-child.js +130 -0
  177. package/dist/mcp-project-child.js.map +1 -0
  178. package/dist/mcp-reflect.d.ts.map +1 -1
  179. package/dist/mcp-reflect.js +2 -2
  180. package/dist/mcp-reflect.js.map +1 -1
  181. package/dist/mcp-render.d.ts.map +1 -1
  182. package/dist/mcp-render.js +64 -45
  183. package/dist/mcp-render.js.map +1 -1
  184. package/dist/mcp-root.d.ts +37 -9
  185. package/dist/mcp-root.d.ts.map +1 -1
  186. package/dist/mcp-root.js +124 -25
  187. package/dist/mcp-root.js.map +1 -1
  188. package/dist/mcp-run.d.ts +18 -1
  189. package/dist/mcp-run.d.ts.map +1 -1
  190. package/dist/mcp-run.js +159 -55
  191. package/dist/mcp-run.js.map +1 -1
  192. package/dist/mcp-ws.js +4 -1
  193. package/dist/mcp-ws.js.map +1 -1
  194. package/dist/mcp.d.ts +18 -5
  195. package/dist/mcp.d.ts.map +1 -1
  196. package/dist/mcp.js +165 -82
  197. package/dist/mcp.js.map +1 -1
  198. package/dist/migrate-layout.d.ts +44 -0
  199. package/dist/migrate-layout.d.ts.map +1 -0
  200. package/dist/migrate-layout.js +982 -0
  201. package/dist/migrate-layout.js.map +1 -0
  202. package/dist/openapi-tool.d.ts +2 -2
  203. package/dist/openapi-tool.d.ts.map +1 -1
  204. package/dist/openapi-tool.js.map +1 -1
  205. package/dist/openapi-types.d.ts +2 -1
  206. package/dist/openapi-types.d.ts.map +1 -1
  207. package/dist/openapi-types.js +240 -12
  208. package/dist/openapi-types.js.map +1 -1
  209. package/dist/pipeline-report.d.ts +3 -2
  210. package/dist/pipeline-report.d.ts.map +1 -1
  211. package/dist/pipeline-report.js +19 -16
  212. package/dist/pipeline-report.js.map +1 -1
  213. package/dist/port.d.ts +5 -4
  214. package/dist/port.d.ts.map +1 -1
  215. package/dist/port.js +46 -29
  216. package/dist/port.js.map +1 -1
  217. package/dist/project-facts.d.ts +28 -4
  218. package/dist/project-facts.d.ts.map +1 -1
  219. package/dist/project-facts.js +12 -2
  220. package/dist/project-facts.js.map +1 -1
  221. package/dist/recipes/4.0.0.d.ts +9 -0
  222. package/dist/recipes/4.0.0.d.ts.map +1 -0
  223. package/dist/recipes/4.0.0.js +34 -0
  224. package/dist/recipes/4.0.0.js.map +1 -0
  225. package/dist/recipes/index.d.ts.map +1 -1
  226. package/dist/recipes/index.js +2 -0
  227. package/dist/recipes/index.js.map +1 -1
  228. package/dist/release-verification.d.ts +11 -1
  229. package/dist/release-verification.d.ts.map +1 -1
  230. package/dist/release-verification.js +47 -4
  231. package/dist/release-verification.js.map +1 -1
  232. package/dist/review.d.ts +21 -0
  233. package/dist/review.d.ts.map +1 -0
  234. package/dist/review.js +823 -0
  235. package/dist/review.js.map +1 -0
  236. package/dist/route-types.d.ts +28 -0
  237. package/dist/route-types.d.ts.map +1 -0
  238. package/dist/route-types.js +106 -0
  239. package/dist/route-types.js.map +1 -0
  240. package/dist/rules/codes.d.ts +9 -0
  241. package/dist/rules/codes.d.ts.map +1 -1
  242. package/dist/rules/codes.js +9 -0
  243. package/dist/rules/codes.js.map +1 -1
  244. package/dist/rules/data-guard.d.ts +12 -0
  245. package/dist/rules/data-guard.d.ts.map +1 -0
  246. package/dist/rules/data-guard.js +213 -0
  247. package/dist/rules/data-guard.js.map +1 -0
  248. package/dist/rules/index.d.ts +3 -0
  249. package/dist/rules/index.d.ts.map +1 -1
  250. package/dist/rules/index.js +17 -5
  251. package/dist/rules/index.js.map +1 -1
  252. package/dist/rules/islands.d.ts.map +1 -1
  253. package/dist/rules/islands.js +7 -4
  254. package/dist/rules/islands.js.map +1 -1
  255. package/dist/rules/legacy.d.ts +3 -1
  256. package/dist/rules/legacy.d.ts.map +1 -1
  257. package/dist/rules/legacy.js +829 -34
  258. package/dist/rules/legacy.js.map +1 -1
  259. package/dist/rules/nano.d.ts.map +1 -1
  260. package/dist/rules/nano.js +15 -6
  261. package/dist/rules/nano.js.map +1 -1
  262. package/dist/rules/pages.d.ts +15 -0
  263. package/dist/rules/pages.d.ts.map +1 -0
  264. package/dist/rules/pages.js +88 -0
  265. package/dist/rules/pages.js.map +1 -0
  266. package/dist/rules/routes.d.ts +11 -0
  267. package/dist/rules/routes.d.ts.map +1 -1
  268. package/dist/rules/routes.js +180 -2
  269. package/dist/rules/routes.js.map +1 -1
  270. package/dist/rules/secrets.d.ts +10 -0
  271. package/dist/rules/secrets.d.ts.map +1 -0
  272. package/dist/rules/secrets.js +169 -0
  273. package/dist/rules/secrets.js.map +1 -0
  274. package/dist/rules/security.d.ts +7 -0
  275. package/dist/rules/security.d.ts.map +1 -1
  276. package/dist/rules/security.js +171 -65
  277. package/dist/rules/security.js.map +1 -1
  278. package/dist/rules/typescript.d.ts +16 -0
  279. package/dist/rules/typescript.d.ts.map +1 -0
  280. package/dist/rules/typescript.js +59 -0
  281. package/dist/rules/typescript.js.map +1 -0
  282. package/dist/rules/zones.d.ts +17 -0
  283. package/dist/rules/zones.d.ts.map +1 -0
  284. package/dist/rules/zones.js +174 -0
  285. package/dist/rules/zones.js.map +1 -0
  286. package/dist/scaffold.d.ts +25 -12
  287. package/dist/scaffold.d.ts.map +1 -1
  288. package/dist/scaffold.js +154 -90
  289. package/dist/scaffold.js.map +1 -1
  290. package/dist/sdk.d.ts +17 -3
  291. package/dist/sdk.d.ts.map +1 -1
  292. package/dist/sdk.js +941 -153
  293. package/dist/sdk.js.map +1 -1
  294. package/dist/search-terms.d.ts +2 -0
  295. package/dist/search-terms.d.ts.map +1 -1
  296. package/dist/search-terms.js +23 -8
  297. package/dist/search-terms.js.map +1 -1
  298. package/dist/smoke.d.ts +79 -0
  299. package/dist/smoke.d.ts.map +1 -0
  300. package/dist/smoke.js +494 -0
  301. package/dist/smoke.js.map +1 -0
  302. package/dist/sync-manifest.d.ts.map +1 -1
  303. package/dist/sync-manifest.js +2 -1
  304. package/dist/sync-manifest.js.map +1 -1
  305. package/dist/sync-routes.d.ts +4 -3
  306. package/dist/sync-routes.d.ts.map +1 -1
  307. package/dist/sync-routes.js +4 -3
  308. package/dist/sync-routes.js.map +1 -1
  309. package/dist/test-tool.d.ts.map +1 -1
  310. package/dist/test-tool.js +30 -11
  311. package/dist/test-tool.js.map +1 -1
  312. package/dist/types-search.d.ts.map +1 -1
  313. package/dist/types-search.js +11 -5
  314. package/dist/types-search.js.map +1 -1
  315. package/dist/upgrade.d.ts +36 -6
  316. package/dist/upgrade.d.ts.map +1 -1
  317. package/dist/upgrade.js +167 -29
  318. package/dist/upgrade.js.map +1 -1
  319. package/dist/verification-plan.d.ts.map +1 -1
  320. package/dist/verification-plan.js +8 -0
  321. package/dist/verification-plan.js.map +1 -1
  322. package/dist/verification.d.ts.map +1 -1
  323. package/dist/verification.js +45 -12
  324. package/dist/verification.js.map +1 -1
  325. package/dist/web-app-options.d.ts +19 -0
  326. package/dist/web-app-options.d.ts.map +1 -0
  327. package/dist/web-app-options.js +49 -0
  328. package/dist/web-app-options.js.map +1 -0
  329. package/dist/work-graph.d.ts +3 -0
  330. package/dist/work-graph.d.ts.map +1 -1
  331. package/dist/work-graph.js +33 -16
  332. package/dist/work-graph.js.map +1 -1
  333. package/docs/examples.json +185 -29
  334. package/docs/llms-full.txt +4123 -548
  335. package/docs/types.json +14391 -10060
  336. package/package.json +19 -11
  337. package/src/app-files.ts +40 -0
  338. package/src/assure-hydration.ts +139 -46
  339. package/src/assure.ts +17 -2
  340. package/src/capabilities-tool.ts +35 -9
  341. package/src/cdn-check.ts +268 -0
  342. package/src/check-diagnostics.ts +199 -797
  343. package/src/check-scan.ts +394 -63
  344. package/src/check.ts +146 -58
  345. package/src/cli.ts +347 -92
  346. package/src/command-catalog.ts +407 -45
  347. package/src/config-target.ts +47 -0
  348. package/src/contracts.ts +66 -15
  349. package/src/db-audit.ts +128 -0
  350. package/src/db-child.ts +285 -0
  351. package/src/db-config.ts +224 -0
  352. package/src/db-tool.ts +690 -0
  353. package/src/dev-bun-config.ts +116 -40
  354. package/src/dev-feed-tool.ts +912 -0
  355. package/src/dev-server-client.ts +267 -0
  356. package/src/diagnostics.ts +179 -0
  357. package/src/diff-tool.ts +6 -5
  358. package/src/docs-search.ts +10 -3
  359. package/src/doctor.ts +98 -15
  360. package/src/env-file.ts +78 -1
  361. package/src/fix-recipes.ts +44 -31
  362. package/src/frontend-guidance.ts +5 -5
  363. package/src/i18n-check.ts +179 -0
  364. package/src/init-agents.ts +301 -38
  365. package/src/internal/code-unit-order.ts +5 -0
  366. package/src/internal/script-kind.ts +11 -0
  367. package/src/internal/source-facts.ts +96 -22
  368. package/src/internal/typescript-import.ts +663 -68
  369. package/src/introspect.ts +93 -82
  370. package/src/learn.ts +3 -3
  371. package/src/levels-tool.ts +47 -2
  372. package/src/load.ts +144 -32
  373. package/src/manifest-tool.ts +3 -2
  374. package/src/mcp-app-tool.ts +2 -0
  375. package/src/mcp-context.ts +5 -7
  376. package/src/mcp-delegate.ts +162 -0
  377. package/src/mcp-docs-tools.ts +23 -5
  378. package/src/mcp-exec.ts +301 -161
  379. package/src/mcp-http.ts +22 -5
  380. package/src/mcp-io.ts +130 -0
  381. package/src/mcp-isolate.ts +287 -0
  382. package/src/mcp-path.ts +50 -0
  383. package/src/mcp-project-child.ts +182 -0
  384. package/src/mcp-reflect.ts +3 -2
  385. package/src/mcp-render.ts +72 -42
  386. package/src/mcp-root.ts +130 -24
  387. package/src/mcp-run.ts +190 -56
  388. package/src/mcp-ws.ts +4 -1
  389. package/src/mcp.ts +193 -91
  390. package/src/migrate-layout.ts +1121 -0
  391. package/src/openapi-tool.ts +7 -3
  392. package/src/openapi-types.ts +315 -13
  393. package/src/pipeline-report.ts +22 -18
  394. package/src/port.ts +53 -30
  395. package/src/project-facts.ts +42 -8
  396. package/src/recipes/4.0.0.ts +35 -0
  397. package/src/recipes/index.ts +2 -0
  398. package/src/release-verification.ts +74 -5
  399. package/src/review.ts +988 -0
  400. package/src/route-types.ts +133 -0
  401. package/src/rules/codes.ts +9 -0
  402. package/src/rules/data-guard.ts +230 -0
  403. package/src/rules/index.ts +18 -5
  404. package/src/rules/islands.ts +12 -4
  405. package/src/rules/legacy.ts +947 -35
  406. package/src/rules/nano.ts +18 -6
  407. package/src/rules/pages.ts +99 -0
  408. package/src/rules/routes.ts +181 -2
  409. package/src/rules/secrets.ts +174 -0
  410. package/src/rules/security.ts +198 -70
  411. package/src/rules/typescript.ts +77 -0
  412. package/src/rules/zones.ts +195 -0
  413. package/src/scaffold.ts +175 -98
  414. package/src/sdk.ts +1215 -158
  415. package/src/search-terms.ts +24 -7
  416. package/src/smoke.ts +607 -0
  417. package/src/sync-manifest.ts +2 -1
  418. package/src/sync-routes.ts +4 -3
  419. package/src/test-tool.ts +32 -11
  420. package/src/types-search.ts +11 -4
  421. package/src/upgrade.ts +205 -25
  422. package/src/verification-plan.ts +27 -0
  423. package/src/verification.ts +50 -12
  424. package/src/web-app-options.ts +57 -0
  425. package/src/work-graph.ts +40 -16
@@ -1,8 +1,10 @@
1
1
  import type * as TSApi from "typescript"
2
+ import { isBrowserSource } from "../check-scan.ts"
2
3
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
3
- import { importProjectTypeScript } from "../internal/typescript-import.ts"
4
+ import { scriptKindOf } from "../internal/script-kind.ts"
4
5
  import { commentBlockHasMarker } from "./comment-markers.ts"
5
6
  import type { CheckRule, SourceIndex } from "./index.ts"
7
+ import { loadRuleTypeScript } from "./typescript.ts"
6
8
 
7
9
  /**
8
10
  * A security rule that cannot run says so as an advisory finding instead of silently returning no
@@ -42,17 +44,33 @@ function reviewedEvidence(lines: readonly string[], line: number): readonly stri
42
44
 
43
45
  /**
44
46
  * NF-S002 severity by file role. Server-side code compares real secret material - a timing oracle
45
- * there is exploitable, so it fails the gate. Client-bundled code (route modules, .tsx/.jsx)
47
+ * there is exploitable, so it fails the gate. Browser code (a route's frontend half, `frontend/`)
46
48
  * compares values the client already holds, so the same shape is advisory rather than a gate
47
- * failure. A server marker beats a client marker (`routes/x.server.ts` is server), and a plain .ts
48
- * that cannot be classified is treated as server - fail closed.
49
+ * failure. `shared/` also runs on the server, and a file that cannot be classified is treated as
50
+ * server - fail closed.
49
51
  */
50
52
  function secretComparisonSeverity(file: string): "error" | "warn" {
51
53
  const path = file.replaceAll("\\", "/")
52
- if (/\.server\.[cm]?[tj]sx?$/.test(path)) return "error"
53
- if (/(?:^|\/)server\//.test(path) || /(?:^|\/)backend\.[cm]?[tj]s$/.test(path)) return "error"
54
- if (/\.[tj]sx$/.test(path) || /(?:^|\/)routes\//.test(path)) return "warn"
55
- return "error"
54
+ if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path)) return "error"
55
+ if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path)) return "error"
56
+ return "warn"
57
+ }
58
+
59
+ const CONFIRMATION_NAME =
60
+ /^(?:confirm|confirmation|confirmPassword|passwordConfirmation|passwordConfirm|passwordRepeat|repeatPassword)$/i
61
+
62
+ /** Client form confirmation compares two values already held by the browser, not secret material. */
63
+ function isClientConfirmationPair(
64
+ file: string,
65
+ left: string | undefined,
66
+ right: string | undefined,
67
+ ): boolean {
68
+ if (secretComparisonSeverity(file) !== "warn") return false
69
+ const names = [left, right]
70
+ return (
71
+ names.some((name) => name?.toLowerCase() === "password") &&
72
+ names.some((name) => name !== undefined && CONFIRMATION_NAME.test(name))
73
+ )
56
74
  }
57
75
 
58
76
  function nameOf(ts: typeof TSApi, node: TSApi.Node): string | undefined {
@@ -63,18 +81,20 @@ function nameOf(ts: typeof TSApi, node: TSApi.Node): string | undefined {
63
81
 
64
82
  /**
65
83
  * The operand name to match secret-like patterns against for NF-S002, or undefined when the operand
66
- * cannot hold runtime secret material. A property access to an uppercase-initial member
84
+ * cannot hold runtime secret material. A property access to a PascalCase member
67
85
  * (`ts.SyntaxKind.PlusToken`, `MediaKind.Audio`) reads an enum/type-constant discriminant, never a
68
86
  * secret string or byte buffer, so comparing against it is a kind check a timing oracle does not
69
- * apply to. Runtime secret fields follow the camelCase convention (`apiToken`, `signature`), so an
70
- * uppercase-initial member is a safe exclusion. Identifiers stay matched in every case, so an
71
- * `UPPER_SNAKE` secret constant still surfaces.
87
+ * apply to. An `UPPER_SNAKE` member is the configuration spelling (`process.env.API_TOKEN`,
88
+ * `env.WEBHOOK_SECRET`) and stays matched, as does a string-literal element access
89
+ * (`process.env["API_KEY"]`).
72
90
  */
73
91
  function secretName(ts: typeof TSApi, node: TSApi.Node): string | undefined {
74
92
  if (ts.isPropertyAccessExpression(node)) {
75
93
  const member = node.name.text
76
- return /^[A-Z]/.test(member) ? undefined : member
94
+ return /^[A-Z]/.test(member) && !/^[A-Z][A-Z0-9_]*$/.test(member) ? undefined : member
77
95
  }
96
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression))
97
+ return node.argumentExpression.text
78
98
  return nameOf(ts, node)
79
99
  }
80
100
 
@@ -99,8 +119,7 @@ function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): b
99
119
  }
100
120
 
101
121
  function parse(ts: typeof TSApi, file: string, source: string): TSApi.SourceFile {
102
- const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
103
- return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
122
+ return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKindOf(ts, file))
104
123
  }
105
124
 
106
125
  function parsedFile(
@@ -123,62 +142,138 @@ function parsedFile(
123
142
  return parsed
124
143
  }
125
144
 
145
+ interface SecretComparison {
146
+ readonly node: TSApi.BinaryExpression
147
+ readonly left: string | undefined
148
+ readonly right: string | undefined
149
+ readonly line: number
150
+ }
151
+
152
+ function secretComparisons(
153
+ ts: typeof TSApi,
154
+ file: string,
155
+ tree: TSApi.SourceFile,
156
+ ): readonly SecretComparison[] {
157
+ const operators = [
158
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
159
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
160
+ ts.SyntaxKind.EqualsEqualsToken,
161
+ ts.SyntaxKind.ExclamationEqualsToken,
162
+ ]
163
+ const found: SecretComparison[] = []
164
+ const visit = (node: TSApi.Node): void => {
165
+ if (
166
+ ts.isBinaryExpression(node) &&
167
+ operators.includes(node.operatorToken.kind) &&
168
+ !isPresenceComparison(ts, node)
169
+ ) {
170
+ const left = secretName(ts, node.left)
171
+ const right = secretName(ts, node.right)
172
+ if (
173
+ ((left !== undefined && SECRET.test(left)) ||
174
+ (right !== undefined && SECRET.test(right))) &&
175
+ !isClientConfirmationPair(file, left, right)
176
+ ) {
177
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
178
+ found.push({ node, left, right, line })
179
+ }
180
+ }
181
+ ts.forEachChild(node, visit)
182
+ }
183
+ visit(tree)
184
+ return found
185
+ }
186
+
187
+ function timingSafeHelper(file: string): string {
188
+ const typed = /\.[cm]?tsx?$/.test(file)
189
+ return [
190
+ 'import { timingSafeEqual as nifraTimingSafeEqualBytes } from "node:crypto"',
191
+ "",
192
+ typed
193
+ ? "function nifraTimingSafeEqual(left: string, right: string): boolean {"
194
+ : "function nifraTimingSafeEqual(left, right) {",
195
+ " const leftBytes = Buffer.from(left)",
196
+ " const rightBytes = Buffer.from(right)",
197
+ " return leftBytes.length === rightBytes.length && nifraTimingSafeEqualBytes(leftBytes, rightBytes)",
198
+ "}",
199
+ ].join("\n")
200
+ }
201
+
202
+ /**
203
+ * Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
204
+ * add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
205
+ * them, so a line number a check run reported no longer names the same statement after the first fix.
206
+ */
207
+ export function rewriteSecretComparisons(ts: typeof TSApi, file: string, source: string): string {
208
+ const tree = parse(ts, file, source)
209
+ const lines = tree.text.split("\n")
210
+ const sites = secretComparisons(ts, file, tree).filter((site) => !hasReview(lines, site.line))
211
+ // A comparison nested inside another (`(token === a) === flag`) is left for the next run, so every
212
+ // edit below covers a disjoint range and applying them from the end keeps earlier offsets valid.
213
+ const outer = sites.filter(
214
+ ({ node }) =>
215
+ !sites.some(
216
+ (other) => other.node !== node && other.node.pos >= node.pos && other.node.end <= node.end,
217
+ ),
218
+ )
219
+ if (outer.length === 0) return source
220
+ let text = source
221
+ for (const { node } of outer.toSorted((a, b) => b.node.end - a.node.end)) {
222
+ const call = `nifraTimingSafeEqual(${node.left.getText(tree)}, ${node.right.getText(tree)})`
223
+ const negated =
224
+ node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
225
+ node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken
226
+ text = `${text.slice(0, node.getStart(tree))}${negated ? `!${call}` : call}${text.slice(node.end)}`
227
+ }
228
+ if (source.includes("function nifraTimingSafeEqual")) return text
229
+ // After a shebang and the directive prologue: an import above `"use client"` turns it into a no-op.
230
+ let at = 0
231
+ for (const statement of tree.statements) {
232
+ if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) break
233
+ at = statement.end
234
+ }
235
+ if (at === 0 && text.startsWith("#!")) at = text.includes("\n") ? text.indexOf("\n") : text.length
236
+ const helper = timingSafeHelper(file)
237
+ return at === 0 ? `${helper}\n\n${text}` : `${text.slice(0, at)}\n\n${helper}\n${text.slice(at)}`
238
+ }
239
+
126
240
  export const secretComparisonRule: CheckRule = {
127
241
  code: "NF-S002",
128
242
  title: "Non-constant-time secret comparison",
129
243
  async scan(ctx) {
130
- const ts = await importProjectTypeScript(ctx.root)
244
+ const loaded = await loadRuleTypeScript(ctx)
245
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
246
+ const ts = loaded.compiler
131
247
  if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
132
248
  const findings: Diagnostic[] = []
133
249
  for (const file of ctx.project.source.files) {
134
250
  const parsed = parsedFile(ts, ctx.project.source, file)
135
251
  if (parsed === undefined) continue
136
252
  const { tree, lines } = parsed
137
- const visit = (node: TSApi.Node): void => {
138
- if (
139
- ts.isBinaryExpression(node) &&
140
- [
141
- ts.SyntaxKind.EqualsEqualsEqualsToken,
142
- ts.SyntaxKind.ExclamationEqualsEqualsToken,
143
- ts.SyntaxKind.EqualsEqualsToken,
144
- ts.SyntaxKind.ExclamationEqualsToken,
145
- ].includes(node.operatorToken.kind) &&
146
- !isPresenceComparison(ts, node)
147
- ) {
148
- const left = secretName(ts, node.left)
149
- const right = secretName(ts, node.right)
150
- if (
151
- (left !== undefined && SECRET.test(left)) ||
152
- (right !== undefined && SECRET.test(right))
153
- ) {
154
- const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
155
- const reviewed = hasReview(lines, line)
156
- findings.push(
157
- diagnostic({
158
- code: "NF-S002",
159
- severity: reviewed ? "info" : secretComparisonSeverity(file),
160
- file,
161
- line,
162
- message: reviewed
163
- ? "secret comparison is explicitly marked as reviewed"
164
- : "secret-like values must use a length check and timing-safe comparison",
165
- evidence: [left ?? right ?? "secret comparison", ...reviewedEvidence(lines, line)],
166
- ...(reviewed
167
- ? {}
168
- : {
169
- fix: {
170
- recipe: "security.timing-safe-equal",
171
- command: "nifra fix --code NF-S002",
172
- },
173
- }),
174
- verify: "nifra check --lints-only",
175
- }),
176
- )
177
- }
178
- }
179
- ts.forEachChild(node, visit)
253
+ for (const { left, right, line } of secretComparisons(ts, file, tree)) {
254
+ const reviewed = hasReview(lines, line)
255
+ findings.push(
256
+ diagnostic({
257
+ code: "NF-S002",
258
+ severity: reviewed ? "info" : secretComparisonSeverity(file),
259
+ file,
260
+ line,
261
+ message: reviewed
262
+ ? "secret comparison is explicitly marked as reviewed"
263
+ : "secret-like values must use a length check and timing-safe comparison",
264
+ evidence: [left ?? right ?? "secret comparison", ...reviewedEvidence(lines, line)],
265
+ ...(reviewed
266
+ ? {}
267
+ : {
268
+ fix: {
269
+ recipe: "security.timing-safe-equal",
270
+ command: "nifra fix --code NF-S002",
271
+ },
272
+ }),
273
+ verify: "nifra check --lints-only",
274
+ }),
275
+ )
180
276
  }
181
- visit(tree)
182
277
  }
183
278
  return findings
184
279
  },
@@ -188,7 +283,9 @@ export const piiLogRule: CheckRule = {
188
283
  code: "NF-S003",
189
284
  title: "Sensitive value in log call",
190
285
  async scan(ctx) {
191
- const ts = await importProjectTypeScript(ctx.root)
286
+ const loaded = await loadRuleTypeScript(ctx)
287
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
288
+ const ts = loaded.compiler
192
289
  if (ts === undefined) return didNotRun("NF-S003", "Sensitive-value-in-log scan")
193
290
  const findings: Diagnostic[] = []
194
291
  for (const file of ctx.project.source.files) {
@@ -232,11 +329,37 @@ export const piiLogRule: CheckRule = {
232
329
  },
233
330
  }
234
331
 
332
+ /**
333
+ * A function's own name, or for an anonymous arrow/function expression the name it is bound to
334
+ * (`const requireAuth = async () => …`, `{ canEdit: () => … }`), which is how a gate is usually written.
335
+ */
336
+ function functionName(ts: typeof TSApi, fn: TSApi.Node): string | undefined {
337
+ if (ts.isFunctionLike(fn) && fn.name !== undefined)
338
+ return ts.isIdentifier(fn.name) ? fn.name.text : undefined
339
+ let binding = fn.parent
340
+ while (
341
+ binding !== undefined &&
342
+ (ts.isParenthesizedExpression(binding) ||
343
+ ts.isAsExpression(binding) ||
344
+ ts.isSatisfiesExpression(binding))
345
+ )
346
+ binding = binding.parent
347
+ if (
348
+ binding !== undefined &&
349
+ (ts.isVariableDeclaration(binding) || ts.isPropertyAssignment(binding)) &&
350
+ ts.isIdentifier(binding.name)
351
+ )
352
+ return binding.name.text
353
+ return undefined
354
+ }
355
+
235
356
  export const failOpenGateRule: CheckRule = {
236
357
  code: "NF-S001",
237
358
  title: "Fail-open gate",
238
359
  async scan(ctx) {
239
- const ts = await importProjectTypeScript(ctx.root)
360
+ const loaded = await loadRuleTypeScript(ctx)
361
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
362
+ const ts = loaded.compiler
240
363
  if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
241
364
  const findings: Diagnostic[] = []
242
365
  for (const file of ctx.project.source.files) {
@@ -247,10 +370,7 @@ export const failOpenGateRule: CheckRule = {
247
370
  if (ts.isCatchClause(node)) {
248
371
  let parent: TSApi.Node | undefined = node.parent
249
372
  while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
250
- const name =
251
- ts.isFunctionLike(parent) && parent.name && ts.isIdentifier(parent.name)
252
- ? parent.name.text
253
- : undefined
373
+ const name = parent === undefined ? undefined : functionName(ts, parent)
254
374
  // `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
255
375
  // case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them
256
376
  // authorization decisions. The other roots stay case-insensitive.
@@ -317,7 +437,9 @@ export const corsOriginPredicateRule: CheckRule = {
317
437
  code: "NF-S004",
318
438
  title: "CORS origin predicate ignores the origin",
319
439
  async scan(ctx) {
320
- const ts = await importProjectTypeScript(ctx.root)
440
+ const loaded = await loadRuleTypeScript(ctx)
441
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
442
+ const ts = loaded.compiler
321
443
  if (ts === undefined) return didNotRun("NF-S004", "CORS origin predicate scan")
322
444
  const findings: Diagnostic[] = []
323
445
  for (const file of ctx.project.source.files) {
@@ -378,7 +500,9 @@ export const externalRedirectRule: CheckRule = {
378
500
  code: "NF-S005",
379
501
  title: "External redirect opt-out",
380
502
  async scan(ctx) {
381
- const ts = await importProjectTypeScript(ctx.root)
503
+ const loaded = await loadRuleTypeScript(ctx)
504
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
505
+ const ts = loaded.compiler
382
506
  if (ts === undefined) return didNotRun("NF-S005", "External redirect opt-out scan")
383
507
  const findings: Diagnostic[] = []
384
508
  for (const file of ctx.project.source.files) {
@@ -435,7 +559,9 @@ export const assuranceEscapeHatchRule: CheckRule = {
435
559
  code: "NF-S006",
436
560
  title: "Security escape hatch enabled",
437
561
  async scan(ctx) {
438
- const ts = await importProjectTypeScript(ctx.root)
562
+ const loaded = await loadRuleTypeScript(ctx)
563
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
564
+ const ts = loaded.compiler
439
565
  if (ts === undefined) return didNotRun("NF-S006", "Security escape hatch scan")
440
566
  const findings: Diagnostic[] = []
441
567
  for (const file of ctx.project.source.files) {
@@ -478,7 +604,9 @@ export const unprefixedSecureCookieRule: CheckRule = {
478
604
  code: "NF-S007",
479
605
  title: "Secure cookie without a __Host-/__Secure- prefix",
480
606
  async scan(ctx) {
481
- const ts = await importProjectTypeScript(ctx.root)
607
+ const loaded = await loadRuleTypeScript(ctx)
608
+ if (loaded.diagnostics !== undefined) return loaded.diagnostics
609
+ const ts = loaded.compiler
482
610
  if (ts === undefined) return didNotRun("NF-S007", "Secure cookie prefix scan")
483
611
  const findings: Diagnostic[] = []
484
612
  for (const file of ctx.project.source.files) {
@@ -0,0 +1,77 @@
1
+ import type { Diagnostic } from "../diagnostics.ts"
2
+ import {
3
+ loadProjectTypeScript,
4
+ type TypeScriptApi,
5
+ type TypeScriptSession,
6
+ unsupportedTypeScriptDiagnostic,
7
+ } from "../internal/typescript-import.ts"
8
+ import type { RuleContext } from "./index.ts"
9
+
10
+ interface RuleTypeScriptLoad {
11
+ readonly compiler: TypeScriptApi | undefined
12
+ readonly session: TypeScriptSession | undefined
13
+ readonly diagnostics: readonly Diagnostic[] | undefined
14
+ }
15
+
16
+ interface SessionCacheEntry {
17
+ readonly pending: ReturnType<typeof loadProjectTypeScript>
18
+ leases: number
19
+ }
20
+
21
+ const sessionCache = new WeakMap<RuleContext["sources"], SessionCacheEntry>()
22
+
23
+ function cachedLoad(ctx: RuleContext): SessionCacheEntry {
24
+ const cached = sessionCache.get(ctx.sources)
25
+ if (cached !== undefined) return cached
26
+ const entry: SessionCacheEntry = {
27
+ pending: loadProjectTypeScript(ctx.root, undefined, ctx.sources),
28
+ leases: 0,
29
+ }
30
+ sessionCache.set(ctx.sources, entry)
31
+ return entry
32
+ }
33
+
34
+ /** Retain a direct rule-registry session until the whole registry run has completed. */
35
+ export function retainRuleTypeScript(ctx: RuleContext): void {
36
+ const entry = cachedLoad(ctx)
37
+ entry.leases += 1
38
+ }
39
+
40
+ /** Release a direct rule-registry session and terminate its TypeScript 7 worker when unused. */
41
+ export async function releaseRuleTypeScript(ctx: RuleContext): Promise<void> {
42
+ const entry = sessionCache.get(ctx.sources)
43
+ if (entry === undefined) return
44
+ entry.leases = Math.max(0, entry.leases - 1)
45
+ if (entry.leases !== 0) return
46
+ sessionCache.delete(ctx.sources)
47
+ try {
48
+ const loaded = await entry.pending
49
+ await loaded.session?.close()
50
+ } catch {
51
+ // The scanner that consumed the same pending load owns the diagnostic for load failures.
52
+ }
53
+ }
54
+
55
+ /** Keep every AST-backed rule on the same project compiler session. */
56
+ export async function loadRuleTypeScript(ctx: RuleContext): Promise<RuleTypeScriptLoad> {
57
+ if (ctx.project.check.unsupportedTypeScriptVersion !== undefined) {
58
+ // The check-wide loader already supplied the single structured failure diagnostic.
59
+ return { compiler: undefined, session: undefined, diagnostics: [] }
60
+ }
61
+ if (ctx.typescriptSession !== undefined) {
62
+ return {
63
+ compiler: ctx.typescriptSession.compiler,
64
+ session: ctx.typescriptSession,
65
+ diagnostics: undefined,
66
+ }
67
+ }
68
+ const loaded = await cachedLoad(ctx).pending
69
+ if (loaded.unsupported !== undefined) {
70
+ return {
71
+ compiler: undefined,
72
+ session: undefined,
73
+ diagnostics: [unsupportedTypeScriptDiagnostic(loaded.unsupported.version)],
74
+ }
75
+ }
76
+ return { compiler: loaded.compiler, session: loaded.session, diagnostics: undefined }
77
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * NF-C028 and NF-C029: the frontend/backend zone rules, checked from source.
3
+ *
4
+ * Every browser build and dev server enforces these already; this reports them without building,
5
+ * with the same classifier (`@nifrajs/web/zones`) so the three can never disagree. NF-C004 owns
6
+ * browser code reaching server code (with the chain); NF-C028 owns the other directions: a zoned
7
+ * file importing one in no zone, backend code importing frontend code, and shared code importing
8
+ * anything but shared code. NF-C029 owns private environment reads in browser code.
9
+ */
10
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
11
+ import { dirname, isAbsolute, join, relative } from "node:path"
12
+ import {
13
+ type Classification,
14
+ createZoneClassifier,
15
+ importAllowed,
16
+ importRuleMessage,
17
+ privateEnvReads,
18
+ privateEnvReason,
19
+ type ZoneClassifier,
20
+ } from "@nifrajs/web/zones"
21
+ import { CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
22
+ import { importSites, stripComments } from "../check-scan.ts"
23
+ import { type Diagnostic, diagnostic } from "../diagnostics.ts"
24
+ import { codeUnitOrder } from "../internal/code-unit-order.ts"
25
+ import type { CheckRule, RuleContext } from "./index.ts"
26
+
27
+ const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
28
+ const FRONTEND = new Set(["route-frontend", "frontend"])
29
+ const SERVER = new Set(["backend", "route-backend", "fn"])
30
+ const BACKEND = new Set(["backend", "route-backend"])
31
+ const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
32
+ const CODE_FILE = /\.(?:[cm]?[jt]sx?|svelte|vue|mdx)$/
33
+
34
+ /** The app a project file belongs to: the path before its first zone folder, or `undefined`. */
35
+ function appOf(file: string): string | undefined {
36
+ const parts = file.split("/")
37
+ const at = parts.findIndex((part, i) => i < parts.length - 1 && ZONE_FOLDER.has(part))
38
+ return at === -1 ? undefined : parts.slice(0, at).join("/")
39
+ }
40
+
41
+ interface ZonedFile {
42
+ readonly file: string
43
+ readonly app: string
44
+ readonly classification: Classification
45
+ }
46
+
47
+ /** Every scanned source file that belongs to an app, classified once per app classifier. */
48
+ export function zonedFiles(ctx: RuleContext): {
49
+ readonly files: readonly ZonedFile[]
50
+ readonly classifierFor: (app: string) => ZoneClassifier
51
+ } {
52
+ const classifiers = new Map<string, ZoneClassifier>()
53
+ const classifierFor = (app: string): ZoneClassifier => {
54
+ let classifier = classifiers.get(app)
55
+ if (classifier === undefined) {
56
+ const appRoot = join(ctx.root, app)
57
+ classifier = createZoneClassifier({
58
+ appRoot,
59
+ generatedFiles: [join(appRoot, "server-manifest.ts")],
60
+ })
61
+ classifiers.set(app, classifier)
62
+ }
63
+ return classifier
64
+ }
65
+ const files: ZonedFile[] = []
66
+ for (const file of ctx.sources.files) {
67
+ if (!CODE_FILE.test(file)) continue
68
+ const app = appOf(file)
69
+ if (app === undefined) continue
70
+ files.push({ file, app, classification: classifierFor(app).classify(join(ctx.root, file)) })
71
+ }
72
+ return { files, classifierFor }
73
+ }
74
+
75
+ const real = (path: string): string => {
76
+ try {
77
+ return realpathSync(path)
78
+ } catch {
79
+ return path
80
+ }
81
+ }
82
+
83
+ /** Where a resolved import lands, as the project names it. Both sides are real paths: a checkout
84
+ * reached through a symlink (`/var` -> `/private/var` on macOS) still names files project-relative. */
85
+ function display(root: string, file: string): string {
86
+ const rel = relative(real(root), real(file)).replaceAll("\\", "/")
87
+ return rel.startsWith("../") || isAbsolute(rel) ? file : rel
88
+ }
89
+
90
+ /** A relative import resolved against the importing file, or `undefined` when it does not resolve. */
91
+ function resolveRelative(root: string, from: string, specifier: string): string | undefined {
92
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
93
+ try {
94
+ // Relative specifiers only: a bare one would let the resolver reach for a global install.
95
+ return Bun.resolveSync(specifier, dirname(join(root, from)))
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+
101
+ export const zoneImportRule: CheckRule = {
102
+ code: "NF-C028",
103
+ title: "Zone import check",
104
+ async scan(ctx) {
105
+ const { files, classifierFor } = zonedFiles(ctx)
106
+ const findings: Diagnostic[] = []
107
+ for (const { file, app, classification: from } of files) {
108
+ if (from.zone === "error" || from.zone === "library" || from.zone === "generated") continue
109
+ const content = ctx.sources.read(file)
110
+ if (content === undefined) continue
111
+ for (const { specifier, line } of importSites(content, file)) {
112
+ const target = resolveRelative(ctx.root, file, specifier)
113
+ if (target === undefined) continue
114
+ const to = classifierFor(app).classify(target)
115
+ const targetName = display(ctx.root, target)
116
+ let message: string
117
+ if (to.zone === "error") message = `${file} imports ${targetName}: ${to.reason}`
118
+ else if (
119
+ (SERVER.has(from.zone) && FRONTEND.has(to.zone)) ||
120
+ // Shared code reaching backend code is NF-C004's, reported there with the chain.
121
+ (from.zone === "shared" && !importAllowed(from.zone, to.zone) && !BACKEND.has(to.zone))
122
+ ) {
123
+ message = importRuleMessage(file, from.zone, targetName, to.zone)
124
+ } else continue
125
+ findings.push(
126
+ diagnostic({
127
+ code: "NF-C028",
128
+ severity: "error",
129
+ file,
130
+ line,
131
+ message,
132
+ evidence: [`import: ${specifier}`, `from zone: ${from.zone}`, `to zone: ${to.zone}`],
133
+ verify: "nifra check",
134
+ }),
135
+ )
136
+ }
137
+ }
138
+ return findings.sort(
139
+ (a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
140
+ )
141
+ },
142
+ }
143
+
144
+ const PREFIX_EXPORT =
145
+ /\bexport\s+const\s+publicEnvPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')/
146
+
147
+ /** The app's public-env prefix when its config declares it as a string literal, else the default. */
148
+ function publicPrefix(root: string, app: string): string {
149
+ for (const config of [FRAMEWORK_FILE, CONFIG_FILE]) {
150
+ const path = join(root, app, config)
151
+ if (!existsSync(path)) continue
152
+ const match = PREFIX_EXPORT.exec(stripComments(readFileSync(path, "utf8")))
153
+ if (match !== null) return match[1] ?? match[2] ?? "PUBLIC_"
154
+ }
155
+ return "PUBLIC_"
156
+ }
157
+
158
+ export const privateEnvRule: CheckRule = {
159
+ code: "NF-C029",
160
+ title: "Private environment read in browser code",
161
+ async scan(ctx) {
162
+ const { files } = zonedFiles(ctx)
163
+ const prefixes = new Map<string, string>()
164
+ const findings: Diagnostic[] = []
165
+ for (const { file, app, classification } of files) {
166
+ if (!BROWSER_CODE.has(classification.zone)) continue
167
+ const content = ctx.sources.read(file)
168
+ if (content === undefined) continue
169
+ let prefix = prefixes.get(app)
170
+ if (prefix === undefined) {
171
+ prefix = publicPrefix(ctx.root, app)
172
+ prefixes.set(app, prefix)
173
+ }
174
+ const reads = privateEnvReads(file, content, prefix)
175
+ if (reads.length === 0) continue
176
+ const first = reads[0] ?? ""
177
+ const name = /[.[(]["']?([A-Za-z_$][\w$]*)["']?[)\]]?$/.exec(first)?.[1]
178
+ const index = name === undefined ? -1 : content.indexOf(name)
179
+ findings.push(
180
+ diagnostic({
181
+ code: "NF-C029",
182
+ severity: "error",
183
+ file,
184
+ ...(index === -1 ? {} : { line: content.slice(0, index).split("\n").length }),
185
+ message: `${file} ${privateEnvReason(reads, prefix)}`,
186
+ evidence: reads.map((read) => `read: ${read}`),
187
+ verify: "nifra check",
188
+ }),
189
+ )
190
+ }
191
+ return findings
192
+ },
193
+ }
194
+
195
+ export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule])