@intentius/chant 0.124.45 → 0.124.48

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 (772) hide show
  1. package/dist/__golden__/wave-loop/op-by-apply-after-fails.json +10 -0
  2. package/dist/__golden__/wave-loop/op-by-apply-hold.json +10 -0
  3. package/dist/__golden__/wave-loop/op-by-apply-ignored.json +10 -0
  4. package/dist/__golden__/wave-loop/op-by-apply-pass.json +10 -0
  5. package/dist/__golden__/wave-loop/op-by-apply-skip.json +10 -0
  6. package/dist/__golden__/wave-loop/op-cost-member-clash.json +6 -0
  7. package/dist/__golden__/wave-loop/op-cost-member.json +13 -0
  8. package/dist/__golden__/wave-loop/op-denied-source.json +16 -0
  9. package/dist/__golden__/wave-loop/op-denied.json +16 -0
  10. package/dist/__golden__/wave-loop/op-discover-rest-short.json +6 -0
  11. package/dist/__golden__/wave-loop/op-discover-rest-stops.json +13 -0
  12. package/dist/__golden__/wave-loop/op-discover-rest.json +26 -0
  13. package/dist/__golden__/wave-loop/op-discover-wide.json +26 -0
  14. package/dist/__golden__/wave-loop/op-held-on-again.json +16 -0
  15. package/dist/__golden__/wave-loop/op-held-on-before-apply.json +16 -0
  16. package/dist/__golden__/wave-loop/op-held-on.json +16 -0
  17. package/dist/__golden__/wave-loop/op-hooks-apply-fails.json +13 -0
  18. package/dist/__golden__/wave-loop/op-hooks-fail-after-apply.json +13 -0
  19. package/dist/__golden__/wave-loop/op-hooks-fail-before-apply.json +13 -0
  20. package/dist/__golden__/wave-loop/op-hooks-fail-before-plan.json +13 -0
  21. package/dist/__golden__/wave-loop/op-hooks-held.json +13 -0
  22. package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +13 -0
  23. package/dist/__golden__/wave-loop/op-hooks-pass.json +13 -0
  24. package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +13 -0
  25. package/dist/__golden__/wave-loop/op-hooks-share.json +33 -0
  26. package/dist/__golden__/wave-loop/op-outcome-held.json +10 -0
  27. package/dist/__golden__/wave-loop/op-outcome-replan-approved.json +10 -0
  28. package/dist/__golden__/wave-loop/op-outcome-replan-forever.json +10 -0
  29. package/dist/__golden__/wave-loop/op-outcome-replan.json +10 -0
  30. package/dist/__golden__/wave-loop/op-outcome-stood-down.json +10 -0
  31. package/dist/__golden__/wave-loop/op-outcome-unreadable.json +10 -0
  32. package/dist/__golden__/wave-loop/op-overridden.json +16 -0
  33. package/dist/__golden__/wave-loop/op-per-run.json +20 -0
  34. package/dist/__golden__/wave-loop/op-point.json +13 -0
  35. package/dist/__golden__/wave-loop/op-pr-review.json +10 -0
  36. package/dist/__golden__/wave-loop/op-sealed.json +10 -0
  37. package/dist/__golden__/wave-loop/op-spend-change-only.json +10 -0
  38. package/dist/__golden__/wave-loop/op-spend.json +10 -0
  39. package/dist/__golden__/wave-loop/op-wave-commands.json +55 -0
  40. package/dist/__golden__/wave-loop/op-wide-refused.json +26 -0
  41. package/dist/__golden__/wave-loop/op-wide-waiting.json +29 -0
  42. package/dist/__golden__/wave-loop/op-wide.json +39 -0
  43. package/dist/action-pins.d.ts +61 -0
  44. package/dist/action-pins.d.ts.map +1 -0
  45. package/dist/action-pins.js +77 -0
  46. package/dist/capabilities/aws.paths +148 -0
  47. package/dist/capabilities/base-mappings.json +122 -0
  48. package/dist/capabilities/catalog.d.ts +89 -0
  49. package/dist/capabilities/catalog.d.ts.map +1 -0
  50. package/dist/capabilities/catalog.js +204 -0
  51. package/dist/capabilities/language.d.ts +103 -0
  52. package/dist/capabilities/language.d.ts.map +1 -0
  53. package/dist/capabilities/language.js +335 -0
  54. package/dist/capabilities/matcher.d.ts +65 -0
  55. package/dist/capabilities/matcher.d.ts.map +1 -0
  56. package/dist/capabilities/matcher.js +299 -0
  57. package/dist/capabilities/view.d.ts +207 -0
  58. package/dist/capabilities/view.d.ts.map +1 -0
  59. package/dist/capabilities/view.js +484 -0
  60. package/dist/cli/commands/carve.d.ts +3 -0
  61. package/dist/cli/commands/carve.d.ts.map +1 -1
  62. package/dist/cli/commands/carve.js +36 -7
  63. package/dist/cli/commands/import-report.d.ts +26 -0
  64. package/dist/cli/commands/import-report.d.ts.map +1 -0
  65. package/dist/cli/commands/import-report.js +65 -0
  66. package/dist/cli/commands/import.d.ts +11 -1
  67. package/dist/cli/commands/import.d.ts.map +1 -1
  68. package/dist/cli/commands/import.js +22 -3
  69. package/dist/cli/commands/lint.d.ts.map +1 -1
  70. package/dist/cli/commands/lint.js +1 -0
  71. package/dist/cli/commands/migrate.d.ts +13 -1
  72. package/dist/cli/commands/migrate.d.ts.map +1 -1
  73. package/dist/cli/commands/migrate.js +134 -10
  74. package/dist/cli/handlers/approve.d.ts.map +1 -1
  75. package/dist/cli/handlers/approve.js +21 -2
  76. package/dist/cli/handlers/build.d.ts.map +1 -1
  77. package/dist/cli/handlers/build.js +6 -1
  78. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  79. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  80. package/dist/cli/handlers/declared-graph.js +87 -0
  81. package/dist/cli/handlers/graph.d.ts +25 -0
  82. package/dist/cli/handlers/graph.d.ts.map +1 -1
  83. package/dist/cli/handlers/graph.js +93 -87
  84. package/dist/cli/handlers/ledger.d.ts +3 -0
  85. package/dist/cli/handlers/ledger.d.ts.map +1 -0
  86. package/dist/cli/handlers/ledger.js +55 -0
  87. package/dist/cli/handlers/migrate.d.ts.map +1 -1
  88. package/dist/cli/handlers/migrate.js +18 -11
  89. package/dist/cli/handlers/misc.d.ts.map +1 -1
  90. package/dist/cli/handlers/misc.js +12 -1
  91. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  92. package/dist/cli/handlers/pr-env.js +4 -3
  93. package/dist/cli/handlers/pr.d.ts +15 -1
  94. package/dist/cli/handlers/pr.d.ts.map +1 -1
  95. package/dist/cli/handlers/pr.js +143 -39
  96. package/dist/cli/handlers/query.d.ts +7 -2
  97. package/dist/cli/handlers/query.d.ts.map +1 -1
  98. package/dist/cli/handlers/query.js +16 -4
  99. package/dist/cli/handlers/release.d.ts.map +1 -1
  100. package/dist/cli/handlers/release.js +6 -3
  101. package/dist/cli/handlers/search-count.d.ts +89 -0
  102. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  103. package/dist/cli/handlers/search-count.js +132 -0
  104. package/dist/cli/handlers/search.d.ts +356 -6
  105. package/dist/cli/handlers/search.d.ts.map +1 -1
  106. package/dist/cli/handlers/search.js +1039 -155
  107. package/dist/cli/main.d.ts.map +1 -1
  108. package/dist/cli/main.js +88 -17
  109. package/dist/cli/mcp/server.d.ts.map +1 -1
  110. package/dist/cli/mcp/server.js +3 -0
  111. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  112. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  113. package/dist/cli/mcp/tools/capabilities.js +27 -0
  114. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  115. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  116. package/dist/cli/mcp/workspace-tools.js +47 -2
  117. package/dist/cli/parse-args.d.ts.map +1 -1
  118. package/dist/cli/parse-args.js +100 -19
  119. package/dist/cli/registry.d.ts +36 -4
  120. package/dist/cli/registry.d.ts.map +1 -1
  121. package/dist/components/builders.d.ts +5 -1
  122. package/dist/components/builders.d.ts.map +1 -1
  123. package/dist/components/builders.js +5 -0
  124. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  125. package/dist/components/capability-plugin-loader.js +16 -1
  126. package/dist/components/capability-plugin.d.ts +14 -0
  127. package/dist/components/capability-plugin.d.ts.map +1 -1
  128. package/dist/components/pr-env-ports.d.ts +29 -0
  129. package/dist/components/pr-env-ports.d.ts.map +1 -0
  130. package/dist/components/pr-env-ports.js +77 -0
  131. package/dist/components/pr-env-run.d.ts +5 -21
  132. package/dist/components/pr-env-run.d.ts.map +1 -1
  133. package/dist/components/pr-env-run.js +6 -70
  134. package/dist/components/pr-environment.d.ts +2 -1
  135. package/dist/components/pr-environment.d.ts.map +1 -1
  136. package/dist/components/pr-environment.js +1 -1
  137. package/dist/components/pr-hooks.d.ts +21 -1
  138. package/dist/components/pr-hooks.d.ts.map +1 -1
  139. package/dist/components/pr-hooks.js +72 -4
  140. package/dist/components/pr-pipeline.d.ts +45 -10
  141. package/dist/components/pr-pipeline.d.ts.map +1 -1
  142. package/dist/components/pr-pipeline.js +47 -14
  143. package/dist/components/pr-policy.d.ts +36 -0
  144. package/dist/components/pr-policy.d.ts.map +1 -1
  145. package/dist/components/pr-policy.js +36 -0
  146. package/dist/components/starter-plugin.d.ts +1 -0
  147. package/dist/components/starter-plugin.d.ts.map +1 -1
  148. package/dist/components/starter-plugin.js +5 -1
  149. package/dist/components/verbs/index.d.ts +2 -0
  150. package/dist/components/verbs/index.d.ts.map +1 -1
  151. package/dist/components/verbs/index.js +2 -0
  152. package/dist/components/verbs/publish-artifact.d.ts +65 -0
  153. package/dist/components/verbs/publish-artifact.d.ts.map +1 -0
  154. package/dist/components/verbs/publish-artifact.js +74 -0
  155. package/dist/components/verbs/publish-image.d.ts +129 -0
  156. package/dist/components/verbs/publish-image.d.ts.map +1 -0
  157. package/dist/components/verbs/publish-image.js +145 -0
  158. package/dist/config-environments.d.ts +112 -0
  159. package/dist/config-environments.d.ts.map +1 -0
  160. package/dist/config-environments.js +114 -0
  161. package/dist/config.d.ts +32 -84
  162. package/dist/config.d.ts.map +1 -1
  163. package/dist/config.js +13 -96
  164. package/dist/gate-decision.d.ts +11 -2
  165. package/dist/gate-decision.d.ts.map +1 -1
  166. package/dist/gate-decision.js +15 -3
  167. package/dist/graph-detail.d.ts.map +1 -1
  168. package/dist/graph-detail.js +15 -3
  169. package/dist/graph-effective.d.ts +33 -2
  170. package/dist/graph-effective.d.ts.map +1 -1
  171. package/dist/graph-effective.js +215 -68
  172. package/dist/graph-ir.d.ts +48 -0
  173. package/dist/graph-ir.d.ts.map +1 -1
  174. package/dist/graph-ir.js +130 -0
  175. package/dist/graph-sub-template.d.ts +11 -0
  176. package/dist/graph-sub-template.d.ts.map +1 -0
  177. package/dist/graph-sub-template.js +19 -0
  178. package/dist/graph-walk.d.ts +66 -0
  179. package/dist/graph-walk.d.ts.map +1 -0
  180. package/dist/graph-walk.js +70 -0
  181. package/dist/import/parser.d.ts +19 -0
  182. package/dist/import/parser.d.ts.map +1 -1
  183. package/dist/lexicon.d.ts +80 -6
  184. package/dist/lexicon.d.ts.map +1 -1
  185. package/dist/lifecycle/gate-ledger.d.ts +11 -0
  186. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  187. package/dist/lifecycle/git.d.ts +2 -5
  188. package/dist/lifecycle/git.d.ts.map +1 -1
  189. package/dist/lifecycle/ledger-read.d.ts +65 -0
  190. package/dist/lifecycle/ledger-read.d.ts.map +1 -0
  191. package/dist/lifecycle/ledger-read.js +160 -0
  192. package/dist/lifecycle/observe.d.ts +28 -0
  193. package/dist/lifecycle/observe.d.ts.map +1 -1
  194. package/dist/lifecycle/observe.js +23 -1
  195. package/dist/lifecycle/release-workstation.d.ts +7 -0
  196. package/dist/lifecycle/release-workstation.d.ts.map +1 -1
  197. package/dist/lifecycle/release-workstation.js +25 -0
  198. package/dist/lifecycle/run-ledger.d.ts +4 -3
  199. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  200. package/dist/lifecycle/run-ledger.js +1 -0
  201. package/dist/lint/discover.js +3 -1
  202. package/dist/lint/post-synth.d.ts +127 -1
  203. package/dist/lint/post-synth.d.ts.map +1 -1
  204. package/dist/lint/post-synth.js +84 -2
  205. package/dist/lint/rule.d.ts +6 -0
  206. package/dist/lint/rule.d.ts.map +1 -1
  207. package/dist/lint/rules/op/index.d.ts +1 -0
  208. package/dist/lint/rules/op/index.d.ts.map +1 -1
  209. package/dist/lint/rules/op/index.js +2 -0
  210. package/dist/lint/rules/op/ops019-workspace-op-target.d.ts +32 -0
  211. package/dist/lint/rules/op/ops019-workspace-op-target.d.ts.map +1 -0
  212. package/dist/lint/rules/op/ops019-workspace-op-target.js +63 -0
  213. package/dist/live-endpoint.d.ts +1 -1
  214. package/dist/live-endpoint.d.ts.map +1 -1
  215. package/dist/live-endpoint.js +1 -1
  216. package/dist/observation.d.ts +48 -1
  217. package/dist/observation.d.ts.map +1 -1
  218. package/dist/observation.js +21 -5
  219. package/dist/op/activities/activity-contracts.d.ts +9 -6
  220. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  221. package/dist/op/activities/activity-contracts.js +5 -2
  222. package/dist/op/activities/index.d.ts +2 -2
  223. package/dist/op/activities/index.d.ts.map +1 -1
  224. package/dist/op/activities/index.js +1 -1
  225. package/dist/op/activities/review-agent.d.ts +90 -4
  226. package/dist/op/activities/review-agent.d.ts.map +1 -1
  227. package/dist/op/activities/review-agent.js +217 -19
  228. package/dist/op/activities/work-evidence.d.ts +8 -1
  229. package/dist/op/activities/work-evidence.d.ts.map +1 -1
  230. package/dist/op/activities/work-evidence.js +1 -0
  231. package/dist/op/builders.d.ts +9 -5
  232. package/dist/op/builders.d.ts.map +1 -1
  233. package/dist/op/builders.js +9 -5
  234. package/dist/op/composites/apply-op.d.ts +29 -2
  235. package/dist/op/composites/apply-op.d.ts.map +1 -1
  236. package/dist/op/composites/apply-op.js +20 -1
  237. package/dist/op/gate-approval.d.ts +11 -5
  238. package/dist/op/gate-approval.d.ts.map +1 -1
  239. package/dist/op/gate-approval.js +11 -6
  240. package/dist/op/gate-quorum.d.ts +100 -3
  241. package/dist/op/gate-quorum.d.ts.map +1 -1
  242. package/dist/op/gate-quorum.js +108 -6
  243. package/dist/op/gate-review.d.ts +9 -0
  244. package/dist/op/gate-review.d.ts.map +1 -1
  245. package/dist/op/gate-review.js +13 -0
  246. package/dist/op/gate.d.ts +44 -3
  247. package/dist/op/gate.d.ts.map +1 -1
  248. package/dist/op/gate.js +25 -4
  249. package/dist/op/index.d.ts +3 -3
  250. package/dist/op/index.d.ts.map +1 -1
  251. package/dist/op/index.js +1 -1
  252. package/dist/op/local-executor.d.ts +38 -3
  253. package/dist/op/local-executor.d.ts.map +1 -1
  254. package/dist/op/local-executor.js +48 -6
  255. package/dist/op/op-waves-run.d.ts +26 -1
  256. package/dist/op/op-waves-run.d.ts.map +1 -1
  257. package/dist/op/op-waves-run.js +259 -13
  258. package/dist/op/op-waves.d.ts +72 -5
  259. package/dist/op/op-waves.d.ts.map +1 -1
  260. package/dist/op/op-waves.js +110 -15
  261. package/dist/op/runtime.d.ts +11 -0
  262. package/dist/op/runtime.d.ts.map +1 -1
  263. package/dist/op/schedule-overdue.d.ts +16 -0
  264. package/dist/op/schedule-overdue.d.ts.map +1 -1
  265. package/dist/op/schedule-overdue.js +36 -0
  266. package/dist/op/stage-hooks.d.ts +9 -2
  267. package/dist/op/stage-hooks.d.ts.map +1 -1
  268. package/dist/op/stage-hooks.js +7 -5
  269. package/dist/op/workspace-run-env.d.ts +22 -0
  270. package/dist/op/workspace-run-env.d.ts.map +1 -0
  271. package/dist/op/workspace-run-env.js +21 -0
  272. package/dist/pr-forge.d.ts +24 -0
  273. package/dist/pr-forge.d.ts.map +1 -1
  274. package/dist/pr-forge.js +47 -0
  275. package/dist/pr-post.d.ts +42 -0
  276. package/dist/pr-post.d.ts.map +1 -1
  277. package/dist/pr-post.js +67 -1
  278. package/dist/read-filter.d.ts +65 -0
  279. package/dist/read-filter.d.ts.map +1 -0
  280. package/dist/read-filter.js +179 -0
  281. package/dist/run-event.d.ts +1 -1
  282. package/dist/run-event.d.ts.map +1 -1
  283. package/dist/terraform/carve.d.ts +24 -3
  284. package/dist/terraform/carve.d.ts.map +1 -1
  285. package/dist/terraform/carve.js +32 -14
  286. package/dist/terraform/cuts.d.ts +97 -0
  287. package/dist/terraform/cuts.d.ts.map +1 -0
  288. package/dist/terraform/cuts.js +138 -0
  289. package/dist/terraform/folds.d.ts +26 -0
  290. package/dist/terraform/folds.d.ts.map +1 -0
  291. package/dist/terraform/folds.js +38 -0
  292. package/dist/terraform/graph.d.ts.map +1 -1
  293. package/dist/terraform/graph.js +10 -0
  294. package/dist/terraform/reach.d.ts +140 -0
  295. package/dist/terraform/reach.d.ts.map +1 -0
  296. package/dist/terraform/reach.js +170 -0
  297. package/dist/terraform/score.d.ts +14 -0
  298. package/dist/terraform/score.d.ts.map +1 -1
  299. package/dist/terraform/score.js +14 -31
  300. package/dist/terraform/state.d.ts.map +1 -1
  301. package/dist/terraform/state.js +4 -2
  302. package/dist/terraform/types.d.ts +8 -0
  303. package/dist/terraform/types.d.ts.map +1 -1
  304. package/dist/wave-blocked.d.ts +10 -1
  305. package/dist/wave-blocked.d.ts.map +1 -1
  306. package/dist/wave-blocked.js +31 -3
  307. package/dist/wave-runtime.mjs +266 -256
  308. package/dist/workspace/capabilities-contract.d.ts +66 -0
  309. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  310. package/dist/workspace/capabilities-contract.js +107 -0
  311. package/dist/workspace/capabilities.schema.json +229 -0
  312. package/dist/workspace/check.schema.json +11 -2
  313. package/dist/workspace/checks/ci-branch.d.ts +44 -0
  314. package/dist/workspace/checks/ci-branch.d.ts.map +1 -0
  315. package/dist/workspace/checks/ci-branch.js +133 -0
  316. package/dist/workspace/checks/links.d.ts +3 -0
  317. package/dist/workspace/checks/links.d.ts.map +1 -1
  318. package/dist/workspace/checks/links.js +12 -1
  319. package/dist/workspace/checks/org.d.ts +48 -0
  320. package/dist/workspace/checks/org.d.ts.map +1 -0
  321. package/dist/workspace/checks/org.js +156 -0
  322. package/dist/workspace/checks.d.ts +11 -1
  323. package/dist/workspace/checks.d.ts.map +1 -1
  324. package/dist/workspace/checks.js +59 -7
  325. package/dist/workspace/ci-branches.d.ts +16 -0
  326. package/dist/workspace/ci-branches.d.ts.map +1 -1
  327. package/dist/workspace/ci-branches.js +27 -0
  328. package/dist/workspace/ci-green-cli.js +7 -7
  329. package/dist/workspace/ci-green-workflow.d.ts +1 -1
  330. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  331. package/dist/workspace/ci-green-workflow.js +13 -8
  332. package/dist/workspace/ci-green.d.ts +14 -0
  333. package/dist/workspace/ci-green.d.ts.map +1 -1
  334. package/dist/workspace/ci-green.js +44 -11
  335. package/dist/workspace/ci-run.d.ts +9 -1
  336. package/dist/workspace/ci-run.d.ts.map +1 -1
  337. package/dist/workspace/ci-run.js +67 -6
  338. package/dist/workspace/ci-serve.d.ts +9 -0
  339. package/dist/workspace/ci-serve.d.ts.map +1 -1
  340. package/dist/workspace/ci-serve.js +114 -76
  341. package/dist/workspace/compose-graph.d.ts +23 -0
  342. package/dist/workspace/compose-graph.d.ts.map +1 -1
  343. package/dist/workspace/compose-graph.js +3 -1
  344. package/dist/workspace/composites.d.ts +4 -1
  345. package/dist/workspace/composites.d.ts.map +1 -1
  346. package/dist/workspace/composites.js +5 -2
  347. package/dist/workspace/composites.schema.json +22 -0
  348. package/dist/workspace/conformance/__writer_fixture__/work/work.schema.json +105 -1
  349. package/dist/workspace/conformance/index.d.ts +1 -1
  350. package/dist/workspace/conformance/index.d.ts.map +1 -1
  351. package/dist/workspace/conformance/index.js +39 -2
  352. package/dist/workspace/cross-root-lint.d.ts +56 -0
  353. package/dist/workspace/cross-root-lint.d.ts.map +1 -0
  354. package/dist/workspace/cross-root-lint.js +111 -0
  355. package/dist/workspace/dbt-models-reader.d.ts +44 -0
  356. package/dist/workspace/dbt-models-reader.d.ts.map +1 -0
  357. package/dist/workspace/dbt-models-reader.js +356 -0
  358. package/dist/workspace/dbt-project-common.d.ts +40 -0
  359. package/dist/workspace/dbt-project-common.d.ts.map +1 -0
  360. package/dist/workspace/dbt-project-common.js +106 -0
  361. package/dist/workspace/dbt-project-reader.d.ts +4 -3
  362. package/dist/workspace/dbt-project-reader.d.ts.map +1 -1
  363. package/dist/workspace/dbt-project-reader.js +14 -108
  364. package/dist/workspace/decision-rank.d.ts +26 -0
  365. package/dist/workspace/decision-rank.d.ts.map +1 -0
  366. package/dist/workspace/decision-rank.js +16 -0
  367. package/dist/workspace/declaration.d.ts +43 -2
  368. package/dist/workspace/declaration.d.ts.map +1 -1
  369. package/dist/workspace/declaration.js +45 -11
  370. package/dist/workspace/declaration.schema.json +133 -24
  371. package/dist/workspace/environments.d.ts +26 -2
  372. package/dist/workspace/environments.d.ts.map +1 -1
  373. package/dist/workspace/environments.js +26 -2
  374. package/dist/workspace/flyway-dir.d.ts +39 -0
  375. package/dist/workspace/flyway-dir.d.ts.map +1 -0
  376. package/dist/workspace/flyway-dir.js +104 -0
  377. package/dist/workspace/flyway-layout.d.ts +96 -0
  378. package/dist/workspace/flyway-layout.d.ts.map +1 -0
  379. package/dist/workspace/flyway-layout.js +308 -0
  380. package/dist/workspace/graph-cache.d.ts +8 -0
  381. package/dist/workspace/graph-cache.d.ts.map +1 -1
  382. package/dist/workspace/graph-cache.js +27 -3
  383. package/dist/workspace/graph-cli.d.ts +39 -2
  384. package/dist/workspace/graph-cli.d.ts.map +1 -1
  385. package/dist/workspace/graph-cli.js +92 -32
  386. package/dist/workspace/graph.schema.json +182 -0
  387. package/dist/workspace/intent.d.ts +2 -3
  388. package/dist/workspace/intent.d.ts.map +1 -1
  389. package/dist/workspace/intent.js +5 -9
  390. package/dist/workspace/kind-link-readers.d.ts +59 -2
  391. package/dist/workspace/kind-link-readers.d.ts.map +1 -1
  392. package/dist/workspace/kind-link-readers.js +17 -1
  393. package/dist/workspace/kind-readers.d.ts +3 -0
  394. package/dist/workspace/kind-readers.d.ts.map +1 -1
  395. package/dist/workspace/kind-readers.js +7 -1
  396. package/dist/workspace/kinds.d.ts +66 -2
  397. package/dist/workspace/kinds.d.ts.map +1 -1
  398. package/dist/workspace/kinds.js +98 -4
  399. package/dist/workspace/ledger.schema.json +158 -0
  400. package/dist/workspace/lexicon-outputs.d.ts +20 -1
  401. package/dist/workspace/lexicon-outputs.d.ts.map +1 -1
  402. package/dist/workspace/lexicon-outputs.js +102 -10
  403. package/dist/workspace/lineage-check.d.ts +3 -0
  404. package/dist/workspace/lineage-check.d.ts.map +1 -1
  405. package/dist/workspace/lineage-check.js +23 -4
  406. package/dist/workspace/links.d.ts +8 -1
  407. package/dist/workspace/links.d.ts.map +1 -1
  408. package/dist/workspace/links.js +25 -1
  409. package/dist/workspace/ls.d.ts +8 -0
  410. package/dist/workspace/ls.d.ts.map +1 -1
  411. package/dist/workspace/ls.js +7 -1
  412. package/dist/workspace/ls.schema.json +7 -0
  413. package/dist/workspace/member-commands.d.ts +10 -1
  414. package/dist/workspace/member-commands.d.ts.map +1 -1
  415. package/dist/workspace/member-commands.js +71 -6
  416. package/dist/workspace/migrate-links.d.ts +45 -0
  417. package/dist/workspace/migrate-links.d.ts.map +1 -0
  418. package/dist/workspace/migrate-links.js +100 -0
  419. package/dist/workspace/migration-dirs.d.ts +74 -0
  420. package/dist/workspace/migration-dirs.d.ts.map +1 -0
  421. package/dist/workspace/migration-dirs.js +254 -0
  422. package/dist/workspace/nested-graph.d.ts +2 -0
  423. package/dist/workspace/nested-graph.d.ts.map +1 -1
  424. package/dist/workspace/nested-graph.js +32 -1
  425. package/dist/workspace/reason-codes.d.ts +18 -0
  426. package/dist/workspace/reason-codes.d.ts.map +1 -1
  427. package/dist/workspace/reason-codes.js +23 -0
  428. package/dist/workspace/records-cli.d.ts +11 -0
  429. package/dist/workspace/records-cli.d.ts.map +1 -1
  430. package/dist/workspace/records-cli.js +57 -5
  431. package/dist/workspace/records.schema.json +5 -0
  432. package/dist/workspace/release-candidates.d.ts +2 -0
  433. package/dist/workspace/release-candidates.d.ts.map +1 -1
  434. package/dist/workspace/release-candidates.js +5 -2
  435. package/dist/workspace/release-train.d.ts +53 -0
  436. package/dist/workspace/release-train.d.ts.map +1 -0
  437. package/dist/workspace/release-train.js +134 -0
  438. package/dist/workspace/release-workflow.d.ts.map +1 -1
  439. package/dist/workspace/release-workflow.js +4 -3
  440. package/dist/workspace/repo-ref.d.ts +45 -0
  441. package/dist/workspace/repo-ref.d.ts.map +1 -0
  442. package/dist/workspace/repo-ref.js +114 -0
  443. package/dist/workspace/run-event.schema.json +2 -2
  444. package/dist/workspace/runs-cli.d.ts +9 -0
  445. package/dist/workspace/runs-cli.d.ts.map +1 -1
  446. package/dist/workspace/runs-cli.js +48 -3
  447. package/dist/workspace/runs.schema.json +7 -0
  448. package/dist/workspace/search-cli.d.ts +204 -0
  449. package/dist/workspace/search-cli.d.ts.map +1 -0
  450. package/dist/workspace/search-cli.js +466 -0
  451. package/dist/workspace/search-contract.d.ts +56 -0
  452. package/dist/workspace/search-contract.d.ts.map +1 -0
  453. package/dist/workspace/search-contract.js +70 -0
  454. package/dist/workspace/search.schema.json +822 -0
  455. package/dist/workspace/status-decisions.d.ts +37 -0
  456. package/dist/workspace/status-decisions.d.ts.map +1 -0
  457. package/dist/workspace/status-decisions.js +147 -0
  458. package/dist/workspace/status-gates.d.ts +9 -0
  459. package/dist/workspace/status-gates.d.ts.map +1 -1
  460. package/dist/workspace/status.d.ts +4 -0
  461. package/dist/workspace/status.d.ts.map +1 -1
  462. package/dist/workspace/status.js +14 -2
  463. package/dist/workspace/status.schema.json +6 -0
  464. package/dist/workspace/work-cli.d.ts.map +1 -1
  465. package/dist/workspace/work-cli.js +5 -3
  466. package/dist/workspace/work-evidence-ops.d.ts +83 -0
  467. package/dist/workspace/work-evidence-ops.d.ts.map +1 -0
  468. package/dist/workspace/work-evidence-ops.js +169 -0
  469. package/dist/workspace/work-evidence.d.ts +12 -2
  470. package/dist/workspace/work-evidence.d.ts.map +1 -1
  471. package/dist/workspace/work-evidence.js +21 -3
  472. package/dist/workspace/work-evidence.schema.json +49 -2
  473. package/dist/workspace/workspace-gates.d.ts +110 -0
  474. package/dist/workspace/workspace-gates.d.ts.map +1 -0
  475. package/dist/workspace/workspace-gates.js +140 -0
  476. package/dist/workspace/workspace-kinds.schema.json +58 -3
  477. package/dist/workspace/workspace-ledger.d.ts +76 -0
  478. package/dist/workspace/workspace-ledger.d.ts.map +1 -0
  479. package/dist/workspace/workspace-ledger.js +91 -0
  480. package/dist/workspace/workspace-run.d.ts +112 -0
  481. package/dist/workspace/workspace-run.d.ts.map +1 -0
  482. package/dist/workspace/workspace-run.js +432 -0
  483. package/package.json +1 -1
  484. package/src/__golden__/wave-loop/op-by-apply-after-fails.json +10 -0
  485. package/src/__golden__/wave-loop/op-by-apply-hold.json +10 -0
  486. package/src/__golden__/wave-loop/op-by-apply-ignored.json +10 -0
  487. package/src/__golden__/wave-loop/op-by-apply-pass.json +10 -0
  488. package/src/__golden__/wave-loop/op-by-apply-skip.json +10 -0
  489. package/src/__golden__/wave-loop/op-cost-member-clash.json +6 -0
  490. package/src/__golden__/wave-loop/op-cost-member.json +13 -0
  491. package/src/__golden__/wave-loop/op-denied-source.json +16 -0
  492. package/src/__golden__/wave-loop/op-denied.json +16 -0
  493. package/src/__golden__/wave-loop/op-discover-rest-short.json +6 -0
  494. package/src/__golden__/wave-loop/op-discover-rest-stops.json +13 -0
  495. package/src/__golden__/wave-loop/op-discover-rest.json +26 -0
  496. package/src/__golden__/wave-loop/op-discover-wide.json +26 -0
  497. package/src/__golden__/wave-loop/op-held-on-again.json +16 -0
  498. package/src/__golden__/wave-loop/op-held-on-before-apply.json +16 -0
  499. package/src/__golden__/wave-loop/op-held-on.json +16 -0
  500. package/src/__golden__/wave-loop/op-hooks-apply-fails.json +13 -0
  501. package/src/__golden__/wave-loop/op-hooks-fail-after-apply.json +13 -0
  502. package/src/__golden__/wave-loop/op-hooks-fail-before-apply.json +13 -0
  503. package/src/__golden__/wave-loop/op-hooks-fail-before-plan.json +13 -0
  504. package/src/__golden__/wave-loop/op-hooks-held.json +13 -0
  505. package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +13 -0
  506. package/src/__golden__/wave-loop/op-hooks-pass.json +13 -0
  507. package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +13 -0
  508. package/src/__golden__/wave-loop/op-hooks-share.json +33 -0
  509. package/src/__golden__/wave-loop/op-outcome-held.json +10 -0
  510. package/src/__golden__/wave-loop/op-outcome-replan-approved.json +10 -0
  511. package/src/__golden__/wave-loop/op-outcome-replan-forever.json +10 -0
  512. package/src/__golden__/wave-loop/op-outcome-replan.json +10 -0
  513. package/src/__golden__/wave-loop/op-outcome-stood-down.json +10 -0
  514. package/src/__golden__/wave-loop/op-outcome-unreadable.json +10 -0
  515. package/src/__golden__/wave-loop/op-overridden.json +16 -0
  516. package/src/__golden__/wave-loop/op-per-run.json +20 -0
  517. package/src/__golden__/wave-loop/op-point.json +13 -0
  518. package/src/__golden__/wave-loop/op-pr-review.json +10 -0
  519. package/src/__golden__/wave-loop/op-sealed.json +10 -0
  520. package/src/__golden__/wave-loop/op-spend-change-only.json +10 -0
  521. package/src/__golden__/wave-loop/op-spend.json +10 -0
  522. package/src/__golden__/wave-loop/op-wave-commands.json +55 -0
  523. package/src/__golden__/wave-loop/op-wide-refused.json +26 -0
  524. package/src/__golden__/wave-loop/op-wide-waiting.json +29 -0
  525. package/src/__golden__/wave-loop/op-wide.json +39 -0
  526. package/src/__snapshots__/okf.test.ts.snap +10 -0
  527. package/src/action-pins.test.ts +78 -0
  528. package/src/action-pins.ts +92 -0
  529. package/src/capabilities/aws.paths +148 -0
  530. package/src/capabilities/base-mappings.json +122 -0
  531. package/src/capabilities/catalog.ts +230 -0
  532. package/src/capabilities/language.test.ts +191 -0
  533. package/src/capabilities/language.ts +373 -0
  534. package/src/capabilities/matcher.ts +315 -0
  535. package/src/capabilities/view-compose.test.ts +76 -0
  536. package/src/capabilities/view.ts +631 -0
  537. package/src/cli/commands/carve-score-once.test.ts +99 -0
  538. package/src/cli/commands/carve.ts +37 -6
  539. package/src/cli/commands/check-lexicon.test.ts +2 -2
  540. package/src/cli/commands/import-report.ts +71 -0
  541. package/src/cli/commands/import.ts +33 -3
  542. package/src/cli/commands/lint.ts +1 -0
  543. package/src/cli/commands/migrate-dialect.test.ts +145 -0
  544. package/src/cli/commands/migrate.ts +132 -11
  545. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +4 -4
  546. package/src/cli/handlers/approve.ts +23 -2
  547. package/src/cli/handlers/build.ts +7 -1
  548. package/src/cli/handlers/declared-graph.ts +99 -0
  549. package/src/cli/handlers/graph.test.ts +94 -3
  550. package/src/cli/handlers/graph.ts +123 -96
  551. package/src/cli/handlers/ledger.ts +54 -0
  552. package/src/cli/handlers/migrate.ts +21 -12
  553. package/src/cli/handlers/misc.ts +10 -1
  554. package/src/cli/handlers/pr-env.ts +3 -4
  555. package/src/cli/handlers/pr.ts +138 -36
  556. package/src/cli/handlers/query.ts +16 -4
  557. package/src/cli/handlers/release.ts +6 -3
  558. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  559. package/src/cli/handlers/search-count.test.ts +191 -0
  560. package/src/cli/handlers/search-count.ts +165 -0
  561. package/src/cli/handlers/search-json.test.ts +333 -0
  562. package/src/cli/handlers/search-live.test.ts +387 -0
  563. package/src/cli/handlers/search-paths.test.ts +235 -0
  564. package/src/cli/handlers/search.ts +1291 -166
  565. package/src/cli/main.test.ts +43 -3
  566. package/src/cli/main.ts +88 -17
  567. package/src/cli/mcp/server.test.ts +3 -3
  568. package/src/cli/mcp/server.ts +3 -0
  569. package/src/cli/mcp/tools/capabilities.ts +32 -0
  570. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  571. package/src/cli/mcp/workspace-tools.ts +47 -2
  572. package/src/cli/parse-args.ts +79 -15
  573. package/src/cli/registry.ts +36 -4
  574. package/src/components/builders.ts +8 -0
  575. package/src/components/capability-plugin-loader.ts +13 -1
  576. package/src/components/capability-plugin.ts +16 -0
  577. package/src/components/pr-env-ports.ts +89 -0
  578. package/src/components/pr-env-run-bundle.test.ts +54 -0
  579. package/src/components/pr-env-run.ts +5 -76
  580. package/src/components/pr-environment.ts +2 -1
  581. package/src/components/pr-hooks.test.ts +73 -5
  582. package/src/components/pr-hooks.ts +95 -5
  583. package/src/components/pr-pipeline.ts +64 -16
  584. package/src/components/pr-policy.test.ts +64 -1
  585. package/src/components/pr-policy.ts +53 -0
  586. package/src/components/registry.test.ts +1 -1
  587. package/src/components/starter-plugin.ts +6 -0
  588. package/src/components/verbs/index.ts +2 -0
  589. package/src/components/verbs/publish-artifact.test.ts +60 -0
  590. package/src/components/verbs/publish-artifact.ts +122 -0
  591. package/src/components/verbs/publish-image.test.ts +101 -0
  592. package/src/components/verbs/publish-image.ts +265 -0
  593. package/src/config-environments.ts +160 -0
  594. package/src/config.ts +44 -124
  595. package/src/gate-decision-bundle.test.ts +1 -0
  596. package/src/gate-decision-identity.test.ts +9 -0
  597. package/src/gate-decision.ts +26 -3
  598. package/src/graph-detail.ts +15 -3
  599. package/src/graph-effective.ts +226 -75
  600. package/src/graph-ir.test.ts +163 -0
  601. package/src/graph-ir.ts +174 -0
  602. package/src/graph-sub-template.ts +22 -0
  603. package/src/graph-walk.test.ts +44 -0
  604. package/src/graph-walk.ts +102 -0
  605. package/src/import/parser.ts +20 -0
  606. package/src/lexicon.ts +84 -6
  607. package/src/lifecycle/gate-ledger.ts +8 -0
  608. package/src/lifecycle/git.ts +2 -2
  609. package/src/lifecycle/ledger-read.test.ts +107 -0
  610. package/src/lifecycle/ledger-read.ts +195 -0
  611. package/src/lifecycle/observe.test.ts +27 -1
  612. package/src/lifecycle/observe.ts +43 -1
  613. package/src/lifecycle/release-workstation.ts +24 -0
  614. package/src/lifecycle/run-ledger.ts +5 -2
  615. package/src/lint/cross-root.test.ts +122 -0
  616. package/src/lint/discover.ts +3 -1
  617. package/src/lint/post-synth.ts +212 -2
  618. package/src/lint/rule.ts +6 -0
  619. package/src/lint/rules/op/index.ts +2 -0
  620. package/src/lint/rules/op/ops019-workspace-op-target.ts +89 -0
  621. package/src/live-endpoint.ts +1 -1
  622. package/src/observation.test.ts +25 -0
  623. package/src/observation.ts +69 -5
  624. package/src/op/activities/activity-contracts.ts +5 -2
  625. package/src/op/activities/index.ts +2 -2
  626. package/src/op/activities/review-agent.test.ts +221 -4
  627. package/src/op/activities/review-agent.ts +265 -22
  628. package/src/op/activities/work-evidence.ts +9 -1
  629. package/src/op/builders.ts +9 -5
  630. package/src/op/composites/apply-op.test.ts +108 -0
  631. package/src/op/composites/apply-op.ts +37 -2
  632. package/src/op/gate-approval.test.ts +6 -1
  633. package/src/op/gate-approval.ts +12 -6
  634. package/src/op/gate-quorum-at-base.test.ts +211 -0
  635. package/src/op/gate-quorum.ts +156 -8
  636. package/src/op/gate-review.ts +14 -0
  637. package/src/op/gate.ts +67 -8
  638. package/src/op/index.ts +3 -2
  639. package/src/op/local-executor.ts +69 -9
  640. package/src/op/op-waves-depends-on.test.ts +81 -0
  641. package/src/op/op-waves-hook-targets.test.ts +4 -2
  642. package/src/op/op-waves-quorum-product.test.ts +158 -0
  643. package/src/op/op-waves-run.ts +288 -14
  644. package/src/op/op-waves.ts +143 -14
  645. package/src/op/runtime.ts +8 -0
  646. package/src/op/schedule-overdue.ts +33 -0
  647. package/src/op/stage-hooks.ts +13 -5
  648. package/src/op/workspace-run-env.ts +29 -0
  649. package/src/pr-forge-bundle.test.ts +2 -2
  650. package/src/pr-forge.test.ts +47 -0
  651. package/src/pr-forge.ts +68 -0
  652. package/src/pr-post-open.e2e.test.ts +36 -0
  653. package/src/pr-post.test.ts +102 -3
  654. package/src/pr-post.ts +89 -1
  655. package/src/read-filter.test.ts +54 -0
  656. package/src/read-filter.ts +193 -0
  657. package/src/run-event.ts +1 -1
  658. package/src/stage-hooks-bundle.test.ts +1 -0
  659. package/src/terraform/carve.ts +52 -15
  660. package/src/terraform/cuts.test.ts +173 -0
  661. package/src/terraform/cuts.ts +188 -0
  662. package/src/terraform/folds.ts +44 -0
  663. package/src/terraform/graph.ts +9 -0
  664. package/src/terraform/radius.test.ts +90 -0
  665. package/src/terraform/reach.test.ts +146 -0
  666. package/src/terraform/reach.ts +247 -0
  667. package/src/terraform/score.ts +27 -29
  668. package/src/terraform/state.ts +3 -1
  669. package/src/terraform/types.ts +8 -0
  670. package/src/wave-blocked.ts +28 -3
  671. package/src/wave-loop-golden.test.ts +4 -3
  672. package/src/workspace/behold-kinds.test.ts +1 -1
  673. package/src/workspace/capabilities-contract.test.ts +88 -0
  674. package/src/workspace/capabilities-contract.ts +132 -0
  675. package/src/workspace/capabilities.schema.json +229 -0
  676. package/src/workspace/check-contract.test.ts +7 -6
  677. package/src/workspace/check.schema.json +11 -2
  678. package/src/workspace/checks/ci-branch.test.ts +128 -0
  679. package/src/workspace/checks/ci-branch.ts +141 -0
  680. package/src/workspace/checks/links.ts +12 -1
  681. package/src/workspace/checks/org.ts +169 -0
  682. package/src/workspace/checks.ts +64 -7
  683. package/src/workspace/ci-branches.ts +31 -0
  684. package/src/workspace/ci-green-branches.test.ts +153 -0
  685. package/src/workspace/ci-green-cli.ts +7 -7
  686. package/src/workspace/ci-green-workflow.test.ts +4 -3
  687. package/src/workspace/ci-green-workflow.ts +13 -9
  688. package/src/workspace/ci-green.ts +49 -12
  689. package/src/workspace/ci-run.test.ts +72 -1
  690. package/src/workspace/ci-run.ts +68 -8
  691. package/src/workspace/ci-serve.test.ts +95 -0
  692. package/src/workspace/ci-serve.ts +106 -65
  693. package/src/workspace/component-environments.test.ts +25 -2
  694. package/src/workspace/compose-graph.ts +19 -3
  695. package/src/workspace/composites.schema.json +22 -0
  696. package/src/workspace/composites.test.ts +39 -8
  697. package/src/workspace/composites.ts +6 -2
  698. package/src/workspace/conformance/__writer_fixture__/work/work.schema.json +105 -1
  699. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  700. package/src/workspace/conformance/index.ts +31 -2
  701. package/src/workspace/cross-root-lint.test.ts +90 -0
  702. package/src/workspace/cross-root-lint.ts +126 -0
  703. package/src/workspace/dbt-kind.test.ts +3 -2
  704. package/src/workspace/dbt-models-reader.ts +362 -0
  705. package/src/workspace/dbt-models.test.ts +248 -0
  706. package/src/workspace/dbt-project-common.ts +113 -0
  707. package/src/workspace/dbt-project-reader.ts +29 -112
  708. package/src/workspace/decision-rank.ts +40 -0
  709. package/src/workspace/declaration.schema.json +133 -24
  710. package/src/workspace/declaration.ts +84 -15
  711. package/src/workspace/environments.ts +40 -4
  712. package/src/workspace/flyway-dir.ts +113 -0
  713. package/src/workspace/flyway-layout.ts +337 -0
  714. package/src/workspace/graph-cache.ts +27 -3
  715. package/src/workspace/graph-cli.ts +119 -32
  716. package/src/workspace/graph-contract.test.ts +4 -2
  717. package/src/workspace/graph.schema.json +182 -0
  718. package/src/workspace/identity.test.ts +14 -2
  719. package/src/workspace/intent.ts +6 -14
  720. package/src/workspace/kind-link-readers.ts +68 -3
  721. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  722. package/src/workspace/kind-readers.test.ts +1 -1
  723. package/src/workspace/kind-readers.ts +7 -1
  724. package/src/workspace/kinds.test.ts +12 -7
  725. package/src/workspace/kinds.ts +132 -7
  726. package/src/workspace/ledger.schema.json +158 -0
  727. package/src/workspace/lexicon-outputs.ts +132 -13
  728. package/src/workspace/lineage-check.ts +30 -7
  729. package/src/workspace/links.ts +24 -1
  730. package/src/workspace/ls-contract.test.ts +1 -1
  731. package/src/workspace/ls.schema.json +7 -0
  732. package/src/workspace/ls.ts +10 -1
  733. package/src/workspace/member-commands.ts +82 -3
  734. package/src/workspace/member-file-schema.test.ts +124 -0
  735. package/src/workspace/migrate-links.test.ts +99 -0
  736. package/src/workspace/migrate-links.ts +108 -0
  737. package/src/workspace/migration-dirs.test.ts +272 -0
  738. package/src/workspace/migration-dirs.ts +267 -0
  739. package/src/workspace/nested-graph.ts +32 -1
  740. package/src/workspace/org-scope.test.ts +238 -0
  741. package/src/workspace/reason-codes.test.ts +13 -1
  742. package/src/workspace/reason-codes.ts +23 -0
  743. package/src/workspace/records-cli.ts +58 -5
  744. package/src/workspace/records.schema.json +5 -0
  745. package/src/workspace/release-candidates.ts +7 -2
  746. package/src/workspace/release-train.ts +157 -0
  747. package/src/workspace/release-workflow.ts +4 -3
  748. package/src/workspace/repo-ref.ts +113 -0
  749. package/src/workspace/run-event.schema.json +2 -2
  750. package/src/workspace/runs-cli.ts +48 -4
  751. package/src/workspace/runs.schema.json +7 -0
  752. package/src/workspace/search-cli.e2e.test.ts +305 -0
  753. package/src/workspace/search-cli.ts +591 -0
  754. package/src/workspace/search-contract.ts +95 -0
  755. package/src/workspace/search.schema.json +822 -0
  756. package/src/workspace/status-contract.test.ts +46 -1
  757. package/src/workspace/status-decisions.test.ts +100 -0
  758. package/src/workspace/status-decisions.ts +151 -0
  759. package/src/workspace/status-gates.ts +9 -0
  760. package/src/workspace/status.schema.json +6 -0
  761. package/src/workspace/status.ts +17 -3
  762. package/src/workspace/where-filters.test.ts +39 -0
  763. package/src/workspace/work-cli.ts +5 -3
  764. package/src/workspace/work-evidence-ops.test.ts +176 -0
  765. package/src/workspace/work-evidence-ops.ts +189 -0
  766. package/src/workspace/work-evidence.schema.json +49 -2
  767. package/src/workspace/work-evidence.ts +22 -3
  768. package/src/workspace/workspace-gates.ts +200 -0
  769. package/src/workspace/workspace-kinds.schema.json +58 -3
  770. package/src/workspace/workspace-ledger.ts +130 -0
  771. package/src/workspace/workspace-run.test.ts +322 -0
  772. package/src/workspace/workspace-run.ts +461 -0
