@intentius/chant 0.46.0 → 0.50.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 (377) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +30 -3
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +9 -2
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts +3 -3
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/cli/commands/audit.d.ts +7 -0
  20. package/dist/cli/commands/audit.d.ts.map +1 -1
  21. package/dist/cli/commands/build.d.ts +23 -0
  22. package/dist/cli/commands/build.d.ts.map +1 -1
  23. package/dist/cli/commands/lint.d.ts.map +1 -1
  24. package/dist/cli/handlers/build.d.ts.map +1 -1
  25. package/dist/cli/handlers/components.d.ts +31 -0
  26. package/dist/cli/handlers/components.d.ts.map +1 -1
  27. package/dist/cli/handlers/lifecycle.d.ts +12 -1
  28. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/scenario.d.ts +39 -0
  32. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  35. package/dist/cli/mcp/server.d.ts +35 -2
  36. package/dist/cli/mcp/server.d.ts.map +1 -1
  37. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  38. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  39. package/dist/cli/mcp/types.d.ts +29 -1
  40. package/dist/cli/mcp/types.d.ts.map +1 -1
  41. package/dist/cli/plugins.d.ts +1 -1
  42. package/dist/cli/plugins.d.ts.map +1 -1
  43. package/dist/cli/registry.d.ts +14 -2
  44. package/dist/cli/registry.d.ts.map +1 -1
  45. package/dist/cli/reporters/stylish.d.ts +15 -1
  46. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  47. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  48. package/dist/components/auto-release.d.ts +4 -0
  49. package/dist/components/auto-release.d.ts.map +1 -1
  50. package/dist/components/capability.d.ts +17 -2
  51. package/dist/components/capability.d.ts.map +1 -1
  52. package/dist/components/cli-support.d.ts +7 -0
  53. package/dist/components/cli-support.d.ts.map +1 -1
  54. package/dist/components/component.d.ts +15 -0
  55. package/dist/components/component.d.ts.map +1 -1
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/starter-plugin.d.ts +2 -0
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  60. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  61. package/dist/components/verbs/index.d.ts +13 -0
  62. package/dist/components/verbs/index.d.ts.map +1 -1
  63. package/dist/components/verbs/r2-sync.d.ts +76 -0
  64. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  65. package/dist/components/verbs/run-agent.d.ts +499 -0
  66. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  67. package/dist/components/verbs/sign.d.ts +30 -0
  68. package/dist/components/verbs/sign.d.ts.map +1 -1
  69. package/dist/components/verbs/wrangler.d.ts +108 -0
  70. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  71. package/dist/composite.d.ts +6 -1
  72. package/dist/composite.d.ts.map +1 -1
  73. package/dist/config.d.ts +26 -0
  74. package/dist/config.d.ts.map +1 -1
  75. package/dist/deep-observation.d.ts +14 -0
  76. package/dist/deep-observation.d.ts.map +1 -1
  77. package/dist/discovery/collect.d.ts.map +1 -1
  78. package/dist/discovery/fold-import.d.ts +15 -1
  79. package/dist/discovery/fold-import.d.ts.map +1 -1
  80. package/dist/discovery/fold-rank.d.ts +66 -0
  81. package/dist/discovery/fold-rank.d.ts.map +1 -0
  82. package/dist/discovery/index.d.ts +15 -0
  83. package/dist/discovery/index.d.ts.map +1 -1
  84. package/dist/discovery/param-deps.d.ts +17 -0
  85. package/dist/discovery/param-deps.d.ts.map +1 -0
  86. package/dist/effect-receipt.d.ts +177 -0
  87. package/dist/effect-receipt.d.ts.map +1 -0
  88. package/dist/fold/fold.d.ts +55 -2
  89. package/dist/fold/fold.d.ts.map +1 -1
  90. package/dist/fold/subset.d.ts +20 -0
  91. package/dist/fold/subset.d.ts.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/lexicon-schema.d.ts +2 -0
  95. package/dist/lexicon-schema.d.ts.map +1 -1
  96. package/dist/lexicon.d.ts +137 -3
  97. package/dist/lexicon.d.ts.map +1 -1
  98. package/dist/lifecycle/change-set.d.ts +33 -5
  99. package/dist/lifecycle/change-set.d.ts.map +1 -1
  100. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  101. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  102. package/dist/lifecycle/deep-diff.d.ts +18 -0
  103. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  104. package/dist/lifecycle/deep-observe.d.ts +9 -1
  105. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  106. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  107. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  108. package/dist/lifecycle/git.d.ts +145 -21
  109. package/dist/lifecycle/git.d.ts.map +1 -1
  110. package/dist/lifecycle/index.d.ts +6 -0
  111. package/dist/lifecycle/index.d.ts.map +1 -1
  112. package/dist/lifecycle/lease.d.ts +113 -0
  113. package/dist/lifecycle/lease.d.ts.map +1 -0
  114. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  115. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  116. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  117. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  118. package/dist/lifecycle/release-ledger.d.ts +20 -0
  119. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  120. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  121. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  122. package/dist/lifecycle/scenario.d.ts +163 -0
  123. package/dist/lifecycle/scenario.d.ts.map +1 -0
  124. package/dist/lifecycle/symptoms.d.ts +63 -0
  125. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  126. package/dist/lifecycle/teardown.d.ts +6 -4
  127. package/dist/lifecycle/teardown.d.ts.map +1 -1
  128. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  129. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  130. package/dist/lint/knowledge-checks.d.ts +48 -0
  131. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  132. package/dist/lint/output-checks.d.ts +5 -0
  133. package/dist/lint/output-checks.d.ts.map +1 -0
  134. package/dist/lint/output-docs.d.ts +94 -0
  135. package/dist/lint/output-docs.d.ts.map +1 -0
  136. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  137. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  138. package/dist/lint/post-synth.d.ts +41 -0
  139. package/dist/lint/post-synth.d.ts.map +1 -1
  140. package/dist/lint/receipt-checks.d.ts +9 -0
  141. package/dist/lint/receipt-checks.d.ts.map +1 -0
  142. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  143. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  144. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  145. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  146. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  147. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  148. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  149. package/dist/lint/rules/index.d.ts +3 -1
  150. package/dist/lint/rules/index.d.ts.map +1 -1
  151. package/dist/lsp/lexicon-providers.d.ts +7 -0
  152. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  153. package/dist/okf-read.d.ts +78 -0
  154. package/dist/okf-read.d.ts.map +1 -0
  155. package/dist/op/activity-contract.d.ts +139 -0
  156. package/dist/op/activity-contract.d.ts.map +1 -0
  157. package/dist/op/builders.d.ts +140 -3
  158. package/dist/op/builders.d.ts.map +1 -1
  159. package/dist/op/converge-rule.d.ts +161 -0
  160. package/dist/op/converge-rule.d.ts.map +1 -0
  161. package/dist/op/generate-pipeline.d.ts +39 -0
  162. package/dist/op/generate-pipeline.d.ts.map +1 -0
  163. package/dist/op/index.d.ts +18 -2
  164. package/dist/op/index.d.ts.map +1 -1
  165. package/dist/op/local-executor.d.ts +2 -1
  166. package/dist/op/local-executor.d.ts.map +1 -1
  167. package/dist/op/op-verb-class.d.ts +42 -0
  168. package/dist/op/op-verb-class.d.ts.map +1 -0
  169. package/dist/op/operator.d.ts +128 -0
  170. package/dist/op/operator.d.ts.map +1 -0
  171. package/dist/op/receipt-store.d.ts +138 -0
  172. package/dist/op/receipt-store.d.ts.map +1 -0
  173. package/dist/op/step-output-ref.d.ts +187 -0
  174. package/dist/op/step-output-ref.d.ts.map +1 -0
  175. package/dist/op/types.d.ts +49 -2
  176. package/dist/op/types.d.ts.map +1 -1
  177. package/dist/provenance.d.ts +73 -3
  178. package/dist/provenance.d.ts.map +1 -1
  179. package/dist/runtime-adapter.d.ts +7 -1
  180. package/dist/runtime-adapter.d.ts.map +1 -1
  181. package/dist/secret-materialization.d.ts +138 -0
  182. package/dist/secret-materialization.d.ts.map +1 -0
  183. package/dist/secret-provenance.d.ts +218 -0
  184. package/dist/secret-provenance.d.ts.map +1 -0
  185. package/dist/serializer.d.ts +29 -0
  186. package/dist/serializer.d.ts.map +1 -1
  187. package/dist/toml.d.ts +40 -5
  188. package/dist/toml.d.ts.map +1 -1
  189. package/dist/yaml.d.ts.map +1 -1
  190. package/package.json +4 -1
  191. package/src/audit/catalog.test.ts +1 -1
  192. package/src/audit/catalog.ts +75 -3
  193. package/src/audit/core.test.ts +57 -0
  194. package/src/audit/core.ts +0 -0
  195. package/src/audit/detect-bundle.test.ts +1 -1
  196. package/src/audit/discover.test.ts +24 -0
  197. package/src/audit/discover.ts +40 -4
  198. package/src/audit/fetch.test.ts +216 -3
  199. package/src/audit/fetch.ts +270 -59
  200. package/src/audit/report-html.ts +5 -2
  201. package/src/audit/report-model.ts +9 -0
  202. package/src/audit/report.test.ts +22 -0
  203. package/src/audit/report.ts +3 -2
  204. package/src/audit/rules-doc.ts +13 -1
  205. package/src/audit/secrets.test.ts +303 -0
  206. package/src/audit/secrets.ts +406 -0
  207. package/src/audit/wrangler.test.ts +230 -0
  208. package/src/audit/wrangler.ts +290 -0
  209. package/src/build.test.ts +41 -0
  210. package/src/build.ts +39 -6
  211. package/src/cli/command-group.ts +1 -1
  212. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  213. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  214. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  215. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  216. package/src/cli/commands/audit.test.ts +268 -1
  217. package/src/cli/commands/audit.ts +87 -18
  218. package/src/cli/commands/build.test.ts +245 -0
  219. package/src/cli/commands/build.ts +210 -21
  220. package/src/cli/commands/lint.ts +15 -3
  221. package/src/cli/handlers/build.ts +2 -0
  222. package/src/cli/handlers/components.test.ts +199 -1
  223. package/src/cli/handlers/components.ts +160 -3
  224. package/src/cli/handlers/explain.test.ts +70 -1
  225. package/src/cli/handlers/graph.test.ts +20 -0
  226. package/src/cli/handlers/graph.ts +12 -3
  227. package/src/cli/handlers/lifecycle.test.ts +115 -1
  228. package/src/cli/handlers/lifecycle.ts +96 -15
  229. package/src/cli/handlers/operator.test.ts +255 -0
  230. package/src/cli/handlers/operator.ts +240 -0
  231. package/src/cli/handlers/scenario.test.ts +456 -0
  232. package/src/cli/handlers/scenario.ts +330 -0
  233. package/src/cli/main.test.ts +23 -0
  234. package/src/cli/main.ts +72 -1
  235. package/src/cli/mcp/resource-handlers.ts +38 -1
  236. package/src/cli/mcp/server.test.ts +323 -3
  237. package/src/cli/mcp/server.ts +84 -7
  238. package/src/cli/mcp/tools/explain.ts +51 -2
  239. package/src/cli/mcp/types.ts +27 -1
  240. package/src/cli/plugins.ts +4 -2
  241. package/src/cli/registry.ts +14 -2
  242. package/src/cli/reporters/stylish.test.ts +154 -0
  243. package/src/cli/reporters/stylish.ts +154 -33
  244. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  245. package/src/codegen/docs-rule-scanning.ts +25 -2
  246. package/src/components/README.md +7 -0
  247. package/src/components/auto-release.ts +6 -0
  248. package/src/components/capability.ts +17 -2
  249. package/src/components/cli-support.test.ts +17 -0
  250. package/src/components/cli-support.ts +13 -1
  251. package/src/components/component-schema.test.ts +32 -0
  252. package/src/components/component.schema.json +6 -0
  253. package/src/components/component.test.ts +21 -0
  254. package/src/components/component.ts +15 -0
  255. package/src/components/driver.ts +12 -4
  256. package/src/components/registry.test.ts +7 -2
  257. package/src/components/starter-plugin.ts +17 -0
  258. package/src/components/verbs/ensure-secret.test.ts +130 -0
  259. package/src/components/verbs/ensure-secret.ts +79 -0
  260. package/src/components/verbs/index.ts +13 -0
  261. package/src/components/verbs/r2-sync.test.ts +107 -0
  262. package/src/components/verbs/r2-sync.ts +124 -0
  263. package/src/components/verbs/run-agent.test.ts +683 -0
  264. package/src/components/verbs/run-agent.ts +786 -0
  265. package/src/components/verbs/sign.test.ts +19 -0
  266. package/src/components/verbs/sign.ts +34 -2
  267. package/src/components/verbs/wrangler.test.ts +170 -0
  268. package/src/components/verbs/wrangler.ts +241 -0
  269. package/src/composite.ts +31 -2
  270. package/src/config.test.ts +15 -0
  271. package/src/config.ts +30 -0
  272. package/src/deep-observation.test.ts +19 -0
  273. package/src/deep-observation.ts +17 -0
  274. package/src/discovery/collect.ts +11 -2
  275. package/src/discovery/fold-import.test.ts +54 -0
  276. package/src/discovery/fold-import.ts +178 -38
  277. package/src/discovery/fold-rank.test.ts +197 -0
  278. package/src/discovery/fold-rank.ts +346 -0
  279. package/src/discovery/index.ts +16 -1
  280. package/src/discovery/param-deps.test.ts +118 -0
  281. package/src/discovery/param-deps.ts +170 -0
  282. package/src/effect-receipt-exclusion.test.ts +190 -0
  283. package/src/effect-receipt.test.ts +419 -0
  284. package/src/effect-receipt.ts +412 -0
  285. package/src/fold/fold.test.ts +6 -2
  286. package/src/fold/fold.ts +184 -3
  287. package/src/fold/subset.test.ts +95 -6
  288. package/src/fold/subset.ts +66 -2
  289. package/src/index.ts +4 -0
  290. package/src/lexicon-schema.ts +3 -0
  291. package/src/lexicon.ts +151 -5
  292. package/src/lifecycle/change-set.ts +46 -7
  293. package/src/lifecycle/converge-ledger.test.ts +199 -0
  294. package/src/lifecycle/converge-ledger.ts +179 -0
  295. package/src/lifecycle/deep-diff.test.ts +79 -1
  296. package/src/lifecycle/deep-diff.ts +23 -0
  297. package/src/lifecycle/deep-observe.ts +13 -2
  298. package/src/lifecycle/gate-ledger.test.ts +103 -0
  299. package/src/lifecycle/gate-ledger.ts +140 -0
  300. package/src/lifecycle/git.test.ts +430 -0
  301. package/src/lifecycle/git.ts +446 -84
  302. package/src/lifecycle/index.ts +6 -0
  303. package/src/lifecycle/lease.test.ts +343 -0
  304. package/src/lifecycle/lease.ts +270 -0
  305. package/src/lifecycle/observation-baseline.test.ts +46 -0
  306. package/src/lifecycle/observation-baseline.ts +33 -1
  307. package/src/lifecycle/receipt-plan.test.ts +250 -0
  308. package/src/lifecycle/receipt-plan.ts +249 -0
  309. package/src/lifecycle/release-ledger.ts +20 -0
  310. package/src/lifecycle/scenario-eval.test.ts +199 -0
  311. package/src/lifecycle/scenario-eval.ts +158 -0
  312. package/src/lifecycle/scenario.test.ts +195 -0
  313. package/src/lifecycle/scenario.ts +321 -0
  314. package/src/lifecycle/symptoms.test.ts +116 -0
  315. package/src/lifecycle/symptoms.ts +126 -0
  316. package/src/lifecycle/teardown.test.ts +31 -0
  317. package/src/lifecycle/teardown.ts +6 -4
  318. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  319. package/src/lifecycle/unobserved-gate.ts +102 -0
  320. package/src/lint/knowledge-checks.test.ts +80 -0
  321. package/src/lint/knowledge-checks.ts +74 -0
  322. package/src/lint/output-checks.test.ts +85 -0
  323. package/src/lint/output-checks.ts +99 -0
  324. package/src/lint/output-docs.test.ts +220 -0
  325. package/src/lint/output-docs.ts +204 -0
  326. package/src/lint/pipeline-change-gate.test.ts +144 -0
  327. package/src/lint/pipeline-change-gate.ts +153 -0
  328. package/src/lint/post-synth.test.ts +97 -0
  329. package/src/lint/post-synth.ts +60 -0
  330. package/src/lint/receipt-checks.test.ts +101 -0
  331. package/src/lint/receipt-checks.ts +93 -0
  332. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  333. package/src/lint/rules/comp/comp.test.ts +49 -1
  334. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  335. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  336. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  337. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  338. package/src/lint/rules/evl001-non-literal-expression.test.ts +35 -3
  339. package/src/lint/rules/evl001-non-literal-expression.ts +7 -0
  340. package/src/lint/rules/index.ts +7 -1
  341. package/src/lsp/lexicon-providers.test.ts +44 -0
  342. package/src/lsp/lexicon-providers.ts +11 -1
  343. package/src/okf-read.test.ts +149 -0
  344. package/src/okf-read.ts +197 -0
  345. package/src/op/activity-contract.test.ts +180 -0
  346. package/src/op/activity-contract.ts +278 -0
  347. package/src/op/builders-exports.test.ts +17 -1
  348. package/src/op/builders.ts +198 -6
  349. package/src/op/converge-rule.test.ts +179 -0
  350. package/src/op/converge-rule.ts +311 -0
  351. package/src/op/effect-step.test.ts +311 -0
  352. package/src/op/generate-pipeline.test.ts +53 -0
  353. package/src/op/generate-pipeline.ts +99 -0
  354. package/src/op/index.ts +40 -3
  355. package/src/op/local-executor.test.ts +92 -0
  356. package/src/op/local-executor.ts +212 -29
  357. package/src/op/op-verb-class.test.ts +126 -0
  358. package/src/op/op-verb-class.ts +115 -0
  359. package/src/op/op.test.ts +25 -2
  360. package/src/op/operator.test.ts +346 -0
  361. package/src/op/operator.ts +213 -0
  362. package/src/op/receipt-store.ts +211 -0
  363. package/src/op/step-output-ref.test.ts +334 -0
  364. package/src/op/step-output-ref.ts +453 -0
  365. package/src/op/types.ts +51 -2
  366. package/src/provenance.test.ts +151 -4
  367. package/src/provenance.ts +118 -4
  368. package/src/runtime-adapter.ts +31 -10
  369. package/src/secret-materialization.test.ts +199 -0
  370. package/src/secret-materialization.ts +235 -0
  371. package/src/secret-provenance.test.ts +388 -0
  372. package/src/secret-provenance.ts +475 -0
  373. package/src/serializer.ts +30 -0
  374. package/src/toml.test.ts +157 -384
  375. package/src/toml.ts +371 -5
  376. package/src/yaml.test.ts +88 -0
  377. package/src/yaml.ts +76 -6
