@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
@@ -0,0 +1,346 @@
1
+ import { relative } from "node:path";
2
+ import { buildProjectImportEdges } from "./fold-import";
3
+ import type { FoldDecision } from "./index";
4
+
5
+ /**
6
+ * chant #1083 — rank fold blockers by dominator retained-count over the
7
+ * import graph discovery already builds.
8
+ *
9
+ * ## Why dominators, not a flat "files downstream of X" count
10
+ *
11
+ * The import graph is a DAG (occasionally cyclic, when project source
12
+ * itself has an import cycle), not a tree: a file can be reachable through
13
+ * several paths, so naively summing "files that import X, transitively"
14
+ * double-counts every diamond and the totals stop meaning anything. The
15
+ * fix is the same one heap snapshot tools use for object retained sizes
16
+ * (Cooper–Harvey–Kennedy immediate dominators, prior art: spicypath's
17
+ * `src/heap-dominators.js`, cited on chant #1083): file `d` dominates file
18
+ * `n` when every causal path to `n`'s failure passes through `d`. A file's
19
+ * retained count is then the size of its dominator subtree — the number of
20
+ * files that would fold if `d` folded — and every node lands in exactly one
21
+ * dominator subtree, so retained counts are conserved: they sum to the
22
+ * total across the tree's top-level blockers, with nothing counted twice
23
+ * through a diamond, and a file with two INDEPENDENT blockers (no common
24
+ * ancestor besides the synthetic root) inflates neither.
25
+ *
26
+ * ## One direction only
27
+ *
28
+ * This models FORWARD contagion only: file `n` fails to fold because one of
29
+ * its own imports, `d`, also fails — an unresolved cross-file reference.
30
+ * The edge direction for dominance purposes is therefore `d -> n` (the
31
+ * blocker points at what it blocks), which is the REVERSE of the literal
32
+ * import edge `n -> d` (n imports d).
33
+ *
34
+ * Fold has a second, opposite-direction rule (chant #1044, implemented in
35
+ * {@link import("./fold-import").planFoldTaint}): a file that WOULD fold
36
+ * fine in isolation is forced back to run anyway because some file that
37
+ * imports it (directly or transitively) itself runs — folding it
38
+ * independently would create a second, non-identical instance. That is
39
+ * contagion from importER to importEE, the opposite of the direction this
40
+ * tree models, and a dominator tree over import edges cannot express it: a
41
+ * fix to the "blocker" here wouldn't actually unblock the reverse-tainted
42
+ * file (it was never broken on its own terms), so crediting it would
43
+ * overstate the fix's blast radius. Every file discovery marked
44
+ * {@link FoldDecision.reverseTainted} is therefore excluded from the tree
45
+ * entirely and reported in {@link FoldRankResult.reverseTainted} instead —
46
+ * see chant #1083's re-scope comment.
47
+ */
48
+
49
+ /** Sentinel "file path" for the synthetic dominator-tree root. Never a real file — file paths never contain NUL. */
50
+ const ROOT = "\0chant-fold-rank-root\0";
51
+
52
+ /** One node in the forward-contagion dominator tree — a `"run"`, non-reverse-tainted file. */
53
+ export interface FoldBlocker {
54
+ /** Absolute source file path (matches {@link FoldDecision.file}). */
55
+ file: string;
56
+ /** Why THIS file itself falls back to run (its own {@link FoldDecision.reason}). */
57
+ reason?: string;
58
+ /**
59
+ * Number of files — including this one — that would fold if this file's
60
+ * own fold problem were fixed: this node's dominator-subtree size.
61
+ * Conserved: summing this field over every blocker with no closer
62
+ * dominator ({@link topLevel}) equals {@link FoldRankResult.totalBlocked}.
63
+ */
64
+ retained: number;
65
+ /** True when nothing else in the tree dominates this file more closely than the synthetic root — i.e. this file's own fold failure isn't explained by importing another blocker already in the tree. */
66
+ topLevel: boolean;
67
+ /** This file's immediate dominator's file path, or `undefined` for a {@link topLevel} blocker. */
68
+ dominatedBy?: string;
69
+ }
70
+
71
+ /** A `"run"` file held back only by the chant #1044 reverse rule — never part of the forward dominator tree (see this module's doc). */
72
+ export interface FoldReverseTaintedFile {
73
+ file: string;
74
+ reason?: string;
75
+ }
76
+
77
+ export interface FoldRankResult {
78
+ /**
79
+ * Every forward-contagion blocker (a `"run"`, non-reverse-tainted file),
80
+ * sorted by {@link FoldBlocker.retained} descending, ties broken by file
81
+ * path for determinism. Includes every node in the tree, not just
82
+ * top-level ones — an intermediate hub file (e.g. a `params.ts` many
83
+ * others funnel through) is itself a meaningful blocker to rank.
84
+ */
85
+ blockers: FoldBlocker[];
86
+ /** `"run"` files excluded from the tree by the reverse rule (chant #1044) — see this module's doc. */
87
+ reverseTainted: FoldReverseTaintedFile[];
88
+ /** Total forward-blocked files considered ({@link blockers}.length). Top-level blockers' retained counts sum to exactly this. */
89
+ totalBlocked: number;
90
+ }
91
+
92
+ /**
93
+ * Compute immediate dominators for `nodes` over `edges` (blocker -> blocked,
94
+ * i.e. flowing in the CAUSAL direction, not the import direction), rooted at
95
+ * a synthetic {@link ROOT}. Handles a cyclic/irreducible graph correctly —
96
+ * Cooper–Harvey–Kennedy's iterative algorithm converges on any graph, not
97
+ * just reducible ones (this is exactly why it's the right tool here: an
98
+ * import graph can have cycles).
99
+ *
100
+ * `rootChildren` must already guarantee every node in `nodes` is reachable
101
+ * from {@link ROOT} via `rootChildren` + `edges` — see `rankFoldBlockers`'s
102
+ * two-phase root-selection below.
103
+ */
104
+ function computeImmediateDominators(
105
+ nodes: readonly string[],
106
+ edges: ReadonlyMap<string, ReadonlySet<string>>,
107
+ rootChildren: ReadonlySet<string>,
108
+ ): Map<string, string> {
109
+ const succ = (n: string): ReadonlySet<string> => (n === ROOT ? rootChildren : edges.get(n) ?? new Set());
110
+
111
+ // Predecessors, including the synthetic ROOT edge into every root child.
112
+ const preds = new Map<string, Set<string>>();
113
+ for (const n of nodes) preds.set(n, new Set());
114
+ for (const [from, targets] of edges) {
115
+ for (const to of targets) {
116
+ if (!preds.has(to)) preds.set(to, new Set());
117
+ preds.get(to)!.add(from);
118
+ }
119
+ }
120
+ for (const rc of rootChildren) preds.get(rc)?.add(ROOT);
121
+
122
+ // Reverse-postorder numbering via one DFS from ROOT — every node in
123
+ // `nodes` must be reached (guaranteed by the caller's root selection).
124
+ const postorderNumber = new Map<string, number>();
125
+ const visited = new Set<string>([ROOT]);
126
+ let counter = 0;
127
+ const visitStack: Array<{ node: string; iter: Iterator<string> }> = [
128
+ { node: ROOT, iter: succ(ROOT).values() },
129
+ ];
130
+ while (visitStack.length > 0) {
131
+ const top = visitStack[visitStack.length - 1];
132
+ const next = top.iter.next();
133
+ if (next.done) {
134
+ postorderNumber.set(top.node, counter++);
135
+ visitStack.pop();
136
+ continue;
137
+ }
138
+ const child = next.value;
139
+ if (!visited.has(child)) {
140
+ visited.add(child);
141
+ visitStack.push({ node: child, iter: succ(child).values() });
142
+ }
143
+ }
144
+
145
+ const rpo = [...nodes, ROOT].filter((n) => postorderNumber.has(n));
146
+ rpo.sort((a, b) => postorderNumber.get(b)! - postorderNumber.get(a)!); // reverse postorder: highest number first
147
+
148
+ const idom = new Map<string, string>();
149
+ idom.set(ROOT, ROOT);
150
+
151
+ const intersect = (a: string, b: string): string => {
152
+ let finger1 = a;
153
+ let finger2 = b;
154
+ while (finger1 !== finger2) {
155
+ while (postorderNumber.get(finger1)! < postorderNumber.get(finger2)!) finger1 = idom.get(finger1)!;
156
+ while (postorderNumber.get(finger2)! < postorderNumber.get(finger1)!) finger2 = idom.get(finger2)!;
157
+ }
158
+ return finger1;
159
+ };
160
+
161
+ let changed = true;
162
+ while (changed) {
163
+ changed = false;
164
+ for (const node of rpo) {
165
+ if (node === ROOT) continue;
166
+ let newIdom: string | undefined;
167
+ for (const p of preds.get(node) ?? []) {
168
+ if (!idom.has(p)) continue; // not yet processed this pass
169
+ newIdom = newIdom === undefined ? p : intersect(newIdom, p);
170
+ }
171
+ if (newIdom !== undefined && idom.get(node) !== newIdom) {
172
+ idom.set(node, newIdom);
173
+ changed = true;
174
+ }
175
+ }
176
+ }
177
+
178
+ idom.delete(ROOT);
179
+ return idom;
180
+ }
181
+
182
+ /**
183
+ * Rank fold blockers by dominator retained-count over the (forward-only)
184
+ * import graph among this build's `"run"`-mode files. See this module's doc
185
+ * for the model. `decisions` is expected to be a `--fold` build's complete
186
+ * {@link FoldDecision}[] — every discovered file, fold or run.
187
+ */
188
+ export async function rankFoldBlockers(decisions: readonly FoldDecision[]): Promise<FoldRankResult> {
189
+ const allFiles = decisions.map((d) => d.file);
190
+ const reverseTainted = decisions
191
+ .filter((d) => d.mode === "run" && d.reverseTainted === true)
192
+ .map((d) => ({ file: d.file, reason: d.reason }))
193
+ .sort((a, b) => a.file.localeCompare(b.file));
194
+
195
+ const nodeDecisions = decisions.filter((d) => d.mode === "run" && d.reverseTainted !== true);
196
+ const reasonByFile = new Map(nodeDecisions.map((d) => [d.file, d.reason] as const));
197
+ const nodes = nodeDecisions.map((d) => d.file).sort((a, b) => a.localeCompare(b));
198
+ const nodeSet = new Set(nodes);
199
+
200
+ // file -> the OTHER discovered files it imports (real import direction).
201
+ const importEdges = await buildProjectImportEdges(allFiles);
202
+
203
+ // Cause graph, blocker -> blocked: importee -> importer, restricted to
204
+ // both endpoints being forward-tree nodes (a fold-mode or reverse-tainted
205
+ // file is neither a blocker nor blocked in this tree).
206
+ const causeEdges = new Map<string, Set<string>>();
207
+ for (const n of nodes) causeEdges.set(n, new Set());
208
+ for (const [importer, targets] of importEdges) {
209
+ if (!nodeSet.has(importer)) continue;
210
+ for (const target of targets) {
211
+ if (!nodeSet.has(target)) continue;
212
+ causeEdges.get(target)!.add(importer);
213
+ }
214
+ }
215
+
216
+ // Predecessor count, to find true root causes (a blocked file that is
217
+ // itself never blocked by another tree node).
218
+ const predCount = new Map<string, number>();
219
+ for (const n of nodes) predCount.set(n, 0);
220
+ for (const targets of causeEdges.values()) {
221
+ for (const t of targets) predCount.set(t, (predCount.get(t) ?? 0) + 1);
222
+ }
223
+
224
+ // Two-phase root selection, so every node is reachable from ROOT even
225
+ // across an import cycle with no external entry point (chant #1083's doc
226
+ // cites spicypath's dominators working over a graph that is "cyclic and
227
+ // irreducible" as the precedent this needs to match).
228
+ const rootChildren = new Set<string>();
229
+ const reached = new Set<string>();
230
+ const expandFrom = (start: string): void => {
231
+ if (reached.has(start)) return;
232
+ const stack = [start];
233
+ reached.add(start);
234
+ while (stack.length > 0) {
235
+ const cur = stack.pop()!;
236
+ for (const next of causeEdges.get(cur) ?? []) {
237
+ if (!reached.has(next)) {
238
+ reached.add(next);
239
+ stack.push(next);
240
+ }
241
+ }
242
+ }
243
+ };
244
+ for (const n of nodes) {
245
+ if (predCount.get(n) === 0) {
246
+ rootChildren.add(n);
247
+ expandFrom(n);
248
+ }
249
+ }
250
+ for (const n of nodes) {
251
+ if (!reached.has(n)) {
252
+ rootChildren.add(n);
253
+ expandFrom(n);
254
+ }
255
+ }
256
+
257
+ const idom = computeImmediateDominators(nodes, causeEdges, rootChildren);
258
+
259
+ // Dominator-tree children, to compute retained (subtree size) bottom-up.
260
+ const domChildren = new Map<string, string[]>();
261
+ for (const n of nodes) domChildren.set(n, []);
262
+ for (const [n, parent] of idom) {
263
+ if (parent !== ROOT) domChildren.get(parent)!.push(n);
264
+ }
265
+
266
+ const retained = new Map<string, number>();
267
+ const computeRetained = (n: string): number => {
268
+ const cached = retained.get(n);
269
+ if (cached !== undefined) return cached;
270
+ let size = 1;
271
+ for (const child of domChildren.get(n) ?? []) size += computeRetained(child);
272
+ retained.set(n, size);
273
+ return size;
274
+ };
275
+ for (const n of nodes) computeRetained(n);
276
+
277
+ const blockers: FoldBlocker[] = nodes.map((file) => {
278
+ const parent = idom.get(file);
279
+ const topLevel = parent === undefined || parent === ROOT;
280
+ return {
281
+ file,
282
+ reason: reasonByFile.get(file),
283
+ retained: retained.get(file)!,
284
+ topLevel,
285
+ dominatedBy: topLevel ? undefined : parent,
286
+ };
287
+ });
288
+ blockers.sort((a, b) => b.retained - a.retained || a.file.localeCompare(b.file));
289
+
290
+ return { blockers, reverseTainted, totalBlocked: nodes.length };
291
+ }
292
+
293
+ /** `file -> dominatedBy` lookup built from a {@link FoldRankResult}, for reconstructing a blocker's dominator-chain (used by {@link toCollapsedFormat}). */
294
+ function dominatorChain(result: FoldRankResult, file: string): string[] {
295
+ const dominatedByFile = new Map(result.blockers.map((b) => [b.file, b.dominatedBy] as const));
296
+ const chain: string[] = [file];
297
+ let current: string | undefined = file;
298
+ const guard = new Set<string>([file]);
299
+ for (;;) {
300
+ const parent = dominatedByFile.get(current!);
301
+ if (parent === undefined) break;
302
+ if (guard.has(parent)) break; // defensive: never trust a cycle in the tree itself
303
+ chain.push(parent);
304
+ guard.add(parent);
305
+ current = parent;
306
+ }
307
+ return chain.reverse(); // top-level ancestor first, `file` last
308
+ }
309
+
310
+ /** A collapsed-format frame may not contain `;` (the frame separator) or a newline; sanitize rather than silently mis-render. */
311
+ function sanitizeFrame(label: string): string {
312
+ return label.replace(/[\n\r]/g, " ").replace(/;/g, ",");
313
+ }
314
+
315
+ /**
316
+ * Export the dominator tree in Brendan Gregg collapsed stack format
317
+ * (`frame;frame;...;frame count`), weighted by retained count, so it opens
318
+ * in any flame or icicle graph viewer with no chant-specific tooling (the
319
+ * seam is the file format, per chant #1083's original scope).
320
+ *
321
+ * One line per blocker, `count` always `1`: each file's stack is the chain
322
+ * of dominators from the top-level blocker down to itself. Folding
323
+ * identical prefixes — what every collapsed-format consumer does — then
324
+ * reproduces each blocker's retained count as that prefix's total weight,
325
+ * with nothing double-counted through a diamond. `rootLabel` is a single
326
+ * synthetic top frame (default `"fold"`) so every top-level blocker renders
327
+ * under one shared root instead of the viewer showing several disconnected
328
+ * trees; pass `relativeTo` (typically the build's infra path) to shorten
329
+ * frame labels to project-relative paths.
330
+ */
331
+ export function toCollapsedFormat(
332
+ result: FoldRankResult,
333
+ options?: { rootLabel?: string; relativeTo?: string },
334
+ ): string[] {
335
+ const rootLabel = options?.rootLabel ?? "fold";
336
+ const toLabel = (file: string): string =>
337
+ sanitizeFrame(options?.relativeTo ? relative(options.relativeTo, file) || file : file);
338
+
339
+ return result.blockers
340
+ .slice()
341
+ .sort((a, b) => a.file.localeCompare(b.file))
342
+ .map((blocker) => {
343
+ const chain = dominatorChain(result, blocker.file).map(toLabel);
344
+ return `${sanitizeFrame(rootLabel)};${chain.join(";")} 1`;
345
+ });
346
+ }
@@ -85,6 +85,21 @@ export interface FoldDecision {
85
85
  reason?: string;
86
86
  /** Number of entities the fold produced. Present only when `mode === "fold"`. */
87
87
  resourceCount?: number;
88
+ /**
89
+ * chant #1083 — true when this file's OWN fold attempt succeeded (it would
90
+ * fold cleanly in isolation) but it was forced to `"run"` anyway by
91
+ * {@link planFoldTaint}'s REVERSE rule (chant #1044): some other file that
92
+ * imports it, directly or transitively, itself falls back to run, so
93
+ * folding this one independently would create a duplicate, non-identical
94
+ * instance. Present only when `mode === "run"`.
95
+ *
96
+ * The fold-blocker dominator ranking (`./fold-rank.ts`) keys on this: a
97
+ * reverse-tainted file is not a forward-contagion blocker (fixing it
98
+ * unblocks nothing — it was never the cause), and the tree can't express
99
+ * the edge that DOES hold it back (an importer, not something it imports).
100
+ * It's reported in a separate bucket instead of a dominator subtree.
101
+ */
102
+ reverseTainted?: boolean;
88
103
  }