@@ -2,21 +2,29 @@ import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
3
  import { buildGraphIr, buildLiveGraphIr, collectUnobserved, sourceOverlayGraphs, type GraphIR, type IRNode, type IREdge } from "../../graph-ir";
4
4
  import { buildDeclaredPerStack } from "../../graph-declared";
5
- import { enrichEffectiveTopology } from "../../graph-effective";
5
+ import { enrichEffectiveTopology, terraformResourceType, terraformType } from "../../graph-effective";
6
+ import { attachCapabilities, attachView, capabilityEngine, parseCapabilityCall } from "../../capabilities/catalog";
7
+ import { CapabilityError, type CapabilityEngine, type Evidence } from "../../capabilities/matcher";
8
+ import type { CapabilityPack, View } from "../../capabilities/view";
6
9
  import { reconstructEdges, mergeCatalogs, type ReferenceCatalog } from "../../graph-refs";
7
10
  import { discover } from "../../discovery/index";
8
11
 
9
- import { observeResources } from "../../lifecycle/observe";
12
+ import { carryAnnotations, observeResources } from "../../lifecycle/observe";
10
13
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
11
14
  import { diffLive, type LiveDiffResult } from "../../lifecycle/live-diff";
12
15
  import type { LiveObservation } from "../../graph-ir";