@@ -266,6 +266,20 @@ export interface DeepNormalizationHooks {
266
266
  * reads as permanent drift.
267
267
  */
268
268
  unresolved?(node: DeepNode): boolean;
269
+ /**
270
+ * Return true when this value is secret material the diff must never hold —
271
+ * it is collapsed to {@link MASKED} on BOTH sides (the hook runs over the
272
+ * declared and the live tree alike), so presence and key names still
273
+ * classify while values never reach a diff row, a log line, or a snapshot.
274
+ *
275
+ * The pass masks by key name on its own ({@link isSensitiveKey}); this hook
276
+ * exists for the lexicons whose secret-bearing paths are structural rather
277
+ * than name-shaped — a Kubernetes Secret's `data` carries arbitrary key
278
+ * names (`app.conf`), and #1365 decision 6 draws the hard line: drift on a
279
+ * secret observes presence, declared key-set, and metadata — never a value
280
+ * or a value-derived hash.
281
+ */
282
+ mask?(node: DeepNode): boolean;
269
283
  }
270
284
 
271
285
  /** Everything the pass needs besides the tree itself. */
@@ -405,6 +419,9 @@ export function normalizeDeepProperties(
405
419
 
406
420
  const normalizeValue = (value: unknown, path: string, pattern: string, key: string): unknown => {
407
421
  if (isSensitiveKey(key)) return MASKED;
422
+ // The lexicon's structural mask — same collapse, path-shaped rather than
423
+ // key-named (a k8s Secret's `data.*` no matter what the key is called).
424
+ if (hooks?.mask?.(nodeOf(path, pattern, key, value))) return MASKED;
408
425
  // A value the lexicon says cannot be known without deploying — an
409
426
  // expression-string reference — collapses exactly like a class-instance
410
427
  // intrinsic does below.
@@ -34,6 +34,15 @@ function stackPrefix(file: string, buildRoot: string | undefined): string {
34
34
  return segments.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
35
35
  }
36
36
 
37
+ /**
38
+ * Whole-entity origin for a declarable a file exported directly (chant #1443).
39
+ * A composite's members get theirs from `expandComposite`, which knows the
40
+ * composite and the instance; a direct export has nothing between the source
41
+ * file and the resource. Merged key-by-key, so a path the fold path already
42
+ * attributed to a build parameter is not overwritten.
43
+ */
44
+ const AUTHORED_ROOT: EntityProvenance["paths"] = { "": { kind: "authored" } };
45
+
37
46
  /** One entity to be placed into the map, produced by {@link enumerateEntries}. */
38
47
  interface PendingEntry {
39
48
  /** The un-disambiguated map key this entity would take (export name, indexed
@@ -73,12 +82,12 @@ function enumerateEntries(
73
82
  for (const [rawName, value] of sortedExports) {
74
83
  const name = exportKey(rawName, file);
75
84
  if (isDeclarable(value)) {
76
- entries.push({ bareKey: name, value, file, provenance: { sourceFile: file } });
85
+ entries.push({ bareKey: name, value, file, provenance: { sourceFile: file, paths: AUTHORED_ROOT } });
77
86
  } else if (Array.isArray(value)) {
78
87
  for (let i = 0; i < value.length; i++) {
79
88
  const item = value[i];
80
89
  if (isDeclarable(item)) {
81
- entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: { sourceFile: file } });
90
+ entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: { sourceFile: file, paths: AUTHORED_ROOT } });
82
91
  } else if (isCompositeInstance(item)) {
83
92
  const indexedName = `${name}_${i}`;
84
93
  for (const [expandedName, entity] of expandComposite(indexedName, item)) {
@@ -9,6 +9,7 @@ import { isCompositeInstance } from "../composite";
9
9
  import { isAttrRefLike } from "../utils";
10
10
  import { isIntrinsic } from "../intrinsic";
11
11
  import { params as sharedParams } from "../params";
12
+ import { getPathProvenance } from "../provenance";
12
13
  import type { AttrRef } from "../attrref";
13
14
  import type { IntrinsicDef } from "../lexicon";
14
15
 
@@ -1153,6 +1154,59 @@ describe("tryFoldFile — build-time parameters (chant #1064)", () => {
1153
1154
 
1154
1155
  setBuildParams({});
1155
1156
  });
1157
+
1158
+ // chant #1443 — fold substitutes `params.<name>` away, so the emitted value
1159
+ // is a literal and unattributable. These record which parameters the
1160
+ // AUTHORED expression read, before that happens.
1161
+ test("a folded resource records which build parameters each of its paths reads", async () => {
1162
+ await writeResourceDefs();
1163
+ const file = join(testDir, "main.ts");
1164
+ await writeFile(
1165
+ file,
1166
+ `
1167
+ import { Bucket } from "./resources";
1168
+ import { params } from ${JSON.stringify(paramsPath)};
1169
+ const suffix = params.env === "prod" ? "" : "-dev";
1170
+ export const bucket = new Bucket({
1171
+ name: \`assets\${suffix}\`,
1172
+ fixed: "never-moves",
1173
+ nested: { region: params.region },
1174
+ });
1175
+ `,
1176
+ );
1177
+
1178
+ const session = createFoldSession([], { env: "prod", region: "us-east-1" });
1179
+ const result = await tryFoldFile(file, [], session);
1180
+
1181
+ expect(result.ok).toBe(true);
1182
+ if (!result.ok) return;
1183
+ const [, entity] = result.entities[0];
1184
+ expect(getPathProvenance(entity as object)).toEqual({
1185
+ name: { kind: "build-param", params: ["env"] },
1186
+ "nested.region": { kind: "build-param", params: ["region"] },
1187
+ });
1188
+ });
1189
+
1190
+ test("a folded resource that reads no parameter records no path origins", async () => {
1191
+ await writeResourceDefs();
1192
+ const file = join(testDir, "main.ts");
1193
+ await writeFile(
1194
+ file,
1195
+ `
1196
+ import { Bucket } from "./resources";
1197
+ import { params } from ${JSON.stringify(paramsPath)};
1198
+ export const other = params.env;
1199
+ export const bucket = new Bucket({ name: "static" });
1200
+ `,
1201
+ );
1202
+
1203
+ const result = await tryFoldFile(file, [], createFoldSession([], { env: "prod" }));
1204
+
1205
+ expect(result.ok).toBe(true);
1206
+ if (!result.ok) return;
1207
+ const entity = result.entities.find(([name]) => name === "bucket")![1];
1208
+ expect(getPathProvenance(entity as object)).toBeUndefined();
1209
+ });
1156
1210
  });
1157
1211
 
1158
1212
  /**
@@ -22,6 +22,7 @@ import {
22
22
  type FoldedIntrinsic,
23
23
  type FoldedHelperCall,
24
24
  type SymbolicValue,
25
+ type FoldedCompositeStepCall,
25
26
  } from "../fold/fold";
26
27
  import { isChantOwnedSpecifier, isFoldableHelperName } from "../fold/foldable-helpers";
27
28
  import {
@@ -37,7 +38,9 @@ import {
37
38
  SUPPORTED_UNARY_OPERATORS,
38
39
  } from "../fold/subset";
39
40
  import { importModule } from "./import";
40
- import type { IntrinsicDef } from "../lexicon";
41
+ import { collectParamDependencies } from "./param-deps";
42
+ import { setPathProvenance } from "../provenance";
43
+ import { intrinsicCallFoldsEagerly, type IntrinsicDef } from "../lexicon";
41
44
  import type { BuildParamValue } from "../build-params";
42
45
 
43
46
  /**
@@ -1096,19 +1099,25 @@ function activeLexiconPackage(specifier: string, lexiconPackages: ReadonlySet<st
1096
1099
  * and intrinsic functions; the only thing new is that a plain DATA export is
1097
1100
  * now reachable too.
1098
1101
  *
1099
- * Callable exports are deliberately excluded. A lexicon's functions — its
1102
+ * Callable exports are otherwise excluded. A lexicon's functions — its
1100
1103
  * resource classes, composite factories, intrinsic implementations — already
1101
1104
  * have dedicated resolution paths that know how to INVOKE them
1102
1105
  * (`resolveResourceEntity`, `resolveCallExpression`, `reviveFoldedValue`), and
1103
- * binding them as plain identifier values here would widen what folds in ways
1104
- * this issue neither needs nor measured: none of the references #1063 exists
1105
- * to unblock (`Azure`, `GCP`, `S3Actions`, gitlab's `CI`) is a function.
1106
+ * binding an arbitrary one as a plain identifier value here would widen what
1107
+ * folds in unmeasured ways. The one exception (chant #1966) is a function its
1108
+ * lexicon registered with {@link IntrinsicDef.foldsEagerly} — the same
1109
+ * per-name, lexicon-declared opt-in `foldsAsCall` is for an intrinsic's
1110
+ * envelope-and-revive form, admitting this one because `fold()`
1111
+ * (../fold/fold.ts) needs to CALL it eagerly — synchronously, before
1112
+ * revival — to support a lexicon's own string-building helpers used inline
1113
+ * (`matrix("os").toString()`, `` `${inputs("x")}` ``), where an envelope
1114
+ * would still be a plain object when the enclosing template folds.
1106
1115
  *
1107
1116
  * Returns `undefined` — never throws — for a package that isn't an active
1108
- * lexicon, an export the package doesn't have, a callable export, or an
1109
- * import that fails. The binding is then simply absent from `externals`,
1110
- * exactly as before, and `fold()`'s ordinary "unresolved identifier" failure
1111
- * still fires if the name is actually referenced.
1117
+ * lexicon, an export the package doesn't have, a non-admitted callable
1118
+ * export, or an import that fails. The binding is then simply absent from
1119
+ * `externals`, exactly as before, and `fold()`'s ordinary "unresolved
1120
+ * identifier" failure still fires if the name is actually referenced.
1112
1121
  */
1113
1122
  async function resolveActiveLexiconExport(
1114
1123
  binding: ImportBinding,
@@ -1133,7 +1142,10 @@ async function resolveActiveLexiconExport(
1133
1142
 
1134
1143
  if (!(binding.imported in mod)) return undefined;
1135
1144
  const value = mod[binding.imported];
1136
- if (typeof value === "function") return undefined;
1145
+ if (typeof value === "function") {
1146
+ const eager = session.intrinsics.some((i) => i.name === binding.imported && intrinsicCallFoldsEagerly(i));
1147
+ if (!eager) return undefined;
1148
+ }
1137
1149
  return { value };
1138
1150
  }
1139
1151
 
@@ -1415,19 +1427,19 @@ async function resolveImportedCall(
1415
1427
  }
1416
1428
 
1417
1429
  /**
1418
- * Arm 2 — import the module the callee came from and call it, in this process.
1419
- * Unchanged from #1022 in both behavior and ORDER (refusal, resolve, import,
1420
- * callable check, arguments, call); #1023 only factored it out so the
1421
- * interpretation arm above can fall back into it.
1422
- *
1423
- * @param args - Already-evaluated arguments, when the caller has them.
1430
+ * Arm 2 — import the module the callee came from and call it, in this
1431
+ * process, given ALREADY-RESOLVED argument values. Factored out of
1432
+ * {@link invokeImportedCallee} (chant #1174) so a caller with no raw
1433
+ * `ts.CallExpression` node — {@link resolveCompositeCall}, the nested-value
1434
+ * counterpart to {@link resolveCallExpression} — can reach the identical
1435
+ * refusal/resolve/import/callable-check/call sequence #1022 established,
1436
+ * without inventing a second copy of it.
1424
1437
  */
1425
- async function invokeImportedCallee(
1426
- node: ts.CallExpression,
1438
+ async function invokeResolvedCallee(
1427
1439
  calleeName: string,
1428
1440
  binding: ImportBinding,
1429
1441
  ctx: ResolveCtx,
1430
- args?: readonly unknown[],
1442
+ args: readonly unknown[],
1431
1443
  ): Promise<unknown> {
1432
1444
  // chant #1093 — THE gap this check exists for. Invoking the callee runs
1433
1445
  // project code (the factory body, and its whole module's top level) in the
@@ -1473,12 +1485,67 @@ async function invokeImportedCallee(
1473
1485
  throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a function`);
1474
1486
  }
1475
1487
 
1476
- const callArgs = args ?? (await resolveCallArguments(node, calleeName, ctx));
1477
-
1478
1488
  executionCounts.factoryInvocations += 1;
1479
1489
  if (isProjectFileSpecifier(binding.specifier)) executionCounts.projectFactoryInvocations += 1;
1480
1490
 
1481
- return (Fn as (...fnArgs: unknown[]) => unknown)(...callArgs);
1491
+ return (Fn as (...fnArgs: unknown[]) => unknown)(...args);
1492
+ }
1493
+
1494
+ /**
1495
+ * Arm 2's original entry point — a raw call-expression node in hand. Resolves
1496
+ * arguments (unless the caller already has them) and delegates to
1497
+ * {@link invokeResolvedCallee}. Unchanged from #1022 in both behavior and
1498
+ * ORDER (refusal, resolve, import, callable check, arguments, call); #1023
1499
+ * factored the body out once already, #1174 a second time.
1500
+ *
1501
+ * @param args - Already-evaluated arguments, when the caller has them.
1502
+ */
1503
+ async function invokeImportedCallee(
1504
+ node: ts.CallExpression,
1505
+ calleeName: string,
1506
+ binding: ImportBinding,
1507
+ ctx: ResolveCtx,
1508
+ args?: readonly unknown[],
1509
+ ): Promise<unknown> {
1510
+ const callArgs = args ?? (await resolveCallArguments(node, calleeName, ctx));
1511
+ return invokeResolvedCallee(calleeName, binding, ctx, callArgs);
1512
+ }
1513
+
1514
+ /**
1515
+ * Resolve a composite-factory call used as a NESTED value — chant #1174's
1516
+ * `<Identifier>(...).step` idiom ({@link FoldedCompositeStepCall}) — given
1517
+ * its callee NAME and already-REVIVED argument values. The nested-value
1518
+ * counterpart to {@link resolveCallExpression}, which the top-level
1519
+ * declarator path (`export const x = Checkout({...})`) already used: the
1520
+ * raw `ts.CallExpression` node isn't needed here because
1521
+ * {@link resolveInterpretableFactory} only needs the import `binding`, and
1522
+ * {@link interpretCompositeFactory}/{@link invokeResolvedCallee} only need
1523
+ * the resolved argument values — which `fold()`'s envelope already folded
1524
+ * and {@link reviveFoldedValue} has already revived by the time this runs.
1525
+ *
1526
+ * Same two arms, same order, as the top-level path: interpret a project-file
1527
+ * registered `Composite` (chant #1023) when its body qualifies, otherwise
1528
+ * import and invoke for real — which is the arm every lexicon-package
1529
+ * composite (`Checkout`, `SetupNode`, …) always takes, exactly as it does
1530
+ * today for a top-level `export const x = Checkout({...})`. An unbound
1531
+ * callee name throws the same "function call as a value" message `fold()`
1532
+ * itself would have, for a caller with no `FoldedCompositeStepCall` special
1533
+ * case to fall into.
1534
+ */
1535
+ async function resolveCompositeCall(calleeName: string, args: readonly unknown[], ctx: ResolveCtx): Promise<unknown> {
1536
+ const binding = ctx.imports.get(calleeName);
1537
+ if (!binding) throw cheapError(`unresolved identifier: ${calleeName}`);
1538
+
1539
+ const factory = await resolveInterpretableFactory(binding, ctx);
1540
+ if (factory) {
1541
+ const interpreted = await interpretCompositeFactory(factory, args, ctx);
1542
+ if (interpreted) return interpreted.value;
1543
+ // Declined — falls through to arm 2 with the SAME arguments, so a nested
1544
+ // composite-call argument (or a live cross-file reference) is not
1545
+ // resolved a second time. Identical discipline to `resolveImportedCall`.
1546
+ }
1547
+
1548
+ return invokeResolvedCallee(calleeName, binding, ctx, args);
1482
1549
  }
1483
1550
 
1484
1551
  /**
@@ -2502,6 +2569,24 @@ async function reviveFoldedValue(value: FoldedValue, ctx: ResolveCtx, requireLiv
2502
2569
  return reviveHelperCall(value as FoldedHelperCall, ctx);
2503
2570
  }
2504
2571
 
2572
+ if ("__compositeStep" in value) {
2573
+ // chant #1174 — `<Identifier>(...).step`, see {@link FoldedCompositeStepCall}.
2574
+ // Arguments revive with `requireLiveRefs: false` — the same rule
2575
+ // `resolveCallArguments` applies to a NON-helper callee's arguments
2576
+ // (a composite factory stores its props, it doesn't inspect them the way
2577
+ // an intrinsic/helper does), so a `{__attrRef}` among them stays the
2578
+ // symbolic envelope the composite's own resource construction resolves
2579
+ // by name, exactly as a top-level resource's props would.
2580
+ const call = value as FoldedCompositeStepCall;
2581
+ const revivedArgs: unknown[] = [];
2582
+ for (const a of call.args) revivedArgs.push(await reviveFoldedValue(a, ctx, false));
2583
+ const result = await resolveCompositeCall(call.__compositeStep, revivedArgs, ctx);
2584
+ if (!isIndexableObject(result)) {
2585
+ throw cheapError(`composite call \`${call.__compositeStep}(...)\` did not resolve to an object with a "step" member`);
2586
+ }
2587
+ return (result as Record<string, unknown>).step;
2588
+ }
2589
+
2505
2590
  if ("__attrRef" in value) {
2506
2591
  if (requireLiveRefs) {
2507
2592
  throw cheapError(
@@ -2889,6 +2974,46 @@ async function constructFoldedResource(
2889
2974
  * performs unconditionally for the same file, and through the same
2890
2975
  * already-memoized `importModule`.
2891
2976
  */
2977
+ /**
2978
+ * The local names this file bound to the build's parameter object (chant
2979
+ * #1443). Object identity against {@link FoldSession.buildParams}, which
2980
+ * `buildExternals` substituted directly, so an unrelated import that happens to
2981
+ * be called `params` is not mistaken for it.
2982
+ */
2983
+ function paramLocalNames(ctx: ResolveCtx): Set<string> {
2984
+ const out = new Set<string>();
2985
+ const buildParams = ctx.session.buildParams;
2986
+ if (!buildParams) return out;
2987
+ for (const [name, value] of ctx.externals) {
2988
+ if (value === buildParams) out.add(name);
2989
+ }
2990
+ return out;
2991
+ }
2992
+
2993
+ /**
2994
+ * chant #1443 — record which build parameters each of a resource's authored
2995
+ * property expressions reads, before fold substitutes them away. Best-effort
2996
+ * and additive: a file with no `params` import, or a constructor called with no
2997
+ * object literal, records nothing.
2998
+ */
2999
+ function stampParamDependencies(entity: unknown, node: ts.NewExpression, ctx: ResolveCtx): void {
3000
+ if (typeof entity !== "object" || entity === null) return;
3001
+ const paramLocals = paramLocalNames(ctx);
3002
+ if (paramLocals.size === 0) return;
3003
+ // The same argument `foldResource` treats as props: the first object literal.
3004
+ let propsArg: ts.ObjectLiteralExpression | undefined;
3005
+ for (const argument of node.arguments ?? []) {
3006
+ if (ts.isObjectLiteralExpression(argument)) {
3007
+ propsArg = argument;
3008
+ break;
3009
+ }
3010
+ }
3011
+ if (!propsArg) return;
3012
+ for (const [path, origin] of Object.entries(collectParamDependencies(propsArg, ctx.consts, paramLocals))) {
3013
+ setPathProvenance(entity, path, origin);
3014
+ }
3015
+ }
3016
+
2892
3017
  async function preresolveResourceConsts(ctx: ResolveCtx): Promise<Map<ts.Expression, unknown>> {
2893
3018
  const built = new Map<ts.Expression, unknown>();
2894
3019
  for (const [name, initializer] of ctx.consts) {
@@ -2896,6 +3021,7 @@ async function preresolveResourceConsts(ctx: ResolveCtx): Promise<Map<ts.Express
2896
3021
  try {
2897
3022
  const spec = foldResource(initializer, ctx.consts, ctx.intrinsics, ctx.externals);
2898
3023
  const instance = await constructFoldedResource(spec, ctx, false);
3024
+ stampParamDependencies(instance, initializer, ctx);
2899
3025
  built.set(initializer, instance);
2900
3026
  ctx.externals.set(name, instance);
2901
3027
  } catch {
@@ -2933,6 +3059,7 @@ async function resolveResourceEntity(
2933
3059
 
2934
3060
  try {
2935
3061
  const entity = (await instantiateFoldedResource(spec.__resource, ctorArgs, ctx, ` for "${name}"`)) as Declarable;
3062
+ stampParamDependencies(entity, node, ctx);
2936
3063
  return { ok: true, entity };
2937
3064
  } catch (err) {
2938
3065
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
@@ -3552,22 +3679,21 @@ export async function tryFoldFile(
3552
3679
  * so the exact same forced-taint safety net still applies to that remaining
3553
3680
  * boundary, unchanged.
3554
3681
  */
3555
- export async function planFoldTaint(
3556
- files: readonly string[],
3557
- wouldFold: ReadonlyMap<string, boolean>,
3558
- liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
3559
- ): Promise<Set<string>> {
3682
+ /**
3683
+ * file -> set of OTHER files in `files` that it relatively imports OR
3684
+ * re-exports from, regardless of that file's own fold outcome. Extracted
3685
+ * from {@link planFoldTaint} (chant #1083) so a second consumer — the fold
3686
+ * blocker dominator ranking (`./fold-rank.ts`) — can walk the exact same
3687
+ * project-file edges instead of re-parsing every file with its own
3688
+ * specifier-resolution logic. chant #1020 — this counts a namespace import
3689
+ * (`import * as ns from "./x"`, previously skipped — see `collectImports`)
3690
+ * and a re-export (`export { x } from "./y"`, which `collectImports` never
3691
+ * saw at all, since it only looks at `import` declarations): both are real
3692
+ * cross-file value dependencies fold's own resolution follows, so both need
3693
+ * the same edge an ordinary named import already gets.
3694
+ */
3695
+ export async function buildProjectImportEdges(files: readonly string[]): Promise<Map<string, Set<string>>> {
3560
3696
  const fileSet = new Set(files);
3561
-
3562
- // file -> set of OTHER discovered files it relatively imports OR re-exports
3563
- // from, regardless of that file's own fold outcome (taint needs the full
3564
- // edge set to propagate transitively). chant #1020 — this now ALSO counts
3565
- // a namespace import (`import * as ns from "./x"`, previously skipped —
3566
- // see `collectImports`) and a re-export (`export { x } from "./y"`, which
3567
- // `collectImports` never saw at all, since it only looks at `import`
3568
- // declarations): both are real cross-file value dependencies my own new
3569
- // resolution follows, so both need the same identity guarantee an
3570
- // ordinary named import already got.
3571
3697
  const edges = new Map<string, Set<string>>();
3572
3698
  for (const file of files) {
3573
3699
  const targets = new Set<string>();
@@ -3600,10 +3726,24 @@ export async function planFoldTaint(
3600
3726
  } catch {
3601
3727
  // Unreadable/unparseable — no edges contributed; tryFoldFile's own
3602
3728
  // top-level catch already turns this into a "run" decision for the
3603
- // file itself, which is enough to seed it as tainted below.
3729
+ // file itself, which is enough to seed it as tainted below (or, for
3730
+ // the dominator ranking, leaves it a childless node).
3604
3731
  }
3605
3732
  edges.set(file, targets);
3606
3733
  }
3734
+ return edges;
3735
+ }
3736
+
3737
+ export async function planFoldTaint(
3738
+ files: readonly string[],
3739
+ wouldFold: ReadonlyMap<string, boolean>,
3740
+ liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
3741
+ ): Promise<Set<string>> {
3742
+ const fileSet = new Set(files);
3743
+
3744
+ // file -> set of OTHER discovered files it relatively imports OR re-exports
3745
+ // from — see {@link buildProjectImportEdges}.
3746
+ const edges = await buildProjectImportEdges(files);
3607
3747
 
3608
3748
  // chant #1044 — reverse edges: consumed-file -> the folded files that
3609
3749
  // captured its objects. Same taint set, same fixpoint walk; see this
@@ -0,0 +1,197 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { rankFoldBlockers, toCollapsedFormat } from "./fold-rank";
6
+ import type { FoldDecision } from "./index";
7
+
8
+ describe("rankFoldBlockers", () => {
9
+ let testDir: string;
10
+
11
+ beforeEach(async () => {
12
+ testDir = join(tmpdir(), `chant-fold-rank-test-${Date.now()}-${Math.random()}`);
13
+ await mkdir(testDir, { recursive: true });
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await rm(testDir, { recursive: true, force: true });
18
+ });
19
+
20
+ function run(file: string, reason = "unresolved identifier"): FoldDecision {
21
+ return { file, mode: "run", reason };
22
+ }
23
+ function folded(file: string): FoldDecision {
24
+ return { file, mode: "fold", resourceCount: 1 };
25
+ }
26
+ function reverseTaintedDecision(file: string, reason: string): FoldDecision {
27
+ return { file, mode: "run", reason, reverseTainted: true };
28
+ }
29
+
30
+ test("a chain — one root-cause blocker retains every file behind it", async () => {
31
+ // a.ts <- b.ts <- c.ts (b imports a, c imports b); all three fall back to run.
32
+ const a = join(testDir, "a.ts");
33
+ const b = join(testDir, "b.ts");
34
+ const c = join(testDir, "c.ts");
35
+ await writeFile(a, `export const a = process.env.X;`);
36
+ await writeFile(b, `import { a } from "./a";\nexport const b = a;`);
37
+ await writeFile(c, `import { b } from "./b";\nexport const c = b;`);
38
+
39
+ const result = await rankFoldBlockers([run(a), run(b), run(c)]);
40
+
41
+ expect(result.totalBlocked).toBe(3);
42
+ const byFile = new Map(result.blockers.map((x) => [x.file, x]));
43
+ expect(byFile.get(a)).toMatchObject({ retained: 3, topLevel: true });
44
+ expect(byFile.get(b)).toMatchObject({ retained: 2, topLevel: false, dominatedBy: a });
45
+ expect(byFile.get(c)).toMatchObject({ retained: 1, topLevel: false, dominatedBy: b });
46
+
47
+ // Ranked descending by retained count — the root cause comes first.
48
+ expect(result.blockers.map((x) => x.file)).toEqual([a, b, c]);
49
+ });
50
+
51
+ test("a diamond — a single root cause reached two ways is not double-counted", async () => {
52
+ // root.ts <- {left.ts, right.ts} <- both.ts (both.ts imports BOTH left and right).
53
+ const root = join(testDir, "root.ts");
54
+ const left = join(testDir, "left.ts");
55
+ const right = join(testDir, "right.ts");
56
+ const both = join(testDir, "both.ts");
57
+ await writeFile(root, `export const root = process.env.X;`);
58
+ await writeFile(left, `import { root } from "./root";\nexport const left = root;`);
59
+ await writeFile(right, `import { root } from "./root";\nexport const right = root;`);
60
+ await writeFile(
61
+ both,
62
+ `import { left } from "./left";\nimport { right } from "./right";\nexport const both = left + right;`,
63
+ );
64
+
65
+ const result = await rankFoldBlockers([run(root), run(left), run(right), run(both)]);
66
+
67
+ expect(result.totalBlocked).toBe(4);
68
+ const rootBlocker = result.blockers.find((x) => x.file === root)!;
69
+ // root retains ALL FOUR files — left, right, and "both" reached via
70
+ // either path — not eight (double-counted through the diamond).
71
+ expect(rootBlocker.retained).toBe(4);
72
+ expect(rootBlocker.topLevel).toBe(true);
73
+
74
+ const bothBlocker = result.blockers.find((x) => x.file === both)!;
75
+ expect(bothBlocker.retained).toBe(1);
76
+
77
+ // Conservation: top-level blockers' retained counts sum to totalBlocked.
78
+ const topLevelSum = result.blockers.filter((x) => x.topLevel).reduce((s, x) => s + x.retained, 0);
79
+ expect(topLevelSum).toBe(result.totalBlocked);
80
+ });
81
+
82
+ test("two independent blockers — a shared dependent is not credited to either", async () => {
83
+ // x.ts and y.ts fail independently (no relation to each other).
84
+ // both.ts imports both, and needs BOTH fixed to fold — so it is its
85
+ // own top-level entry, inflating neither x's nor y's retained count.
86
+ const x = join(testDir, "x.ts");
87
+ const y = join(testDir, "y.ts");
88
+ const both = join(testDir, "both.ts");
89
+ await writeFile(x, `export const x = process.env.X;`);
90
+ await writeFile(y, `export const y = process.env.Y;`);
91
+ await writeFile(both, `import { x } from "./x";\nimport { y } from "./y";\nexport const both = x + y;`);
92
+
93
+ const result = await rankFoldBlockers([run(x), run(y), run(both)]);
94
+
95
+ expect(result.totalBlocked).toBe(3);
96
+ const byFile = new Map(result.blockers.map((b) => [b.file, b]));
97
+ expect(byFile.get(x)).toMatchObject({ retained: 1, topLevel: true });
98
+ expect(byFile.get(y)).toMatchObject({ retained: 1, topLevel: true });
99
+ // "both" is dominated by neither x nor y alone — it becomes its own
100
+ // top-level entry rather than inflating either blocker.
101
+ expect(byFile.get(both)).toMatchObject({ retained: 1, topLevel: true });
102
+
103
+ const topLevelSum = result.blockers.filter((b) => b.topLevel).reduce((s, b) => s + b.retained, 0);
104
+ expect(topLevelSum).toBe(3);
105
+ });
106
+
107
+ test("a reverse-tainted file is reported separately and never enters the tree", async () => {
108
+ const blocker = join(testDir, "blocker.ts");
109
+ const wouldFold = join(testDir, "would-fold.ts");
110
+ await writeFile(blocker, `import { p } from "./would-fold";\nexport const b = p + process.env.X;`);
111
+ await writeFile(wouldFold, `export const p = "fine";`);
112
+
113
+ const decisions: FoldDecision[] = [
114
+ run(blocker),
115
+ reverseTaintedDecision(
116
+ wouldFold,
117
+ "would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance",
118
+ ),
119
+ ];
120
+
121
+ const result = await rankFoldBlockers(decisions);
122
+
123
+ expect(result.totalBlocked).toBe(1);
124
+ expect(result.blockers.map((b) => b.file)).toEqual([blocker]);
125
+ expect(result.reverseTainted).toEqual([
126
+ {
127
+ file: wouldFold,
128
+ reason:
129
+ "would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance",
130
+ },
131
+ ]);
132
+ });
133
+
134
+ test("fold-mode files are excluded from the tree entirely", async () => {
135
+ const okFile = join(testDir, "ok.ts");
136
+ const badFile = join(testDir, "bad.ts");
137
+ await writeFile(okFile, `export const ok = 1;`);
138
+ await writeFile(badFile, `export const bad = process.env.X;`);
139
+
140
+ const result = await rankFoldBlockers([folded(okFile), run(badFile)]);
141
+
142
+ expect(result.totalBlocked).toBe(1);
143
+ expect(result.blockers.map((b) => b.file)).toEqual([badFile]);
144
+ });
145
+
146
+ test("retained counts are conserved across a mixed graph (chain + independent leaf)", async () => {
147
+ const a = join(testDir, "a.ts");
148
+ const b = join(testDir, "b.ts");
149
+ const leaf = join(testDir, "leaf.ts");
150
+ await writeFile(a, `export const a = process.env.X;`);
151
+ await writeFile(b, `import { a } from "./a";\nexport const b = a;`);
152
+ await writeFile(leaf, `export const leaf = process.env.Y;`); // unrelated second root cause
153
+
154
+ const result = await rankFoldBlockers([run(a), run(b), run(leaf)]);
155
+
156
+ expect(result.totalBlocked).toBe(3);
157
+ const topLevelSum = result.blockers.filter((x) => x.topLevel).reduce((s, x) => s + x.retained, 0);
158
+ expect(topLevelSum).toBe(3);
159
+ // a.ts ranks ahead of leaf.ts (retains 2 vs 1) — sorted descending.
160
+ expect(result.blockers[0].file).toBe(a);
161
+ expect(result.blockers[0].retained).toBe(2);
162
+ });
163
+ });
164
+
165
+ describe("toCollapsedFormat", () => {
166
+ let testDir: string;
167
+
168
+ beforeEach(async () => {
169
+ testDir = join(tmpdir(), `chant-fold-rank-collapsed-test-${Date.now()}-${Math.random()}`);
170
+ await mkdir(testDir, { recursive: true });
171
+ });
172
+
173
+ afterEach(async () => {
174
+ await rm(testDir, { recursive: true, force: true });
175
+ });
176
+
177
+ test("emits one weighted line per file, folding to retained counts under a shared root frame", async () => {
178
+ const a = join(testDir, "a.ts");
179
+ const b = join(testDir, "b.ts");
180
+ await writeFile(a, `export const a = process.env.X;`);
181
+ await writeFile(b, `import { a } from "./a";\nexport const b = a;`);
182
+
183
+ const result = await rankFoldBlockers([
184
+ { file: a, mode: "run", reason: "r" },
185
+ { file: b, mode: "run", reason: "r" },
186
+ ]);
187
+ const lines = toCollapsedFormat(result, { relativeTo: testDir });
188
+
189
+ expect(lines.sort()).toEqual(["fold;a.ts 1", "fold;a.ts;b.ts 1"].sort());
190
+
191
+ // Every line for a's own subtree (its own line, plus every line whose
192
+ // stack is prefixed by it) sums to a's retained count.
193
+ const aPrefixCount = lines.filter((l) => l.startsWith("fold;a.ts")).length;
194
+ const aBlocker = result.blockers.find((x) => x.file === a)!;
195
+ expect(aPrefixCount).toBe(aBlocker.retained);
196
+ });
197
+ });