@intentius/chant 0.124.32 → 0.124.34

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 (320) hide show
  1. package/dist/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +277 -74
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +99 -22
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +30 -1
  24. package/dist/cli/registry.d.ts +21 -5
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/promote-rounds.d.ts +102 -0
  40. package/dist/components/promote-rounds.d.ts.map +1 -0
  41. package/dist/components/promote-rounds.js +174 -0
  42. package/dist/components/release-compile.d.ts +37 -2
  43. package/dist/components/release-compile.d.ts.map +1 -1
  44. package/dist/components/release-compile.js +64 -8
  45. package/dist/components/release-gate.d.ts +30 -0
  46. package/dist/components/release-gate.d.ts.map +1 -1
  47. package/dist/components/release-gate.js +92 -42
  48. package/dist/components/release-promote.d.ts +17 -3
  49. package/dist/components/release-promote.d.ts.map +1 -1
  50. package/dist/components/release-promote.js +79 -18
  51. package/dist/components/verbs/bom-writer.d.ts +1 -1
  52. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  53. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  54. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  55. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  56. package/dist/components/wave-plan.d.ts +10 -0
  57. package/dist/components/wave-plan.d.ts.map +1 -1
  58. package/dist/components/wave-plan.js +32 -4
  59. package/dist/graph-declared.d.ts +4 -0
  60. package/dist/graph-declared.d.ts.map +1 -1
  61. package/dist/graph-declared.js +11 -1
  62. package/dist/graph-detail.d.ts.map +1 -1
  63. package/dist/graph-detail.js +32 -7
  64. package/dist/health.d.ts +110 -0
  65. package/dist/health.d.ts.map +1 -0
  66. package/dist/health.js +105 -0
  67. package/dist/health.schema.json +191 -0
  68. package/dist/lexicon.d.ts +27 -0
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/merge-record.d.ts +104 -0
  71. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  72. package/dist/lifecycle/merge-record.js +102 -0
  73. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  74. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  75. package/dist/lifecycle/publish-attachments.js +27 -13
  76. package/dist/lifecycle/publish-npm.d.ts +3 -2
  77. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  78. package/dist/lifecycle/publish-npm.js +4 -3
  79. package/dist/lifecycle/release-at.d.ts +40 -0
  80. package/dist/lifecycle/release-at.d.ts.map +1 -0
  81. package/dist/lifecycle/release-at.js +83 -0
  82. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  83. package/dist/lifecycle/release-cut.js +23 -12
  84. package/dist/lifecycle/release-publish.d.ts +28 -12
  85. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  86. package/dist/lifecycle/release-publish.js +29 -17
  87. package/dist/lifecycle/release-store.d.ts +25 -2
  88. package/dist/lifecycle/release-store.d.ts.map +1 -1
  89. package/dist/lifecycle/release-store.js +40 -13
  90. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  91. package/dist/lifecycle/run-ledger.js +1 -0
  92. package/dist/lint/rules/op/index.d.ts +2 -1
  93. package/dist/lint/rules/op/index.d.ts.map +1 -1
  94. package/dist/lint/rules/op/index.js +4 -2
  95. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  96. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  97. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  98. package/dist/op/activities/activity-contracts.d.ts +5 -0
  99. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  100. package/dist/op/activities/activity-contracts.js +3 -0
  101. package/dist/op/activities/shell.d.ts +38 -0
  102. package/dist/op/activities/shell.d.ts.map +1 -1
  103. package/dist/op/activities/shell.js +89 -3
  104. package/dist/op/builders.d.ts +7 -0
  105. package/dist/op/builders.d.ts.map +1 -1
  106. package/dist/op/builders.js +7 -0
  107. package/dist/op/runtime.d.ts +2 -0
  108. package/dist/op/runtime.d.ts.map +1 -1
  109. package/dist/pr-loop.d.ts +14 -3
  110. package/dist/pr-loop.d.ts.map +1 -1
  111. package/dist/pr-loop.js +15 -5
  112. package/dist/pr-report-url.d.ts +2 -2
  113. package/dist/pr-report-url.d.ts.map +1 -1
  114. package/dist/runner-start.d.ts +179 -0
  115. package/dist/runner-start.d.ts.map +1 -0
  116. package/dist/runner-start.js +277 -0
  117. package/dist/wave-runtime.mjs +236 -229
  118. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  119. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  120. package/dist/workspace/check.schema.json +88 -2
  121. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  122. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  123. package/dist/workspace/checks/ci-workflow.js +123 -0
  124. package/dist/workspace/checks/security.d.ts +50 -0
  125. package/dist/workspace/checks/security.d.ts.map +1 -0
  126. package/dist/workspace/checks/security.js +305 -0
  127. package/dist/workspace/checks.d.ts +2 -0
  128. package/dist/workspace/checks.d.ts.map +1 -1
  129. package/dist/workspace/checks.js +8 -0
  130. package/dist/workspace/ci-builds.d.ts +163 -0
  131. package/dist/workspace/ci-builds.d.ts.map +1 -0
  132. package/dist/workspace/ci-builds.js +315 -0
  133. package/dist/workspace/ci-green-cli.d.ts +2 -1
  134. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  135. package/dist/workspace/ci-green-cli.js +25 -5
  136. package/dist/workspace/ci-green-forge.d.ts +12 -0
  137. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  138. package/dist/workspace/ci-green-forge.js +12 -0
  139. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  140. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  141. package/dist/workspace/ci-green-workflow.js +128 -1
  142. package/dist/workspace/ci-green.d.ts +89 -4
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +310 -23
  145. package/dist/workspace/ci-init.d.ts +100 -0
  146. package/dist/workspace/ci-init.d.ts.map +1 -0
  147. package/dist/workspace/ci-init.js +434 -0
  148. package/dist/workspace/ci-last-green.schema.json +49 -0
  149. package/dist/workspace/ci-list.schema.json +35 -1
  150. package/dist/workspace/ci-run-index.d.ts +1 -1
  151. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  152. package/dist/workspace/ci-run-index.schema.json +1 -1
  153. package/dist/workspace/ci-run.d.ts +12 -6
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +72 -16
  156. package/dist/workspace/ci-runner-start.d.ts +77 -0
  157. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  158. package/dist/workspace/ci-runner-start.js +185 -0
  159. package/dist/workspace/ci-serve.d.ts +76 -7
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +153 -19
  162. package/dist/workspace/ci-workflow.d.ts +91 -0
  163. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  164. package/dist/workspace/ci-workflow.js +33 -0
  165. package/dist/workspace/declaration.d.ts +35 -0
  166. package/dist/workspace/declaration.d.ts.map +1 -1
  167. package/dist/workspace/declaration.js +12 -2
  168. package/dist/workspace/declaration.schema.json +51 -0
  169. package/dist/workspace/init.js +2 -2
  170. package/dist/workspace/ls.d.ts +2 -0
  171. package/dist/workspace/ls.d.ts.map +1 -1
  172. package/dist/workspace/ls.js +1 -1
  173. package/dist/workspace/ls.schema.json +2 -1
  174. package/dist/workspace/member-pipeline.d.ts +1 -0
  175. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  176. package/dist/workspace/member-pipeline.js +2 -0
  177. package/dist/workspace/merge-record.schema.json +49 -0
  178. package/dist/workspace/pr-report.schema.json +3 -3
  179. package/dist/workspace/reason-codes.d.ts +26 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/reason-codes.js +28 -0
  182. package/dist/workspace/release-candidates.d.ts +129 -0
  183. package/dist/workspace/release-candidates.d.ts.map +1 -0
  184. package/dist/workspace/release-candidates.js +233 -0
  185. package/dist/workspace/release-candidates.schema.json +353 -0
  186. package/dist/workspace/release-workflow.d.ts +31 -0
  187. package/dist/workspace/release-workflow.d.ts.map +1 -0
  188. package/dist/workspace/release-workflow.js +150 -0
  189. package/dist/workspace/security.d.ts +40 -0
  190. package/dist/workspace/security.d.ts.map +1 -0
  191. package/dist/workspace/security.js +99 -0
  192. package/package.json +8 -1
  193. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  194. package/src/ci-jobs.ts +11 -2
  195. package/src/cli/handlers/build.test.ts +36 -0
  196. package/src/cli/handlers/build.ts +18 -3
  197. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  198. package/src/cli/handlers/lifecycle-health.ts +157 -0
  199. package/src/cli/handlers/lifecycle.ts +1 -1
  200. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  201. package/src/cli/handlers/pr-record.test.ts +201 -0
  202. package/src/cli/handlers/pr.test.ts +22 -0
  203. package/src/cli/handlers/pr.ts +127 -4
  204. package/src/cli/handlers/promote.test.ts +17 -0
  205. package/src/cli/handlers/promote.ts +296 -84
  206. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  207. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  208. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  209. package/src/cli/handlers/release.e2e.test.ts +19 -28
  210. package/src/cli/handlers/release.ts +81 -11
  211. package/src/cli/main.ts +99 -22
  212. package/src/cli/parse-args.ts +22 -1
  213. package/src/cli/registry.ts +21 -5
  214. package/src/components/deploy-inputs.test.ts +108 -0
  215. package/src/components/deploy-inputs.ts +225 -0
  216. package/src/components/driver.ts +11 -0
  217. package/src/components/gate-plan.ts +9 -0
  218. package/src/components/pr-pipeline.ts +56 -15
  219. package/src/components/pr-run.ts +1 -1
  220. package/src/components/promote-rounds.test.ts +220 -0
  221. package/src/components/promote-rounds.ts +232 -0
  222. package/src/components/release-compile.test.ts +34 -0
  223. package/src/components/release-compile.ts +89 -8
  224. package/src/components/release-gate.test.ts +47 -0
  225. package/src/components/release-gate.ts +111 -46
  226. package/src/components/release-promote.test.ts +55 -2
  227. package/src/components/release-promote.ts +93 -23
  228. package/src/components/verbs/bom-writer.ts +1 -1
  229. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  230. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  231. package/src/components/wave-plan.ts +32 -4
  232. package/src/graph-declared-exports.test.ts +40 -0
  233. package/src/graph-declared.ts +9 -1
  234. package/src/graph-detail.test.ts +57 -0
  235. package/src/graph-detail.ts +33 -7
  236. package/src/health.schema.json +191 -0
  237. package/src/health.test.ts +62 -0
  238. package/src/health.ts +178 -0
  239. package/src/lexicon.ts +41 -1
  240. package/src/lifecycle/merge-record.test.ts +111 -0
  241. package/src/lifecycle/merge-record.ts +154 -0
  242. package/src/lifecycle/publish-attachments.ts +25 -13
  243. package/src/lifecycle/publish-npm.ts +4 -3
  244. package/src/lifecycle/publish-targets.test.ts +34 -4
  245. package/src/lifecycle/release-at.test.ts +48 -0
  246. package/src/lifecycle/release-at.ts +100 -0
  247. package/src/lifecycle/release-cut.test.ts +41 -0
  248. package/src/lifecycle/release-cut.ts +22 -11
  249. package/src/lifecycle/release-publish.test.ts +43 -13
  250. package/src/lifecycle/release-publish.ts +38 -18
  251. package/src/lifecycle/release-store.ts +57 -13
  252. package/src/lifecycle/run-ledger.test.ts +13 -0
  253. package/src/lifecycle/run-ledger.ts +1 -0
  254. package/src/lint/rules/op/index.ts +4 -2
  255. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  256. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  257. package/src/op/activities/activity-contracts.ts +3 -0
  258. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  259. package/src/op/activities/shell.test.ts +44 -1
  260. package/src/op/activities/shell.ts +110 -3
  261. package/src/op/builders.ts +7 -0
  262. package/src/op/runtime.ts +2 -0
  263. package/src/op/step-console.test.ts +4 -2
  264. package/src/pr-loop.test.ts +29 -0
  265. package/src/pr-loop.ts +26 -7
  266. package/src/pr-report-url.ts +2 -2
  267. package/src/runner-start-bundle.test.ts +29 -0
  268. package/src/runner-start.test.ts +179 -0
  269. package/src/runner-start.ts +329 -0
  270. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  271. package/src/workspace/check-contract.test.ts +4 -2
  272. package/src/workspace/check.schema.json +88 -2
  273. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  274. package/src/workspace/checks/ci-workflow.ts +129 -0
  275. package/src/workspace/checks/security.test.ts +138 -0
  276. package/src/workspace/checks/security.ts +305 -0
  277. package/src/workspace/checks.ts +8 -0
  278. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  279. package/src/workspace/ci-builds.test.ts +174 -0
  280. package/src/workspace/ci-builds.ts +385 -0
  281. package/src/workspace/ci-green-builds.test.ts +180 -0
  282. package/src/workspace/ci-green-cli.test.ts +54 -0
  283. package/src/workspace/ci-green-cli.ts +26 -5
  284. package/src/workspace/ci-green-forge.ts +24 -0
  285. package/src/workspace/ci-green-workflow.test.ts +84 -1
  286. package/src/workspace/ci-green-workflow.ts +139 -2
  287. package/src/workspace/ci-green.test.ts +5 -2
  288. package/src/workspace/ci-green.ts +359 -27
  289. package/src/workspace/ci-init.test.ts +159 -0
  290. package/src/workspace/ci-init.ts +445 -0
  291. package/src/workspace/ci-last-green.schema.json +49 -0
  292. package/src/workspace/ci-list.schema.json +35 -1
  293. package/src/workspace/ci-report-url.test.ts +6 -1
  294. package/src/workspace/ci-run-index.schema.json +1 -1
  295. package/src/workspace/ci-run-index.ts +1 -1
  296. package/src/workspace/ci-run.test.ts +24 -7
  297. package/src/workspace/ci-run.ts +78 -16
  298. package/src/workspace/ci-runner-start.test.ts +151 -0
  299. package/src/workspace/ci-runner-start.ts +193 -0
  300. package/src/workspace/ci-serve-builds.test.ts +86 -0
  301. package/src/workspace/ci-serve.test.ts +59 -0
  302. package/src/workspace/ci-serve.ts +177 -22
  303. package/src/workspace/ci-workflow.ts +71 -0
  304. package/src/workspace/declaration.schema.json +51 -0
  305. package/src/workspace/declaration.test.ts +17 -0
  306. package/src/workspace/declaration.ts +53 -3
  307. package/src/workspace/init.ts +2 -2
  308. package/src/workspace/ls.schema.json +2 -1
  309. package/src/workspace/ls.ts +3 -2
  310. package/src/workspace/member-pipeline.ts +2 -1
  311. package/src/workspace/merge-record.schema.json +49 -0
  312. package/src/workspace/pr-report.schema.json +3 -3
  313. package/src/workspace/reason-codes.test.ts +4 -0
  314. package/src/workspace/reason-codes.ts +28 -0
  315. package/src/workspace/release-candidates.schema.json +353 -0
  316. package/src/workspace/release-candidates.test.ts +106 -0
  317. package/src/workspace/release-candidates.ts +315 -0
  318. package/src/workspace/release-workflow.ts +167 -0
  319. package/src/workspace/security.test.ts +70 -0
  320. package/src/workspace/security.ts +118 -0