13
- import type { ResourceMetadata } from "../../lexicon";
16
+ import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
14
17
  import type { ObservationDepth } from "../../lifecycle/types";
15
- import { formatUnobserved } from "../../observation";
18
+ import { formatUnobserved, type RootRead } from "../../observation";
19
+ import { readChantPackageVersion } from "../../package-version";
16
20
  import { loadChantConfig, matchesDeclaredEnvironment } from "../../config";
17
- import { loadPlugins, resolveProjectLexicons } from "../plugins";
21
+ import { collectBuildRootContributors, loadPlugins, resolveProjectLexicons } from "../plugins";
22
+ import { declaredBuildRootContributors, mergeDeclaredBuildRoots, withLexiconEdges } from "./declared-graph";
23
+ import { parseCountBy, countGroups, countsJson, formatCounts, groupMembers, hasManyTerraformRoots, type CountBy } from "./search-count";
18
24
  import { formatError, formatWarning } from "../format";
25
+ import { adjacencyOf, walkBreadthFirst } from "../../graph-walk";
19
26
  import type { CommandContext } from "../registry";
27
+ import { SEARCH_CONTRACT_VERSION, SEARCH_OUTPUT_SCHEMA_ID, type SearchErrorCode, type SearchFailure, type SearchHead } from "../../workspace/search-contract";
20
28
 
