@intentius/chant 0.124.44 → 0.124.46

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 (249) hide show
  1. package/dist/capabilities/aws.paths +148 -0
  2. package/dist/capabilities/base-mappings.json +122 -0
  3. package/dist/capabilities/catalog.d.ts +89 -0
  4. package/dist/capabilities/catalog.d.ts.map +1 -0
  5. package/dist/capabilities/catalog.js +204 -0
  6. package/dist/capabilities/language.d.ts +103 -0
  7. package/dist/capabilities/language.d.ts.map +1 -0
  8. package/dist/capabilities/language.js +335 -0
  9. package/dist/capabilities/matcher.d.ts +65 -0
  10. package/dist/capabilities/matcher.d.ts.map +1 -0
  11. package/dist/capabilities/matcher.js +299 -0
  12. package/dist/capabilities/view.d.ts +207 -0
  13. package/dist/capabilities/view.d.ts.map +1 -0
  14. package/dist/capabilities/view.js +484 -0
  15. package/dist/ci-identity.d.ts +183 -12
  16. package/dist/ci-identity.d.ts.map +1 -1
  17. package/dist/ci-identity.js +362 -53
  18. package/dist/cli/commands/lint.d.ts.map +1 -1
  19. package/dist/cli/commands/lint.js +1 -0
  20. package/dist/cli/handlers/build.d.ts.map +1 -1
  21. package/dist/cli/handlers/build.js +17 -1
  22. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  23. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  24. package/dist/cli/handlers/declared-graph.js +87 -0
  25. package/dist/cli/handlers/graph.d.ts +25 -0
  26. package/dist/cli/handlers/graph.d.ts.map +1 -1
  27. package/dist/cli/handlers/graph.js +93 -87
  28. package/dist/cli/handlers/pr-env.d.ts +8 -2
  29. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  30. package/dist/cli/handlers/pr-env.js +127 -225
  31. package/dist/cli/handlers/pr.d.ts +3 -0
  32. package/dist/cli/handlers/pr.d.ts.map +1 -1
  33. package/dist/cli/handlers/pr.js +1 -0
  34. package/dist/cli/handlers/search-count.d.ts +89 -0
  35. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  36. package/dist/cli/handlers/search-count.js +132 -0
  37. package/dist/cli/handlers/search.d.ts +358 -6
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/handlers/search.js +1065 -155
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +23 -3
  42. package/dist/cli/mcp/server.d.ts.map +1 -1
  43. package/dist/cli/mcp/server.js +3 -0
  44. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  45. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  46. package/dist/cli/mcp/tools/capabilities.js +27 -0
  47. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  48. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  49. package/dist/cli/mcp/workspace-tools.js +47 -2
  50. package/dist/cli/parse-args.d.ts.map +1 -1
  51. package/dist/cli/parse-args.js +64 -17
  52. package/dist/cli/registry.d.ts +22 -0
  53. package/dist/cli/registry.d.ts.map +1 -1
  54. package/dist/components/cli-support.d.ts +2 -0
  55. package/dist/components/cli-support.d.ts.map +1 -1
  56. package/dist/components/cli-support.js +4 -2
  57. package/dist/components/fan-out-support.d.ts +3 -0
  58. package/dist/components/fan-out-support.d.ts.map +1 -1
  59. package/dist/components/fan-out-support.js +1 -1
  60. package/dist/components/pr-env-ports.d.ts +29 -0
  61. package/dist/components/pr-env-ports.d.ts.map +1 -0
  62. package/dist/components/pr-env-ports.js +77 -0
  63. package/dist/components/pr-env-run.d.ts +254 -0
  64. package/dist/components/pr-env-run.d.ts.map +1 -0
  65. package/dist/components/pr-env-run.js +376 -0
  66. package/dist/components/pr-environment.d.ts +91 -15
  67. package/dist/components/pr-environment.d.ts.map +1 -1
  68. package/dist/components/pr-environment.js +95 -9
  69. package/dist/components/pr-pipeline.d.ts +10 -1
  70. package/dist/components/pr-pipeline.d.ts.map +1 -1
  71. package/dist/config-environments.d.ts +92 -0
  72. package/dist/config-environments.d.ts.map +1 -0
  73. package/dist/config-environments.js +102 -0
  74. package/dist/config.d.ts +24 -84
  75. package/dist/config.d.ts.map +1 -1
  76. package/dist/config.js +9 -96
  77. package/dist/graph-detail.d.ts.map +1 -1
  78. package/dist/graph-detail.js +15 -3
  79. package/dist/graph-effective.d.ts +33 -2
  80. package/dist/graph-effective.d.ts.map +1 -1
  81. package/dist/graph-effective.js +215 -68
  82. package/dist/graph-ir.d.ts +23 -0
  83. package/dist/graph-ir.d.ts.map +1 -1
  84. package/dist/graph-ir.js +130 -0
  85. package/dist/graph-sub-template.d.ts +11 -0
  86. package/dist/graph-sub-template.d.ts.map +1 -0
  87. package/dist/graph-sub-template.js +19 -0
  88. package/dist/lexicon.d.ts +9 -0
  89. package/dist/lexicon.d.ts.map +1 -1
  90. package/dist/lifecycle/observe.d.ts +28 -0
  91. package/dist/lifecycle/observe.d.ts.map +1 -1
  92. package/dist/lifecycle/observe.js +23 -1
  93. package/dist/lint/rule.d.ts +6 -0
  94. package/dist/lint/rule.d.ts.map +1 -1
  95. package/dist/live-endpoint.d.ts +1 -1
  96. package/dist/live-endpoint.d.ts.map +1 -1
  97. package/dist/live-endpoint.js +1 -1
  98. package/dist/observation.d.ts +48 -1
  99. package/dist/observation.d.ts.map +1 -1
  100. package/dist/observation.js +21 -5
  101. package/dist/op/activities/activity-contracts.d.ts +4 -4
  102. package/dist/op/op-waves.d.ts +17 -0
  103. package/dist/op/op-waves.d.ts.map +1 -1
  104. package/dist/op/op-waves.js +23 -1
  105. package/dist/pr-forge.d.ts +15 -1
  106. package/dist/pr-forge.d.ts.map +1 -1
  107. package/dist/pr-forge.js +28 -16
  108. package/dist/pr-requires.d.ts +5 -1
  109. package/dist/pr-requires.d.ts.map +1 -1
  110. package/dist/wave-runtime.mjs +251 -250
  111. package/dist/workspace/capabilities-contract.d.ts +66 -0
  112. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  113. package/dist/workspace/capabilities-contract.js +107 -0
  114. package/dist/workspace/capabilities.schema.json +229 -0
  115. package/dist/workspace/ci-cloud-identity.d.ts +3 -1
  116. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  117. package/dist/workspace/ci-cloud-identity.js +16 -1
  118. package/dist/workspace/ci-run.d.ts +35 -8
  119. package/dist/workspace/ci-run.d.ts.map +1 -1
  120. package/dist/workspace/ci-run.js +95 -21
  121. package/dist/workspace/ci-secrets.d.ts.map +1 -1
  122. package/dist/workspace/ci-secrets.js +4 -3
  123. package/dist/workspace/compose-graph.d.ts +9 -0
  124. package/dist/workspace/compose-graph.d.ts.map +1 -1
  125. package/dist/workspace/compose-graph.js +3 -1
  126. package/dist/workspace/conformance/index.d.ts +1 -1
  127. package/dist/workspace/conformance/index.d.ts.map +1 -1
  128. package/dist/workspace/conformance/index.js +39 -2
  129. package/dist/workspace/declaration.d.ts +10 -0
  130. package/dist/workspace/declaration.d.ts.map +1 -1
  131. package/dist/workspace/declaration.js +17 -1
  132. package/dist/workspace/declaration.schema.json +36 -1
  133. package/dist/workspace/graph-cache.d.ts +8 -0
  134. package/dist/workspace/graph-cache.d.ts.map +1 -1
  135. package/dist/workspace/graph-cache.js +27 -3
  136. package/dist/workspace/graph-cli.d.ts +39 -2
  137. package/dist/workspace/graph-cli.d.ts.map +1 -1
  138. package/dist/workspace/graph-cli.js +66 -24
  139. package/dist/workspace/graph.schema.json +148 -0
  140. package/dist/workspace/kind-readers.d.ts +3 -0
  141. package/dist/workspace/kind-readers.d.ts.map +1 -1
  142. package/dist/workspace/kind-readers.js +7 -1
  143. package/dist/workspace/member-commands.d.ts +10 -1
  144. package/dist/workspace/member-commands.d.ts.map +1 -1
  145. package/dist/workspace/member-commands.js +17 -1
  146. package/dist/workspace/nested-graph.d.ts +2 -0
  147. package/dist/workspace/nested-graph.d.ts.map +1 -1
  148. package/dist/workspace/nested-graph.js +32 -1
  149. package/dist/workspace/reason-codes.d.ts +10 -0
  150. package/dist/workspace/reason-codes.d.ts.map +1 -1
  151. package/dist/workspace/reason-codes.js +12 -0
  152. package/dist/workspace/search-cli.d.ts +204 -0
  153. package/dist/workspace/search-cli.d.ts.map +1 -0
  154. package/dist/workspace/search-cli.js +466 -0
  155. package/dist/workspace/search-contract.d.ts +56 -0
  156. package/dist/workspace/search-contract.d.ts.map +1 -0
  157. package/dist/workspace/search-contract.js +70 -0
  158. package/dist/workspace/search.schema.json +822 -0
  159. package/package.json +1 -1
  160. package/src/__snapshots__/okf.test.ts.snap +10 -0
  161. package/src/capabilities/aws.paths +148 -0
  162. package/src/capabilities/base-mappings.json +122 -0
  163. package/src/capabilities/catalog.ts +230 -0
  164. package/src/capabilities/language.test.ts +191 -0
  165. package/src/capabilities/language.ts +373 -0
  166. package/src/capabilities/matcher.ts +315 -0
  167. package/src/capabilities/view-compose.test.ts +76 -0
  168. package/src/capabilities/view.ts +631 -0
  169. package/src/ci-identity-targets.test.ts +275 -0
  170. package/src/ci-identity.ts +394 -61
  171. package/src/cli/commands/lint.ts +1 -0
  172. package/src/cli/handlers/build.ts +15 -1
  173. package/src/cli/handlers/declared-graph.ts +99 -0
  174. package/src/cli/handlers/graph.test.ts +94 -3
  175. package/src/cli/handlers/graph.ts +123 -96
  176. package/src/cli/handlers/pr-env.ts +135 -227
  177. package/src/cli/handlers/pr.ts +4 -0
  178. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  179. package/src/cli/handlers/search-count.test.ts +191 -0
  180. package/src/cli/handlers/search-count.ts +165 -0
  181. package/src/cli/handlers/search-json.test.ts +333 -0
  182. package/src/cli/handlers/search-live.test.ts +387 -0
  183. package/src/cli/handlers/search-paths.test.ts +235 -0
  184. package/src/cli/handlers/search.ts +1312 -166
  185. package/src/cli/main.test.ts +28 -3
  186. package/src/cli/main.ts +23 -3
  187. package/src/cli/mcp/server.test.ts +3 -3
  188. package/src/cli/mcp/server.ts +3 -0
  189. package/src/cli/mcp/tools/capabilities.ts +32 -0
  190. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  191. package/src/cli/mcp/workspace-tools.ts +47 -2
  192. package/src/cli/parse-args.ts +52 -13
  193. package/src/cli/registry.ts +22 -0
  194. package/src/components/cli-support.ts +6 -1
  195. package/src/components/fan-out-support.ts +4 -1
  196. package/src/components/pr-env-ports.ts +89 -0
  197. package/src/components/pr-env-run-bundle.test.ts +54 -0
  198. package/src/components/pr-env-run.test.ts +290 -0
  199. package/src/components/pr-env-run.ts +563 -0
  200. package/src/components/pr-environment.ts +161 -13
  201. package/src/components/pr-pipeline.ts +10 -1
  202. package/src/config-environments.ts +133 -0
  203. package/src/config.ts +37 -124
  204. package/src/graph-detail.ts +15 -3
  205. package/src/graph-effective.ts +226 -75
  206. package/src/graph-ir.test.ts +163 -0
  207. package/src/graph-ir.ts +147 -0
  208. package/src/graph-sub-template.ts +22 -0
  209. package/src/lexicon.ts +10 -0
  210. package/src/lifecycle/observe.test.ts +27 -1
  211. package/src/lifecycle/observe.ts +43 -1
  212. package/src/lint/rule.ts +6 -0
  213. package/src/live-endpoint.ts +1 -1
  214. package/src/observation.test.ts +25 -0
  215. package/src/observation.ts +69 -5
  216. package/src/op/op-waves.ts +38 -1
  217. package/src/pr-forge-bundle.test.ts +1 -1
  218. package/src/pr-forge.ts +44 -17
  219. package/src/pr-requires.test.ts +43 -6
  220. package/src/pr-requires.ts +5 -1
  221. package/src/workspace/capabilities-contract.test.ts +88 -0
  222. package/src/workspace/capabilities-contract.ts +132 -0
  223. package/src/workspace/capabilities.schema.json +229 -0
  224. package/src/workspace/ci-cloud-identity.test.ts +20 -0
  225. package/src/workspace/ci-cloud-identity.ts +14 -1
  226. package/src/workspace/ci-run.test.ts +74 -1
  227. package/src/workspace/ci-run.ts +122 -29
  228. package/src/workspace/ci-secrets.test.ts +1 -1
  229. package/src/workspace/ci-secrets.ts +4 -3
  230. package/src/workspace/compose-graph.ts +10 -1
  231. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  232. package/src/workspace/conformance/index.ts +31 -2
  233. package/src/workspace/declaration.schema.json +36 -1
  234. package/src/workspace/declaration.test.ts +7 -0
  235. package/src/workspace/declaration.ts +24 -2
  236. package/src/workspace/graph-cache.ts +27 -3
  237. package/src/workspace/graph-cli.ts +96 -24
  238. package/src/workspace/graph.schema.json +148 -0
  239. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  240. package/src/workspace/kind-readers.test.ts +1 -1
  241. package/src/workspace/kind-readers.ts +7 -1
  242. package/src/workspace/member-commands.ts +22 -1
  243. package/src/workspace/nested-graph.ts +32 -1
  244. package/src/workspace/reason-codes.test.ts +7 -0
  245. package/src/workspace/reason-codes.ts +12 -0
  246. package/src/workspace/search-cli.e2e.test.ts +305 -0
  247. package/src/workspace/search-cli.ts +591 -0
  248. package/src/workspace/search-contract.ts +95 -0
  249. package/src/workspace/search.schema.json +822 -0
