@intentius/chant 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/operator.d.ts +32 -0
  28. package/dist/cli/handlers/operator.d.ts.map +1 -0
  29. package/dist/cli/handlers/scenario.d.ts +39 -0
  30. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/mcp/server.d.ts +35 -2
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/types.d.ts +29 -1
  35. package/dist/cli/mcp/types.d.ts.map +1 -1
  36. package/dist/cli/registry.d.ts +14 -2
  37. package/dist/cli/registry.d.ts.map +1 -1
  38. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  39. package/dist/components/capability.d.ts +17 -2
  40. package/dist/components/capability.d.ts.map +1 -1
  41. package/dist/components/cli-support.d.ts +7 -0
  42. package/dist/components/cli-support.d.ts.map +1 -1
  43. package/dist/components/component.d.ts +15 -0
  44. package/dist/components/component.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts.map +1 -1
  46. package/dist/components/verbs/index.d.ts +6 -1
  47. package/dist/components/verbs/index.d.ts.map +1 -1
  48. package/dist/components/verbs/run-agent.d.ts +499 -0
  49. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  50. package/dist/components/verbs/sign.d.ts +30 -0
  51. package/dist/components/verbs/sign.d.ts.map +1 -1
  52. package/dist/composite.d.ts +6 -1
  53. package/dist/composite.d.ts.map +1 -1
  54. package/dist/discovery/collect.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -1
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/discovery/fold-rank.d.ts +66 -0
  58. package/dist/discovery/fold-rank.d.ts.map +1 -0
  59. package/dist/discovery/index.d.ts +15 -0
  60. package/dist/discovery/index.d.ts.map +1 -1
  61. package/dist/discovery/param-deps.d.ts +17 -0
  62. package/dist/discovery/param-deps.d.ts.map +1 -0
  63. package/dist/fold/fold.d.ts +55 -2
  64. package/dist/fold/fold.d.ts.map +1 -1
  65. package/dist/fold/subset.d.ts +21 -14
  66. package/dist/fold/subset.d.ts.map +1 -1
  67. package/dist/lexicon-schema.d.ts +2 -0
  68. package/dist/lexicon-schema.d.ts.map +1 -1
  69. package/dist/lexicon.d.ts +93 -0
  70. package/dist/lexicon.d.ts.map +1 -1
  71. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  72. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  73. package/dist/lifecycle/deep-diff.d.ts +18 -0
  74. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  75. package/dist/lifecycle/deep-observe.d.ts +9 -1
  76. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  77. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  78. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  79. package/dist/lifecycle/git.d.ts +145 -21
  80. package/dist/lifecycle/git.d.ts.map +1 -1
  81. package/dist/lifecycle/index.d.ts +4 -0
  82. package/dist/lifecycle/index.d.ts.map +1 -1
  83. package/dist/lifecycle/lease.d.ts +113 -0
  84. package/dist/lifecycle/lease.d.ts.map +1 -0
  85. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  86. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  87. package/dist/lifecycle/scenario.d.ts +163 -0
  88. package/dist/lifecycle/scenario.d.ts.map +1 -0
  89. package/dist/lifecycle/symptoms.d.ts +63 -0
  90. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  91. package/dist/lint/output-docs.d.ts +94 -0
  92. package/dist/lint/output-docs.d.ts.map +1 -0
  93. package/dist/lint/post-synth.d.ts +29 -0
  94. package/dist/lint/post-synth.d.ts.map +1 -1
  95. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  96. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  97. package/dist/lsp/lexicon-providers.d.ts +7 -0
  98. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  99. package/dist/op/activity-contract.d.ts +139 -0
  100. package/dist/op/activity-contract.d.ts.map +1 -0
  101. package/dist/op/builders.d.ts +42 -2
  102. package/dist/op/builders.d.ts.map +1 -1
  103. package/dist/op/converge-rule.d.ts +161 -0
  104. package/dist/op/converge-rule.d.ts.map +1 -0
  105. package/dist/op/generate-pipeline.d.ts +39 -0
  106. package/dist/op/generate-pipeline.d.ts.map +1 -0
  107. package/dist/op/index.d.ts +14 -0
  108. package/dist/op/index.d.ts.map +1 -1
  109. package/dist/op/local-executor.d.ts.map +1 -1
  110. package/dist/op/op-verb-class.d.ts +42 -0
  111. package/dist/op/op-verb-class.d.ts.map +1 -0
  112. package/dist/op/operator.d.ts +128 -0
  113. package/dist/op/operator.d.ts.map +1 -0
  114. package/dist/op/step-output-ref.d.ts +187 -0
  115. package/dist/op/step-output-ref.d.ts.map +1 -0
  116. package/dist/op/types.d.ts +18 -1
  117. package/dist/op/types.d.ts.map +1 -1
  118. package/dist/provenance.d.ts +73 -3
  119. package/dist/provenance.d.ts.map +1 -1
  120. package/dist/runtime-adapter.d.ts +7 -1
  121. package/dist/runtime-adapter.d.ts.map +1 -1
  122. package/dist/serializer.d.ts +18 -0
  123. package/dist/serializer.d.ts.map +1 -1
  124. package/dist/toml.d.ts +40 -5
  125. package/dist/toml.d.ts.map +1 -1
  126. package/package.json +1 -1
  127. package/src/audit/catalog.test.ts +1 -1
  128. package/src/audit/catalog.ts +75 -3
  129. package/src/audit/core.ts +9 -0
  130. package/src/audit/discover.ts +29 -2
  131. package/src/audit/fetch.test.ts +216 -3
  132. package/src/audit/fetch.ts +270 -59
  133. package/src/audit/report-html.ts +5 -2
  134. package/src/audit/report-model.ts +9 -0
  135. package/src/audit/report.test.ts +22 -0
  136. package/src/audit/report.ts +3 -2
  137. package/src/audit/rules-doc.ts +2 -0
  138. package/src/audit/secrets.test.ts +303 -0
  139. package/src/audit/secrets.ts +406 -0
  140. package/src/audit/wrangler.test.ts +230 -0
  141. package/src/audit/wrangler.ts +290 -0
  142. package/src/build.ts +8 -3
  143. package/src/cli/command-group.ts +1 -1
  144. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  145. package/src/cli/commands/audit.test.ts +215 -1
  146. package/src/cli/commands/audit.ts +86 -17
  147. package/src/cli/commands/build.test.ts +167 -2
  148. package/src/cli/commands/build.ts +114 -23
  149. package/src/cli/handlers/build.ts +2 -0
  150. package/src/cli/handlers/components.test.ts +199 -1
  151. package/src/cli/handlers/components.ts +160 -3
  152. package/src/cli/handlers/graph.test.ts +20 -0
  153. package/src/cli/handlers/graph.ts +10 -1
  154. package/src/cli/handlers/lifecycle.ts +12 -4
  155. package/src/cli/handlers/operator.test.ts +255 -0
  156. package/src/cli/handlers/operator.ts +240 -0
  157. package/src/cli/handlers/scenario.test.ts +456 -0
  158. package/src/cli/handlers/scenario.ts +330 -0
  159. package/src/cli/main.test.ts +23 -0
  160. package/src/cli/main.ts +72 -1
  161. package/src/cli/mcp/server.test.ts +265 -2
  162. package/src/cli/mcp/server.ts +84 -7
  163. package/src/cli/mcp/types.ts +27 -1
  164. package/src/cli/registry.ts +14 -2
  165. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  166. package/src/codegen/docs-rule-scanning.ts +25 -2
  167. package/src/components/README.md +7 -0
  168. package/src/components/capability.ts +17 -2
  169. package/src/components/cli-support.test.ts +17 -0
  170. package/src/components/cli-support.ts +13 -1
  171. package/src/components/component-schema.test.ts +32 -0
  172. package/src/components/component.schema.json +6 -0
  173. package/src/components/component.test.ts +21 -0
  174. package/src/components/component.ts +15 -0
  175. package/src/components/driver.ts +12 -4
  176. package/src/components/verbs/index.ts +6 -1
  177. package/src/components/verbs/run-agent.test.ts +683 -0
  178. package/src/components/verbs/run-agent.ts +786 -0
  179. package/src/components/verbs/sign.test.ts +19 -0
  180. package/src/components/verbs/sign.ts +34 -2
  181. package/src/composite.ts +31 -2
  182. package/src/discovery/collect.ts +11 -2
  183. package/src/discovery/fold-import.test.ts +54 -0
  184. package/src/discovery/fold-import.ts +178 -38
  185. package/src/discovery/fold-rank.test.ts +197 -0
  186. package/src/discovery/fold-rank.ts +346 -0
  187. package/src/discovery/index.ts +16 -1
  188. package/src/discovery/param-deps.test.ts +118 -0
  189. package/src/discovery/param-deps.ts +170 -0
  190. package/src/fold/fold.test.ts +6 -2
  191. package/src/fold/fold.ts +184 -3
  192. package/src/fold/subset.test.ts +82 -19
  193. package/src/fold/subset.ts +79 -41
  194. package/src/lexicon-schema.ts +3 -0
  195. package/src/lexicon.ts +103 -2
  196. package/src/lifecycle/converge-ledger.test.ts +199 -0
  197. package/src/lifecycle/converge-ledger.ts +179 -0
  198. package/src/lifecycle/deep-diff.test.ts +79 -1
  199. package/src/lifecycle/deep-diff.ts +23 -0
  200. package/src/lifecycle/deep-observe.ts +13 -2
  201. package/src/lifecycle/gate-ledger.test.ts +103 -0
  202. package/src/lifecycle/gate-ledger.ts +140 -0
  203. package/src/lifecycle/git.test.ts +430 -0
  204. package/src/lifecycle/git.ts +446 -84
  205. package/src/lifecycle/index.ts +4 -0
  206. package/src/lifecycle/lease.test.ts +343 -0
  207. package/src/lifecycle/lease.ts +270 -0
  208. package/src/lifecycle/scenario-eval.test.ts +199 -0
  209. package/src/lifecycle/scenario-eval.ts +158 -0
  210. package/src/lifecycle/scenario.test.ts +195 -0
  211. package/src/lifecycle/scenario.ts +321 -0
  212. package/src/lifecycle/symptoms.test.ts +116 -0
  213. package/src/lifecycle/symptoms.ts +126 -0
  214. package/src/lint/output-docs.test.ts +220 -0
  215. package/src/lint/output-docs.ts +204 -0
  216. package/src/lint/post-synth.test.ts +97 -0
  217. package/src/lint/post-synth.ts +45 -0
  218. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  219. package/src/lint/rules/comp/comp.test.ts +49 -1
  220. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  221. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  222. package/src/lsp/lexicon-providers.test.ts +44 -0
  223. package/src/lsp/lexicon-providers.ts +11 -1
  224. package/src/op/activity-contract.test.ts +180 -0
  225. package/src/op/activity-contract.ts +278 -0
  226. package/src/op/builders-exports.test.ts +17 -1
  227. package/src/op/builders.ts +59 -5
  228. package/src/op/converge-rule.test.ts +179 -0
  229. package/src/op/converge-rule.ts +311 -0
  230. package/src/op/generate-pipeline.test.ts +53 -0
  231. package/src/op/generate-pipeline.ts +99 -0
  232. package/src/op/index.ts +30 -0
  233. package/src/op/local-executor.test.ts +92 -0
  234. package/src/op/local-executor.ts +45 -9
  235. package/src/op/op-verb-class.test.ts +126 -0
  236. package/src/op/op-verb-class.ts +115 -0
  237. package/src/op/operator.test.ts +346 -0
  238. package/src/op/operator.ts +213 -0
  239. package/src/op/step-output-ref.test.ts +334 -0
  240. package/src/op/step-output-ref.ts +453 -0
  241. package/src/op/types.ts +18 -1
  242. package/src/provenance.test.ts +151 -4
  243. package/src/provenance.ts +118 -4
  244. package/src/runtime-adapter.ts +31 -10
  245. package/src/serializer.ts +18 -0
  246. package/src/toml.test.ts +157 -384
  247. package/src/toml.ts +371 -5