89
104
 
90
105
  /**
@@ -295,7 +310,7 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
295
310
  const reason = !folded.ok
296
311
  ? folded.reason
297
312
  : `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`;
298
- foldDecisions.push({ file, mode: "run", reason });
313
+ foldDecisions.push({ file, mode: "run", reason, reverseTainted: folded.ok });
299
314
  }
300
315
 
301
316
  if (options?.sandbox) {
@@ -0,0 +1,118 @@
1
+ import * as ts from "typescript";
2
+ import { describe, expect, test } from "vitest";
3
+ import { collectConsts } from "../fold/fold";
4
+ import { collectParamDependencies } from "./param-deps";
5
+ import type { PathOrigin } from "../provenance";
6
+
7
+ /**
8
+ * Collect dependencies for the props of the file's single
9
+ * `export const x = new Type({...})`, which is the shape every case here uses.
10
+ */
11
+ function depsOf(source: string, paramLocals = ["params"]): Record<string, PathOrigin> {
12
+ const file = ts.createSourceFile("fixture.ts", source, ts.ScriptTarget.Latest, true);
13
+ const consts = collectConsts(file);
14
+ let props: ts.ObjectLiteralExpression | undefined;
15
+ for (const statement of file.statements) {
16
+ if (!ts.isVariableStatement(statement)) continue;
17
+ for (const decl of statement.declarationList.declarations) {
18
+ if (decl.name.getText() !== "x" || !decl.initializer) continue;
19
+ if (!ts.isNewExpression(decl.initializer)) continue;
20
+ for (const argument of decl.initializer.arguments ?? []) {
21
+ if (ts.isObjectLiteralExpression(argument)) {
22
+ props = argument;
23
+ break;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ if (!props) throw new Error("fixture has no `const x = new Type({...})`");
29
+ return collectParamDependencies(props, consts, new Set(paramLocals));
30
+ }
31
+
32
+ const param = (...names: string[]): PathOrigin => ({ kind: "build-param", params: names });
33
+
34
+ describe("collectParamDependencies", () => {
35
+ test("a direct read is attributed to its own path", () => {
36
+ expect(depsOf(`export const x = new Thing({ replicas: params.replicas, name: "fixed" });`)).toEqual({
37
+ replicas: param("replicas"),
38
+ });
39
+ });
40
+
41
+ test("nested object literals are descended into, dotted", () => {
42
+ const source = `export const x = new Thing({ spec: { template: { image: params.image } } });`;
43
+ expect(depsOf(source)).toEqual({ "spec.template.image": param("image") });
44
+ });
45
+
46
+ test("an expression is attributed to every parameter it can read, not to its value", () => {
47
+ const source = `export const x = new Thing({ replicas: params.tier === "prod" ? params.big : 1 });`;
48
+ expect(depsOf(source)).toEqual({ replicas: param("big", "tier") });
49
+ });
50
+
51
+ test("a parameter hoisted into a const is followed to the field that uses it", () => {
52
+ const source = [
53
+ `const replicas = params.tier === "prod" ? 5 : 1;`,
54
+ `export const x = new Thing({ replicas });`,
55
+ ].join("\n");
56
+ expect(depsOf(source)).toEqual({ replicas: param("tier") });
57
+ });
58
+
59
+ test("const chains are followed transitively, and a cycle terminates", () => {
60
+ const source = [
61
+ `const a = b;`,
62
+ `const b = \`\${params.region}-\${a}\`;`,
63
+ `export const x = new Thing({ zone: a });`,
64
+ ].join("\n");
65
+ expect(depsOf(source)).toEqual({ zone: param("region") });
66
+ });
67
+
68
+ test("template literals and calls are walked", () => {
69
+ const source = `export const x = new Thing({ bucket: \`\${params.env}-assets\`.toLowerCase() });`;
70
+ expect(depsOf(source)).toEqual({ bucket: param("env") });
71
+ });
72
+
73
+ test("an array is attributed whole, never per index", () => {
74
+ const source = `export const x = new Thing({ containers: [{ image: params.image }, { image: "sidecar" }] });`;
75
+ expect(depsOf(source)).toEqual({ containers: param("image") });
76
+ });
77
+
78
+ test("a spread is attributed to the object it spreads into", () => {
79
+ const source = [
80
+ `const base = { region: params.region };`,
81
+ `export const x = new Thing({ ...base, spec: { ...base, replicas: 1 } });`,
82
+ ].join("\n");
83
+ expect(depsOf(source)).toEqual({ "": param("region"), spec: param("region") });
84
+ });
85
+
86
+ test("bracket access with a literal key names the parameter", () => {
87
+ expect(depsOf(`export const x = new Thing({ zone: params["region"] });`)).toEqual({ zone: param("region") });
88
+ });
89
+
90
+ test("a property KEY that happens to match a const is not a reference", () => {
91
+ const source = [`const tier = params.tier;`, `export const x = new Thing({ spec: { tier: "fixed" } });`].join("\n");
92
+ expect(depsOf(source)).toEqual({});
93
+ });
94
+
95
+ test("a bare reference to the whole params object records nothing", () => {
96
+ // It names no single parameter; under-reporting is the safe direction.
97
+ expect(depsOf(`export const x = new Thing({ all: params });`)).toEqual({});
98
+ });
99
+
100
+ test("a file that never imported params records nothing", () => {
101
+ expect(depsOf(`export const x = new Thing({ replicas: params.replicas });`, [])).toEqual({});
102
+ });
103
+
104
+ test("the local name the import bound is what counts, not the word 'params'", () => {
105
+ const source = `export const x = new Thing({ replicas: p.replicas, other: params.replicas });`;
106
+ expect(depsOf(source, ["p"])).toEqual({ replicas: param("replicas") });
107
+ });
108
+
109
+ test("parameter names are sorted and de-duplicated", () => {
110
+ const source = `export const x = new Thing({ n: params.z + params.a + params.z });`;
111
+ expect(depsOf(source)).toEqual({ n: param("a", "z") });
112
+ });
113
+
114
+ test("a computed key is skipped rather than guessed at", () => {
115
+ const source = `export const x = new Thing({ [params.key]: 1, name: params.name });`;
116
+ expect(depsOf(source)).toEqual({ name: param("name") });
117
+ });
118
+ });
@@ -0,0 +1,170 @@
1
+ import * as ts from "typescript";
2
+ import type { PathOrigin } from "../provenance";
3
+
4
+ /**
5
+ * Which build parameters an authored props expression READS, per path (chant
6
+ * #1443) — the declared-side counterpart of `managed-fields.ts`'s per-path live
7
+ * `owners`.
8
+ *
9
+ * This is a syntactic dependency, not a value taint. Fold substitutes
10
+ * `params.<name>` before anything is emitted, so by output time the value is a
11
+ * literal and indistinguishable from one typed by hand; tainting the value
12
+ * through fold's evaluator would mean propagating a tag through every operator
13
+ * it supports, and one operator that forgot would produce silently wrong
14
+ * provenance. Recording which parameters the *expression* mentions cannot
15
+ * degrade that way: a shape this walk does not follow drops a dependency, it
16
+ * never invents one.
17
+ *
18
+ * It also answers the more useful question. For
19
+ * `replicas: params.tier === "prod" ? 5 : 1`, a taint reports that the value
20
+ * came from the literal `5` — true, and no help to anyone about to edit the
21
+ * field. The dependency reports that the field is governed by `tier`.
22
+ *
23
+ * Known under-reporting, all in the safe direction: destructured parameters
24
+ * (`const { tier } = params`) are not tracked, because `collectConsts` only
25
+ * records identifier bindings; a bare reference to the whole `params` object
26
+ * names no single parameter and records nothing; and the run path has no
27
+ * expression to walk at all.
28
+ *
29
+ * The one direction it can over-report is a local binding that shadows a
30
+ * top-level `const` of the same name, since names resolve against the file's
31
+ * consts without scope tracking. That reports a parameter the field could
32
+ * plausibly be governed by rather than one it definitely is, which is the
33
+ * failure the "could affect" reading is written to absorb.
34
+ */
35
+
36
+ /** The property name a member declares, when it is a literal one. */
37
+ function literalName(name: ts.PropertyName | undefined): string | undefined {
38
+ if (!name) return undefined;
39
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
40
+ return undefined;
41
+ }
42
+
43
+ /** Strip the wrappers that do not change what an initializer reads. */
44
+ function unwrap(expr: ts.Expression): ts.Expression {
45
+ let current = expr;
46
+ for (;;) {
47
+ if (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current)) {
48
+ current = current.expression;
49
+ continue;
50
+ }
51
+ return current;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Every parameter name `expr` reads, following this file's own top-level
57
+ * `const` bindings so a value hoisted into a local is still attributed
58
+ * (`const replicas = params.tier === "prod" ? 5 : 1` used as `replicas`).
59
+ */
60
+ function readParams(
61
+ expr: ts.Expression,
62
+ consts: ReadonlyMap<string, ts.Expression>,
63
+ paramLocals: ReadonlySet<string>,
64
+ out: Set<string>,
65
+ ): void {
66
+ const followed = new Set<string>();
67
+
68
+ const visit = (node: ts.Node): void => {
69
+ if (ts.isPropertyAccessExpression(node)) {
70
+ if (ts.isIdentifier(node.expression) && paramLocals.has(node.expression.text)) {
71
+ out.add(node.name.text);
72
+ return;
73
+ }
74
+ visit(node.expression);
75
+ return;
76
+ }
77
+
78
+ if (ts.isElementAccessExpression(node)) {
79
+ if (ts.isIdentifier(node.expression) && paramLocals.has(node.expression.text)) {
80
+ if (ts.isStringLiteralLike(node.argumentExpression)) out.add(node.argumentExpression.text);
81
+ } else {
82
+ visit(node.expression);
83
+ }
84
+ visit(node.argumentExpression);
85
+ return;
86
+ }
87
+
88
+ if (ts.isIdentifier(node)) {
89
+ // A bare `params` names no single parameter — see the under-reporting
90
+ // note above.
91
+ if (paramLocals.has(node.text)) return;
92
+ const initializer = consts.get(node.text);
93
+ if (initializer && !followed.has(node.text)) {
94
+ followed.add(node.text);
95
+ visit(initializer);
96
+ }
97
+ return;
98
+ }
99
+
100
+ // A property KEY is not a reference; without this a nested `{ tier: 1 }`
101
+ // would resolve `tier` against `consts` and manufacture a dependency.
102
+ if (ts.isPropertyAssignment(node)) {
103
+ visit(node.initializer);
104
+ return;
105
+ }
106
+
107
+ ts.forEachChild(node, visit);
108
+ };
109
+
110
+ visit(expr);
111
+ }
112
+
113
+ /**
114
+ * Path → build-parameter origin for one resource's props object literal.
115
+ *
116
+ * Paths are dotted property names only, per `EntityProvenance.paths`: an object
117
+ * literal is descended into, and anything else — an array literal included — is
118
+ * attributed whole at its own path. An array's elements are deliberately not
119
+ * indexed, because an index-shaped key would not survive an element moving and
120
+ * would not match the `[#key]` addressing a diff uses.
121
+ *
122
+ * A spread is attributed to the object it spreads INTO, at that object's own
123
+ * path (the entity root for a top-level spread), since which keys it
124
+ * contributes is not knowable here.
125
+ */
126
+ export function collectParamDependencies(
127
+ props: ts.ObjectLiteralExpression,
128
+ consts: ReadonlyMap<string, ts.Expression>,
129
+ paramLocals: ReadonlySet<string>,
130
+ ): Record<string, PathOrigin> {
131
+ const out: Record<string, PathOrigin> = {};
132
+ if (paramLocals.size === 0) return out;
133
+
134
+ const record = (path: string, expr: ts.Expression): void => {
135
+ const found = new Set<string>();
136
+ readParams(expr, consts, paramLocals, found);
137
+ if (found.size === 0) return;
138
+ const existing = out[path];
139
+ const merged =
140
+ existing && existing.kind === "build-param" ? new Set([...existing.params, ...found]) : found;
141
+ out[path] = { kind: "build-param", params: [...merged].sort() };
142
+ };
143
+
144
+ const walk = (object: ts.ObjectLiteralExpression, prefix: string): void => {
145
+ for (const member of object.properties) {
146
+ if (ts.isSpreadAssignment(member)) {
147
+ record(prefix, member.expression);
148
+ continue;
149
+ }
150
+ if (ts.isShorthandPropertyAssignment(member)) {
151
+ const key = member.name.text;
152
+ record(prefix ? `${prefix}.${key}` : key, member.name);
153
+ continue;
154
+ }
155
+ if (!ts.isPropertyAssignment(member)) continue;
156
+ const key = literalName(member.name);
157
+ if (key === undefined) continue;
158
+ const path = prefix ? `${prefix}.${key}` : key;
159
+ const initializer = unwrap(member.initializer);
160
+ if (ts.isObjectLiteralExpression(initializer)) {
161
+ walk(initializer, path);
162
+ continue;
163
+ }
164
+ record(path, member.initializer);
165
+ }
166
+ };
167
+
168
+ walk(props, "");
169
+ return out;
170
+ }