@@ -25,8 +25,14 @@
25
25
  * `down` reads the environment's record on chant/lifecycle, checks it names
26
26
  * this pull request's environment, and tears down what `planTeardown` finds
27
27
  * under the record's marker, after dropping any entry whose marker is not
28
- * exactly that one. No gate holds it: closing the pull request is the
29
- * decision, as is the TTL the base set for `sweep`.
28
+ * exactly that one. The lexicons tear down what they own, and the command
29
+ * `ci.prEnvironment.teardown` names tears down the rest (#4330). No gate
30
+ * holds it: closing the pull request is the decision, as is the TTL the base
31
+ * set for `sweep`.
32
+ *
33
+ * The runs themselves are ../../components/pr-env-run.ts, which a product
34
+ * calls with an adapter of its own (#4330). This file supplies the
35
+ * components adapter and the CLI's ports.
30
36
  */
31
37
 
32
38
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -37,39 +43,40 @@ import { isEmptyChangeSet } from "../../change-set";
37
43
  import {
38
44
  PR_ENV_EXIT,
39
45
  PR_ENV_GATE,
40
- PR_ENV_LEDGER_DIR,
41
- PR_ENV_LEDGER_FILE,
42
46
  PR_ENV_RECORD_FILE,
43
47
  PR_ENV_REPORT_DIR,
44
48
  acceptHandedRecord,
45
49
  handedPrEnvReport,
46
50
  PR_ENV_TEMPLATE_VAR,
47
51
  derivePrEnvironment,
48
- expired,
49
- livePrEnvironments,
50
- parsePrEnvRecords,
51
52
  prEnvApproveCommand,
52
- prEnvNoteMarker,
53
- prEnvStatusContext,
54
- prEnvStatusDescription,
55
53
  prEnvStatusState,
56
54
  prEnvironmentName,
55
+ prEnvironmentSite,
57
56
  renderPrEnvNote,
58
- teardownComplete,
59
- teardownCounts,
60
- tearDownPrEnvironment,
61
57
  ttlMs,
62
- type LivePrEnvironment,
63
- type PrEnvRecord,
64
58
  type PrEnvReport,
59
+ type PrEnvReportRow,
60
+ type PrEnvTeardownAdapter,
65
61
  } from "../../components/pr-environment";
66
- import { applyPrSet, approvalOf, decidePrGate, readOnlyLedger } from "../../components/pr-run";
67
- import { gitGateLedgerPort } from "../../op/gate";
62
+ import {
63
+ commandTeardownAdapter,
64
+ prEnvDown as prEnvDownRun,
65
+ prEnvOp,
66
+ prEnvSweep as prEnvSweepRun,
67
+ prEnvUp as prEnvUpRun,
68
+ type PrEnvAdapter,
69
+ type PrEnvPorts,
70
+ type PrEnvTarget,
71
+ } from "../../components/pr-env-run";
72
+ import { forgePrEnvPublisher, lifecyclePrEnvPorts } from "../../components/pr-env-ports";
73
+ import { applyPrSet, type PrPlanSet } from "../../components/pr-run";
68
74
  import { prOp } from "../../pr-loop";
69
75
  import { FORGE_KINDS, forgeFromEnv, forgePrincipalOf, type ForgeKind, type PrForge } from "../../pr-forge";
70
76
  import { formatError, formatInfo, formatWarning } from "../format";
71
77
  import { withComponentEndpoint } from "../component-endpoint";
72
78
  import type { CommandContext } from "../registry";
79
+ import type { Prepared } from "./pr";
73
80
 
74
81
  const USAGE = "chant components pr-env up --base <ref> --pr <n> [--no-push] | post --pr <n> --head <sha> | down --pr <n> [--reason closed|expired] | sweep";
75
82
 
@@ -107,36 +114,30 @@ function memberOf(ctx: CommandContext): string | undefined {
107
114
  return ctx.args.members?.length === 1 ? ctx.args.members[0] : undefined;
108
115
  }
109
116
 
110
- // ── the ledger ─────────────────────────────────────────────────────────────
117
+ // ── the ports ──────────────────────────────────────────────────────────────
111
118
 
112
- async function readRecords(): Promise<PrEnvRecord[]> {
113
- const { fetchLifecycle, readBlobFromPath } = await import("../../lifecycle/git");
114
- await fetchLifecycle().catch(() => false);
115
- return parsePrEnvRecords(await readBlobFromPath(PR_ENV_LEDGER_DIR, PR_ENV_LEDGER_FILE));
116
- }
119
+ const log = (line: string): void => console.error(formatInfo(line));
120
+ const warn = (message: string): void => console.error(formatWarning({ message }));
117
121
 
118
122
  /**
119
- * Append `record` to chant/lifecycle and push it. With `--no-push` (#4295)
120
- * the record is written to `pr-env-record.json` under `--output` instead,
121
- * for `pr-env post` to append from a job that runs none of the change's
122
- * code. A failure warns: what was applied or torn down stands.
123
+ * The ports `pr-env` runs with (../../components/pr-env-run.ts): the records
124
+ * and the gate ledger on chant/lifecycle here, and the report written under
125
+ * `--output` and posted through `forge`. With `--no-push` (#4295) the record
126
+ * is written to `pr-env-record.json` under `--output` instead, for `pr-env
127
+ * post` to append from a job that runs none of the change's code.
123
128
  */
124
- async function appendRecord(ctx: CommandContext, record: PrEnvRecord): Promise<void> {
125
- if (ctx.args.noPush) {
126
- const dir = resolve(ctx.args.output ?? PR_ENV_REPORT_DIR);
127
- mkdirSync(dir, { recursive: true });
128
- writeFileSync(join(dir, PR_ENV_RECORD_FILE), JSON.stringify(record, null, 2) + "\n");
129
- return;
130
- }
131
- const { appendLedgerLine, fetchLifecycle, pushLifecycleReport } = await import("../../lifecycle/git");
132
- try {
133
- await fetchLifecycle().catch(() => false);
134
- await appendLedgerLine(PR_ENV_LEDGER_DIR, PR_ENV_LEDGER_FILE, JSON.stringify(record), `Pull request environment ${record.env} ${record.kind} (${record.result})`);
135
- const pushed = await pushLifecycleReport();
136
- if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
137
- } catch (err) {
138
- console.error(formatWarning({ message: `could not record ${record.env}'s ${record.kind} on chant/lifecycle: ${err instanceof Error ? err.message : String(err)}` }));
139
- }
129
+ function cliPorts(ctx: CommandContext, forge: PrForge | undefined): PrEnvPorts {
130
+ const lifecycle = lifecyclePrEnvPorts({ ...(forge ? { forge } : {}), log, warn });
131
+ return {
132
+ ...lifecycle,
133
+ async appendRecord(record) {
134
+ if (!ctx.args.noPush) return lifecycle.appendRecord(record);
135
+ const dir = resolve(ctx.args.output ?? PR_ENV_REPORT_DIR);
136
+ mkdirSync(dir, { recursive: true });
137
+ writeFileSync(join(dir, PR_ENV_RECORD_FILE), JSON.stringify(record, null, 2) + "\n");
138
+ },
139
+ publish: (report) => finish(ctx, forge, report),
140
+ };
140
141
  }
141
142
 
142
143
  // ── the report and the note ────────────────────────────────────────────────
@@ -148,24 +149,7 @@ async function finish(ctx: CommandContext, forge: PrForge | undefined, report: P
148
149
  writeFileSync(join(dir, `pr-env-${report.stage}.json`), JSON.stringify(report, null, 2) + "\n");
149
150
  writeFileSync(join(dir, "pr-env-note.md"), note);
150
151
  if (ctx.args.json) console.log(JSON.stringify(report, null, 2));
151
- if (!forge) return;
152
- if (report.head) {
153
- try {
154
- await forge.setStatus(report.head, {
155
- context: prEnvStatusContext(report.member),
156
- state: prEnvStatusState(report),
157
- description: prEnvStatusDescription(report),
158
- ...(forge.runUrl ? { url: forge.runUrl } : {}),
159
- });
160
- } catch (err) {
161
- console.error(formatWarning({ message: `could not set the ${prEnvStatusContext(report.member)} status: ${err instanceof Error ? err.message : String(err)}` }));
162
- }
163
- }
164
- try {
165
- await forge.upsertNote(report.pr, prEnvNoteMarker(report.env, report.member), note);
166
- } catch (err) {
167
- console.error(formatWarning({ message: `could not post the note on #${report.pr}: ${err instanceof Error ? err.message : String(err)}` }));
168
- }
152
+ if (forge) await forgePrEnvPublisher(forge, warn)(report);
169
153
  }
170
154
 
171
155
  // ── up ─────────────────────────────────────────────────────────────────────
@@ -190,7 +174,6 @@ async function prEnvUp(ctx: CommandContext): Promise<number> {
190
174
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
191
175
  const derived = derivePrEnvironment(config, setting.template, pr);
192
176
  if ("refusal" in derived) return fail(derived.refusal);
193
- const ttl = ttlMs(setting.ttl) ?? ttlMs("72h")!;
194
177
 
195
178
  // The build sees pr-<n> as its environment and the template's name beside it.
196
179
  process.env[ENV_VAR] = derived.env;
@@ -200,111 +183,76 @@ async function prEnvUp(ctx: CommandContext): Promise<number> {
200
183
  args: { ...args, env: derived.env, param: [...(args.param ?? []).filter((p) => !p.startsWith(`${derived.param}=`)), `${derived.param}=${derived.env}`] },
201
184
  };
202
185
  console.error(formatInfo(`pr-env up: ${derived.env}, derived from ${derived.template}${derived.endpoint ? ` at ${derived.endpoint}` : ""}, marker ${derived.stack}/${derived.env}`));
203
- return withComponentEndpoint(envCtx, () => up(envCtx, pr, derived, ttl), { config: { ...config, environments: derived.environments }, environment: derived.env });
186
+ return withComponentEndpoint(envCtx, () => up(envCtx, pr, derived, setting.ttl), { config: { ...config, environments: derived.environments }, environment: derived.env });
204
187
  }
205
188
 
206
- async function up(ctx: CommandContext, pr: number, derived: Exclude<ReturnType<typeof derivePrEnvironment>, { refusal: string }>, ttl: number): Promise<number> {
189
+ async function up(ctx: CommandContext, pr: number, derived: Exclude<ReturnType<typeof derivePrEnvironment>, { refusal: string }>, ttl: string): Promise<number> {
207
190
  const { prepare, planAll, placeHooks, runStageHook } = await import("./pr");
208
191
  const p = await prepare(ctx, "plan");
209
192
  if (typeof p === "number") return p;
210
- const forge = p.forge;
211
- const op = prOp(pr, p.member);
212
193
  // ci.hooks (#4288), read at base as the other pull-request jobs read them, around the plan and the apply.
213
- placeHooks(ctx, p, "apply", op);
214
- const base: PrEnvReport = {
215
- stage: "up",
194
+ placeHooks(ctx, p, "apply", prEnvOp(pr, p.member));
195
+ const result = await prEnvUpRun({
216
196
  pr,
217
- env: derived.env,
218
- ...(p.member ? { member: p.member } : {}),
219
- template: derived.template,
220
197
  head: p.head,
221
- op,
222
- gate: PR_ENV_GATE,
223
- digest: "",
224
- status: "nothing",
198
+ template: derived.template,
199
+ ttl,
200
+ stack: derived.stack,
201
+ ...(p.member ? { member: p.member } : {}),
225
202
  ...(derived.endpoint ? { endpoint: derived.endpoint } : {}),
226
- components: [],
227
- };
228
- if (p.plan.order.length === 0) {
229
- await finish(ctx, forge, { ...base, ...(p.routedAway ? { message: p.routedAway } : {}) });
230
- console.error(formatInfo(`pr-env up: nothing this change touches is deployed by a component, so ${derived.env} gets nothing`));
231
- return PR_ENV_EXIT.done;
232
- }
203
+ adapter: componentsPrEnvAdapter(p, { planAll, runStageHook }),
204
+ ports: cliPorts(ctx, p.forge),
205
+ actor: actor(),
206
+ ...(p.forge?.runUrl ? { runUrl: p.forge.runUrl } : {}),
207
+ });
208
+ if (result.report.status === "waiting" || result.report.status === "changed") console.error(formatInfo(`approve : ${prEnvApproveCommand(result.report.op, result.report.digest)}`));
209
+ return result.exit;
210
+ }
233
211
 
234
- const set = await planAll(p);
235
- const rows = (status: (component: string) => string) =>
212
+ /**
213
+ * The components as a pull request environment's adapter: the plan is
214
+ * `pr-plan`'s, with the plan-side hooks around it, and the apply is the
215
+ * fan-out's, with the apply-side hooks around it. Each row carries the
216
+ * component's site in `pr-<n>`, carried over from the template's (#4330,
217
+ * `prEnvironmentSite`).
218
+ */
219
+ function componentsPrEnvAdapter(p: Prepared, run: { planAll: typeof import("./pr").planAll; runStageHook: typeof import("./pr").runStageHook }): PrEnvAdapter {
220
+ let set: PrPlanSet | undefined;
221
+ const rows = (target: PrEnvTarget, status: (component: string) => string): PrEnvReportRow[] =>
236
222
  p.plan.order.map((component) => {
237
- const ms = set.members.filter((m) => m.component === component);
223
+ const ms = (set?.members ?? []).filter((m) => m.component === component);
238
224
  const sum = (k: "create" | "update" | "replace" | "delete") => ms.reduce((n, m) => n + m.counts[k], 0);
239
- return { component, status: status(component), create: sum("create"), update: sum("update"), replace: sum("replace"), delete: sum("delete") };
225
+ const found = prEnvironmentSite(p.sites?.[component], target.template, target.env);
226
+ const site = found ? { ...(found.runtime ? { runtime: found.runtime } : {}), ...(found.url ? { url: found.url } : {}), ...(found.domain ? { domain: found.domain } : {}) } : undefined;
227
+ return { component, status: status(component), create: sum("create"), update: sum("update"), replace: sum("replace"), delete: sum("delete"), ...(site && Object.keys(site).length > 0 ? { site } : {}) };
240
228
  });
241
- const digest = set.doc.digest;
242
- if (set.failed) {
243
- await finish(ctx, forge, { ...base, digest, status: "plan-failed", ...(p.hookFailed ? { message: p.hookFailed } : {}), components: rows((c) => (set.members.some((m) => m.component === c && m.status === "plan-failed") ? "plan failed" : "planned")) });
244
- return PR_ENV_EXIT.failed;
245
- }
246
-
247
- // ws-080, ws-104: a non-empty plan applies only under a person's approval of its digest. An empty one passes.
248
- let approvedBy: string[] | undefined;
249
- if (!isEmptyChangeSet(set.doc)) {
250
- let approval;
251
- try {
252
- approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: PR_ENV_GATE, digest, description: `${derived.env}: ${p.plan.order.join(", ")}` }));
253
- } catch (err) {
254
- return fail(`could not read the gate ledger, so ${derived.env} does not apply: ${err instanceof Error ? err.message : String(err)}`);
255
- }
256
- if (approval.status !== "approved") {
257
- const status = approval.status === "changed" ? "changed" : "waiting";
258
- await finish(ctx, forge, { ...base, digest, status, components: rows(() => "planned") });
259
- console.error(formatInfo(`pr-env up: ${derived.env} waits for an approval of ${digest}`));
260
- console.error(formatInfo(`approve : ${prEnvApproveCommand(op, digest)}`));
261
- return PR_ENV_EXIT.waiting;
262
- }
263
- approvedBy = approval.approvedBy ?? [];
264
- }
265
-
266
- const hookStop = runStageHook(p, "before-apply", p.plan.order, set);
267
- if (hookStop) {
268
- await finish(ctx, forge, { ...base, digest, status: "failed", message: hookStop, components: rows(() => "planned"), ...(approvedBy ? { approvedBy } : {}) });
269
- return PR_ENV_EXIT.failed;
270
- }
271
- const applied = await applyPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: derived.env, set, gate: PR_ENV_GATE });
272
- const afterHook = runStageHook(p, "after-apply", p.plan.order, set, "the environment stands as applied");
273
- const status = (component: string) => applied.members.find((m) => m.component === component)?.status ?? "planned";
274
- const ok = applied.status === "applied";
275
- const now = new Date();
276
- const expiresAt = new Date(now.getTime() + ttl).toISOString();
277
- // A copy that failed part way is live too: what applied is torn down on close or at expiry like the rest.
278
- await appendRecord(ctx, {
279
- version: 1,
280
- kind: "up",
281
- pr,
282
- env: derived.env,
283
- ...(p.member ? { member: p.member } : {}),
284
- template: derived.template,
285
- marker: { stack: derived.stack, env: derived.env },
286
- commit: p.head,
287
- components: p.plan.order,
288
- planDigest: digest,
289
- result: ok ? "applied" : "failed",
290
- by: actor(),
291
- timestamp: now.toISOString(),
292
- expiresAt,
293
- ...(approvedBy ? { approvedBy } : {}),
294
- releaseTarget: false,
295
- ...(forge?.runUrl ? { runUrl: forge.runUrl } : {}),
296
- });
297
- await finish(ctx, forge, {
298
- ...base,
299
- digest,
300
- status: ok ? "applied" : "failed",
301
- components: rows(status),
302
- ...(approvedBy ? { approvedBy } : {}),
303
- expiresAt,
304
- ...(ok ? (afterHook ? { message: afterHook } : {}) : { message: applied.status === "waiting" ? "A component read an upstream output that moved when it applied; run the job again to plan it against the new value." : "A component failed to apply; what applied stays in the environment and is torn down with it." }),
305
- });
306
- console.error((ok ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-env up: ${derived.env} ${ok ? "applied" : applied.status}; it expires ${expiresAt}`));
307
- return ok && !afterHook ? PR_ENV_EXIT.done : PR_ENV_EXIT.failed;
229
+ return {
230
+ async plan(target) {
231
+ if (p.plan.order.length === 0) return { digest: "", empty: true, rows: [], ...(p.routedAway ? { message: p.routedAway } : {}) };
232
+ set = await run.planAll(p);
233
+ const planned = set;
234
+ return {
235
+ digest: planned.doc.digest,
236
+ empty: isEmptyChangeSet(planned.doc),
237
+ ...(planned.failed ? { failed: true } : {}),
238
+ rows: rows(target, (c) => (planned.failed && planned.members.some((m) => m.component === c && m.status === "plan-failed") ? "plan failed" : "planned")),
239
+ ...(p.hookFailed ? { message: p.hookFailed } : {}),
240
+ };
241
+ },
242
+ async apply(target) {
243
+ const planned = set!;
244
+ const hookStop = run.runStageHook(p, "before-apply", p.plan.order, planned);
245
+ if (hookStop) return { status: "stopped", rows: rows(target, () => "planned"), message: hookStop };
246
+ const applied = await applyPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: target.env, set: planned, gate: PR_ENV_GATE });
247
+ const afterHook = run.runStageHook(p, "after-apply", p.plan.order, planned, "the environment stands as applied");
248
+ const status = (component: string) => applied.members.find((m) => m.component === component)?.status ?? "planned";
249
+ return {
250
+ status: applied.status === "applied" ? "applied" : applied.status === "waiting" ? "waiting" : "failed",
251
+ rows: rows(target, status),
252
+ ...(afterHook ? { afterFailure: afterHook } : {}),
253
+ };
254
+ },
255
+ };
308
256
  }
309
257
 
310
258
  // ── post ───────────────────────────────────────────────────────────────────
@@ -352,7 +300,7 @@ async function prEnvPost(ctx: CommandContext): Promise<number> {
352
300
  report.message = `${report.message ? `${report.message} ` : ""}${accepted.refusal}`;
353
301
  code = PR_ENV_EXIT.failed;
354
302
  } else {
355
- await appendRecord({ ...ctx, args: { ...args, noPush: false } }, accepted.record);
303
+ await lifecyclePrEnvPorts({ warn }).appendRecord(accepted.record);
356
304
  if (accepted.record.expiresAt) report.expiresAt = accepted.record.expiresAt;
357
305
  }
358
306
  }
@@ -363,6 +311,19 @@ async function prEnvPost(ctx: CommandContext): Promise<number> {
363
311
 
364
312
  // ── down ───────────────────────────────────────────────────────────────────
365
313
 
314
+ /**
315
+ * What tears the environment down: the lexicons, and the command
316
+ * `ci.prEnvironment.teardown` names in the declaration at this commit
317
+ * (#4330), which the down and sweep jobs check out from the base branch.
318
+ */
319
+ async function teardownSources(): Promise<{ config: ChantConfig; adapters: PrEnvTeardownAdapter[] }> {
320
+ const projectPath = resolve(".");
321
+ const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
322
+ const { scopeSource } = await import("../../workspace/write-scope");
323
+ const hook = scopeSource(projectPath, "HEAD").declaration?.ci?.prEnvironment?.teardown;
324
+ return { config, adapters: hook ? [commandTeardownAdapter(hook, { cwd: projectPath })] : [] };
325
+ }
326
+
366
327
  async function prEnvDown(ctx: CommandContext): Promise<number> {
367
328
  const { args } = ctx;
368
329
  if (args.pr === undefined) return fail("pr-env down needs the pull or merge request: --pr <n>");
@@ -370,57 +331,20 @@ async function prEnvDown(ctx: CommandContext): Promise<number> {
370
331
  if (reason !== "closed" && reason !== "expired") return fail(`--reason ${reason} is not closed or expired`);
371
332
  const forge = forgeOf(ctx);
372
333
  if (typeof forge === "number") return forge;
373
- const live = livePrEnvironments(await readRecords()).get(prEnvironmentName(args.pr));
374
- return teardown(ctx, forge, args.pr, live, reason);
375
- }
376
-
377
- async function teardown(ctx: CommandContext, forge: PrForge | undefined, pr: number, live: LivePrEnvironment | undefined, reason: "closed" | "expired"): Promise<number> {
378
- const env = prEnvironmentName(pr);
379
334
  const member = memberOf(ctx);
380
- const report: PrEnvReport = { stage: "down", pr, env, ...(member ? { member } : {}), template: live?.template ?? "", head: "", op: prOp(pr, member), gate: PR_ENV_GATE, digest: "", status: "absent", components: [] };
381
- if (!live) {
382
- console.error(formatInfo(`pr-env down: ${env} has no live record, so there is nothing to tear down`));
383
- if (forge) await finish(ctx, forge, report);
384
- return PR_ENV_EXIT.done;
385
- }
386
- const { config } = await loadChantConfig(resolve(".")).catch(() => ({ config: {} as ChantConfig }));
387
- const torn = await tearDownPrEnvironment({ pr, live, config, plugins: ctx.plugins, warn: (message) => console.error(formatWarning({ message })) });
388
- if ("refusal" in torn) {
389
- await finish(ctx, forge, { ...report, template: live.template, status: "refused", message: torn.refusal });
390
- return fail(torn.refusal);
391
- }
392
- const { report: result, dropped, marker } = torn;
393
- const counts = teardownCounts(result);
394
- const complete = teardownComplete(result);
395
- const lexicons = [...new Set(result.outcomes.map((o) => o.lexicon))].sort();
396
- const head = await headCommit();
397
- await appendRecord(ctx, {
398
- version: 1,
399
- kind: "down",
400
- pr,
401
- env,
402
- ...(live.member ? { member: live.member } : {}),
403
- template: live.template,
404
- marker,
405
- commit: head,
406
- components: lexicons,
407
- planDigest: "",
408
- result: complete ? "destroyed" : "incomplete",
409
- by: actor(),
410
- timestamp: new Date().toISOString(),
335
+ const result = await prEnvDownRun({
336
+ pr: args.pr,
411
337
  reason,
412
- outcomes: counts,
413
- releaseTarget: false,
338
+ ...(member ? { member } : {}),
339
+ ports: cliPorts(ctx, forge),
340
+ ...(await teardownSources()),
341
+ plugins: ctx.plugins,
342
+ commit: await headCommit(),
343
+ actor: actor(),
414
344
  ...(forge?.runUrl ? { runUrl: forge.runUrl } : {}),
415
345
  });
416
- const why: string[] = [];
417
- if (dropped > 0) why.push(`${dropped} candidate${dropped === 1 ? "" : "s"} whose marker was not ${marker.stack}/${env} left alone`);
418
- if (result.unimplemented.length > 0) why.push(`no teardown execution in ${result.unimplemented.join(", ")}`);
419
- if (result.plan.holes.length > 0) why.push(`${result.plan.holes.length} part${result.plan.holes.length === 1 ? "" : "s"} could not be read`);
420
- for (const o of result.outcomes.filter((x) => x.outcome === "failed")) why.push(`${o.lexicon} ${o.name}: ${o.detail ?? "failed"}`);
421
- await finish(ctx, forge, { ...report, template: live.template, status: complete ? "destroyed" : "incomplete", outcomes: counts, ...(why.length > 0 ? { message: why.join("; ") } : {}) });
422
- console.error((complete ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-env down: ${env} ${complete ? "torn down" : "not wholly torn down; it stays live and the sweep tries again"} (${counts.deleted} deleted, ${counts.failed} failed, ${counts.skipped} skipped)`));
423
- return complete ? PR_ENV_EXIT.done : PR_ENV_EXIT.failed;
346
+ if (result.report.status === "refused") return fail(result.report.message ?? "refused");
347
+ return result.exit;
424
348
  }