@@ -26,20 +26,23 @@
26
26
  * `componentBom` fall back to `null` exactly as before — no regression for
27
27
  * ledgers with no persisted manifests at all.
28
28
  */
29
- import { resolve } from "node:path";
29
+ import { resolve, join, dirname } from "node:path";
30
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
30
31
  import { getHeadCommit, fetchLifecycle, pushLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
31
32
  import {
32
33
  appendReleaseRecord,
33
34
  readReleaseLedger,
34
35
  listReleaseEnvironments,
36
+ latestPerComponent,
35
37
  InvalidReleaseRecordError,
36
38
  } from "../../lifecycle/release-ledger";
37
39
  import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence } from "../../lifecycle/status";
38
40
  import { commandBuildParams } from "../build-params-cli";
39
41
  import { buildChangeSet } from "../../lifecycle/change-set";
40
42
  import { buildLedgerEntries, componentBomSummary, type BuildLedgerEntry } from "../../lifecycle/build-ledger";
41
- import { findBuildManifestByArtifactDigest } from "../../lifecycle/build-ledger-store";
43
+ import { findBuildManifestByArtifactDigest, readBuildManifest } from "../../lifecycle/build-ledger-store";
42
44
  import type { ComponentBomSummary } from "../../lifecycle/build-ledger";