21
29
  /**
22
30
  * `chant search <query> [--live --env <name>]` — answer an estate question with
@@ -26,11 +34,14 @@ import type { CommandContext } from "../registry";
26
34
  *
27
35
  * Query grammar (space-separated terms, all must match — AND):
28
36
  * bare word case-insensitive substring over id, kind, and attrs
29
- * kind:<substr> node kind contains <substr> (e.g. kind:EC2::Instance)
37
+ * kind:<substr> node kind contains <substr> (e.g. kind:EC2::Instance);
38
+ * on a terraform resource, its type (kind:aws_instance, kind:aws_iam_)
30
39
  * tag:<key>=<val> a Tags entry with Key=key and Value containing val
31
40
  * attr:<name>=<val> attribute <name> equals/contains <val>
32
41
  * ->kind:X / ->attr:.. this node has an edge TO a node matching the right side
33
42
  * <-kind:X / <-attr:.. this node has an edge FROM a node matching the right side
43
+ * ->*X, ->{n}X, ->{m,n}X the far end is any depth / n / m..n hops away (#4359)
44
+ * ->[via=a]X, ->[to=a]X only edges through consumer attr a / reading producer attr a
34
45
  *
35
46
  * The edge operators are the point of "edge-aware" search (#1139): a small
36
47
  * model shouldn't hand-join instance→subnet→public across many results — one
@@ -44,47 +55,34 @@ import type { CommandContext } from "../registry";
44
55
  export async function runSearch(ctx: CommandContext): Promise<number> {
45
56
  const { args } = ctx;
46
57
  const query = (args.path ?? "").trim();
47
- if (!query) {
48
- console.error(formatError({ message: "chant search needs a query: chant search \"<terms>\" [--live --env <name>]" }));
58
+ // A refusal (#4367): the error on stderr as always, and under --json the
59
+ // same message and hint as the one document on stdout, so a program reading
60
+ // stdout sees why there are no rows. Exit 1 either way. The code is from the
61
+ // read contract's closed list (#4374).
62
+ const refuse = (error: { code: SearchErrorCode; message: string; hint?: string }): number => {
63
+ console.error(formatError(error));
64
+ if (args.json) console.log(JSON.stringify(searchErrorJson(query, error)));
49
65
  return 1;
66
+ };
67
+ // The capability catalog (#4375): the `capabilities` document, not an answer.
68
+ if (args.capabilities) return printCapabilities(args, query, await resolveProjectLexicons(resolve(".")).catch(() => []));
69
+ // The snapshot to replay (#4374): `--snapshot <ref>`, or `--at <ref>`, which
70
+ // plain search keeps for compatibility. Everywhere else in the read contract
71
+ // `--at` is a git revision, so the workspace form takes only `--snapshot`.
72
+ if (args.snapshot !== undefined && args.at !== undefined && String(args.snapshot) !== String(args.at)) {
73
+ return refuse({
74
+ code: "usage-invalid",
75
+ message: `chant search was given --snapshot ${args.snapshot} and --at ${args.at}`,
76
+ hint: "they name the same thing, the recorded snapshot to answer from; give one",
77
+ });
50
78
  }
51
- let terms: Term[];
52
- try {
53
- terms = parseQuery(query);
54
- } catch (err) {
55
- if (err instanceof QueryError) {
56
- console.error(formatError({ message: err.message, hint: err.hint }));
57
- return 1;
58
- }
59
- throw err;
60
- }
79
+ const snapshotRef: string | undefined = args.snapshot ?? (args.at !== undefined ? String(args.at) : undefined);
80
+ const snapshotFlag = args.snapshot !== undefined ? "--snapshot" : "--at";
81
+ const checked = checkSearchArgs(args, query, { ref: snapshotRef, flag: snapshotFlag });
82
+ if ("error" in checked) return refuse(checked.error);
83
+ const { terms, countBy } = checked;
61
84
  const show = parseShow(args);
62
85
 
63
- // Query-scoped drift (#1268): --check-live checks a snapshot answer against
64
- // a live read, --check-snapshot checks a live answer against the recorded
65
- // snapshot — the direction each needs is the source it does NOT already have.
66
- if (args.checkLive && !args.at) {
67
- console.error(formatError({
68
- message: "chant search --check-live needs --at <ref>",
69
- hint: "answer from a snapshot and check the matched rows against a live read: --at latest --check-live --env <name>",
70
- }));
71
- return 1;
72
- }
73
- if (args.checkSnapshot && !args.live) {
74
- console.error(formatError({
75
- message: "chant search --check-snapshot needs --live",
76
- hint: "answer live and check the matched rows against the recorded snapshot: --live --check-snapshot --env <name>",
77
- }));
78
- return 1;
79
- }
80
- if (args.failOnDrift && !args.checkLive && !args.checkSnapshot) {
81
- console.error(formatError({
82
- message: "chant search --fail-on-drift needs --check-live or --check-snapshot",
83
- hint: "it fails the check those flags run, so it is meaningless without one",
84
- }));
85
- return 1;
86
- }
87
-
88
86
  const projectPath = resolve(".");
89
87
  const { config } = await loadChantConfig(projectPath);
90
88
 
@@ -97,6 +95,9 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
97
95
  // on this path" — printed with the provenance footer, after the rows, so the
98
96
  // answer is not preceded by what qualifies it.
99
97
  let liveNotes: string[] = [];
98
+ // Which read answered each entity (#2267), from a live read.
99
+ let readSources: Record<string, string> = {};
100
+ let rootReads: Record<string, RootRead> = {};
100
101
  // Kinds that can exist in the account without being declared (#1278). Known
101
102
  // without a scan, so it costs nothing to mention.
102
103
  let ambientKinds: string[] = [];
@@ -110,53 +111,46 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
110
111
  let declaredForDrift: GraphIR | undefined;
111
112
  let snapshotDepth: ObservationDepth | undefined;
112
113
  let driftFailure = false;
113
- if (args.live || args.at) {
114
- const environment = args.env;
115
- if (!environment) {
116
- const flag = args.at ? "--at" : "--live";
117
- console.error(formatError({ message: `chant search ${flag} needs an environment: ${flag} --env <name>` }));
118
- return 1;
119
- }
120
- if (args.live && args.at) {
121
- // Two different observations of the same estate, and no rule for which
122
- // wins. Comparing them is a real question (#1268) but it is not this one.
123
- console.error(formatError({
124
- message: "chant search takes --live or --at, not both",
125
- hint: "--live reads the estate now; --at answers from a recorded snapshot",
126
- }));
127
- return 1;
128
- }
114
+ // Why --check-snapshot had nothing to check against, for --json (#4367).
115
+ let driftUnavailable: string | undefined;
116
+ if (args.live || snapshotRef) {
117
+ // checkSearchArgs refused a live or snapshot read without one.
118
+ const environment = String(args.env);
129
119
  // Membership via matchesDeclaredEnvironment (#1221): handles `{ name,
130
120
  // endpoint }` entries (#1166) — a plain `.includes` never matched those —
131
121
  // and glob-pattern entries like `"pr-*"`.
132
122
  if (config.environments && config.environments.length > 0 && !matchesDeclaredEnvironment(config.environments, environment)) {
133
- console.error(formatError({ message: `Unknown environment "${environment}"` }));
134
- return 1;
123
+ return refuse({ code: "environment-unknown", message: `Unknown environment "${environment}"` });
135
124
  }
136
125
  const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
126
+ // Build roots too (#4358), as `chant graph --live` does: a terraform
127
+ // root's blocks are entities only once its root is rendered, and an
128
+ // entity the build never produced is never observed.
129
+ const buildRoots = collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, projectPath);
137
130
  const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer), undefined, {
131
+ buildRoots,
138
132
  config: config as unknown as Record<string, unknown>,
139
133
  });
140
134
  if (buildResult.errors.length > 0) {
141
- console.error(formatError({ message: "Build failed — fix errors before searching live state" }));
142
- return 1;
135
+ return refuse({ code: "build-failed", message: "Build failed — fix errors before searching live state" });
143
136
  }
144
137
  const observing = plugins.filter((p) => p.describeResources);
145
138
  ambientKinds = observing.flatMap((p) => p.ambientKinds?.() ?? []);
146
139
  const stacks = (config.stacks ?? []).map((s) => ({ name: s.name, region: s.region, src: s.src }));
147
140
 
148
141
  let observations: LiveObservation[];
142
+ // Facts about absent entities (#4372): a planned create's plan attributes.
143
+ let annotations: Record<string, Record<string, unknown>> | undefined;
149
144
  const liveAttrs: Record<string, Record<string, unknown>> = {};
150
- if (args.at) {
145
+ if (snapshotRef) {
151
146
  // Answer from a recorded observation (#1266). Everything downstream is
152
147
  // the live path's — the point is that a snapshot replays into the same
153
148
  // shape a live read produces, so one pipeline serves both and a fold
154
149
  // improvement reaches an old snapshot for free.
155
150
  const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
156
- const replay = await replaySnapshots(environment, String(args.at), scoped);
151
+ const replay = await replaySnapshots(environment, snapshotRef, scoped);
157
152
  if ("error" in replay) {
158
- console.error(formatError({ message: replay.error, ...(replay.hint ? { hint: replay.hint } : {}) }));
159
- return 1;
153
+ return refuse({ code: "snapshot-unavailable", message: replay.error, ...(replay.hint ? { hint: replay.hint } : {}) });
160
154
  }
161
155
  observations = replay.observations;
162
156
  replayAmbient = {
@@ -164,7 +158,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
164
158
  Object.values(o.resources).some((m) => m.ambient === true),
165
159
  ),
166
160
  };
167
- source = { kind: "snapshot", commit: replay.commit, timestamp: replay.timestamp };
161
+ source = { kind: "snapshot", snapshot: snapshotRef, commit: replay.commit, timestamp: replay.timestamp };
168
162
  snapshotDepth = replay.depth;
169
163
  if (args.checkLive) {
170
164
  // A fresh live read, additional to the snapshot the answer itself came
@@ -194,6 +188,9 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
194
188
  console.error(formatError({ message: `live read failed — ${e}` }));
195
189
  }
196
190
  observations = observed.observations;
191
+ annotations = observed.annotations;
192
+ readSources = observed.sources ?? {};
193
+ rootReads = observed.rootReads ?? {};
197
194
  liveNotes = observed.notes ?? [];
198
195
  source = { kind: "live" };
199
196
  if (args.checkSnapshot) {
@@ -205,6 +202,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
205
202
  const checkReplay = await replaySnapshots(environment, "latest", scoped);
206
203
  if ("error" in checkReplay) {
207
204
  console.error(formatWarning({ message: `--check-snapshot: ${checkReplay.error}` }));
205
+ driftUnavailable = checkReplay.error;
208
206
  } else {
209
207
  checkObservations = checkReplay.observations;
210
208
  snapshotDepth = checkReplay.depth;
@@ -217,7 +215,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
217
215
  let containmentEdges: IREdge[] = [];
218
216
  // Live-only: enrichment is a provider call, so it has no place in a replay.
219
217
  // A recorded answer that quietly reached for the API would stop being one.
220
- if (!args.at) {
218
+ if (!snapshotRef) {
221
219
  for (const p of observing) {
222
220
  if (!p.enrichLiveAttrs) continue;
223
221
  try {
@@ -271,10 +269,16 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
271
269
  // `${stack}::${id}` — matching how observation qualifies. A flat whole-
272
270
  // project discovery would disambiguate colliding names by module path
273
271
  // (UsEast1Src…), which never matches the observed bare LogicalResourceIds.
272
+ //
273
+ // The flat canvas is the declared path's graph (#4365, #4358): discovery
274
+ // plus the build-root contributions (a terraform root's blocks, a rendered
275
+ // kustomize root) plus the lexicon edges, so a live answer over a Terraform
276
+ // root overlays onto a canvas that has the root's resources on it.
274
277
  const declared =
275
278
  stacks.length > 0
276
- ? await buildDeclaredPerStack(stacks, projectPath)
277
- : buildGraphIr((await discover(resolve(args.src ?? config.sourceDir ?? "."))).entities, projectPath);
279
+ ? await buildDeclaredPerStack(stacks, projectPath, (g, entities) => withLexiconEdges(g, plugins, entities))
280
+ : await declaredGraph(ctx, projectPath, resolve(args.src ?? config.sourceDir ?? "."), plugins);
281
+ if (!declared) return refuse(BUILD_ROOT_FAILED);
278
282
  declaredForDrift = declared;
279
283
  // Carry the NOT-OBSERVED half of the tri-state (#1089) onto the rows, so a
280
284
  // declared entity nobody could read is painted `_unobserved` and a row can
@@ -315,28 +319,109 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
315
319
  // account's default VPC, whose route table chant does not model). The
316
320
  // overlay copies physical identity but not attrs, so merge them here.
317
321
  ir = { ...ir, nodes: ir.nodes.map((n) => (liveAttrs[n.id] ? { ...n, attrs: { ...n.attrs, ...liveAttrs[n.id] } } : n)) };
322
+ // And what the read learned about an entity it found absent (#4372), so
323
+ // `attr:planAction=create` matches a block the plan will create. The node
324
+ // stays pending: only its attributes change.
325
+ ir = { ...ir, nodes: carryAnnotations(ir.nodes, annotations) };
318
326
  } else {
319
- const discovered = await discover(resolve(args.src ?? config.sourceDir ?? "."));
320
- ir = buildGraphIr(discovered.entities);
327
+ const declared = await declaredGraph(ctx, projectPath, resolve(args.src ?? config.sourceDir ?? "."));
328
+ if (!declared) return refuse(BUILD_ROOT_FAILED);
329
+ ir = declared;
321
330
  }
322
331
 
323
332
  // Fold derived reachability facts (effectiveIngress, internetFacing) onto
324
333
  // instance nodes so multi-hop/launch-template joins are one node predicate (#1139).
325
334
  ir = enrichEffectiveTopology(ir);
335
+ // Capability terms (#4375) read the graph through the lexicons' mappings.
336
+ if (terms.some(usesCapability)) {
337
+ const refused = await bindCapabilities(ctx, projectPath, ir, terms);
338
+ if (refused) return refuse({ code: "query-invalid", ...refused });
339
+ }
326
340
  // Resolve `kind:` terms against what is actually in this graph, before any
327
341
  // node is tested against them.
328
342
  resolveKindTerms(terms, ir.nodes);
329
343
  const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
330
344
  const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, nodeById)));
345
+ // Query-scoped drift (#1268): the matched rows, and only the matched rows,
346
+ // checked against the observation the primary answer did NOT use. Scoping
347
+ // both sides of diffLive() to the matched ids is what keeps a query-scoped-
348
+ // out resource from reading as `missing` or `unobserved` — it was never
349
+ // asked about, which is a third thing from both.
350
+ let drift: LiveDiffResult | undefined;
351
+ if (checkObservations && declaredForDrift && primaryObservations) {
352
+ const declaredIds = new Set(declaredForDrift.nodes.map((n) => n.id));
353
+ const matchedIds = new Set(matches.map((n) => n.id));
354
+ const nowObservations = args.checkLive ? checkObservations : primaryObservations;
355
+ const thenObservations = args.checkLive ? primaryObservations : checkObservations;
356
+ drift = diffLive({
357
+ declared: new Set([...matchedIds].filter((id) => declaredIds.has(id))),
358
+ observedNow: pickResources(mergeResources(nowObservations), matchedIds),
359
+ observedThen: pickResources(mergeResources(thenObservations), matchedIds),
360
+ unobserved: pickResources(collectUnobserved(nowObservations), matchedIds),
361
+ });
362
+ }
363
+ // Name the plan read when it answered (#4384).
364
+ if (source.kind === "live") source = withReadDepth(source, matches, readSources, rootReads);
365
+ const driftAgainst: "live" | "snapshot" = args.checkLive ? "live" : "snapshot";
366
+ // Machine-readable answer (#4359, #4367): the rows, their paths, and every
367
+ // footer the text prints as prose, as one document for a caller that draws
368
+ // it (behold#913). Same exit codes as the text path.
369
+ if (args.json) {
370
+ const recorded =
371
+ source.kind === "live" && !matches.some((n) => n.physicalId) && args.env
372
+ ? (await hasSnapshot(String(args.env))) ? "yes" : undefined
373
+ : undefined;
374
+ const env = args.env && source.kind !== "declared" ? String(args.env) : undefined;
375
+ const provenanceText = provenanceLine(matches, source, recorded, liveFailures, [...derivedUsed(terms, matches, ir), ...capabilitiesUsed(terms)]).replace(/^— /, "");
376
+ const capNotes = capabilityNotes(terms, ir, source);
377
+ const plannedCreates = source.kind !== "declared" && liveFailures.length === 0 ? plannedCreateCount(matches) : 0;
378
+ const driftJson = drift
379
+ ? searchDriftJson(drift, matches.length, driftAgainst, snapshotDepth)
380
+ : driftUnavailable !== undefined
381
+ ? { against: driftAgainst, unavailable: driftUnavailable }
382
+ : undefined;
383
+ // With --count-by (#4360) the object carries the groups and the total in
384
+ // place of the matches, the same answer the grouped lines give, with the
385
+ // provenance line beside it (#4367).
386
+ const head = { ...searchHead(), ...(await workspaceIdentity(projectPath)) };
387
+ const payload = countBy
388
+ ? {
389
+ ...head,
390
+ ...countsJson(query, env ? { ...source, env } : source, groupMembers(matches, countBy, hasManyTerraformRoots(ir)), countBy, liveFailures),
391
+ provenance: provenanceText,
392
+ ...(plannedCreates > 0 ? { plannedCreates } : {}),
393
+ ...(driftJson ? { drift: driftJson } : {}),
394
+ }
395
+ : {
396
+ ...head,
397
+ ...searchJson(query, source, matches, terms, ir, nodeById, liveFailures, {
398
+ ...(env ? { env } : {}),
399
+ show,
400
+ provenance: provenanceText,
401
+ ...(plannedCreates > 0 ? { plannedCreates } : {}),
402
+ ...(capNotes.length > 0 ? { capabilities: capNotes } : {}),
403
+ ...(args.explain ? { explain: explainData(terms, matches, ir, nodeById) } : {}),
404
+ ...(driftJson ? { drift: driftJson } : {}),
405
+ }),
406
+ };
407
+ console.log(JSON.stringify(payload));
408
+ for (const n of liveNotes) console.error(formatWarning({ message: n }));
409
+ if (liveFailures.length > 0) return 1;
410
+ return args.failOnDrift && drift && driftCountOf(drift) > 0 ? 1 : 0;
411
+ }
331
412
  if (matches.length === 0) {
332
- console.log("(no matches)");
413
+ console.log(countBy ? "(no matches) · 0 total" : "(no matches)");
333
414
  availableAttrs(terms, ir);
334
415
  if (args.explain) explain(terms, matches, ir, nodeById, query);
335
416
  for (const n of liveNotes) console.error(formatWarning({ message: n }));
336
417
  return 0;
337
418
  }
338
- for (const n of matches) {
339
- console.log(formatRow(n, show));
419
+ if (countBy) {
420
+ for (const line of formatCounts(countGroups(matches, countBy, hasManyTerraformRoots(ir)), countBy)) console.log(line);
421
+ } else {
422
+ for (const n of matches) {
423
+ console.log(formatRow(n, show));
424
+ }
340
425
  }
341
426
  const backed = source.kind === "declared" || matches.some((n) => n.physicalId);
342
427
  // Only worth asking when the live read came back empty — that is the one case
@@ -345,36 +430,23 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
345
430
  source.kind === "live" && !matches.some((n) => n.physicalId) && args.env
346
431
  ? (await hasSnapshot(String(args.env))) ? "yes" : undefined
347
432
  : undefined;
348
- provenance(matches, source, recorded, liveFailures);
433
+ provenance(matches, source, recorded, liveFailures, [...derivedUsed(terms, matches, ir), ...capabilitiesUsed(terms)]);
434
+ for (const c of capabilityNotes(terms, ir, source)) {
435
+ if (c.notEvaluated.length > 0) console.log(`— ${c.name} does not evaluate: ${c.notEvaluated.join("; ")}`);
436
+ }
349
437
  // Qualifies the provenance line, so it sits with it: one line per distinct
350
438
  // note for the whole run, not one per stack, and after the rows (#1265).
351
439
  for (const n of liveNotes) console.error(formatWarning({ message: n }));
352
- // Query-scoped drift (#1268): the matched rows, and only the matched rows,
353
- // checked against the observation the primary answer did NOT use. Scoping
354
- // both sides of diffLive() to the matched ids is what keeps a query-scoped-
355
- // out resource from reading as `missing` or `unobserved` — it was never
356
- // asked about, which is a third thing from both.
357
- if (checkObservations && declaredForDrift && primaryObservations) {
358
- const declaredIds = new Set(declaredForDrift.nodes.map((n) => n.id));
359
- const matchedIds = new Set(matches.map((n) => n.id));
360
- const nowObservations = args.checkLive ? checkObservations : primaryObservations;
361
- const thenObservations = args.checkLive ? primaryObservations : checkObservations;
362
- const driftCount = renderDrift(
363
- diffLive({
364
- declared: new Set([...matchedIds].filter((id) => declaredIds.has(id))),
365
- observedNow: pickResources(mergeResources(nowObservations), matchedIds),
366
- observedThen: pickResources(mergeResources(thenObservations), matchedIds),
367
- unobserved: pickResources(collectUnobserved(nowObservations), matchedIds),
368
- }),
369
- matches.length,
370
- args.checkLive ? "live" : "snapshot",
371
- snapshotDepth,
372
- );
440
+ if (drift) {
441
+ const driftCount = renderDrift(drift, matches.length, driftAgainst, snapshotDepth);
373
442
  if (args.failOnDrift && driftCount > 0) driftFailure = true;
374
443
  }
375
444
  ambientHint(matches, ambientKinds, args.ambient === true, replayAmbient);
376
- showMiss(matches, show);
377
- regionSpread(terms, matches, show);
445
+ // Both point at `--show`, which a grouped count does not print (#4360).
446
+ if (!countBy) {
447
+ showMiss(matches, show);
448
+ regionSpread(terms, matches, show);
449
+ }
378
450
  derivedSurface(terms, matches, ir, backed);
379
451
  if (args.explain) explain(terms, matches, ir, nodeById, query);
380
452
  // The caller asked for a live answer and at least one lexicon could not be
@@ -395,11 +467,359 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
395
467
  }
396
468
 
397
469
 
470
+ /** A search's arguments refused before anything is read, or its parsed terms and grouping. */
471
+ export type SearchArgsCheck =
472
+ | { error: { code: SearchErrorCode; message: string; hint?: string } }
473
+ | { terms: Term[]; countBy?: CountBy };
474
+
475
+ /**
476
+ * Check a search's query and flags before anything is read (#4374): the
477
+ * grammar, `--count-by`, and the flags that need or exclude each other. The
478
+ * rules `chant search` and `chant workspace search` share, so the workspace
479
+ * form refuses once, with the same code and message, instead of once per
480
+ * member. `snapshot` is the snapshot asked for and the flag that asked:
481
+ * `--snapshot`, or plain search's `--at`.
482
+ */
483
+ export function checkSearchArgs(
484
+ args: { countBy?: string; checkLive?: boolean; checkSnapshot?: boolean; failOnDrift?: boolean; live?: boolean; env?: string },
485
+ query: string,
486
+ snapshot: { ref?: string; flag: string },
487
+ ): SearchArgsCheck {
488
+ const flag = snapshot.flag;
489
+ // `--count-by` groups the matched set (#4360). It takes any query, and no
490
+ // query at all counts the whole graph: "which kinds does this estate hold".
491
+ let countBy: CountBy | undefined;
492
+ if (args.countBy !== undefined) {
493
+ const parsed = parseCountBy(args.countBy);
494
+ if ("error" in parsed) return { error: { code: "query-invalid", message: parsed.error, hint: parsed.hint } };
495
+ countBy = parsed;
496
+ }
497
+ if (!query && !countBy) {
498
+ return { error: { code: "query-invalid", message: "chant search needs a query: chant search \"<terms>\" [--live --env <name>]" } };
499
+ }
500
+ let terms: Term[];
501
+ try {
502
+ terms = parseQuery(query);
503
+ } catch (err) {
504
+ if (err instanceof QueryError) return { error: { code: "query-invalid", message: err.message, hint: err.hint } };
505
+ throw err;
506
+ }
507
+ // Query-scoped drift (#1268): --check-live checks a snapshot answer against
508
+ // a live read, --check-snapshot checks a live answer against the recorded
509
+ // snapshot — the direction each needs is the source it does NOT already have.
510
+ if (args.checkLive && !snapshot.ref) {
511
+ return {
512
+ error: {
513
+ code: "usage-invalid",
514
+ message: `chant search --check-live needs ${flag} <ref>`,
515
+ hint: `answer from a snapshot and check the matched rows against a live read: ${flag} latest --check-live --env <name>`,
516
+ },
517
+ };
518
+ }
519
+ if (args.checkSnapshot && !args.live) {
520
+ return {
521
+ error: {
522
+ code: "usage-invalid",
523
+ message: "chant search --check-snapshot needs --live",
524
+ hint: "answer live and check the matched rows against the recorded snapshot: --live --check-snapshot --env <name>",
525
+ },
526
+ };
527
+ }
528
+ if (args.failOnDrift && !args.checkLive && !args.checkSnapshot) {
529
+ return {
530
+ error: {
531
+ code: "usage-invalid",
532
+ message: "chant search --fail-on-drift needs --check-live or --check-snapshot",
533
+ hint: "it fails the check those flags run, so it is meaningless without one",
534
+ },
535
+ };
536
+ }
537
+ if ((args.live || snapshot.ref) && !args.env) {
538
+ const needs = snapshot.ref ? flag : "--live";
539
+ return { error: { code: "usage-invalid", message: `chant search ${needs} needs an environment: ${needs} --env <name>` } };
540
+ }
541
+ if (args.live && snapshot.ref) {
542
+ // Two different observations of the same estate, and no rule for which
543
+ // wins. Comparing them is a real question (#1268) but it is not this one.
544
+ return {
545
+ error: {
546
+ code: "usage-invalid",
547
+ message: `chant search takes --live or ${flag}, not both`,
548
+ hint: `--live reads the estate now; ${flag} answers from a recorded snapshot`,
549
+ },
550
+ };
551
+ }
552
+ return { terms, ...(countBy ? { countBy } : {}) };
553
+ }
554
+
555
+ /** Why a search over a project whose build root failed refuses (#4365), as the refusal prints it. */
556
+ const BUILD_ROOT_FAILED = {
557
+ code: "build-failed",
558
+ message: "chant search: a build root failed to render, so the graph it would answer over is incomplete",
559
+ hint: "fix the error above; `chant graph . --format ir` reports the same one",
560
+ } as const;
561
+
562
+ /**
563
+ * The declared graph a search answers over (#4362, #4365, #4368): discovery,
564
+ * plus the build-root entities (a terraform root's blocks, a rendered
565
+ * kustomize root), plus the edges the lexicons' `graphEdges` hooks add. The
566
+ * same join and the same edges `chant graph --format ir` draws, through the
567
+ * same helpers (`./declared-graph.ts`), so a node's id here is its id there.
568
+ *
569
+ * Returns undefined, having printed the build root's own errors, when a build
570
+ * root failed to render or collided with a discovered name; the caller refuses
571
+ * with {@link BUILD_ROOT_FAILED}. A search over the rest would answer
572
+ * `(no matches)` for a root it never read, which is a wrong answer stated as
573
+ * a fact.
574
+ */
575
+ async function declaredGraph(
576
+ ctx: CommandContext,
577
+ projectPath: string,
578
+ srcDir: string,
579
+ loaded?: readonly LexiconPlugin[],
580
+ ): Promise<GraphIR | undefined> {
581
+ const discovered = await discover(srcDir);
582
+ if (!(await mergeDeclaredBuildRoots(discovered.entities, await declaredBuildRootContributors(ctx, projectPath)))) return undefined;
583
+ let plugins = loaded ?? ctx.plugins;
584
+ if (plugins.length === 0) {
585
+ try {
586
+ plugins = await loadPlugins(await resolveProjectLexicons(projectPath));
587
+ } catch {
588
+ plugins = [];
589
+ }
590
+ }
591
+ return withLexiconEdges(buildGraphIr(discovered.entities, projectPath), plugins, discovered.entities);
592
+ }
593
+
594
+ /**
595
+ * Print the capability catalog (#4375): the `capabilities` document under
596
+ * `--json`, a line per capability otherwise. It takes no query and no other
597
+ * search flag, since it answers nothing about the estate. Shared by `chant
598
+ * search` and `chant workspace search`, which differ only in the lexicons
599
+ * they report on.
600
+ */
601
+ export async function printCapabilities(
602
+ args: { json?: boolean; live?: boolean; env?: string; snapshot?: string; at?: string; countBy?: string; explain?: boolean; show?: string },
603
+ query: string,
604
+ lexicons: string[],
605
+ ): Promise<number> {
606
+ const { capabilitiesDocument, capabilitiesFailure, formatCapabilities } = await import("../../workspace/capabilities-contract");
607
+ const extra = [
608
+ // `args.path` is "." when no query was given.
609
+ ...(query && query !== "." ? [`the query "${query}"`] : []),
610
+ ...(["live", "env", "snapshot", "at", "countBy", "explain", "show"] as const)
611
+ .filter((k) => args[k] !== undefined && args[k] !== false)
612
+ .map((k) => `--${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`),
613
+ ];
614
+ if (extra.length > 0) {
615
+ const error = {
616
+ code: "usage-invalid" as const,
617
+ message: `--capabilities prints the catalog, so it takes no query and no other search flag; it was given ${extra.join(", ")}`,
618
+ hint: "ask with a capability instead: chant search \"cap:internet-reachable(22)\"",
619
+ };
620
+ console.error(formatError(error));
621
+ if (args.json) console.log(JSON.stringify(await capabilitiesFailure(error)));
622
+ return 1;
623
+ }
624
+ const doc = await capabilitiesDocument(lexicons);
625
+ if (args.json) console.log(JSON.stringify(doc));
626
+ else for (const line of formatCapabilities(doc)) console.log(line);
627
+ return 0;
628
+ }
629
+
630
+ /** The capabilities a query names, in query order, for the provenance line (#4375). */
631
+ function capabilitiesUsed(terms: Term[]): string[] {
632
+ const out: string[] = [];
633
+ const walk = (t: Term): void => {
634
+ if (t.negated) return;
635
+ const call = t.kind === "cap" ? t.a : t.capability;
636
+ const name = call !== undefined ? parseCapabilityCall(call)?.name : undefined;
637
+ if (name && !out.includes(name)) out.push(name);
638
+ if (t.sub) walk(t.sub);
639
+ };
640
+ terms.forEach(walk);
641
+ return out;
642
+ }
643
+
644
+ /**
645
+ * What each capability the query used could not evaluate (#4375), with a
646
+ * count where this graph holds the thing left out: an answer computed from
647
+ * declared references says what it skipped, so a caller knows the rows are a
648
+ * floor, not the whole of it.
649
+ */
650
+ function capabilityNotes(terms: Term[], ir: GraphIR, source: AnswerSource): { name: string; source: AnswerSource["kind"]; notEvaluated: string[] }[] {
651
+ const names = capabilitiesUsed(terms);
652
+ if (names.length === 0) return [];
653
+ const engine = capabilityEngine(ir);
654
+ const statements = [...engine.view.nodes.values()].filter((n) => n.label === "Statement" && n.attrs.effect === "Allow");
655
+ const counts: Record<string, number> = {
656
+ 'Resource "*"': statements.filter((n) => n.attrs.anyResource).length,
657
+ "IAM Condition keys": statements.filter((n) => n.attrs.condition).length,
658
+ "resources the graph holds but can't resolve, such as an expression naming nothing declared": statements.filter((n) => n.attrs.unresolved).length,
659
+ };
660
+ return names.map((name) => ({
661
+ name,
662
+ source: source.kind,
663
+ notEvaluated: (engine.rule(name)?.meta["not-evaluated"] ?? "")
664
+ .split(/;|\n/)
665
+ .map((x) => x.trim())
666
+ .filter(Boolean)
667
+ .map((x) => (counts[x] ? `${x} (${counts[x]} statement${counts[x] === 1 ? "" : "s"} here)` : x)),
668
+ }));
669
+ }
670
+
671
+ /**
672
+ * Answer capability terms over a graph that is not this project's own (#4375):
673
+ * `chant workspace search` composes its members' graphs and views and asks
674
+ * here, so the rules run once over the whole of it. Given packs, the view is
675
+ * built here; given a view, it is the one the members' own chants mapped
676
+ * (#4378). Returns the rows
677
+ * as `--json` carries them, or the refusal for a capability the query names
678
+ * wrongly.
679
+ */
680
+ export function answerOverGraph(
681
+ ir: GraphIR,
682
+ terms: Term[],
683
+ query: string,
684
+ packsOrView: CapabilityPack[] | View,
685
+ ): { matches: SearchJson["matches"]; capabilities: NonNullable<SearchJson["capabilities"]> } | { error: { message: string; hint?: string } } {
686
+ // A workspace hands over the view its members' chants mapped (#4378).
687
+ const engine = Array.isArray(packsOrView) ? attachCapabilities(ir, packsOrView) : attachView(ir, packsOrView);
688
+ const refused = checkCapabilityTerms(engine, terms);
689
+ if (refused) return { error: refused };
690
+ resolveKindTerms(terms, ir.nodes);
691
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
692
+ const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, byId)));
693
+ const doc = searchJson(query, { kind: "declared" }, matches, terms, ir, byId);
694
+ return { matches: doc.matches, capabilities: capabilityNotes(terms, ir, { kind: "declared" }) };
695
+ }
696
+
697
+ /** Whether a term, or the leaf under it, names a capability (#4375). */
698
+ export function usesCapability(t: Term): boolean {
699
+ return t.kind === "cap" || t.capability !== undefined || (t.sub !== undefined && usesCapability(t.sub));
700
+ }
701
+
702
+ /**
703
+ * Bind the graph to its lexicons' capability packs and check every capability
704
+ * the query names (#4375): an unknown name, a set used as an edge or the
705
+ * wrong arguments is a refusal, never an empty answer.
706
+ */
707
+ async function bindCapabilities(
708
+ ctx: CommandContext,
709
+ projectPath: string,
710
+ ir: GraphIR,
711
+ terms: Term[],
712
+ ): Promise<{ message: string; hint?: string } | undefined> {
713
+ let plugins: readonly LexiconPlugin[] = ctx.plugins;
714
+ if (plugins.length === 0) {
715
+ try {
716
+ plugins = await loadPlugins(await resolveProjectLexicons(projectPath));
717
+ } catch {
718
+ plugins = [];
719
+ }
720
+ }
721
+ const engine = attachCapabilities(ir, plugins.map((p) => p.capabilityPack).filter((p): p is CapabilityPack => !!p));
722
+ return checkCapabilityTerms(engine, terms);
723
+ }
724
+
725
+ /** Refuse a capability the query names that doesn't exist, a set used as an edge, or the wrong arguments. */
726
+ function checkCapabilityTerms(engine: CapabilityEngine, terms: Term[]): { message: string; hint?: string } | undefined {
727
+ const known = [...new Set(engine.publicNames())].join(", ");
728
+ const check = (t: Term): { message: string; hint?: string } | undefined => {
729
+ if (t.sub) {
730
+ const inner = check(t.sub);
731
+ if (inner) return inner;
732
+ }
733
+ const call = t.kind === "cap" ? t.a : t.capability;
734
+ if (call === undefined) return undefined;
735
+ const parsed = parseCapabilityCall(call)!;
736
+ if (!engine.has(parsed.name)) {
737
+ return { message: `no capability "${parsed.name}"`, hint: `the capabilities are ${known}; chant search --capabilities describes each` };
738
+ }
739
+ const shape = engine.rule(parsed.name)!.vars.length === 1 ? "set" : "edge";
740
+ if (t.capability !== undefined && shape === "set") {
741
+ return { message: `"${parsed.name}" is a set, not an edge`, hint: `test it as a term: cap:${call}` };
742
+ }
743
+ try {
744
+ engine.bind(parsed.name, parsed.args);
745
+ } catch (err) {
746
+ if (err instanceof CapabilityError) return { message: `cap:${call}: ${err.message}`, hint: "chant search --capabilities lists each capability's parameters" };
747
+ throw err;
748
+ }
749
+ return undefined;
750
+ };
751
+ for (const t of terms) {
752
+ const r = check(t);
753
+ if (r) return r;
754
+ }
755
+ return undefined;
756
+ }
757
+
758
+ /**
759
+ * Which read answered a live search's matches, when the lexicon reports it
760
+ * (#2267) and at least one answer came from the plan (#4384). Absent for a
761
+ * plain live or state answer, so those documents are unchanged.
762
+ */
763
+ export interface ReadDepth {
764
+ /** `plan` or `live+plan` when every read match came that way, else `mixed`. */
765
+ depth?: "plan" | "live+plan" | "mixed";
766
+ /** The distinct reads that answered the matches, sorted. */
767
+ reads?: string[];
768
+ /** The plan read was asked for and failed: the read that answered instead. */
769
+ planFellBackTo?: "state" | "live" | "declared";
770
+ }
771
+
772
+ /**
773
+ * The read a plan-depth root fell back to, from the lexicon's structured
774
+ * per-root report. Never from note text. "state" wins over "declared" when
775
+ * roots disagree, because some read still answered.
776
+ */
777
+ function planFallback(rootReads: Record<string, RootRead>): "state" | "live" | "declared" | undefined {
778
+ const falls = Object.values(rootReads)
779
+ .filter((r) => (r.depth === "plan" || r.depth === "live+plan") && r.fellBackTo)
780
+ .map((r) => r.fellBackTo);
781
+ if (falls.includes("state")) return "state";
782
+ if (falls.includes("live")) return "live";
783
+ if (falls.includes("declared")) return "declared";
784
+ return undefined;
785
+ }
786
+
787
+ /**
788
+ * Add the read depth to a live source (#4384). Only bound matches count: a
789
+ * row nobody read has no read to name. Nothing is added when no match was
790
+ * answered by the plan and the plan did not fall back.
791
+ */
792
+ function withReadDepth(
793
+ source: AnswerSource & { kind: "live" },
794
+ matches: IRNode[],
795
+ sources: Record<string, string>,
796
+ rootReads: Record<string, RootRead>,
797
+ ): AnswerSource {
798
+ const reads = [...new Set(matches.filter((n) => n.physicalId).map((n) => sources[n.id]).filter((r): r is string => !!r))].sort();
799
+ const fellBack = planFallback(rootReads);
800
+ const planned = reads.filter((r) => r === "plan" || r === "live+plan");
801
+ if (planned.length === 0 && !fellBack) return source;
802
+ return {
803
+ ...source,
804
+ ...(planned.length > 0 ? { depth: reads.length === 1 ? (reads[0] as "plan" | "live+plan") : ("mixed" as const) } : {}),
805
+ ...(reads.length > 0 ? { reads } : {}),
806
+ ...(fellBack ? { planFellBackTo: fellBack } : {}),
807
+ };
808
+ }
809
+
810
+ const READ_PHRASE: Record<string, string> = {
811
+ plan: "at the plan depth (terraform plan -lock=false)",
812
+ "live+plan": "live and at the plan depth (live-plan -json, terraform plan -lock=false)",
813
+ state: "from state",
814
+ live: "live",
815
+ };
816
+
398
817
  /** Where an answer's facts came from, for the provenance line (#1266). */