425
349
 
426
350
  async function headCommit(): Promise<string> {
@@ -434,29 +358,13 @@ async function headCommit(): Promise<string> {
434
358
  async function prEnvSweep(ctx: CommandContext): Promise<number> {
435
359
  const forge = forgeOf(ctx);
436
360
  if (typeof forge === "number") return forge;
437
- const live = [...livePrEnvironments(await readRecords()).values()];
438
- if (live.length === 0) {
439
- console.error(formatInfo("pr-env sweep: no pull request environment is live"));
440
- return PR_ENV_EXIT.done;
441
- }
442
- const now = new Date();
443
- let code: number = PR_ENV_EXIT.done;
444
- for (const e of live) {
445
- let reason: "closed" | "expired" | undefined = expired(e, now) ? "expired" : undefined;
446
- if (!reason && forge?.pullRequestState) {
447
- try {
448
- const state = await forge.pullRequestState(e.pr);
449
- if (state !== "open") reason = "closed";
450
- } catch (err) {
451
- console.error(formatWarning({ message: `could not read whether #${e.pr} is open (${err instanceof Error ? err.message : String(err)}); its TTL still holds` }));
452
- }
453
- }
454
- if (!reason) {
455
- console.error(formatInfo(`pr-env sweep: ${e.env} stays; it expires ${e.expiresAt}`));
456
- continue;
457
- }
458
- const c = await teardown(ctx, forge, e.pr, e, reason);
459
- if (c !== PR_ENV_EXIT.done) code = PR_ENV_EXIT.failed;
460
- }
461
- return code;
361
+ const result = await prEnvSweepRun({
362
+ ports: cliPorts(ctx, forge),
363
+ ...(await teardownSources()),
364
+ plugins: ctx.plugins,
365
+ commit: await headCommit(),
366
+ actor: actor(),
367
+ ...(forge?.runUrl ? { runUrl: forge.runUrl } : {}),
368
+ });
369
+ return result.exit;
462
370
  }
@@ -129,6 +129,7 @@ import { withComponentEndpoint } from "../component-endpoint";
129
129
  import type { ComponentChangeSignal, FanOutPlan } from "../../components/fan-out";
130
130
  import { failedRunExitCode, type DriverComponent } from "../../components/driver";
131
131
  import type { CapabilityRegistry } from "../../components/capability";
132
+ import type { ComponentEnvironmentDeclaration } from "../../components/component";
132
133
  import { prLoopHooksAt, runPrHooks, type PrHookPlace, type PrLoopHooks } from "../../components/pr-hooks";
133
134
  import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrLoopPolicy } from "../../components/pr-policy";
134
135
  import type { OpWaveHookMoment } from "../../op/op-waves";
@@ -157,6 +158,8 @@ export interface Prepared {
157
158
  signal: ComponentChangeSignal;
158
159
  registry: CapabilityRegistry;
159
160
  seededOutputs: Record<string, Record<string, unknown>>;
161
+ /** Component name to the environments it declares (#3153), whose sites `pr-env up` carries over (#4330). */
162
+ sites?: Record<string, ComponentEnvironmentDeclaration[]>;
160
163
  /** `ci.hooks` from the declaration at base (#4288). */
161
164
  hooks: PrLoopHooks;
162
165
  /** Every hook the stage ran so far, for the report's `hooks`. */
@@ -296,6 +299,7 @@ export async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "re
296
299
  components: derived.components,
297
300
  plan: routedAway ? { ...derived.plan, order: [], waves: [], seeds: [], indeterminate: [] } : derived.plan,
298
301
  signal: derived.signal,
302
+ ...(derived.sites ? { sites: derived.sites } : {}),
299
303
  registry,
300
304
  seededOutputs,
301
305
  hooks,
@@ -0,0 +1,144 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
+ import type { LexiconPlugin } from "../../lexicon";
5
+ import { createMockPlugin } from "../../../../test-utils/src/mock-plugin";
6
+
7
+ // #4365 / #4368: search answers over the graph `chant graph` draws. That is
8
+ // discovery plus the build-root contributions (a terraform root, a kustomize
9
+ // root) plus the lexicons' `graphEdges`, on the declared path AND on the
10
+ // `--live`/`--at` canvas, and a contributor that throws fails the search
11
+ // instead of leaving it to answer `(no matches)` over a graph with a hole in it.
12
+
13
+ const discoverMock = vi.fn();
14
+ vi.mock("../../discovery/index", () => ({
15
+ discover: (...a: unknown[]) => discoverMock(...a),
16
+ }));
17
+ const loadPluginsMock = vi.fn();
18
+ const resolveLexMock = vi.fn();
19
+ vi.mock("../plugins", async () => {
20
+ const actual = await vi.importActual<typeof import("../plugins")>("../plugins");
21
+ return {
22
+ ...actual,
23
+ loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
24
+ resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
25
+ };
26
+ });
27
+ const replaySnapshotsMock = vi.fn();
28
+ vi.mock("../../lifecycle/replay", () => ({
29
+ replaySnapshots: (...a: unknown[]) => replaySnapshotsMock(...a),
30
+ hasSnapshot: () => Promise.resolve(false),
31
+ }));
32
+ const loadChantConfigMock = vi.fn();
33
+ vi.mock("../../config", async () => {
34
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
35
+ return {
36
+ ...actual,
37
+ loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a),
38
+ loadChantConfigUpward: (...a: unknown[]) => loadChantConfigMock(...a),
39
+ };
40
+ });
41
+ const buildResultMock = vi.fn();
42
+ vi.mock("../../build", async () => {
43
+ const actual = await vi.importActual<typeof import("../../build")>("../../build");
44
+ return { ...actual, build: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)) };
45
+ });
46
+
47
+ const { runSearch } = await import("./search");
48
+
49
+ function decl<T extends object>(base: T): Declarable & T {
50
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
51
+ }
52
+
53
+ /** A lexicon whose only entities come from a build root, and whose only edge comes from `graphEdges`. */
54
+ function rootLexicon(overrides: Partial<LexiconPlugin> = {}): LexiconPlugin {
55
+ return {
56
+ ...createMockPlugin({ name: "tf" }),
57
+ buildRoots: async () => ({
58
+ entities: new Map<string, Declarable>([
59
+ ["net/aws_vpc.main", decl({ lexicon: "tf", entityType: "Tf::Resource", props: { address: "aws_vpc.main" } })],
60
+ ["net/aws_subnet.a", decl({ lexicon: "tf", entityType: "Tf::Resource", props: { address: "aws_subnet.a" } })],
61
+ ]),
62
+ warnings: [],
63
+ }),
64
+ graphEdges: () => ({ references: [{ from: "net/aws_subnet.a", to: "net/aws_vpc.main", kind: "ref", viaAttr: "vpc_id" }] }),
65
+ ...overrides,
66
+ };
67
+ }
68
+
69
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
70
+ return {
71
+ command: "search", path: "attr:address=aws_subnet ->attr:address=aws_vpc",
72
+ format: "", fix: false, watch: false, verbose: false, help: false, live: false, json: true,
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ describe("search over build-root entities and lexicon edges (#4365, #4368)", () => {
78
+ let out: string[];
79
+ let err: string[];
80
+
81
+ beforeEach(() => {
82
+ out = [];
83
+ err = [];
84
+ vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
85
+ vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); });
86
+ discoverMock.mockReset();
87
+ // A fresh map per call: the merge writes into it.
88
+ discoverMock.mockImplementation(async () => ({ entities: new Map(), errors: [], sourceFiles: [] }));
89
+ buildResultMock.mockReset();
90
+ buildResultMock.mockReturnValue({ errors: [], entities: new Map(), outputs: new Map(), warnings: [] });
91
+ loadChantConfigMock.mockReset();
92
+ loadChantConfigMock.mockResolvedValue({ config: {} });
93
+ resolveLexMock.mockReset();
94
+ resolveLexMock.mockResolvedValue(["tf"]);
95
+ replaySnapshotsMock.mockReset();
96
+ });
97
+
98
+ test("declared: an edge term follows a lexicon edge between two build-root entities", async () => {
99
+ loadPluginsMock.mockResolvedValue([rootLexicon()]);
100
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
101
+ expect(exit).toBe(0);
102
+ const json = JSON.parse(out.join("\n"));
103
+ expect(json.matches.map((m: { id: string }) => m.id)).toEqual(["net/aws_subnet.a"]);
104
+ expect(json.matches[0].paths[0].hops).toEqual([{ from: "net/aws_subnet.a", to: "net/aws_vpc.main", fromAttr: "vpc_id" }]);
105
+ });
106
+
107
+ test("--at: the snapshot overlays onto a canvas that carries the build roots and their lexicon edges", async () => {
108
+ loadPluginsMock.mockResolvedValue([rootLexicon({ describeResources: async () => ({}) })]);
109
+ replaySnapshotsMock.mockResolvedValue({
110
+ observations: [{
111
+ lexicon: "tf",
112
+ resources: {
113
+ "net/aws_vpc.main": { type: "Tf::Resource", physicalId: "vpc-1", status: "OK" },
114
+ "net/aws_subnet.a": { type: "Tf::Resource", physicalId: "subnet-1", status: "OK" },
115
+ },
116
+ }],
117
+ commit: "a1b2c3d4e5f6", timestamp: "2026-10-01T00:00:00.000Z", depth: "identity",
118
+ });
119
+ const exit = await runSearch({ args: makeArgs({ at: "latest", env: "dev" }), plugins: [], serializers: [] });
120
+ expect(exit).toBe(0);
121
+ const json = JSON.parse(out.join("\n"));
122
+ expect(json.source.kind).toBe("snapshot");
123
+ expect(json.matches.map((m: { id: string; physicalId?: string }) => [m.id, m.physicalId])).toEqual([["net/aws_subnet.a", "subnet-1"]]);
124
+ });
125
+
126
+ test("a contributor that throws fails the declared search with its error", async () => {
127
+ loadPluginsMock.mockResolvedValue([rootLexicon({ buildRoots: async () => { throw new Error("roots.net: main.tf does not parse"); } })]);
128
+ const exit = await runSearch({ args: makeArgs({ json: false }), plugins: [], serializers: [] });
129
+ expect(exit).toBe(1);
130
+ expect(out.join("\n")).not.toContain("(no matches)");
131
+ expect(err.join("\n")).toContain("roots.net: main.tf does not parse");
132
+ });
133
+
134
+ test("a contributor that throws fails the --at search too", async () => {
135
+ loadPluginsMock.mockResolvedValue([rootLexicon({
136
+ describeResources: async () => ({}),
137
+ buildRoots: async () => { throw new Error("roots.net: main.tf does not parse"); },
138
+ })]);
139
+ replaySnapshotsMock.mockResolvedValue({ observations: [], commit: "a1b2c3d4e5f6", timestamp: "2026-10-01T00:00:00.000Z", depth: "identity" });
140
+ const exit = await runSearch({ args: makeArgs({ json: false, at: "latest", env: "dev" }), plugins: [], serializers: [] });
141
+ expect(exit).toBe(1);
142
+ expect(err.join("\n")).toContain("roots.net: main.tf does not parse");
143
+ });
144
+ });