45
+ import type { BuildArchiveManifest } from "../../components/verbs/build-archive";
43
46
  import { loadChantConfig } from "../../config";
44
47
  import { applyLiveEndpoint } from "../../live-endpoint";
45
48
  import { isResourceDeclarable } from "../../declarable";
@@ -51,6 +54,7 @@ import type { LexiconPlugin } from "../../lexicon";
51
54
  import { normalizeObservation, mergeObservations, unobservedAll, type NormalizedObservation } from "../../observation";
52
55
  import type { Phase, Component } from "../../components/component";
53
56
  import { deployUnits } from "../../components/deploy-units";
57
+ import { sortedJsonReplacer } from "../../utils";
54
58
 
55
59
  /**
56
60
  * chant components release <env> --component <name> --digest <sha256:...>
@@ -147,6 +151,159 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
147
151
  }
148
152
  }
149
153
 
154
+ /** One entry of `chant components export`'s per-entry result (text and `--json` output). */
155
+ interface ExportEntryResult {
156
+ kind: string;
157
+ path: string;
158
+ digest: string;
159
+ status: "materialized" | "missing";
160
+ /** Set only when `status` is "missing" — why the source file couldn't be read. */
161
+ reason?: string;
162
+ }
163
+
164
+ /**
165
+ * chant components export <env> --component <name> [--digest <manifestDigest>]
166
+ * -o <dir> [--json]
167
+ *
168
+ * Materializes a persisted `BuildArchiveManifest` (#609,
169
+ * ../../lifecycle/build-ledger-store.ts) to a portable directory: every
170
+ * `image`/`template`/`asset`/`sbom` entry is copied byte-for-byte from where
171
+ * the build that produced it left it on disk (its `path`, resolved against
172
+ * the current working directory — the same directory a component's
173
+ * `docker-build`/`addArchiveTemplate`/etc. step wrote it into) under that
174
+ * same archive-relative path in `-o <dir>`, plus a `manifest.json` so the
175
+ * directory is self-describing without chant. No re-synthesis: this reads
176
+ * bytes a prior `chant build`/`run --components` already produced, never
177
+ * regenerates them (#929) — the "byte-identical, promote-by-digest" contract
178
+ * the build archive is built on (see ../../components/verbs/build-archive.ts).
179
+ *
180
+ * Manifest resolution:
181
+ * - `--digest <manifestDigest>` reads it directly (`readBuildManifest`),
182
+ * independent of any environment/release record.
183
+ * - Otherwise resolves "the manifest behind this component's most recent
184
+ * successful run" in `<env>`: the latest release record's own
185
+ * `manifestDigest` when it was recorded, else a reverse lookup by its
186
+ * promoted artifact digest (`findBuildManifestByArtifactDigest`) — the
187
+ * same join `chant components status` already performs.
188
+ *
189
+ * A source file missing on disk (the build that produced the manifest ran
190
+ * somewhere else, or its output was since cleaned) is reported per entry
191
+ * rather than silently dropped; the command exits non-zero so an incomplete
192
+ * export is never mistaken for a complete one.
193
+ */
194
+ export async function runComponentsExport(ctx: CommandContext): Promise<number> {
195
+ const { args } = ctx;
196
+ const environment = args.extraPositional;
197
+ const component = args.component;
198
+ const outDir = args.output;
199
+ const usage = "chant components export <env> --component <name> [--digest <manifestDigest>] -o <dir>";
200
+
201
+ if (!outDir) {
202
+ console.error(formatError({ message: "-o/--output <dir> is required", hint: usage }));
203
+ return 1;
204
+ }
205
+
206
+ await fetchLifecycle();
207
+
208
+ let manifest: BuildArchiveManifest;
209
+
210
+ if (args.digest) {
211
+ const resolved = await readBuildManifest(args.digest);
212
+ if (!resolved) {
213
+ console.error(formatError({ message: `No persisted build manifest found for digest ${args.digest}` }));
214
+ return 1;
215
+ }
216
+ if (component && resolved.component !== component) {
217
+ console.error(formatWarning({
218
+ message: `--component ${component} does not match the resolved manifest's component (${resolved.component}) — exporting the manifest at --digest ${args.digest} anyway`,
219
+ }));
220
+ }
221
+ manifest = resolved;
222
+ } else {
223
+ if (!environment || !component) {
224
+ console.error(formatError({
225
+ message: "Environment and --component are required unless --digest <manifestDigest> is given",
226
+ hint: usage,
227
+ }));
228
+ return 1;
229
+ }
230
+ const ledger = await readReleaseLedger(environment);
231
+ const latest = latestPerComponent(ledger.records).get(component);
232
+ if (!latest) {
233
+ console.error(formatError({
234
+ message: `No release record for component "${component}" in environment "${environment}"`,
235
+ }));
236
+ return 1;
237
+ }
238
+ const resolved = latest.manifestDigest
239
+ ? await readBuildManifest(latest.manifestDigest)
240
+ : await findBuildManifestByArtifactDigest(latest.digest);
241
+ if (!resolved) {
242
+ console.error(formatError({
243
+ message: `No persisted build manifest found for ${component}@${environment}'s recorded digest ${latest.digest}`,
244
+ hint: "The build predates #609, or was recorded via `chant components release` alone with no corresponding `chant build`/`run --components`.",
245
+ }));
246
+ return 1;
247
+ }
248
+ manifest = resolved;
249
+ }
250
+
251
+ const targetDir = resolve(outDir);
252
+ const sourceRoot = resolve(".");
253
+ await mkdir(targetDir, { recursive: true });
254
+
255
+ const entries = [...manifest.contents].sort((a, b) => a.path.localeCompare(b.path));
256
+ const results: ExportEntryResult[] = [];
257
+ for (const entry of entries) {
258
+ try {
259
+ const bytes = await readFile(resolve(sourceRoot, entry.path));
260
+ const target = join(targetDir, entry.path);
261
+ await mkdir(dirname(target), { recursive: true });
262
+ await writeFile(target, bytes);
263
+ results.push({ kind: entry.kind, path: entry.path, digest: entry.digest, status: "materialized" });
264
+ } catch (err) {
265
+ results.push({
266
+ kind: entry.kind,
267
+ path: entry.path,
268
+ digest: entry.digest,
269
+ status: "missing",
270
+ reason: err instanceof Error ? err.message : String(err),
271
+ });
272
+ }
273
+ }
274
+
275
+ await writeFile(join(targetDir, "manifest.json"), JSON.stringify(manifest, sortedJsonReplacer, 2));
276
+
277
+ const missing = results.filter((r) => r.status === "missing");
278
+
279
+ if (args.json) {
280
+ console.log(JSON.stringify({
281
+ component: manifest.component,
282
+ manifestDigest: manifest.manifestDigest,
283
+ outDir: targetDir,
284
+ entries: results,
285
+ }, null, 2));
286
+ } else {
287
+ console.log(formatBold(`${manifest.component} (${manifest.manifestDigest}) -> ${targetDir}`));
288
+ for (const r of results) {
289
+ const status = r.status === "materialized" ? formatSuccess("ok") : formatError({ message: r.reason ?? "missing" });
290
+ console.log(` ${r.kind.padEnd(10)}${r.path.padEnd(40)}${status}`);
291
+ }
292
+ console.log(` ${"manifest".padEnd(10)}${"manifest.json".padEnd(40)}${formatSuccess("ok")}`);
293
+ }
294
+
295
+ if (missing.length > 0) {
296
+ console.error(formatError({
297
+ message: `${missing.length} of ${results.length} archive entr${missing.length === 1 ? "y" : "ies"} not found on disk — export incomplete`,
298
+ hint: `Run this from the checkout where the build that produced ${manifest.manifestDigest} still has its output on disk.`,
299
+ }));
300
+ return 1;
301
+ }
302
+
303
+ console.error(formatSuccess(`Exported ${results.length} entr${results.length === 1 ? "y" : "ies"} + manifest.json to ${targetDir}`));
304
+ return 0;
305
+ }
306
+
150
307
  /** One row of `chant components status` JSON output. */
