@nifrajs/cli 3.5.0 → 4.0.2

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 (404) 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 +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
@@ -0,0 +1,174 @@
1
+ /**
2
+ * NF-C032: what looks like a credential in browser code or in `public/`, checked from source with the
3
+ * scanner every client build runs (`@nifrajs/web/zones`). The build also matches the values of the
4
+ * build environment's private variables in its output; that needs the build's environment, so it
5
+ * stays the build's.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import {
10
+ publicScanFiles,
11
+ type SecretExemption,
12
+ type SecretScanFile,
13
+ scanForSecrets,
14
+ } from "@nifrajs/web/zones"
15
+ import { CONFIG_FILE } from "../app-files.ts"
16
+ import { stripComments } from "../check-scan.ts"
17
+ import { type Diagnostic, diagnostic } from "../diagnostics.ts"
18
+ import { codeUnitOrder } from "../internal/code-unit-order.ts"
19
+ import type { CheckRule } from "./index.ts"
20
+ import { zonedFiles } from "./zones.ts"
21
+
22
+ const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
23
+
24
+ const EXEMPTIONS_START = /\bexport\s+const\s+secretExemptions\b[^=]*=\s*\[/
25
+
26
+ type Token = { readonly kind: "string" | "word" | "punct"; readonly value: string }
27
+
28
+ /**
29
+ * The tokens of an array literal, from just after its `[` to the `]` that closes it. A string is one
30
+ * token, so a bracket or a colon inside one (`"routes/[lang]/index.tsx"`) is text, never syntax.
31
+ */
32
+ function arrayTokens(src: string, from: number): Token[] {
33
+ const tokens: Token[] = []
34
+ let depth = 0
35
+ let i = from
36
+ while (i < src.length) {
37
+ const c = src[i] as string
38
+ if (c === '"' || c === "'" || c === "`") {
39
+ let value = ""
40
+ let j = i + 1
41
+ while (j < src.length && src[j] !== c) {
42
+ if (src[j] === "\\") {
43
+ value += src[j + 1] ?? ""
44
+ j += 2
45
+ } else value += src[j++]
46
+ }
47
+ tokens.push({ kind: "string", value })
48
+ i = j + 1
49
+ } else if (/[\w$]/.test(c)) {
50
+ let j = i
51
+ while (j < src.length && /[\w$]/.test(src[j] as string)) j++
52
+ tokens.push({ kind: "word", value: src.slice(i, j) })
53
+ i = j
54
+ } else {
55
+ if (c === "]" || c === "}" || c === ")") {
56
+ if (depth === 0) break
57
+ depth--
58
+ } else if (c === "[" || c === "{" || c === "(") depth++
59
+ if (!/\s/.test(c)) tokens.push({ kind: "punct", value: c })
60
+ i++
61
+ }
62
+ }
63
+ return tokens
64
+ }
65
+
66
+ const isPunct = (token: Token | undefined, value: string): boolean =>
67
+ token?.kind === "punct" && token.value === value
68
+
69
+ /**
70
+ * The app's `secretExemptions`, read statically from `nifra.config.ts` (check never imports app code):
71
+ * an array literal of object literals with string fields, the form the build error suggests.
72
+ */
73
+ export function readSecretExemptions(appRoot: string): SecretExemption[] {
74
+ const path = join(appRoot, CONFIG_FILE)
75
+ if (!existsSync(path)) return []
76
+ const text = stripComments(readFileSync(path, "utf8"))
77
+ const opened = EXEMPTIONS_START.exec(text)
78
+ if (opened === null) return []
79
+ const tokens = arrayTokens(text, opened.index + opened[0].length)
80
+ const exemptions: SecretExemption[] = []
81
+ let depth = 0
82
+ let fields: Record<string, string> | undefined
83
+ for (let i = 0; i < tokens.length; i++) {
84
+ const token = tokens[i] as Token
85
+ if (token.kind === "punct" && "[{(".includes(token.value)) {
86
+ depth++
87
+ if (depth === 1 && token.value === "{") fields = {}
88
+ } else if (token.kind === "punct" && "]})".includes(token.value)) {
89
+ if (depth === 1 && fields !== undefined) {
90
+ const { rule, reason, file, env } = fields
91
+ if (rule !== undefined && reason !== undefined) {
92
+ exemptions.push({
93
+ rule: rule as SecretExemption["rule"],
94
+ reason,
95
+ ...(file === undefined ? {} : { file }),
96
+ ...(env === undefined ? {} : { env }),
97
+ })
98
+ }
99
+ fields = undefined
100
+ }
101
+ depth--
102
+ } else if (
103
+ depth === 1 &&
104
+ fields !== undefined &&
105
+ token.kind !== "punct" &&
106
+ isPunct(tokens[i + 1], ":") &&
107
+ tokens[i + 2]?.kind === "string" &&
108
+ (isPunct(tokens[i + 3], ",") || isPunct(tokens[i + 3], "}"))
109
+ ) {
110
+ fields[token.value] ??= (tokens[i + 2] as Token).value
111
+ i += 2
112
+ }
113
+ }
114
+ return exemptions
115
+ }
116
+
117
+ export const secretSourceRule: CheckRule = {
118
+ code: "NF-C032",
119
+ title: "Credential in browser code",
120
+ async scan(ctx) {
121
+ const { files } = zonedFiles(ctx)
122
+ const apps = new Map<string, SecretScanFile[]>()
123
+ for (const { file, app, classification } of files) {
124
+ if (!apps.has(app)) apps.set(app, [])
125
+ if (!BROWSER_CODE.has(classification.zone)) continue
126
+ const text = ctx.sources.read(file)
127
+ if (text === undefined) continue
128
+ const name = app === "" ? file : file.slice(app.length + 1)
129
+ apps.get(app)?.push({ name, text, firstParty: true })
130
+ }
131
+ const findings: Diagnostic[] = []
132
+ for (const [app, sources] of apps) {
133
+ const appRoot = join(ctx.root, app)
134
+ const publicDir = join(appRoot, "public")
135
+ try {
136
+ const found = scanForSecrets({
137
+ sources,
138
+ artifacts: existsSync(publicDir) ? publicScanFiles(publicDir, "public") : [],
139
+ exemptions: readSecretExemptions(appRoot),
140
+ })
141
+ for (const finding of found) {
142
+ const file = app === "" ? finding.file : `${app}/${finding.file}`
143
+ findings.push(
144
+ diagnostic({
145
+ code: "NF-C032",
146
+ severity: "error",
147
+ file,
148
+ line: finding.line,
149
+ message: `${file} carries what looks like a credential (${finding.what}), and it reaches the browser. Read it from the environment in a route's backend half or under backend/, and rotate it if it was ever committed`,
150
+ evidence: [`rule: ${finding.rule}`, `match: ${finding.preview}`],
151
+ verify: "nifra check",
152
+ }),
153
+ )
154
+ }
155
+ } catch (error) {
156
+ // A malformed exemption: the build refuses it with the same message.
157
+ findings.push(
158
+ diagnostic({
159
+ code: "NF-C032",
160
+ severity: "error",
161
+ file: join(app, CONFIG_FILE),
162
+ message: error instanceof Error ? error.message : String(error),
163
+ verify: "nifra check",
164
+ }),
165
+ )
166
+ }
167
+ }
168
+ return findings.sort(
169
+ (a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
170
+ )
171
+ },
172
+ }
173
+
174
+ export const secretRules = Object.freeze([secretSourceRule])
@@ -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
+ }