399
- type AnswerSource =
818
+ export type AnswerSource =
400
819
  | { kind: "declared" }
401
- | { kind: "live" }
402
- | { kind: "snapshot"; commit: string; timestamp: string };
820
+ | ({ kind: "live" } & ReadDepth)
821
+ /** `snapshot` is the ref asked for (`latest`), `commit` the snapshot commit it named (#4374). */
822
+ | { kind: "snapshot"; snapshot?: string; commit: string; timestamp: string };
403
823
 
404
824
 
405
825
 
@@ -510,10 +930,38 @@ function ambientHint(
510
930
  * already done. That is a fact about the query, printed for every query, and it
511
931
  * encodes no expected answer.
512
932
  */
513
- function provenance(matches: IRNode[], source: AnswerSource, recorded?: string, liveFailures: string[] = []): void {
933
+ function provenanceLine(
934
+ matches: IRNode[],
935
+ source: AnswerSource,
936
+ recorded?: string,
937
+ liveFailures: string[] = [],
938
+ derived: string[] = [],
939
+ ): string {
940
+ const line = provenanceBase(matches, source, recorded, liveFailures, derived);
941
+ // The plan read fell back (#4384): say to what, on whichever line this is.
942
+ return source.kind === "live" ? line + fellBackText(source) : line;
943
+ }
944
+
945
+ /** ` · the plan read fell back to state`, or nothing. */
946
+ function fellBackText(source: ReadDepth): string {
947
+ if (!source.planFellBackTo) return "";
948
+ // A live root has no state read to fall to: live-plan answered alone.
949
+ const to = source.planFellBackTo === "live" ? "live-plan alone" : source.planFellBackTo;
950
+ return ` · the plan read fell back to ${to}`;
951
+ }
952
+
953
+ function provenanceBase(
954
+ matches: IRNode[],
955
+ source: AnswerSource,
956
+ recorded?: string,
957
+ liveFailures: string[] = [],
958
+ derived: string[] = [],
959
+ ): string {
514
960
  if (source.kind === "declared") {
515
- console.log("— declared only · no observation · physical ids unavailable");
516
- return;
961
+ // A derived fact the query relied on (#4362) was folded from declared
962
+ // references alone, so it describes the source, not the account.
963
+ const from = derived.length > 0 ? ` · ${derived.join(", ")} derived from declared references` : "";
964
+ return `— declared only · no observation · physical ids unavailable${from}`;
517
965
  }
518
966
  const bound = matches.filter((n) => n.physicalId).length;
519
967
  const what = source.kind === "live" ? "live read" : "snapshot";
@@ -525,10 +973,15 @@ function provenance(matches: IRNode[], source: AnswerSource, recorded?: string,
525
973
  const rest = recorded
526
974
  ? "a snapshot of this environment is recorded — answer from it with --at latest"
527
975
  : "answered from the declared graph · physical ids unavailable";
528
- console.log(`— live read failed (${lexicons}) · ${unobserved}/${matches.length} rows unobserved · ${rest}`);
529
- return;
976
+ return `— live read failed (${lexicons}) · ${unobserved}/${matches.length} rows unobserved · ${rest}`;
530
977
  }
531
978
  if (bound === 0) {
979
+ // Every match a planned create (#4372): absent on purpose, so "returned no
980
+ // bound resources" reads as a failed read when the read worked.
981
+ const planned = plannedCreateCount(matches);
982
+ if (planned > 0) {
983
+ return `— ${what}: ${planned} planned ${planned === 1 ? "create" : "creates"}, none bound yet · the plan has not been applied`;
984
+ }
532
985
  // The estate was asked for and nothing came back bound. Naming it is the
533
986
  // difference between "these do not exist" and "nobody could see them".
534
987
  // A snapshot sitting unused is the actionable half of this. Denied network,
@@ -536,25 +989,50 @@ function provenance(matches: IRNode[], source: AnswerSource, recorded?: string,
536
989
  // retrying `--live` — the tool knew the estate was unreachable AND that a
537
990
  // recording of it was on disk, and said only the first half.
538
991
  if (recorded) {
539
- console.log(
540
- `— ${what} returned no bound resources · a snapshot of this environment is recorded — answer from it with --at latest`,
541
- );
542
- return;
992
+ return (
993
+ `— ${what} returned no bound resources · a snapshot of this environment is recorded — answer from it with --at latest`
994
+ );
543
995
  }
544
- console.log(
545
- `— ${what} returned no bound resources · answered from the declared graph · physical ids unavailable`,
996
+ return (
997
+ `— ${what} returned no bound resources · answered from the declared graph · physical ids unavailable`
546
998
  );
547
- return;
548
999
  }
549
1000
  if (source.kind === "live") {
550
- console.log(`— observed live · bound ${bound}/${matches.length}`);
551
- return;
1001
+ // The plan answered some or all of it (#4384): say so, and name each read.
1002
+ if (source.depth && source.reads) {
1003
+ return `— observed ${source.reads.map((r) => READ_PHRASE[r] ?? r).join(" and ")} · bound ${bound}/${matches.length}`;
1004
+ }
1005
+ return `— observed live · bound ${bound}/${matches.length}`;
552
1006
  }
553
1007
  // Time is the whole risk of a recorded answer, so it leads. A caller can see
554
1008
  // how old this is and decide, rather than discovering staleness later.
555
1009
  const taken = source.timestamp ? ` taken ${source.timestamp}` : "";
556
1010
  const at = source.commit ? ` ${source.commit.slice(0, 7)}` : "";
557
- console.log(`— observed from snapshot${at}${taken} · bound ${bound}/${matches.length}`);
1011
+ return `— observed from snapshot${at}${taken} · bound ${bound}/${matches.length}`;
1012
+ }
1013
+
1014
+ /**
1015
+ * How many matches there are when every one is a planned create (#4372): an
1016
+ * absent entity (no physical id) carrying `planAction=create`. Zero for no
1017
+ * matches or any other mix, so a real empty read keeps its wording.
1018
+ */
1019
+ function plannedCreateCount(matches: IRNode[]): number {
1020
+ if (matches.length === 0) return 0;
1021
+ const all = matches.every(
1022
+ (n) => !n.physicalId && (n.attrs as Record<string, unknown> | undefined)?.planAction === "create",
1023
+ );
1024
+ return all ? matches.length : 0;
1025
+ }
1026
+
1027
+ /** Print {@link provenanceLine}: the text footer. */
1028
+ function provenance(
1029
+ matches: IRNode[],
1030
+ source: AnswerSource,
1031
+ recorded?: string,
1032
+ liveFailures: string[] = [],
1033
+ derived: string[] = [],
1034
+ ): void {
1035
+ console.log(provenanceLine(matches, source, recorded, liveFailures, derived));
558
1036
  }
559
1037
 
560
1038
  /** Union `LiveObservation.resources` across lexicons into one name-keyed map. */
@@ -598,7 +1076,7 @@ function renderDrift(
598
1076
  for (const name of diff.newlyObserved) console.log(` · ${name} — newly observed since the recorded snapshot`);
599
1077
  for (const r of diff.runtimeChildren) console.log(` · ${r.name} (${r.type}) — runtime, owned by ${r.owner}`);
600
1078
  for (const u of diff.unobserved) console.log(` ? ${formatUnobserved(u.name, u)}`);
601
- const driftCount = diff.missing.length + diff.orphan.length + diff.disappeared.length + diff.driftedSinceSnapshot.length;
1079
+ const driftCount = driftCountOf(diff);
602
1080
  const label = direction === "live" ? "a live read" : "the recorded snapshot";
603
1081
  const depthNote = depth === "deep" ? " · snapshot recorded at deep depth, compared here at identity" : "";
604
1082
  console.log(
@@ -607,6 +1085,59 @@ function renderDrift(
607
1085
  return driftCount;
608
1086
  }
609
1087
 
1088
+ /** What counts as drift: the set `lifecycle diff` totals (#1268). */
1089
+ function driftCountOf(diff: LiveDiffResult): number {
1090
+ return diff.missing.length + diff.orphan.length + diff.disappeared.length + diff.driftedSinceSnapshot.length;
1091
+ }
1092
+
1093
+ /** The query-scoped drift check as `--json` carries it (#4367). See {@link SearchJson}. */
1094
+ export interface SearchDriftJson {
1095
+ against: "live" | "snapshot";
1096
+ /** Matched rows checked. */
1097
+ checked?: number;
1098
+ /** Rows that drifted: missing + orphan + disappeared + drifted, as the text summary counts. */
1099
+ count?: number;
1100
+ missing?: string[];
1101
+ orphan?: string[];
1102
+ disappeared?: string[];
1103
+ drifted?: { id: string; path: string; old?: unknown; new?: unknown }[];
1104
+ newlyObserved?: string[];
1105
+ runtimeChildren?: { id: string; type: string; owner: string }[];
1106
+ unobserved?: { id: string; type?: string; reason: string; detail?: string }[];
1107
+ /** The snapshot was recorded deeper than the identity-level comparison made here. */
1108
+ depth?: ObservationDepth;
1109
+ /** --check-snapshot found no snapshot to check against: the reason, and no other field. */
1110
+ unavailable?: string;
1111
+ }
1112
+
1113
+ function searchDriftJson(
1114
+ diff: LiveDiffResult,
1115
+ checked: number,
1116
+ against: "live" | "snapshot",
1117
+ depth?: ObservationDepth,
1118
+ ): SearchDriftJson {
1119
+ return {
1120
+ against,
1121
+ checked,
1122
+ count: driftCountOf(diff),
1123
+ missing: diff.missing,
1124
+ orphan: diff.orphan,
1125
+ disappeared: diff.disappeared,
1126
+ drifted: diff.driftedSinceSnapshot.flatMap((d) =>
1127
+ d.changes.map((c) => ({ id: d.name, path: c.path, old: c.oldValue, new: c.newValue })),
1128
+ ),
1129
+ newlyObserved: diff.newlyObserved,
1130
+ runtimeChildren: diff.runtimeChildren.map((r) => ({ id: r.name, type: r.type, owner: r.owner })),
1131
+ unobserved: diff.unobserved.map((u) => ({
1132
+ id: u.name,
1133
+ ...(u.type ? { type: u.type } : {}),
1134
+ reason: u.reason,
1135
+ ...(u.detail ? { detail: u.detail } : {}),
1136
+ })),
1137
+ ...(depth ? { depth } : {}),
1138
+ };
1139
+ }
1140
+
610
1141
  function formatValue(v: unknown): string {
611
1142
  if (v === undefined) return "<unset>";
612
1143
  if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
@@ -634,16 +1165,27 @@ function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR, backed =
634
1165
  if (!derived || matches.length === 0 || !backed) return;
635
1166
  const used = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
636
1167
  const unused = new Set<string>();
637
- for (const n of matches) {
638
- for (const [kind, names] of Object.entries(derived)) {
639
- if (!n.kind?.includes(kind)) continue;
640
- for (const name of names) if (!used.has(name)) unused.add(name);
641
- }
642
- }
1168
+ for (const n of matches) for (const name of derivedNames(n, ir)) if (!used.has(name)) unused.add(name);
643
1169
  if (unused.size === 0) return;
644
1170
  console.log(`— also derived for these resources: ${[...unused].sort().join(", ")}`);
645
1171
  }
646
1172
 
1173
+ /** The derived attributes recorded for a node's kind (or its terraform resource type, #4362). */
1174
+ function derivedNames(n: IRNode, ir: GraphIR): string[] {
1175
+ const tfType = terraformResourceType(n);
1176
+ return Object.entries(ir.derivedAttrs ?? {})
1177
+ .filter(([kind]) => n.kind?.includes(kind) || kind === tfType)
1178
+ .flatMap(([, names]) => names);
1179
+ }
1180
+
1181
+ /** The derived attributes the query's `attr:` terms relied on, for the provenance line. */
1182
+ function derivedUsed(terms: Term[], matches: IRNode[], ir: GraphIR): string[] {
1183
+ const queried = new Set(terms.filter((t) => t.kind === "attr" && !t.negated).map((t) => t.a));
1184
+ const out = new Set<string>();
1185
+ for (const n of matches) for (const name of derivedNames(n, ir)) if (queried.has(name)) out.add(name);
1186
+ return [...out].sort();
1187
+ }
1188
+
647
1189
  /**
648
1190
  * `--explain` footer (#1139): a compact, model-DERIVED summary that gives a
649
1191
  * small model a reason to trust the result instead of re-deriving it with a
@@ -654,30 +1196,75 @@ function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR, backed =
654
1196
  * any expected answer, so it stays a fair, question-agnostic capability.
655
1197
  */
656
1198
  function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string, IRNode>, query: string): void {
657
- const kinds = new Set(matches.map((n) => n.kind).filter((k): k is string => !!k));
658
- const universe = kinds.size > 0 ? ir.nodes.filter((n) => n.kind && kinds.has(n.kind)) : ir.nodes;
659
- const matched = new Set(matches.map((n) => n.id));
660
- const excluded = universe.filter((n) => !matched.has(n.id));
661
- const kindLabel = kinds.size > 0 ? [...kinds].join("/") : "nodes";
662
- console.log(`— ${matches.length} of ${universe.length} ${kindLabel} matched (query: ${query})`);
1199
+ const data = explainData(terms, matches, ir, byId);
1200
+ const kindLabel = data.universe.kinds.length > 0 ? data.universe.kinds.join("/") : "nodes";
1201
+ console.log(`— ${data.universe.matched} of ${data.universe.of} ${kindLabel} matched (query: ${query})`);
663
1202
  // Inclusion evidence: for a derived fact a CLI can't easily re-verify
664
1203
  // (internetFacing, resolved across the default VPC's routing), name WHY each
665
1204
  // match qualifies, so the agent trusts the result instead of dropping it.
1205
+ for (const e of data.evidence) console.log(` ✓ ${bareId(e.id)} ${e.attr} via ${e.via}`);
1206
+ // The chain each match was reached by (#4359), so a caller can quote the
1207
+ // path instead of re-deriving it with one search per hop.
1208
+ for (const n of matches) {
1209
+ for (const p of matchPaths(n, terms, ir, byId)) {
1210
+ // A capability's path (#4375) turns as the rule does, so each hop says which way it went.
1211
+ if (p.term.kind === "cap" || p.term.capability !== undefined) {
1212
+ // A set a node is in by its own attributes (stores-data) has no path to print.
1213
+ if (p.hops.length === 0 && !p.because) continue;
1214
+ console.log(` path ${bareId(n.id)} ${termText({ ...p.term, negated: false })}: ${formatHops(p.hops, n.id) || "(the node itself)"}`);
1215
+ for (const b of p.because ?? []) {
1216
+ console.log(` ${b.capability} ${bareId(b.id)}: ${formatHops(b.hops, b.id) || "(an attribute of the node)"}`);
1217
+ }
1218
+ } else console.log(` path ${formatPath(p.hops, p.term.dir!)}`);
1219
+ }
1220
+ }
1221
+ for (const m of data.nearMisses) {
1222
+ console.log(` · excluded ${bareId(m.id)} — fails ${m.term ? describeTerm(m.term) : "(query)"}`);
1223
+ }
1224
+ if (data.moreExcluded > 0) console.log(` · …and ${data.moreExcluded} more excluded`);
1225
+ }
1226
+
1227
+ /** How many near-misses `--explain` names before summarising the rest. */
1228
+ const NEAR_MISS_LIMIT = 8;
1229
+
1230
+ /**
1231
+ * The facts behind the `--explain` footer, shared by the text and `--json`
1232
+ * (#4367). The universe is every node of the matched kinds (every node when
1233
+ * nothing matched); a near-miss is a node of that universe the query
1234
+ * excluded, with the first term it fails.
1235
+ */
1236
+ function explainData(
1237
+ terms: Term[],
1238
+ matches: IRNode[],
1239
+ ir: GraphIR,
1240
+ byId: Map<string, IRNode>,
1241
+ ): {
1242
+ universe: { matched: number; of: number; kinds: string[] };
1243
+ evidence: { id: string; attr: string; via: string }[];
1244
+ nearMisses: { id: string; term?: Term }[];
1245
+ moreExcluded: number;
1246
+ } {
1247
+ // Counted against a terraform block's type, not `Terraform::Resource` (#4366):
1248
+ // "2 of 2 aws_instance", not "2 of 185 Terraform::Resource".
1249
+ const kinds = new Set(matches.map(displayKind).filter((k): k is string => !!k));
1250
+ const universe = kinds.size > 0 ? ir.nodes.filter((n) => kinds.has(displayKind(n) ?? "")) : ir.nodes;
1251
+ const matched = new Set(matches.map((n) => n.id));
1252
+ const excluded = universe.filter((n) => !matched.has(n.id));
1253
+ const evidence: { id: string; attr: string; via: string }[] = [];
666
1254
  for (const t of terms) {
667
1255
  if (t.kind !== "attr") continue;
668
1256
  for (const n of matches) {
669
1257
  const via = (n.attrs as Record<string, unknown> | undefined)?.[`${t.a}Via`];
670
- const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
671
- if (typeof via === "string") console.log(` ✓ ${id} ${t.a} via ${via}`);
1258
+ if (typeof via === "string") evidence.push({ id: n.id, attr: t.a, via });
672
1259
  }
673
1260
  }
674
- const shown = excluded.slice(0, 8);
675
- for (const n of shown) {
676
- const failing = terms.find((t) => !matchTerm(n, t, ir, byId));
677
- const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
678
- console.log(` · excluded ${id} — fails ${failing ? describeTerm(failing) : "(query)"}`);
679
- }
680
- if (excluded.length > shown.length) console.log(` · …and ${excluded.length - shown.length} more excluded`);
1261
+ const shown = excluded.slice(0, NEAR_MISS_LIMIT);
1262
+ return {
1263
+ universe: { matched: matches.length, of: universe.length, kinds: [...kinds] },
1264
+ evidence,
1265
+ nearMisses: shown.map((n) => ({ id: n.id, term: terms.find((t) => !matchTerm(n, t, ir, byId)) })),
1266
+ moreExcluded: excluded.length - shown.length,
1267
+ };
681
1268
  }
682
1269
 
683
1270
  /**
@@ -691,7 +1278,7 @@ function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string
691
1278
  function availableAttrs(terms: Term[], ir: GraphIR): void {
692
1279
  const kindTerm = terms.find((t) => t.kind === "kind");
693
1280
  if (!kindTerm) return;
694
- const of = ir.nodes.filter((n) => n.kind?.includes(kindTerm.a));
1281
+ const of = ir.nodes.filter((n) => matchTerm(n, { ...kindTerm, negated: false }));
695
1282
  if (of.length === 0) return;
696
1283
  const names = new Set<string>();
697
1284
  for (const n of of) for (const k of Object.keys((n.attrs as Record<string, unknown>) ?? {})) names.add(k);
@@ -727,15 +1314,189 @@ function describeTerm(t: Term): string {
727
1314
  // `--explain` has to say a negated term was negated, or an exclusion reads as
728
1315
  // the opposite of what it is.
729
1316
  if (t.negated) return `!${describeTerm({ ...t, negated: false })}`;
730
- const leaf = (x: Term): string =>
731
- x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
732
- : x.kind === "tag" ? `tag:${x.a}${x.b !== undefined ? "=" + x.b : ""}` : `"${x.a}"`;
733
- if (t.kind === "edge" && t.sub) return `${t.dir === "out" ? "→" : "←"}${leaf(t.sub)} (no such edge)`;
734
- return leaf(t);
1317
+ if (t.kind === "edge") return `${edgeOperator(t, { out: "→", in: "←" })}${t.sub ? leafText(t.sub) : ""} (no such edge)`;
1318
+ return leafText(t);
1319
+ }
1320
+
1321
+ function leafText(x: Term): string {
1322
+ if (x.kind === "cap") return `cap:${x.a}`;
1323
+ return x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
1324
+ : x.kind === "tag" ? `tag:${x.a}${x.b !== undefined ? "=" + x.b : ""}` : `"${x.a}"`;
1325
+ }
1326
+
1327
+ /**
1328
+ * `chant search --json` (#4359, #4367): the whole answer as one document on
1329
+ * stdout, read by behold (behold#913). Warnings and errors still go to stderr;
1330
+ * the exit code is the text path's. It is the `search` document of the read
1331
+ * contract (#4374, `workspace/search.schema.json`): every document opens with
1332
+ * `$schema`, `contract` and `chant`, then `workspace` when the project belongs
1333
+ * to one, and `at: null`, since plain search reads the working tree.
1334
+ *
1335
+ * {
1336
+ * "$schema": "https://intentius.io/chant/schemas/workspace/search/v1/search.schema.json",
1337
+ * "contract": 1, "chant": "0.124.46", "workspace"?: { "name", "root" }, "at": null,
1338
+ * "query": "kind:aws_instance ->*kind:aws_vpc",
1339
+ * "source": { "kind": "declared" | "live" | "snapshot", "env"?, "snapshot"?, "commit"?, "timestamp"? },
1340
+ * "matches": [
1341
+ * { "id": "east::web", "displayId": "web", "kind": "Terraform::Resource", "physicalId"?: "i-1",
1342
+ * "unobserved"?: "read-failed", "show"?: { "InstanceType": "t3.micro" },
1343
+ * "paths": [ { "term": "->*kind:aws_vpc", "dir": "out",
1344
+ * "hops": [ { "from": "web", "fromAttr": "subnet_id", "to": "a", "toAttr": "id" },
1345
+ * { "from": "a", "fromAttr": "vpc_id", "to": "main", "toAttr": "id" } ] } ] }
1346
+ * ],
1347
+ * "provenance": "observed live · bound 2/2",
1348
+ * "liveFailures"?: ["aws: ..."],
1349
+ * "explain"?: { "universe": { "matched": 2, "of": 6, "kinds": ["aws_instance"] },
1350
+ * "evidence": [ { "id": "web", "attr": "internetFacing", "via": "..." } ],
1351
+ * "nearMisses": [ { "id": "db", "fails": "attr:internetFacing=true" } ],
1352
+ * "moreExcluded": 0 },
1353
+ * "drift"?: { "against": "live" | "snapshot", ... see SearchDriftJson }
1354
+ * }
1355
+ *
1356
+ * A refused query or invocation prints the head, `query` and
1357
+ * `error: { code, message, hint? }` instead, and exits 1. The code is from the
1358
+ * read contract's closed list (`SEARCH_ERROR_CODES`).
1359
+ *
1360
+ * `kind` is the IR kind, so a Terraform block's is `Terraform::Resource`;
1361
+ * `kind:aws_instance` matches it by its resource type (#4366).
1362
+ *
1363
+ * `id` is the graph's full id, stack-qualified (`stack::name`) where the graph
1364
+ * qualifies it; `displayId` is the bare name the text row prints. `hops` is in
1365
+ * walk order from the match; each hop keeps the edge's own direction (see
1366
+ * {@link PathHop}). `paths` is empty when the query has no positive edge
1367
+ * term. `show` is present when `--show` was given, holding the requested
1368
+ * attributes a row carries, under the name the graph spells them with.
1369
+ * `explain` is present with `--explain`; `drift` with `--check-live` or
1370
+ * `--check-snapshot`.
1371
+ */
1372
+ export interface SearchJson {
1373
+ query: string;
1374
+ source: AnswerSource & { env?: string };
1375
+ matches: {
1376
+ id: string;
1377
+ displayId: string;
1378
+ kind?: string;
1379
+ physicalId?: string;
1380
+ unobserved?: string;
1381
+ show?: Record<string, unknown>;
1382
+ paths: { term: string; dir: "out" | "in"; hops: PathHop[]; because?: { capability: string; id: string; hops: PathHop[] }[] }[];
1383
+ }[];
1384
+ provenance?: string;
1385
+ /** Present when every match is a planned create (#4372): how many. See the schema. */
1386
+ plannedCreates?: number;
1387
+ liveFailures?: string[];
1388
+ explain?: {
1389
+ universe: { matched: number; of: number; kinds: string[] };
1390
+ evidence: { id: string; attr: string; via: string }[];
1391
+ nearMisses: { id: string; fails?: string }[];
1392
+ moreExcluded: number;
1393
+ };
1394
+ drift?: SearchDriftJson;
1395
+ /**
1396
+ * Present when the query names a capability (#4375): each one, the
1397
+ * observation its facts were derived from, and what it does not evaluate.
1398
+ */
1399
+ capabilities?: { name: string; source: AnswerSource["kind"]; notEvaluated: string[] }[];
1400
+ }
1401
+
1402
+ /**
1403
+ * A refusal under `--json` (#4367): the same message and hint stderr carries,
1404
+ * with its code from the read contract's closed list (#4374).
1405
+ */
1406
+ export type SearchErrorJson = SearchFailure;
1407
+
1408
+ function searchErrorJson(query: string, error: { code: SearchErrorCode; message: string; hint?: string }): SearchErrorJson {
1409
+ return { ...searchHead(), query, error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) } };
1410
+ }
1411
+
1412
+ /** The head of every `search` document (#4374): the contract, its schema, and the chant that wrote it. */
1413
+ function searchHead(): SearchHead {
1414
+ return { $schema: SEARCH_OUTPUT_SCHEMA_ID, contract: SEARCH_CONTRACT_VERSION, chant: readChantPackageVersion() };
1415
+ }
1416
+
1417
+ /**
1418
+ * The workspace a project belongs to, when it belongs to one (#4374, ws-016):
1419
+ * its declared name and its root relative to the git root, with `at: null`,
1420
+ * since plain search reads the working tree. Outside a workspace, or when its
1421
+ * declaration can't be read, only `at`.
1422
+ */
1423
+ async function workspaceIdentity(cwd: string): Promise<{ workspace?: { name: string; root: string }; at: null }> {
1424
+ try {
1425
+ const [{ locateWorkspace }, { readDeclaration }] = await Promise.all([import("../../workspace/which-chant"), import("../../workspace/declaration")]);
1426
+ const located = locateWorkspace(cwd);
1427
+ return { workspace: { name: readDeclaration(located.tree, "").name, root: located.root }, at: null };
1428
+ } catch {
1429
+ return { at: null };
1430
+ }
1431
+ }
1432
+
1433
+ function searchJson(
1434
+ query: string,
1435
+ source: AnswerSource,
1436
+ matches: IRNode[],
1437
+ terms: Term[],
1438
+ ir: GraphIR,
1439
+ byId: Map<string, IRNode>,
1440
+ liveFailures: string[] = [],
1441
+ extra: {
1442
+ env?: string;
1443
+ show?: string[];
1444
+ provenance?: string;
1445
+ plannedCreates?: number;
1446
+ capabilities?: SearchJson["capabilities"];
1447
+ explain?: ReturnType<typeof explainData>;
1448
+ drift?: SearchDriftJson;
1449
+ } = {},
1450
+ ): SearchJson {
1451
+ const show = extra.show ?? [];
1452
+ return {
1453
+ query,
1454
+ source: extra.env ? { ...source, env: extra.env } : source,
1455
+ matches: matches.map((n) => {
1456
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
1457
+ const physical = (n as { physicalId?: unknown }).physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
1458
+ return {
1459
+ id: n.id,
1460
+ displayId: bareId(n.id),
1461
+ ...(n.kind ? { kind: n.kind } : {}),
1462
+ ...(physical != null && typeof physical !== "object" ? { physicalId: String(physical) } : {}),
1463
+ ...(typeof attrs["_unobserved"] === "string" ? { unobserved: attrs["_unobserved"] } : {}),
1464
+ ...(show.length > 0 ? { show: Object.fromEntries(showValues(attrs, show)) } : {}),
1465
+ paths: matchPaths(n, terms, ir, byId).map((p) => ({
1466
+ term: termText({ ...p.term, negated: false }),
1467
+ dir: p.term.dir ?? ("out" as const),
1468
+ hops: p.hops,
1469
+ ...(p.because ? { because: p.because } : {}),
1470
+ })),
1471
+ };
1472
+ }),
1473
+ ...(extra.provenance !== undefined ? { provenance: extra.provenance } : {}),
1474
+ ...(extra.plannedCreates ? { plannedCreates: extra.plannedCreates } : {}),
1475
+ ...(extra.capabilities ? { capabilities: extra.capabilities } : {}),
1476
+ ...(liveFailures.length > 0 ? { liveFailures } : {}),
1477
+ ...(extra.explain
1478
+ ? {
1479
+ explain: {
1480
+ universe: extra.explain.universe,
1481
+ evidence: extra.explain.evidence,
1482
+ nearMisses: extra.explain.nearMisses.map((m) => ({ id: m.id, ...(m.term ? { fails: termText(m.term) } : {}) })),
1483
+ moreExcluded: extra.explain.moreExcluded,
1484
+ },
1485
+ }
1486
+ : {}),
1487
+ ...(extra.drift ? { drift: extra.drift } : {}),
1488
+ };
1489
+ }
1490
+
1491
+ /** A term as the query grammar spells it, `!` and ASCII arrows included (#4367). */
1492
+ function termText(t: Term): string {
1493
+ if (t.negated) return `!${termText({ ...t, negated: false })}`;
1494
+ if (t.kind === "edge") return `${edgeOperator(t, { out: "->", in: "<-" })}${t.sub ? leafText(t.sub) : ""}`;
1495
+ return leafText(t);
735
1496
  }
736
1497
 
737
1498
  export interface Term {
738
- kind: "word" | "kind" | "tag" | "attr" | "edge";
1499
+ kind: "word" | "kind" | "tag" | "attr" | "edge" | "cap";
739
1500
  /** `!term` — the node must NOT satisfy this (#1280). */
740
1501
  negated?: boolean;
741
1502
  a: string;
@@ -743,12 +1504,33 @@ export interface Term {
743
1504
  /** For edge terms: the direction and the sub-predicate matched at the far end. */
744
1505
  dir?: "out" | "in";
745
1506
  sub?: Term;
1507
+ /**
1508
+ * For edge terms (#4359): how many hops the far end may be away. Absent on a
1509
+ * plain `->`/`<-`, which is exactly one hop. `max` is `Infinity` for `*`.
1510
+ */
1511
+ depth?: { min: number; max: number };
1512
+ /** For edge terms (#4359): every edge on the path goes through this consumer attribute (`IREdge.viaAttr`). */
1513
+ viaAttr?: string;
1514
+ /** For edge terms (#4359): every edge on the path reads this producer attribute (`IREdge.toAttr`). */
1515
+ toAttr?: string;
1516
+ /**
1517
+ * For edge terms (#4375): walk this edge capability (`reaches`,
1518
+ * `grants(s3:GetObject)`) instead of the graph's own edges. A `cap:` leaf
1519
+ * keeps its call in `a`.
1520
+ */
1521
+ capability?: string;
746
1522
  /**
747
1523
  * For kind terms: the kinds this term actually means, resolved against the
748
1524
  * graph (see {@link resolveKindTerms}). Absent when nothing resolved it, in
749
1525
  * which case the substring rule applies unchanged.
750
1526
  */
751
1527
  kinds?: Set<string>;
1528
+ /**
1529
+ * For kind terms (#4366): the terraform resource types this term means,
1530
+ * resolved against the graph. A terraform resource matches the term when its
1531
+ * type is in here, whatever `kinds` says.
1532
+ */
1533
+ tfTypes?: Set<string>;
752
1534
  }
753
1535
 
754
1536
  /**
@@ -772,12 +1554,25 @@ export interface Term {
772
1554
  * Substring is kept as the fallback, so `kind:Gateway` still finds both
773
1555
  * `InternetGateway` and `VPCGatewayAttachment` — a genuine substring search
774
1556
  * that no segment rule would serve. Most specific wins; nothing else changes.
1557
+ *
1558
+ * A terraform resource is `Terraform::Resource` whatever it is, so the term is
1559
+ * also read against each resource's type (#4366), with the same boundary rule
1560
+ * on `_`: `kind:aws_iam_role` means that type exactly and never
1561
+ * `aws_iam_role_policy`, and a term ending in `_` (`kind:aws_iam_`) or a bare
1562
+ * provider name (`kind:aws`) means every type it begins. A data source's type
1563
+ * keeps its `data.` prefix (`kind:data.aws_ami`), the same type `--count-by kind`
1564
+ * groups it under, so `kind:aws_ami` is not a data source. A term that names a
1565
+ * type turns off the substring fallback for the IR kind, so `kind:aws_instance`
1566
+ * is the instances and nothing found by accident.
775
1567
  */
776
1568
  export function resolveKindTerms(terms: Term[], nodes: IRNode[]): void {
777
1569
  const present = [...new Set(nodes.map((n) => n.kind).filter((k): k is string => !!k))];
1570
+ const tfPresent = [...new Set(nodes.map(terraformType).filter((k): k is string => !!k))];
778
1571
  const walk = (t: Term): void => {
779
1572
  if (t.sub) walk(t.sub);
780
1573
  if (t.kind !== "kind") return;
1574
+ const types = resolveTerraformTypes(t.a, tfPresent);
1575
+ if (types.length > 0) t.tfTypes = new Set(types);
781
1576
  const want = t.a.toLowerCase().split("::").filter(Boolean);
782
1577
  if (want.length === 0) return;
783
1578
  const onBoundary = present.filter((k) => {
@@ -792,7 +1587,32 @@ export function resolveKindTerms(terms: Term[], nodes: IRNode[]): void {
792
1587
  terms.forEach(walk);
793
1588
  }
794
1589
 
1590
+ /** The terraform resource types a `kind:` term names, by the rule in {@link resolveKindTerms}. */
1591
+ function resolveTerraformTypes(term: string, present: string[]): string[] {
1592
+ const want = term.toLowerCase();
1593
+ if (want.length === 0 || want.includes("::")) return [];
1594
+ const exact = present.filter((p) => p === want);
1595
+ if (exact.length > 0) return exact;
1596
+ const prefix = want.endsWith("_") || want.endsWith(".") ? want : /[_.]/.test(want) ? undefined : `${want}_`;
1597
+ return prefix ? present.filter((p) => p.startsWith(prefix)) : [];
1598
+ }
1599
+
1600
+ /** What a row and `--explain` call a node's kind: a terraform block's type (#4366), else its IR kind. Same as `--count-by kind`. */
1601
+ function displayKind(n: IRNode): string | undefined {
1602
+ return terraformType(n) ?? n.kind;
1603
+ }
1604
+
795
1605
  function parseLeaf(tok: string): Term {
1606
+ const cap = /^cap:(.*)$/i.exec(tok);
1607
+ if (cap) {
1608
+ if (!parseCapabilityCall(cap[1])) {
1609
+ throw new QueryError(
1610
+ `"${tok}" is not a capability term`,
1611
+ "name a capability, with arguments in parentheses when it takes any: cap:internet-reachable, cap:open-ingress(22); chant search --capabilities lists them",
1612
+ );
1613
+ }
1614
+ return { kind: "cap", a: cap[1] };
1615
+ }
796
1616
  const m = /^(kind|tag|attr):(.*)$/i.exec(tok);
797
1617
  if (m) {
798
1618
  const key = m[1].toLowerCase() as Term["kind"];
@@ -833,6 +1653,76 @@ class QueryError extends Error {
833
1653
  }
834
1654
  }
835
1655
 
1656
+ /**
1657
+ * Parse an edge term (#4359): `->` or `<-`, then an optional depth, then an
1658
+ * optional attribute qualifier, then the target leaf.
1659
+ *
1660
+ * ->kind:X one hop, as it always was
1661
+ * ->*kind:X any number of hops, one or more
1662
+ * ->{3}kind:X exactly three hops
1663
+ * ->{1,3}kind:X one to three hops ({2,} is two or more)
1664
+ * <-[via=vpc_id]kind:X only edges through the consumer attribute vpc_id
1665
+ * ->[to=id]kind:X only edges that read the producer attribute id
1666
+ * <-*[via=subnet_id] a qualifier may stand without a target: any node
1667
+ *
1668
+ * A qualifier constrains every edge on the path, not only the first one.
1669
+ */
1670
+ function parseEdge(tok: string, negated: boolean): Term {
1671
+ const dir = tok.startsWith("->") ? ("out" as const) : ("in" as const);
1672
+ const arrow = tok.slice(0, 2);
1673
+ let rest = tok.slice(2);
1674
+ const term: Term = { kind: "edge", a: "", dir };
1675
+ if (rest.startsWith("*")) {
1676
+ term.depth = { min: 1, max: Infinity };
1677
+ rest = rest.slice(1);
1678
+ } else if (rest.startsWith("{")) {
1679
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(rest);
1680
+ const min = m ? Number(m[1]) : NaN;
1681
+ const max = !m ? NaN : m[2] === undefined ? min : m[3] === "" ? Infinity : Number(m[3]);
1682
+ if (!m || min < 1 || max < min) {
1683
+ throw new QueryError(
1684
+ `"${tok}" has a depth this grammar cannot read`,
1685
+ `a depth is {n}, {min,max} or {min,} with 1 <= min <= max, e.g. ${arrow}{1,3}kind:aws_vpc; ${arrow}* is any depth`,
1686
+ );
1687
+ }
1688
+ term.depth = { min, max };
1689
+ rest = rest.slice(m[0].length);
1690
+ }
1691
+ if (rest.startsWith("[")) {
1692
+ const close = rest.indexOf("]");
1693
+ // Split on commas outside parentheses, so `[cap=open-ingress(22,tcp)]` is one part.
1694
+ const parts = close < 0 ? [""] : (rest.slice(1, close).match(/(?:[^,(]|\([^)]*\))+/g) ?? [""]).map((p) => p.trim());
1695
+ for (const part of parts) {
1696
+ const c = /^cap=(.+)$/.exec(part);
1697
+ if (c && parseCapabilityCall(c[1])) {
1698
+ term.capability = c[1];
1699
+ continue;
1700
+ }
1701
+ const q = /^(via|to)=(.+)$/.exec(part);
1702
+ if (!q) {
1703
+ throw new QueryError(
1704
+ `"${tok}" has an edge qualifier this grammar cannot read`,
1705
+ `qualify an edge by attribute with [via=<consumer attr>] or [to=<producer attr>], e.g. <-[via=vpc_id]kind:aws_subnet or ->[via=subnet_id,to=id], or walk a capability with [cap=<name>], e.g. ->[cap=reaches]cap:stores-data`,
1706
+ );
1707
+ }
1708
+ if (q[1] === "via") term.viaAttr = q[2];
1709
+ else term.toAttr = q[2];
1710
+ }
1711
+ rest = rest.slice(close + 1);
1712
+ }
1713
+ if (rest.trim() === "") {
1714
+ if (term.viaAttr === undefined && term.toAttr === undefined && term.capability === undefined) {
1715
+ const bang = negated ? "!" : "";
1716
+ throw new QueryError(
1717
+ `"${bang}${tok}" needs a target`,
1718
+ `say what the edge reaches: ${bang}${tok}kind:EC2::Instance, or qualify it by attribute: ${bang}${tok}[via=vpc_id]`,
1719
+ );
1720
+ }
1721
+ return term;
1722
+ }
1723
+ return { ...term, sub: parseLeaf(rest) };
1724
+ }
1725
+
836
1726
  function parseQuery(query: string): Term[] {
837
1727
  // Split on whitespace but keep quoted phrases together.
838
1728
  const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
@@ -873,11 +1763,7 @@ function parseQuery(query: string): Term[] {
873
1763
  `say what the edge reaches: ${negated ? "!" : ""}${tok}kind:EC2::Instance, or ${negated ? "!" : ""}${tok}attr:Name=web`,
874
1764
  );
875
1765
  }
876
- const term = tok.startsWith("->")
877
- ? { kind: "edge" as const, a: "", dir: "out" as const, sub: parseLeaf(tok.slice(2)) }
878
- : tok.startsWith("<-")
879
- ? { kind: "edge" as const, a: "", dir: "in" as const, sub: parseLeaf(tok.slice(2)) }
880
- : parseLeaf(tok);
1766
+ const term: Term = tok.startsWith("->") || tok.startsWith("<-") ? parseEdge(tok, negated) : parseLeaf(tok);
881
1767
  return negated ? { ...term, negated: true } : term;
882
1768
  });
883
1769
  }
@@ -895,11 +1781,28 @@ function attrString(v: unknown): string {
895
1781
  return String(v);
896
1782
  }
897
1783
 
1784
+ /**
1785
+ * Attributes a bare word does not search, by lexicon (#4368). A terraform
1786
+ * node's `source` is the whole `.tf` file its block came from (the lint
1787
+ * checks need the raw text), so a bare `aws_security_group` matched every
1788
+ * node declared in a file that mentions one: 21 nodes on behold's example
1789
+ * estate, where three are security groups. The block itself is still
1790
+ * searched, through `address` and `body`, and `attr:source=<text>` still
1791
+ * reads the file when that is what the caller means.
1792
+ */
1793
+ const BARE_WORD_SKIPS: Record<string, ReadonlySet<string>> = {
1794
+ terraform: new Set(["source"]),
1795
+ };
1796
+
898
1797
  function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>): boolean {
899
1798
  if (t.negated) return !matchTerm(n, { ...t, negated: false }, ir, byId);
900
1799
  const attrs = n.attrs ?? {};
901
1800
  if (t.kind === "edge") {
902
- if (!ir || !byId || !t.sub) return false;
1801
+ if (!ir || !byId) return false;
1802
+ // Depth and attribute qualifiers (#4359) go through the path search. A
1803
+ // plain one-hop term keeps the original code below, unchanged.
1804
+ if (t.depth || t.viaAttr !== undefined || t.toAttr !== undefined || t.capability !== undefined) return findPath(n, t, ir, byId) !== null;
1805
+ if (!t.sub) return false;
903
1806
  // A node matches if it has an edge (out or in) to a node satisfying `sub`.
904
1807
  const edges = ir.edges ?? [];
905
1808
  const neighbors = edges
@@ -908,10 +1811,14 @@ function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>)
908
1811
  .filter((x): x is IRNode => !!x);
909
1812
  return neighbors.some((m) => matchTerm(m, t.sub!, ir, byId));
910
1813
  }
1814
+ if (t.kind === "cap") return !!ir && capabilityEvidence(n, t, ir) !== undefined;
911
1815
  if (t.kind === "kind") {
912
1816
  // Resolved kinds when the term lined up with `::` boundaries; the
913
- // documented substring otherwise.
1817
+ // documented substring otherwise. A term that named a terraform type
1818
+ // (#4366) matches resources of that type as well, and drops the substring.
1819
+ if (t.tfTypes?.has(terraformType(n) ?? "")) return true;
914
1820
  if (t.kinds) return t.kinds.has(n.kind ?? "");
1821
+ if (t.tfTypes) return false;
915
1822
  return (n.kind ?? "").toLowerCase().includes(t.a.toLowerCase());
916
1823
  }
917
1824
  if (t.kind === "attr") {
@@ -927,16 +1834,222 @@ function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>)
927
1834
  return key.toLowerCase() === t.a.toLowerCase() && (t.b === undefined || val.toLowerCase().includes(t.b.toLowerCase()));
928
1835
  });
929
1836
  }
930
- // bare word: substring over id, kind, and all attr values
931
- const hay = [n.id, n.kind, ...Object.values(attrs).map(attrString)].join(" ").toLowerCase();
1837
+ // bare word: substring over id, kind, and all attr values, less the ones
1838
+ // that hold text about other nodes (see BARE_WORD_SKIPS).
1839
+ const skip = BARE_WORD_SKIPS[n.lexicon];
1840
+ const values = Object.entries(attrs)
1841
+ .filter(([k]) => !skip?.has(k))
1842
+ .map(([, v]) => attrString(v));
1843
+ const hay = [n.id, n.kind, ...values].join(" ").toLowerCase();
932
1844
  return hay.includes(t.a.toLowerCase());
933
1845
  }
934
1846
 
1847
+ /**
1848
+ * One edge on the path a match was reached by (#4359), in walk order from the
1849
+ * match outward. `from` and `to` keep the edge's own direction (`from` holds
1850
+ * the reference, `to` is what it names), whichever way the term walked, so a
1851
+ * reader can find the edge in the graph by its endpoints. Node ids are the
1852
+ * graph's full ids, stack qualification included.
1853
+ */
1854
+ export interface PathHop {
1855
+ from: string;
1856
+ /** The consumer attribute the reference flows through (`IREdge.viaAttr`), when known. */
1857
+ fromAttr?: string;
1858
+ to: string;
1859
+ /** The producer attribute read (`IREdge.toAttr`), when known. */
1860
+ toAttr?: string;
1861
+ /**
1862
+ * On a capability's path (#4375): the name the capability rules give this
1863
+ * edge (`subnet`, `statement`, `resource`). Its `from` may be a node inside
1864
+ * another (a policy statement, `<holder>#policy[0].Statement[1]`).
1865
+ */
1866
+ edge?: string;
1867
+ }
1868
+
1869
+ const adjacency = new WeakMap<GraphIR, { out: Map<string, IREdge[]>; in: Map<string, IREdge[]> }>();
1870
+
1871
+ function edgeIndex(ir: GraphIR): { out: Map<string, IREdge[]>; in: Map<string, IREdge[]> } {
1872
+ let idx = adjacency.get(ir);
1873
+ if (!idx) {
1874
+ idx = adjacencyOf(ir.edges ?? []);
1875
+ adjacency.set(ir, idx);
1876
+ }
1877
+ return idx;
1878
+ }
1879
+
1880
+ function sameName(a: string | undefined, b: string): boolean {
1881
+ return a !== undefined && a.toLowerCase() === b.toLowerCase();
1882
+ }
1883
+
1884
+ /**
1885
+ * The shortest path by which an edge term reaches `n` (#4359), or null.
1886
+ *
1887
+ * Breadth-first over the edges in the term's direction, keeping only edges
1888
+ * whose attributes satisfy its qualifier, and accepting a node that satisfies
1889
+ * the target leaf (any node when the term has none) at a depth inside the
1890
+ * term's range. A plain `->`/`<-` is the range 1..1.
1891
+ *
1892
+ * The walk is `walkBreadthFirst` (graph-walk.ts), shared with carve's
1893
+ * reachability (#2325); it is what terminates on a cycle.
1894
+ */
1895
+ function findPath(n: IRNode, t: Term, ir: GraphIR, byId: Map<string, IRNode>): PathHop[] | null {
1896
+ if (t.kind !== "edge") return null;
1897
+ const { min, max } = t.depth ?? { min: 1, max: 1 };
1898
+ const idx = edgeIndex(ir);
1899
+ const out = t.dir === "out";
1900
+ // A capability edge (#4375) stands for the hops that proved it.
1901
+ const steps = (id: string): { far: string; hops: PathHop[] }[] => {
1902
+ if (t.capability !== undefined) return capabilitySteps(id, t.capability, out, ir);
1903
+ return (((out ? idx.out.get(id) : idx.in.get(id)) ?? [])
1904
+ .filter((e) => (t.viaAttr === undefined || sameName(e.viaAttr, t.viaAttr)) && (t.toAttr === undefined || sameName(e.toAttr, t.toAttr)))
1905
+ .map((e) => ({
1906
+ far: out ? e.to : e.from,
1907
+ hops: [{ from: e.from, ...(e.viaAttr ? { fromAttr: e.viaAttr } : {}), to: e.to, ...(e.toAttr ? { toAttr: e.toAttr } : {}) }],
1908
+ })));
1909
+ };
1910
+ // A node the graph does not hold is no step at all, and the match is the
1911
+ // first arrival in range that satisfies the target leaf (#4359).
1912
+ const found = walkBreadthFirst<PathHop>(n.id, {
1913
+ min,
1914
+ max,
1915
+ steps: (id) => steps(id).filter((step) => byId.has(step.far)),
1916
+ arrive: ({ id }) => !t.sub || matchTerm(byId.get(id)!, t.sub, ir, byId),
1917
+ });
1918
+ return found ? found.hops : null;
1919
+ }
1920
+
1921
+ function bareId(id: string): string {
1922
+ return id.includes("::") ? id.slice(id.lastIndexOf("::") + 2) : id;
1923
+ }
1924
+
1925
+ /**
1926
+ * A path as one line of `id.attr` steps, read from the match outward (#4359).
1927
+ * Walking `->`, each hop reads `consumer.via -> producer.attr`; walking `<-`,
1928
+ * `producer.attr <- consumer.via`. Hops are joined with `; `.
1929
+ */
1930
+ function formatPath(hops: PathHop[], dir: "out" | "in"): string {
1931
+ const end = (id: string, attr?: string): string => (attr ? `${bareId(id)}.${attr}` : bareId(id));
1932
+ return hops
1933
+ .map((h) =>
1934
+ dir === "out"
1935
+ ? `${end(h.from, h.fromAttr)} -> ${end(h.to, h.toAttr)}`
1936
+ : `${end(h.to, h.toAttr)} <- ${end(h.from, h.fromAttr)}`,
1937
+ )
1938
+ .join("; ");
1939
+ }
1940
+
1941
+ /** The edge operator as written: arrow, depth and qualifier, no target. */
1942
+ function edgeOperator(t: Term, arrows: { out: string; in: string }): string {
1943
+ const arrow = t.dir === "out" ? arrows.out : arrows.in;
1944
+ const depth = !t.depth
1945
+ ? ""
1946
+ : t.depth.min === 1 && t.depth.max === Infinity
1947
+ ? "*"
1948
+ : t.depth.min === t.depth.max
1949
+ ? `{${t.depth.min}}`
1950
+ : `{${t.depth.min},${t.depth.max === Infinity ? "" : t.depth.max}}`;
1951
+ const quals = [
1952
+ ...(t.viaAttr !== undefined ? [`via=${t.viaAttr}`] : []),
1953
+ ...(t.toAttr !== undefined ? [`to=${t.toAttr}`] : []),
1954
+ ...(t.capability !== undefined ? [`cap=${t.capability}`] : []),
1955
+ ];
1956
+ return `${arrow}${depth}${quals.length > 0 ? `[${quals.join(",")}]` : ""}`;
1957
+ }
1958
+
1959
+ /** Each positive edge term's path to `n`, in query order (#4359). */
1960
+ function matchPaths(n: IRNode, terms: Term[], ir: GraphIR, byId: Map<string, IRNode>): MatchPath[] {
1961
+ const out: MatchPath[] = [];
1962
+ for (const t of terms) {
1963
+ if (t.negated) continue;
1964
+ if (t.kind === "cap") {
1965
+ const ev = capabilityEvidence(n, t, ir);
1966
+ if (ev) out.push({ term: t, hops: ev.hops.map(pathHop), because: becauseOf(ev) });
1967
+ continue;
1968
+ }
1969
+ if (t.kind !== "edge") continue;
1970
+ const hops = findPath(n, t, ir, byId);
1971
+ if (hops) out.push({ term: t, hops });
1972
+ }
1973
+ return out;
1974
+ }
1975
+
1976
+ /** A term's path to a match: one per positive edge or `cap:` term. */
1977
+ interface MatchPath {
1978
+ term: Term;
1979
+ hops: PathHop[];
1980
+ /** For a `cap:` term (#4375): the node sets the match relied on, each with its own path. */
1981
+ because?: { capability: string; id: string; hops: PathHop[] }[];
1982
+ }
1983
+
1984
+ /**
1985
+ * The evidence for a `cap:` term on a node (#4375): its path when the node
1986
+ * is in the set, or the source of an edge capability, else undefined. A
1987
+ * capability or argument the catalog can't take throws a CapabilityError,
1988
+ * which `runSearch` turns into a refusal before any node is tested.
1989
+ */
1990
+ function capabilityEvidence(n: IRNode, t: Term, ir: GraphIR): Evidence | undefined {
1991
+ const call = parseCapabilityCall(t.a);
1992
+ if (!call) return undefined;
1993
+ const engine = capabilityEngine(ir);
1994
+ const args = engine.bind(call.name, call.args);
1995
+ if (engine.rule(call.name)!.vars.length === 1) return engine.set(call.name, args).get(n.id);
1996
+ const targets = engine.relation(call.name, args).out.get(n.id);
1997
+ return targets ? targets.values().next().value : undefined;
1998
+ }
1999
+
2000
+ /** One step along an edge capability from `id` (#4375), each with the hops that proved it, in walk order. */
2001
+ function capabilitySteps(id: string, call: string, out: boolean, ir: GraphIR): { far: string; hops: PathHop[] }[] {
2002
+ const parsed = parseCapabilityCall(call);
2003
+ if (!parsed) return [];
2004
+ const engine = capabilityEngine(ir);
2005
+ const rel = engine.relation(parsed.name, engine.bind(parsed.name, parsed.args));
2006
+ const m = (out ? rel.out : rel.in).get(id);
2007
+ if (!m) return [];
2008
+ return [...m].map(([far, ev]) => ({ far, hops: (out ? ev.hops : [...ev.hops].reverse()).map(pathHop) }));
2009
+ }
2010
+
2011
+ function pathHop(h: { from: string; fromAttr?: string; to: string; toAttr?: string; edge: string }): PathHop {
2012
+ return { from: h.from, ...(h.fromAttr ? { fromAttr: h.fromAttr } : {}), to: h.to, ...(h.toAttr ? { toAttr: h.toAttr } : {}), edge: h.edge };
2013
+ }
2014
+
2015
+ function becauseOf(ev: Evidence): MatchPath["because"] {
2016
+ const out: NonNullable<MatchPath["because"]> = [];
2017
+ const walk = (e: Evidence): void => {
2018
+ for (const b of e.because) {
2019
+ out.push({ capability: b.capability, id: b.id, hops: b.evidence.hops.map(pathHop) });
2020
+ walk(b.evidence);
2021
+ }
2022
+ };
2023
+ walk(ev);
2024
+ return out.length > 0 ? out : undefined;
2025
+ }
2026
+
2027
+ /**
2028
+ * A capability's path as one line (#4375), in #4359's hop format, read from
2029
+ * `start`: each hop is `consumer.via -> producer.attr` when the walk follows
2030
+ * the reference, `producer.attr <- consumer.via` when it goes against it. A
2031
+ * hop with no IR attribute names the capability edge instead (`-statement->`).
2032
+ */
2033
+ function formatHops(hops: PathHop[], start: string): string {
2034
+ const end = (id: string, attr?: string): string => (attr ? `${bareId(id)}.${attr}` : bareId(id));
2035
+ let at = start;
2036
+ return hops
2037
+ .map((h) => {
2038
+ const forward = h.from === at || h.to !== at;
2039
+ at = forward ? h.to : h.from;
2040
+ return forward
2041
+ ? `${end(h.from, h.fromAttr)} -> ${end(h.to, h.toAttr)}`
2042
+ : `${end(h.to, h.toAttr)} <- ${end(h.from, h.fromAttr)}`;
2043
+ })
2044
+ .join("; ");
2045
+ }
2046
+
935
2047
  function formatRow(n: IRNode, show: string[]): string {
936
2048
  const attrs = (n.attrs ?? {}) as Record<string, unknown>;
937
2049
  // Display the bare logical id, not the `${stack}::` qualification (#1162).
938
2050
  const displayId = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
939
- const parts: string[] = [displayId, n.kind ?? ""];
2051
+ // A terraform resource shows its type (#4366); its IR kind is `Terraform::Resource` for all of them.
2052
+ const parts: string[] = [displayId, displayKind(n) ?? ""];
940
2053
  // Prefer the node-level live physicalId (set by the overlay), then attrs;
941
2054
  // skip source-mode AttrRef placeholders (objects).
942
2055
  const physical = (n as { physicalId?: unknown }).physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
@@ -945,6 +2058,24 @@ function formatRow(n: IRNode, show: string[]): string {
945
2058
  // blank there reads as "declared, not provisioned", which the read never
946
2059
  // established; the reason is the tri-state's (#1089).
947
2060
  else if (typeof attrs["_unobserved"] === "string") parts.push(`(unobserved: ${attrs["_unobserved"]})`);
2061
+ for (const [actual, v] of showValues(attrs, show)) {
2062
+ // A column the caller explicitly asked for is shown whatever shape it is.
2063
+ // Skipping non-scalars silently meant `--show effectiveIngress` — the
2064
+ // derived reachability fact, and the reason to reach for chant at all —
2065
+ // printed a blank column, because it is a list. The agent read that as
2066
+ // "chant does not have this" and hand-rolled the answer from raw
2067
+ // security-group rows, which is exactly the work the fold exists to avoid.
2068
+ parts.push(`${actual}=${typeof v === "object" ? JSON.stringify(v) : attrString(v)}`);
2069
+ }
2070
+ return parts.filter(Boolean).join(" ");
2071
+ }
2072
+
2073
+ /**
2074
+ * The `--show` columns a row carries, as `[name as the graph spells it, value]`,
2075
+ * in the order asked. Shared by the text row and `--json` (#4367).
2076
+ */
2077
+ function showValues(attrs: Record<string, unknown>, show: string[]): [string, unknown][] {
2078
+ const out: [string, unknown][] = [];
948
2079
  for (const key of show) {
949
2080
  // Match the name case-insensitively, and report what was actually found.
950
2081
  // AWS attribute names are PascalCase and chant's derived ones are not, so a
@@ -954,16 +2085,10 @@ function formatRow(n: IRNode, show: string[]): string {
954
2085
  const actual = key in attrs ? key : Object.keys(attrs).find((k) => k.toLowerCase() === key.toLowerCase());
955
2086
  const v = actual == null ? undefined : attrs[actual];
956
2087
  if (v == null) continue;
957
- // A column the caller explicitly asked for is shown whatever shape it is.
958
- // Skipping non-scalars silently meant `--show effectiveIngress` — the
959
- // derived reachability fact, and the reason to reach for chant at all —
960
- // printed a blank column, because it is a list. The agent read that as
961
- // "chant does not have this" and hand-rolled the answer from raw
962
- // security-group rows, which is exactly the work the fold exists to avoid.
963
- parts.push(`${actual}=${typeof v === "object" ? JSON.stringify(v) : attrString(v)}`);
2088
+ out.push([actual!, v]);
964
2089
  }
965
- return parts.filter(Boolean).join(" ");
2090
+ return out;
966
2091
  }
967
2092
 
968
2093
  /** Internals exposed for unit tests. */
969
- export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance, renderDrift, mergeResources, pickResources };
2094
+ export const __searchInternals = { parseQuery, matchTerm, findPath, formatPath, searchJson, searchErrorJson, explainData, termText, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance, provenanceLine, renderDrift, searchDriftJson, mergeResources, pickResources };