151
308
  interface StatusJsonRow {
152
309
  component: string;
@@ -595,7 +752,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
595
752
  export async function runComponentsUnknown(ctx: CommandContext): Promise<number> {
596
753
  console.error(formatError({
597
754
  message: `Unknown components subcommand: ${ctx.args.path}`,
598
- hint: "Available: chant components status [env], chant components release <env>",
755
+ hint: "Available: chant components status [env], chant components release <env>, chant components export <env>",
599
756
  }));
600
757
  return 1;
601
758
  }
@@ -431,6 +431,26 @@ describe("runGraph", () => {
431
431
  expect(discoverMock).not.toHaveBeenCalled();
432
432
  });
433
433
 
434
+ test("--components --format ir carries declared composites (#1492); undeclared components have no composites attr", async () => {
435
+ lintMock.mockResolvedValue({ success: true });
436
+ componentGraphMock.mockResolvedValue({
437
+ success: true,
438
+ order: ["shared-foundation", "loom-backend"],
439
+ waves: [["shared-foundation"], ["loom-backend"]],
440
+ edges: [{ from: "loom-backend", to: "shared-foundation" }],
441
+ files: {},
442
+ composites: { "shared-foundation": ["ArtifactBucket", "OperatorRole"] },
443
+ });
444
+ const exit = await runGraph({ args: makeArgs({ format: "ir", components: true }), plugins: [], serializers: [] });
445
+ expect(exit).toBe(0);
446
+ const ir = JSON.parse(stdoutBuf.join("\n"));
447
+ expect(ir.nodes.find((n: { id: string }) => n.id === "shared-foundation").attrs.composites).toEqual([
448
+ "ArtifactBucket",
449
+ "OperatorRole",
450
+ ]);
451
+ expect("composites" in ir.nodes.find((n: { id: string }) => n.id === "loom-backend").attrs).toBe(false);
452
+ });
453
+
434
454
  test("--components --format mermaid lanes the components by wave", async () => {
435
455
  lintMock.mockResolvedValue({ success: true });
436
456
  componentGraphClean();
@@ -580,7 +580,16 @@ async function runComponentGraphView(
580
580
  // `liveNames` (#1491): the resources this component owns — declared, or
581
581
  // the contract's `[name]` identity fallback — so a consumer can join
582
582
  // the component DAG to the resource graph instead of guessing at kinds.
583
- attrs: { wave: waveOf.get(name) ?? null, liveNames: graph.liveNames?.[name] ?? [name] },
583
+ // `composites` (#1492): the composite kind(s) this component's
584
+ // resources come from, when the component declared them explicitly —
585
+ // omitted from attrs (not defaulted) when it didn't, so a consumer
586
+ // knows to fall back to its own naming-convention guess rather than
587
+ // reading "no composites" from an empty array.
588
+ attrs: {
589
+ wave: waveOf.get(name) ?? null,
590
+ liveNames: graph.liveNames?.[name] ?? [name],
591
+ ...(graph.composites?.[name] ? { composites: graph.composites[name] } : {}),
592
+ },
584
593
  // Deep-link the node to its `*.component.ts` (behold's inspect panel).
585
594
  ...(graph.files?.[name] ? { sourceLoc: { file: graph.files[name] } } : {}),
586
595
  })),
@@ -3,8 +3,9 @@ import { commandBuildParams } from "../build-params-cli";
3
3
  import { build } from "../../build";
4
4
  import { takeSnapshot } from "../../lifecycle/snapshot";
5
5
  import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
6
- import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
6
+ import { deepDiffForLexicon, type DeclaredEntities } from "../../lifecycle/deep-observe";
7
7
  import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
8
+ import { describePathOrigin, getPathProvenance } from "../../provenance";
8
9
  import {
9
10
  acceptDeviations,
10
11
  baselineForLexicon,
@@ -55,7 +56,7 @@ import type { ChantConfig } from "../../config";
55
56
  * then `config.sourceDir`, then "." (the root). Snapshot/diff/plan all use this
56
57
  * so their build digests stay consistent.
57
58
  */
58
- function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
59
+ export function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
59
60
  return resolve(args.src ?? config.sourceDir ?? ".");
60
61
  }
61
62
 
@@ -803,13 +804,17 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
803
804
  // declared name the reader can never resolve reads as missing (see
804
805
  // lifecycle/observe.ts).
805
806
  const declared = new Set<string>();
806
- const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
807
+ const entities: DeclaredEntities = new Map();
807
808
  for (const [name, entity] of args.buildResult.entities) {
808
809
  if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
809
810
  declared.add(name);
811
+ // Path origins are carried across explicitly (#1443): this map is plain
812
+ // objects, and the symbol-keyed provenance channel does not survive it.
813
+ const pathOrigins = getPathProvenance(entity);
810
814
  entities.set(name, {
811
815
  entityType: entity.entityType,
812
816
  props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
817
+ ...(pathOrigins ? { pathOrigins } : {}),
813
818
  });
814
819
  }
815
820
  }