@@ -11,14 +11,24 @@
11
11
  * under `.chant/release-templates/<digest>/` in the member's directory and the
12
12
  * step reads them from there. The path is relative and fixed by the digest,
13
13
  * so a gate approval bound to the promote's plan still matches on a re-run.
14
+ *
15
+ * A release cut since #4155 (a target with `trees`) records every local path
16
+ * a deploy step names (`recordedInputRefs`), directories file by file. A
17
+ * promote points each of them at the recorded copy: a directory is written
18
+ * under `.chant/release-templates/<digest>/<name>/`, its digest taken over its
19
+ * files the way a gate's directory digest is (`./deploy-inputs.ts`). A path
20
+ * outside the template fields that the release does not record, such as a
21
+ * chart named from a repository, is left as written. A target cut before
22
+ * #4155 is pinned as it was then: template fields only, other paths read from
23
+ * the checkout.
14
24
  */
15
25
 
16
26
  import { mkdirSync, readdirSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
17
27
  import { basename, dirname, join } from "node:path";
18
- import { contentDigest } from "../content-digest";
19
28
  import type { DriverComponent, DriverGate, DriverPhase, DriverStep } from "./driver";
20
- import { TEMPLATE_FIELDS, templateRefs } from "./release-compile";
21
- import type { Release, ReleaseTarget } from "../lifecycle/release-store";
29
+ import { recordedFields, recordedInputRefs, TEMPLATE_FIELDS, templateRefs, type RecordedInputRef } from "./release-compile";
30
+ import { templateBytesDigest, type Release, type ReleaseTarget } from "../lifecycle/release-store";
31
+ import { computePlanDigest } from "../lifecycle/plan-digest";
22
32
  import type { ReleaseRecord } from "../lifecycle/release-ledger";
23
33
  import type { BuildArchiveManifest } from "./verbs/build-archive";
24
34
 
@@ -29,9 +39,14 @@ export const RELEASE_TEMPLATES_DIR = ".chant/release-templates";
29
39
  export interface PinnedTemplate {
30
40
  /** Relative to the member's directory. */
31
41
  path: string;
42
+ /** The text, or the base64 of the bytes under `encoding: "base64"`. */
32
43
  content: string;
44
+ encoding?: "base64";
33
45
  }
34
46
 
47
+ /** Reads a recorded template's stored copy by digest: its text, or its base64 under `base64`. */
48
+ export type TemplateReader = (digest: string, encoding?: "base64") => Promise<string | null>;
49
+
35
50
  /** The release's target for `env`, or an error naming the environments it has. */
36
51
  export function targetFor(release: Release, env: string): { target: ReleaseTarget } | { error: string } {
37
52
  const target = release.targets.find((t) => t.env === env);
@@ -65,37 +80,45 @@ export async function pinReleaseTemplates(
65
80
  components: readonly DriverComponent[],
66
81
  release: Release,
67
82
  target: ReleaseTarget,
68
- readTemplate: (digest: string) => Promise<string | null>,
83
+ readTemplate: TemplateReader,
69
84
  ): Promise<{ components: DriverComponent[]; files: PinnedTemplate[] } | { errors: string[] }> {
70
85
  const errors: string[] = [];
71
86
  const files: PinnedTemplate[] = [];
72
87
  const out: DriverComponent[] = [];
88
+ // #4155: a target with `trees` records every deploy input; one without was cut before and records template fields only.
89
+ const full = target.trees !== undefined;
73
90
  for (const component of components) {
74
91
  const replace = new Map<string, string>();
75
- for (const ref of templateRefs(component)) {
92
+ const refs: RecordedInputRef[] = full ? recordedInputRefs(component) : templateRefs(component).map((r) => ({ ...r, required: true }));
93
+ for (const ref of refs) {
76
94
  if (replace.has(ref.value)) continue;
95
+ const dir = ref.path.replace(/\/+$/, "");
96
+ const tree = full ? target.trees!.find((t) => t.component === component.name && t.path === dir) : undefined;
97
+ if (tree) {
98
+ const pinned = await pinTree(component.name, tree.path, target, readTemplate);
99
+ if ("errors" in pinned) errors.push(...pinned.errors);
100
+ else {
101
+ files.push(...pinned.files);
102
+ replace.set(ref.value, pinned.path);
103
+ }
104
+ continue;
105
+ }
77
106
  const recorded = target.templates.find((t) => t.component === component.name && t.path === ref.path);
78
107
  if (!recorded) {
108
+ if (!ref.required) continue;
79
109
  errors.push(
80
110
  `component "${component.name}" reads ${ref.field} "${ref.value}", which release ${release.releaseDigest} does not record for "${target.env}"; ` +
81
111
  `this checkout's deploy is not the one the release was compiled from (commit ${release.commit.slice(0, 12)})`,
82
112
  );
83
113
  continue;
84
114
  }
85
- const content = await readTemplate(recorded.digest);
86
- if (content === null) {
87
- errors.push(`component "${component.name}": the bytes of template "${ref.path}" (${recorded.digest}) are not on the chant/lifecycle branch; fetch it and try again`);
88
- continue;
89
- }
90
- const got = contentDigest(content);
91
- if (got !== recorded.digest) {
92
- errors.push(
93
- `component "${component.name}": template "${ref.path}" hashes to ${got}, but the release's target for "${target.env}" records ${recorded.digest}; refusing to deploy it`,
94
- );
115
+ const read = await readChecked(component.name, ref.path, recorded, target, readTemplate);
116
+ if ("error" in read) {
117
+ errors.push(read.error);
95
118
  continue;
96
119
  }
97
120
  const path = join(RELEASE_TEMPLATES_DIR, recorded.digest.replace(/:/g, "_"), basename(ref.path));
98
- files.push({ path, content });
121
+ files.push({ path, content: read.content, ...(recorded.encoding ? { encoding: recorded.encoding } : {}) });
99
122
  replace.set(ref.value, path);
100
123
  }
101
124
  const rewrite = (phase: DriverPhase): DriverPhase => ({
@@ -104,7 +127,7 @@ export async function pinReleaseTemplates(
104
127
  if (isPhase(step)) return rewrite(step);
105
128
  if (isGate(step)) return step;
106
129
  const next: DriverStep = { ...step };
107
- for (const field of TEMPLATE_FIELDS) {
130
+ for (const field of full ? recordedFields(step.kind) : TEMPLATE_FIELDS) {
108
131
  const v = next[field];
109
132
  if (typeof v === "string" && replace.has(v)) next[field] = replace.get(v);
110
133
  else if (Array.isArray(v)) next[field] = v.map((x) => (typeof x === "string" && replace.has(x) ? replace.get(x) : x));
@@ -117,6 +140,51 @@ export async function pinReleaseTemplates(
117
140
  return errors.length > 0 ? { errors } : { components: out, files };
118
141
  }
119
142
 
143
+ /** One recorded template's stored copy, checked against its digest. */
144
+ async function readChecked(
145
+ component: string | null,
146
+ path: string,
147
+ recorded: ReleaseTarget["templates"][number],
148
+ target: ReleaseTarget,
149
+ readTemplate: TemplateReader,
150
+ ): Promise<{ content: string } | { error: string }> {
151
+ const who = component === null ? "" : `component "${component}": `;
152
+ const content = await readTemplate(recorded.digest, recorded.encoding);
153
+ if (content === null) return { error: `${who}the bytes of template "${path}" (${recorded.digest}) are not on the chant/lifecycle branch; fetch it and try again` };
154
+ const got = templateBytesDigest(content, recorded.encoding);
155
+ if (got !== recorded.digest) {
156
+ return { error: `${who}template "${path}" hashes to ${got}, but the release's target for "${target.env}" records ${recorded.digest}; refusing to deploy it` };
157
+ }
158
+ return { content };
159
+ }
160
+
161
+ /**
162
+ * A recorded directory's files, under `.chant/release-templates/<digest>/<name>/`
163
+ * (#4155). The digest is taken over the files' paths from the directory and
164
+ * their bytes, as a gate's directory digest is, so the path names the tree.
165
+ */
166
+ async function pinTree(
167
+ component: string,
168
+ dir: string,
169
+ target: ReleaseTarget,
170
+ readTemplate: TemplateReader,
171
+ ): Promise<{ path: string; files: PinnedTemplate[] } | { errors: string[] }> {
172
+ const prefix = `${dir}/`;
173
+ const entries = target.templates.filter((t) => t.component === component && t.path.startsWith(prefix));
174
+ const errors: string[] = [];
175
+ const read: Array<{ rel: string; content: string; encoding?: "base64"; digest: string }> = [];
176
+ for (const e of entries) {
177
+ const got = await readChecked(component, e.path, e, target, readTemplate);
178
+ if ("error" in got) errors.push(got.error);
179
+ else read.push({ rel: e.path.slice(prefix.length), content: got.content, digest: e.digest, ...(e.encoding ? { encoding: e.encoding } : {}) });
180
+ }
181
+ if (errors.length > 0) return { errors };
182
+ read.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
183
+ const digest = computePlanDigest("directory", read.map(({ rel, digest }) => ({ path: rel, digest })));
184
+ const path = join(RELEASE_TEMPLATES_DIR, digest.replace(/:/g, "_"), basename(dir));
185
+ return { path, files: read.map((r) => ({ path: join(path, r.rel), content: r.content, ...(r.encoding ? { encoding: r.encoding } : {}) })) };
186
+ }
187
+
120
188
  /**
121
189
  * The files a release's target records, read back by digest and checked, for
122
190
  * `chant release export` (#4046). A project with no components has no deploy
@@ -129,7 +197,7 @@ export async function pinReleaseTemplates(
129
197
  export async function targetFiles(
130
198
  release: Release,
131
199
  target: ReleaseTarget,
132
- readTemplate: (digest: string) => Promise<string | null>,
200
+ readTemplate: TemplateReader,
133
201
  ): Promise<{ files: PinnedTemplate[] } | { errors: string[] }> {
134
202
  const errors: string[] = [];
135
203
  const files: PinnedTemplate[] = [];
@@ -139,12 +207,12 @@ export async function targetFiles(
139
207
  errors.push(`${who}template "${t.path}" is not a path inside the member's directory; refusing to write it`);
140
208
  continue;
141
209
  }
142
- const content = await readTemplate(t.digest);
210
+ const content = await readTemplate(t.digest, t.encoding);
143
211
  if (content === null) {
144
212
  errors.push(`${who}the bytes of template "${t.path}" (${t.digest}) are not on the chant/lifecycle branch; fetch it and try again`);
145
213
  continue;
146
214
  }
147
- const got = contentDigest(content);
215
+ const got = templateBytesDigest(content, t.encoding);
148
216
  if (got !== t.digest) {
149
217
  errors.push(`${who}template "${t.path}" hashes to ${got}, but release ${release.releaseDigest} records ${t.digest} for "${target.env}"; refusing to write it`);
150
218
  continue;
@@ -154,7 +222,7 @@ export async function targetFiles(
154
222
  if (same.content !== content) errors.push(`two components record different bytes at "${t.path}" for "${target.env}", so they can't both be written there`);
155
223
  continue;
156
224
  }
157
- files.push({ path: t.path, content });
225
+ files.push({ path: t.path, content, ...(t.encoding ? { encoding: t.encoding } : {}) });
158
226
  }
159
227
  return errors.length > 0 ? { errors } : { files };
160
228
  }
@@ -164,7 +232,7 @@ export function writePinnedTemplates(dir: string, files: readonly PinnedTemplate
164
232
  for (const f of files) {
165
233
  const full = join(dir, f.path);
166
234
  mkdirSync(dirname(full), { recursive: true });
167
- writeFileSync(full, f.content);
235
+ writeFileSync(full, f.encoding === "base64" ? Buffer.from(f.content, "base64") : f.content);
168
236
  }
169
237
  }
170
238
 
@@ -175,7 +243,9 @@ export function writePinnedTemplates(dir: string, files: readonly PinnedTemplate
175
243
  * again from the release.
176
244
  */
177
245
  export function removePinnedTemplates(dir: string, files: readonly PinnedTemplate[]): void {
178
- for (const f of files) rmSync(dirname(join(dir, f.path)), { recursive: true, force: true });
246
+ // Each file sits under `.chant/release-templates/<digest>/`, a recorded tree's deeper; that digest directory goes.
247
+ const prefix = RELEASE_TEMPLATES_DIR.split("/").length;
248
+ for (const f of files) rmSync(join(dir, ...f.path.split(/[\\/]/).slice(0, prefix + 1)), { recursive: true, force: true });
179
249
  const root = join(dir, RELEASE_TEMPLATES_DIR);
180
250
  try {
181
251
  if (readdirSync(root).length === 0) rmdirSync(root);
@@ -72,7 +72,7 @@ export interface BomPackage {
72
72
  /** Version string, when known. Omitted for resources with no version concept (e.g. a CFN resource). */
73
73
  version?: string;
74
74
  /** Package manager / ecosystem this entry came from (e.g. "npm", "maven"), surfaced in SPDX's `externalRefs`/CycloneDX's `purl` when derivable. */
75
- type?: "npm" | "maven" | "config" | "external-reference";
75
+ type?: "npm" | "maven" | "cargo" | "config" | "external-reference";
76
76
  /** Package URL (purl), when derivable — e.g. `pkg:npm/left-pad@1.3.0`. */
77
77
  purl?: string;
78
78
  /** SPDX download location, when known. Defaults to `"NOASSERTION"` per the SPDX spec's requirement that every package declare one. */
@@ -11,6 +11,7 @@ import { describe, expect, it, afterEach } from "vitest";
11
11
  import {
12
12
  parseNpmPackageLock,
13
13
  parsePomXml,
14
+ parseCargoLock,
14
15
  createLockfileSbomGenerator,
15
16
  sbomOutputPath,
16
17
  } from "./lockfile-sbom-generator";
@@ -193,3 +194,38 @@ describe("createLockfileSbomGenerator (#613) — hermetic SbomGenerator backend"
193
194
  expect(docA.bytes).toBe(docB.bytes);
194
195
  });
195
196
  });
197
+
198
+ describe("parseCargoLock (#4168)", () => {
199
+ const LOCK = [
200
+ "version = 4",
201
+ "",
202
+ "[[package]]",
203
+ 'name = "app"',
204
+ 'version = "0.1.0"',
205
+ "dependencies = [",
206
+ ' "serde",',
207
+ ' "tokio 1.40.0",',
208
+ "]",
209
+ "",
210
+ "[[package]]",
211
+ 'name = "serde"',
212
+ 'version = "1.0.210"',
213
+ 'source = "registry+https://github.com/rust-lang/crates.io-index"',
214
+ "",
215
+ "[[package]]",
216
+ 'name = "tokio"',
217
+ 'version = "1.40.0"',
218
+ 'source = "registry+https://github.com/rust-lang/crates.io-index"',
219
+ "dependencies = [",
220
+ ' "pin-project-lite",',
221
+ "]",
222
+ "",
223
+ ].join("\n");
224
+
225
+ it("lists every package with a source, with its purl and dependencies, and leaves out the workspace's own crates", () => {
226
+ expect(parseCargoLock(LOCK)).toEqual([
227
+ { name: "serde", version: "1.0.210", type: "cargo", purl: "pkg:cargo/serde@1.0.210", dependsOn: undefined },
228
+ { name: "tokio", version: "1.40.0", type: "cargo", purl: "pkg:cargo/tokio@1.40.0", dependsOn: ["pin-project-lite"] },
229
+ ]);
230
+ });
231
+ });
@@ -148,6 +148,44 @@ export function parsePomXml(content: string): BomPackage[] {
148
148
  return packages.sort((a, b) => a.name.localeCompare(b.name));
149
149
  }
150
150
 
151
+ // ── Cargo.lock (Rust, #4168 decision 12) ────────────────────────────────────
152
+
153
+ /**
154
+ * Parse a `Cargo.lock` (version 3 or 4) into a flat `BomPackage[]`: one
155
+ * entry per `[[package]]` with a `source`, so the workspace's own crates,
156
+ * which have none, are left out as the npm parser leaves out the root.
157
+ * A dependency is named `name` or `name version` in the lock; only the name
158
+ * is kept. A small line reader, not a TOML parser: the lock is written by
159
+ * cargo in one fixed shape.
160
+ */
161
+ export function parseCargoLock(content: string): BomPackage[] {
162
+ const seen = new Map<string, BomPackage>();
163
+ const blocks = content.split(/^\[\[package\]\]\s*$/m).slice(1);
164
+ for (const block of blocks) {
165
+ const body = block.split(/^\[/m)[0] ?? "";
166
+ const field = (key: string) => new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m").exec(body)?.[1];
167
+ const name = field("name");
168
+ const version = field("version");
169
+ if (!name || field("source") === undefined) continue;
170
+ const deps = /^dependencies\s*=\s*\[([\s\S]*?)\]/m.exec(body)?.[1];
171
+ const dependsOn = deps ? [...deps.matchAll(/"([^"\s]+)[^"]*"/g)].map((m) => m[1]!) : [];
172
+ const key = `${name}@${version ?? ""}`;
173
+ if (seen.has(key)) continue;
174
+ seen.set(key, {
175
+ name,
176
+ version,
177
+ type: "cargo",
178
+ purl: version ? `pkg:cargo/${encodeURIComponent(name)}@${version}` : undefined,
179
+ dependsOn: dependsOn.length ? [...new Set(dependsOn)] : undefined,
180
+ });
181
+ }
182
+ return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name) || (a.version ?? "").localeCompare(b.version ?? ""));
183
+ }
184
+
185
+ /** Each lockfile kind's parser and file name. */
186
+ export const LOCKFILE_PARSERS = { npm: parseNpmPackageLock, maven: parsePomXml, cargo: parseCargoLock } as const;
187
+ export const LOCKFILE_NAMES = { npm: "package-lock.json", maven: "pom.xml", cargo: "Cargo.lock" } as const;
188
+
151
189
  // ── hermetic SbomGenerator backend ───────────────────────────────────────────
152
190
 
153
191
  export interface LockfileSbomGeneratorOptions {
@@ -158,8 +196,8 @@ export interface LockfileSbomGeneratorOptions {
158
196
  }
159
197
 
160
198
  /** Locate the first readable lockfile/manifest under `dir` this generator knows how to parse, in preference order (npm before Maven — most chant components are Node-first). Returns `undefined` if none is found. */
161
- function findManifest(dir: string): { kind: "npm" | "maven"; path: string } | undefined {
162
- for (const [kind, filename] of [["npm", "package-lock.json"], ["maven", "pom.xml"]] as const) {
199
+ function findManifest(dir: string): { kind: "npm" | "maven" | "cargo"; path: string } | undefined {
200
+ for (const [kind, filename] of [["npm", "package-lock.json"], ["maven", "pom.xml"], ["cargo", "Cargo.lock"]] as const) {
163
201
  const candidate = join(dir, filename);
164
202
  try {
165
203
  readFileSync(candidate, "utf-8");
@@ -195,13 +233,13 @@ export function createLockfileSbomGenerator(options: LockfileSbomGeneratorOption
195
233
  const manifest = findManifest(dir);
196
234
  if (!manifest) {
197
235
  throw new Error(
198
- `no supported lockfile/manifest found under "${dir}" (looked for package-lock.json, pom.xml) — ` +
236
+ `no supported lockfile/manifest found under "${dir}" (looked for package-lock.json, pom.xml, Cargo.lock) — ` +
199
237
  `the hermetic lockfile SBOM backend requires a lockfile on disk; use the deep-scan path (#610) for images with no lockfile`,
200
238
  );
201
239
  }
202
240
  const content = readFileSync(manifest.path, "utf-8");
203
- const packages = manifest.kind === "npm" ? parseNpmPackageLock(content) : parsePomXml(content);
204
- const generatorName = manifest.kind === "npm" ? "chant-lockfile-sbom/package-lock.json" : "chant-lockfile-sbom/pom.xml";
241
+ const packages = LOCKFILE_PARSERS[manifest.kind](content);
242
+ const generatorName = `chant-lockfile-sbom/${LOCKFILE_NAMES[manifest.kind]}`;
205
243
 
206
244
  const doc = writeBom(
207
245
  format,
@@ -13,6 +13,14 @@
13
13
  * (`componentPlanDigest`, ./gate-plan.ts, #2574), so a wave mixing roots and
14
14
  * other components still has one digest that moves when any of them does.
15
15
  *
16
+ * The files a step without a `plan` deploys from are bound too (#4155,
17
+ * ./deploy-inputs.ts). A component with no plannable step folds them into its
18
+ * composition digest. A component with one gets a member more,
19
+ * `<component>#inputs`, over its other steps' files, so a `lambda-deploy`
20
+ * source or a Helm chart beside a `cfn-deploy` moves the wave's digest. A step
21
+ * whose content chant can't name is a hole on that member, so the plan note
22
+ * lists it as unbound.
23
+ *
16
24
  * Known limit: a step input that reads an earlier step of the same
17
25
  * component (`@Phase.field`) has no value before the component runs, so it
18
26
  * plans as `undefined`. Cross-component wiring (`stackOutput()`,
@@ -21,6 +29,8 @@
21
29
 
22
30
  import { resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
23
31
  import { componentPlanDigest } from "./gate-plan";
32
+ import { componentDeployInputs, type ComponentDeployInputs } from "./deploy-inputs";
33
+ import { computePlanDigest } from "../lifecycle/plan-digest";
24
34
  import type { CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
25
35
  import type { WaveMember } from "../gated-waves";
26
36
  import type { ChangeSetPart } from "../change-set";
@@ -37,14 +47,18 @@ export type WaveComponentPlan =
37
47
  | { component: string; members: WaveMember[]; plans: Record<string, unknown>; parts: ChangeSetPart[]; outputs?: PlannedOutputs }
38
48
  | { component: string; error: string };
39
49
 
40
- /** A part with no entries, for a member whose planner describes no changes. */
41
- function emptyPart(member: WaveMember): ChangeSetPart {
50
+ /** A part with no entries, for a member whose planner describes no changes. `unbound` steps become its holes (#4155). */
51
+ function emptyPart(member: WaveMember, unbound: ComponentDeployInputs["unbound"] = []): ChangeSetPart {
52
+ const holes = unbound.map((u) => ({ address: `${u.phase}/${u.kind}`, type: u.kind, reason: `unbound: ${u.reason}` }));
42
53
  return {
43
- member: { member: member.member, lexicon: "chant", planner: "chant", status: "planned", planDigest: member.planDigest, holes: [] },
54
+ member: { member: member.member, lexicon: "chant", planner: "chant", status: "planned", planDigest: member.planDigest, holes },
44
55
  entries: [],
45
56
  };
46
57
  }
47
58
 
59
+ /** The member a planning component's other steps add (#4155): their files, under the component's name. */
60
+ export const inputsMemberName = (component: string): string => `${component}#inputs`;
61
+
48
62
  /** Every capability step in a composition, nested phases included, gates left out. */
49
63
  export function capabilitySteps(phases: readonly DriverPhase[]): DriverStep[] {
50
64
  const out: DriverStep[] = [];
@@ -99,17 +113,31 @@ export async function planWaveComponent(
99
113
  if (planned.artifact !== undefined) plans[planned.member] = planned.artifact;
100
114
  if (planned.outputs) outputs = mergePlannedOutputs(outputs, planned.outputs);
101
115
  }
116
+ // #4155: the steps without a plan, which no member above covers.
117
+ const planless = (step: DriverStep): boolean => !registry.has(step.kind) || registry.resolve(step.kind).plan === undefined;
102
118
  if (members.length === 0) {
119
+ const read = componentDeployInputs(component.deploy, process.cwd());
103
120
  const member = {
104
121
  member: component.name,
105
122
  planDigest: componentPlanDigest({
106
123
  environment: ctx.env,
107
124
  component,
108
125
  ...(ctx.vars ? { vars: ctx.vars } : {}),
126
+ inputs: read.inputs,
109
127
  }),
110
128
  };
111
129
  members.push(member);
112
- parts.push(emptyPart(member));
130
+ parts.push(emptyPart(member, read.unbound));
131
+ } else {
132
+ const read = componentDeployInputs(component.deploy, process.cwd(), planless);
133
+ if (read.inputs.length > 0 || read.unbound.length > 0) {
134
+ const member = {
135
+ member: inputsMemberName(component.name),
136
+ planDigest: computePlanDigest("component-inputs", { environment: ctx.env, component: component.name, inputs: read.inputs }),
137
+ };
138
+ members.push(member);
139
+ parts.push(emptyPart(member, read.unbound));
140
+ }
113
141
  }
114
142
  return { component: component.name, members, plans, parts, ...(outputs ? { outputs } : {}) };
115
143
  } catch (err) {
@@ -0,0 +1,40 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+
3
+ /**
4
+ * Side-by-side stacks keep their exports and imports (#4202). The live overlay
5
+ * builds the declared canvas per stack, and that canvas dropped the member's
6
+ * exports, so a workspace viewer had nothing to join another member's import
7
+ * to once an environment was picked.
8
+ */
9
+ vi.mock("./discovery/index", () => ({ discover: vi.fn(async () => ({ entities: new Map(), errors: [] })) }));
10
+ vi.mock("./graph-ir", () => ({
11
+ buildGraphIr: vi.fn(() => ({
12
+ nodes: [
13
+ { id: "webAlb", kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", lexicon: "aws" },
14
+ { id: "zoneId", kind: "AWS::CloudFormation::Parameter", lexicon: "aws" },
15
+ ],
16
+ edges: [],
17
+ groups: {},
18
+ exports: [{ name: "awsEndpoint", node: "webAlb", attr: "DNSName" }],
19
+ imports: [{ name: "zoneId", node: "zoneId" }],
20
+ })),
21
+ }));
22
+
23
+ const { buildDeclaredPerStack } = await import("./graph-declared");
24
+
25
+ describe("buildDeclaredPerStack keeps exports and imports (#4202)", () => {
26
+ test("an export's node is qualified like the nodes, and its name is unchanged", async () => {
27
+ const ir = await buildDeclaredPerStack([{ name: "three-cloud-aws", src: "src" }], "/project");
28
+ expect(ir.nodes.map((n) => n.id)).toContain("three-cloud-aws::webAlb");
29
+ expect(ir.exports).toEqual([{ name: "awsEndpoint", node: "three-cloud-aws::webAlb", attr: "DNSName" }]);
30
+ expect(ir.imports).toEqual([{ name: "zoneId", node: "three-cloud-aws::zoneId" }]);
31
+ const ids = new Set(ir.nodes.map((n) => n.id));
32
+ for (const e of ir.exports ?? []) expect(ids.has(e.node!)).toBe(true);
33
+ });
34
+
35
+ test("a stack without src brings no exports", async () => {
36
+ const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], "/project");
37
+ expect(ir.exports).toBeUndefined();
38
+ expect(ir.imports).toBeUndefined();
39
+ });
40
+ });
@@ -13,6 +13,10 @@ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
13
13
  * that live side, build each stack's `src` in isolation and qualify its node ids
14
14
  * and edge endpoints the same way. Stacks are merged into one graph.
15
15
  *
16
+ * Each stack's exports and imports come along with their `node` qualified the
17
+ * same way (#4202), so a workspace viewer can still match an import in another
18
+ * member to an export here once an environment is picked.
19
+ *
16
20
  * A stack without `src` contributes nothing here — it has no declared source to
17
21
  * scope to (its live nodes still show as foreign in the overlay).
18
22
  */
@@ -22,6 +26,8 @@ export async function buildDeclaredPerStack(
22
26
  ): Promise<GraphIR> {
23
27
  const nodes: IRNode[] = [];
24
28
  const edges: GraphIR["edges"] = [];
29
+ const exports: NonNullable<GraphIR["exports"]> = [];
30
+ const imports: NonNullable<GraphIR["imports"]> = [];
25
31
  // Which nodes belong to which stack (#1433). This is not derived or guessed:
26
32
  // the stack is declared in config and every node here is being renamed with
27
33
  // its name on the line below. The membership was always known and thrown
@@ -38,8 +44,10 @@ export async function buildDeclaredPerStack(
38
44
  (byStack[st.name] ??= []).push(id);
39
45
  }
40
46
  for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
47
+ for (const e of g.exports ?? []) exports.push({ ...e, ...(e.node ? { node: q(e.node) } : {}) });
48
+ for (const i of g.imports ?? []) imports.push({ ...i, node: q(i.node) });
41
49
  }
42
50
  for (const ids of Object.values(byStack)) ids.sort();
43
51
  const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
44
- return { nodes, edges, groups };
52
+ return { nodes, edges, groups, ...(exports.length ? { exports } : {}), ...(imports.length ? { imports } : {}) };
45
53
  }
@@ -87,6 +87,63 @@ describe("applyDetail", () => {
87
87
  expect(ir.imports).toEqual([{ name: "pClusterArn", node: "pClusterArn" }]);
88
88
  });
89
89
 
90
+ test("T1 keeps exports, each on the node that now holds its producer (#4178)", () => {
91
+ const withExports: GraphIR = {
92
+ ...base,
93
+ version: 2,
94
+ meta: { _behaviour: { engine: "x" } },
95
+ exports: [
96
+ { name: "DbEndpoint", node: "dbDeployment", attr: "status" },
97
+ { name: "SubnetLink", node: "subnet", attr: "selfLink" },
98
+ { name: "Literal" },
99
+ ],
100
+ };
101
+ const ir = applyDetail(withExports, DETAIL.COMPOSITES);
102
+ expect(ir.exports).toEqual([
103
+ { name: "DbEndpoint", node: "db", attr: "status" },
104
+ { name: "SubnetLink", node: "subnet", attr: "selfLink" },
105
+ { name: "Literal" },
106
+ ]);
107
+ expect(ir.version).toBe(2);
108
+ expect(ir.meta).toEqual({ _behaviour: { engine: "x" } });
109
+ });
110
+
111
+ test("T1 keeps two same-named composites from different composite types apart (#4178)", () => {
112
+ const twoWebs: GraphIR = {
113
+ nodes: [
114
+ { id: "webAlb", kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", lexicon: "aws", compositeParent: "FargateAlb", compositeInstance: "web", attrs: {} },
115
+ { id: "webService", kind: "AWS::ECS::Service", lexicon: "aws", compositeParent: "FargateAlb", compositeInstance: "web", attrs: {} },
116
+ { id: "webRun", kind: "RunService", lexicon: "gcp", compositeParent: "CloudRunServiceComposite", compositeInstance: "web", attrs: {} },
117
+ { id: "record", kind: "AWS::Route53::RecordSet", lexicon: "aws", attrs: {} },
118
+ ],
119
+ edges: [
120
+ { from: "record", to: "webAlb", kind: "ref", viaAttr: "AliasTarget" },
121
+ { from: "record", to: "webRun", kind: "ref", viaAttr: "ResourceRecords" },
122
+ { from: "webService", to: "webAlb", kind: "ref", viaAttr: "LoadBalancers" },
123
+ ],
124
+ groups: {},
125
+ exports: [{ name: "WebUri", node: "webRun", attr: "uri" }],
126
+ };
127
+ const ir = applyDetail(twoWebs, DETAIL.COMPOSITES);
128
+ expect(ir.nodes.map((n) => n.id)).toEqual(["record", "web@CloudRunServiceComposite", "web@FargateAlb"]);
129
+ expect(ir.nodes.find((n) => n.id === "web@FargateAlb")).toMatchObject({ kind: "FargateAlb", lexicon: "aws", attrs: { members: 2 } });
130
+ expect(ir.nodes.find((n) => n.id === "web@CloudRunServiceComposite")).toMatchObject({ kind: "CloudRunServiceComposite", lexicon: "gcp", attrs: { members: 1 } });
131
+ expect(ir.edges).toEqual(expect.arrayContaining([
132
+ { from: "record", to: "web@FargateAlb", kind: "ref" },
133
+ { from: "record", to: "web@CloudRunServiceComposite", kind: "ref" },
134
+ ]));
135
+ expect(ir.edges).toHaveLength(2);
136
+ expect(ir.exports).toEqual([{ name: "WebUri", node: "web@CloudRunServiceComposite", attr: "uri" }]);
137
+ });
138
+
139
+ test("T1 keeps one composite whose members span lexicons as one node", () => {
140
+ const ir = applyDetail({
141
+ ...base,
142
+ nodes: base.nodes.map((n) => (n.id === "dbNamespace" ? { ...n, lexicon: "gcp" } : n)),
143
+ }, DETAIL.COMPOSITES);
144
+ expect(ir.nodes.find((n) => n.id === "db")).toMatchObject({ kind: "DbStack", lexicon: "multi", attrs: { members: 2 } });
145
+ });
146
+
90
147
  test("T1 node count is between T0 and T2", () => {
91
148
  const t0 = applyDetail(base, DETAIL.STACKS).nodes.length;
92
149
  const t1 = applyDetail(base, DETAIL.COMPOSITES).nodes.length;
@@ -127,27 +127,46 @@ function toDeclarables(ir: GraphIR): GraphIR {
127
127
  /** T1 — collapse each composite instance to one node; internal edges disappear.
128
128
  * Topology view: surviving plain nodes keep only overlay paint, not properties. */
129
129
  function toComposites(ir: GraphIR): GraphIR {
130
- const idMap = new Map<string, string>();
131
- for (const n of ir.nodes) idMap.set(n.id, n.compositeInstance ?? n.id);
132
-
130
+ // A composite instance is its export name plus the composite that made it:
131
+ // an AWS FargateAlb and a GCP CloudRunServiceComposite both exported as
132
+ // `web` are two instances (#4178). One composite whose members span
133
+ // lexicons stays one instance.
134
+ const instanceKey = (n: IRNode) => `${n.compositeInstance}\0${n.compositeParent ?? ""}`;
133
135
  const instances = new Map<string, IRNode[]>();
134
136
  const plain: IRNode[] = [];
135
137
  for (const n of ir.nodes) {
136
138
  if (n.compositeInstance) {
137
- const arr = instances.get(n.compositeInstance) ?? [];
139
+ const key = instanceKey(n);
140
+ const arr = instances.get(key) ?? [];
138
141
  arr.push(n);
139
- instances.set(n.compositeInstance, arr);
142
+ instances.set(key, arr);
140
143
  } else {
141
144
  plain.push({ ...n, attrs: paintAttrs(n.attrs) });
142
145
  }
143
146
  }
144
147
 
148
+ // The collapsed id is the instance name, or `<instance>@<composite>` when
149
+ // two composites share the name.
150
+ const sharing = new Map<string, number>();
151
+ for (const members of instances.values()) {
152
+ const inst = members[0].compositeInstance!;
153
+ sharing.set(inst, (sharing.get(inst) ?? 0) + 1);
154
+ }
155
+ const collapsedId = new Map<string, string>();
156
+ for (const [key, members] of instances) {
157
+ const { compositeInstance: inst, compositeParent: parent } = members[0];
158
+ collapsedId.set(key, sharing.get(inst!)! > 1 && parent ? `${inst}@${parent}` : inst!);
159
+ }
160
+
161
+ const idMap = new Map<string, string>();
162
+ for (const n of ir.nodes) idMap.set(n.id, n.compositeInstance ? collapsedId.get(instanceKey(n))! : n.id);
163
+
145
164
  const nodes: IRNode[] = [...plain];
146
- for (const [inst, members] of instances) {
165
+ for (const [key, members] of instances) {
147
166
  const types = new Set(members.map((m) => m.compositeParent).filter(Boolean) as string[]);
148
167
  const lexicons = new Set(members.map((m) => m.lexicon));
149
168
  nodes.push({
150
- id: inst,
169
+ id: collapsedId.get(key)!,
151
170
  kind: types.size === 1 ? [...types][0] : "Composite",
152
171
  lexicon: lexicons.size === 1 ? [...lexicons][0] : "multi",
153
172
  attrs: { members: members.length },
@@ -197,11 +216,18 @@ function toComposites(ir: GraphIR): GraphIR {
197
216
  }
198
217
  if (Object.keys(byPipeline).length) groups.byPipeline = byPipeline;
199
218
  }
219
+ // Exports (#4178) stay at this tier, each on the node that now holds its
220
+ // producer, so a viewer can still match another member's import to it.
221
+ const exports = ir.exports?.map((x) => (x.node ? { ...x, node: idMap.get(x.node) ?? x.node } : { ...x }));
200
222
  return {
223
+ ...(ir.version !== undefined ? { version: ir.version } : {}),
201
224
  nodes,
202
225
  edges: sortEdges([...seen.values()]),
203
226
  groups,
227
+ ...(ir.meta ? { meta: ir.meta } : {}),
228
+ ...(exports && exports.length ? { exports } : {}),
204
229
  ...(imports && imports.length ? { imports } : {}),
230
+ ...(ir.pipeline ? { pipeline: ir.pipeline } : {}),
205
231
  ...(dangling && dangling.length ? { dangling } : {}),
206
232
  };
207
233
  }