@@ -964,7 +969,10 @@ function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
964
969
  const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
965
970
  const live = "live" in change ? formatValue(change.live) : "<absent>";
966
971
  const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
967
- console.log(` ${change.path}: ${declared} → ${live}${baseline}`);
972
+ // Both sides of the attribution, where each is known (#1443/#1189).
973
+ const from = change.origin ? ` [from: ${describePathOrigin(change.origin)}]` : "";
974
+ const owner = change.owner ? ` [owner: ${change.owner}]` : "";
975
+ console.log(` ${change.path}: ${declared} → ${live}${baseline}${from}${owner}`);
968
976
  }
969
977
  }
970
978
  }
@@ -0,0 +1,255 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+
4
+ const discoverConvergeOpsMock = vi.fn();
5
+ const runOperatorRoundMock = vi.fn();
6
+ const runOperatorForeverMock = vi.fn();
7
+ const discoverOpsMock = vi.fn();
8
+ const loadActivitiesMock = vi.fn();
9
+ const loadProfilesMock = vi.fn();
10
+ const loadChantConfigMock = vi.fn();
11
+ const readLeaseMock = vi.fn();
12
+ const readConvergeLedgerMock = vi.fn();
13
+ const appendGateResolutionMock = vi.fn();
14
+ const readGateResolutionsMock = vi.fn();
15
+ const pushLifecycleMock = vi.fn();
16
+
17
+ vi.mock("../../op/operator", async () => {
18
+ const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
19
+ return {
20
+ ...actual,
21
+ discoverConvergeOps: (...args: unknown[]) => discoverConvergeOpsMock(...args),
22
+ runOperatorRound: (...args: unknown[]) => runOperatorRoundMock(...args),
23
+ runOperatorForever: (...args: unknown[]) => runOperatorForeverMock(...args),
24
+ };
25
+ });
26
+ vi.mock("../../op/discover", () => ({ discoverOps: () => discoverOpsMock() }));
27
+ vi.mock("../../op/activity-registry", () => ({
28
+ loadActivities: (...args: unknown[]) => loadActivitiesMock(...args),
29
+ loadProfiles: (...args: unknown[]) => loadProfilesMock(...args),
30
+ }));
31
+ vi.mock("../../config", async () => {
32
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
33
+ return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
34
+ });
35
+ vi.mock("../../lifecycle/lease", async () => {
36
+ const actual = await vi.importActual<typeof import("../../lifecycle/lease")>("../../lifecycle/lease");
37
+ return { ...actual, readLease: (...args: unknown[]) => readLeaseMock(...args) };
38
+ });
39
+ vi.mock("../../lifecycle/converge-ledger", async () => {
40
+ const actual = await vi.importActual<typeof import("../../lifecycle/converge-ledger")>("../../lifecycle/converge-ledger");
41
+ return { ...actual, readConvergeLedger: (...args: unknown[]) => readConvergeLedgerMock(...args) };
42
+ });
43
+ vi.mock("../../lifecycle/gate-ledger", async () => {
44
+ const actual = await vi.importActual<typeof import("../../lifecycle/gate-ledger")>("../../lifecycle/gate-ledger");
45
+ return {
46
+ ...actual,
47
+ appendGateResolution: (...args: unknown[]) => appendGateResolutionMock(...args),
48
+ readGateResolutions: (...args: unknown[]) => readGateResolutionsMock(...args),
49
+ };
50
+ });
51
+ vi.mock("../../lifecycle/git", async () => {
52
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
53
+ return { ...actual, pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args) };
54
+ });
55
+
56
+ // Imported after the mocks above are registered.
57
+ const { runOperator, runOperatorStatus, runApprove } = await import("./operator");
58
+
59
+ function ctx(args: Partial<ParsedArgs>) {
60
+ return {
61
+ args: { command: "", path: ".", format: "", fix: false, watch: false, verbose: false, help: false, live: false, ...args } as ParsedArgs,
62
+ plugins: [],
63
+ serializers: [],
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: [] } });
70
+ loadActivitiesMock.mockResolvedValue(new Map());
71
+ loadProfilesMock.mockResolvedValue({});
72
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
73
+ pushLifecycleMock.mockResolvedValue(true);
74
+ });
75
+
76
+ describe("runOperator", () => {
77
+ test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
78
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
79
+ const code = await runOperator(ctx({}));
80
+ expect(code).toBe(0);
81
+ expect(runOperatorRoundMock).not.toHaveBeenCalled();
82
+ expect(runOperatorForeverMock).not.toHaveBeenCalled();
83
+ });
84
+
85
+ test("--once runs exactly one round and exits 0 when nothing failed", async () => {
86
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
87
+ runOperatorRoundMock.mockResolvedValue([{ kind: "ticked", op: "staging-converge", env: "staging", result: { op: "staging-converge", records: [], totalMs: 1, ok: true } }]);
88
+
89
+ const code = await runOperator(ctx({ once: true }));
90
+ expect(code).toBe(0);
91
+ expect(runOperatorRoundMock).toHaveBeenCalledTimes(1);
92
+ expect(runOperatorForeverMock).not.toHaveBeenCalled();
93
+ });
94
+
95
+ test("--once exits 1 when any op's tick failed this round", async () => {
96
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
97
+ runOperatorRoundMock.mockResolvedValue([{ kind: "tick-failed", op: "staging-converge", env: "staging", error: "boom" }]);
98
+
99
+ const code = await runOperator(ctx({ once: true }));
100
+ expect(code).toBe(1);
101
+ });
102
+
103
+ test("without --once, runs the daemon loop (runOperatorForever) and exits 0 when it returns", async () => {
104
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
105
+ runOperatorForeverMock.mockResolvedValue(undefined);
106
+
107
+ const code = await runOperator(ctx({}));
108
+ expect(code).toBe(0);
109
+ expect(runOperatorForeverMock).toHaveBeenCalledTimes(1);
110
+ expect(runOperatorRoundMock).not.toHaveBeenCalled();
111
+ });
112
+
113
+ test("--interval and --lease-ttl are parsed as durations and threaded through", async () => {
114
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
115
+ runOperatorForeverMock.mockResolvedValue(undefined);
116
+
117
+ await runOperator(ctx({ interval: "30s", leaseTtl: "2m" }));
118
+ const passed = runOperatorForeverMock.mock.calls[0][0];
119
+ expect(passed.intervalMs).toBe(30_000);
120
+ expect(passed.leaseTtlMs).toBe(120_000);
121
+ });
122
+
123
+ test("--env is passed through to discovery and the round/loop", async () => {
124
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "prod-converge" } }], errors: [] });
125
+ runOperatorRoundMock.mockResolvedValue([]);
126
+
127
+ await runOperator(ctx({ once: true, env: "prod" }));
128
+ expect(discoverConvergeOpsMock).toHaveBeenCalledWith({ env: "prod" });
129
+ expect(runOperatorRoundMock.mock.calls[0][0].env).toBe("prod");
130
+ });
131
+
132
+ test("a failure loading activities is reported and exits 1", async () => {
133
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
134
+ loadActivitiesMock.mockRejectedValue(new Error("no activities registered"));
135
+
136
+ const code = await runOperator(ctx({ once: true }));
137
+ expect(code).toBe(1);
138
+ });
139
+ });
140
+
141
+ describe("runOperatorStatus", () => {
142
+ test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
143
+ discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
144
+ const code = await runOperatorStatus(ctx({}));
145
+ expect(code).toBe(0);
146
+ });
147
+
148
+ test("--json emits one row per discovered ConvergeOp with last tick, lease, and pending gates", async () => {
149
+ discoverConvergeOpsMock.mockResolvedValue({
150
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
151
+ errors: [],
152
+ });
153
+ readConvergeLedgerMock.mockResolvedValue({
154
+ records: [
155
+ {
156
+ version: 1,
157
+ op: "staging-converge",
158
+ env: "staging",
159
+ timestamp: "2026-01-01T00:00:00.000Z",
160
+ firedRuleIds: ["drift-apply"],
161
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
162
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
163
+ log: "converge(staging): ...",
164
+ },
165
+ ],
166
+ malformed: 0,
167
+ });
168
+ readLeaseMock.mockResolvedValue({ sha: "abc", record: { op: "staging-converge", holder: "op-a", token: "t1", acquiredAt: "x", expiresAt: "y" } });
169
+ readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 }); // no resolution yet — still pending
170
+
171
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
172
+ const code = await runOperatorStatus(ctx({ json: true }));
173
+ expect(code).toBe(0);
174
+
175
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
176
+ expect(printed).toHaveLength(1);
177
+ expect(printed[0].op).toBe("staging-converge");
178
+ expect(printed[0].lease).toEqual({ holder: "op-a", expiresAt: "y" });
179
+ expect(printed[0].pendingGates).toEqual([{ rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate" }]);
180
+ logSpy.mockRestore();
181
+ });
182
+
183
+ test("a gate resolved after the tick that recorded it is no longer pending", async () => {
184
+ discoverConvergeOpsMock.mockResolvedValue({
185
+ ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
186
+ errors: [],
187
+ });
188
+ readConvergeLedgerMock.mockResolvedValue({
189
+ records: [
190
+ {
191
+ version: 1,
192
+ op: "staging-converge",
193
+ env: "staging",
194
+ timestamp: "2026-01-01T00:00:00.000Z",
195
+ firedRuleIds: ["drift-apply"],
196
+ outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
197
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
198
+ log: "converge(staging): ...",
199
+ },
200
+ ],
201
+ malformed: 0,
202
+ });
203
+ readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
204
+ readGateResolutionsMock.mockResolvedValue({
205
+ records: [{ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z" }],
206
+ malformed: 0,
207
+ });
208
+
209
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
210
+ await runOperatorStatus(ctx({ json: true }));
211
+ const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
212
+ expect(printed[0].pendingGates).toEqual([]);
213
+ logSpy.mockRestore();
214
+ });
215
+ });
216
+
217
+ describe("runApprove", () => {
218
+ test("requires both <op> and <gate>", async () => {
219
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
220
+ expect(await runApprove(ctx({ path: "." }))).toBe(1);
221
+ expect(await runApprove(ctx({ path: "fountain-apply" }))).toBe(1);
222
+ errSpy.mockRestore();
223
+ });
224
+
225
+ test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
226
+ appendGateResolutionMock.mockResolvedValue({
227
+ commit: "sha",
228
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
229
+ });
230
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
231
+
232
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", note: "https://pr/1" }));
233
+
234
+ expect(code).toBe(0);
235
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(
236
+ expect.objectContaining({ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", note: "https://pr/1" }),
237
+ );
238
+ expect(pushLifecycleMock).toHaveBeenCalled();
239
+ errSpy.mockRestore();
240
+ });
241
+
242
+ test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
243
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
244
+ appendGateResolutionMock.mockResolvedValue({
245
+ commit: "sha",
246
+ record: { version: 1, op: "unknown-op", gate: "g", resolvedBy: "unknown", timestamp: "2026-01-01T00:00:00.000Z" },
247
+ });
248
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
249
+
250
+ const code = await runApprove(ctx({ path: "unknown-op", extraPositional: "g" }));
251
+ expect(code).toBe(0);
252
+ expect(appendGateResolutionMock).toHaveBeenCalled();
253
+ errSpy.mockRestore();
254
+ });
255
+ });