@intentius/chant 0.49.0 → 0.51.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 (294) 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/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Generate mode's Op counterpart (#927): synthesize scheduled CI for
3
+ * stateless Ops — the cron-triggered sibling of `generateComponentsPipeline`
4
+ * (../components/cli-support.ts, #563/#688). A component pipeline triggers a
5
+ * deploy-time graph on `workflow_dispatch`; this triggers already-built Ops
6
+ * on a cron, each via `chant run <name>` on the local executor — the same
7
+ * one-shot invocation documented at docs/src/content/docs/guide/ops.mdx. Core
8
+ * owns only Op discovery, so a typo'd name fails fast before any CI is
9
+ * generated; the CI-specific synthesis is contributed by the target
10
+ * lexicon's `generateOpPipeline` (../lexicon.ts), exactly like
11
+ * `generateComponentPipeline`.
12
+ *
13
+ * There is no dependency graph between scheduled Ops the way there is
14
+ * between components — `Op.depends` orders phases within one run, not
15
+ * separately scheduled cron triggers — so this does not call
16
+ * `resolveComponentGraph`; each `ScheduledOpSpec` is generated independently.
17
+ */
18
+
19
+ import { discoverOps } from "./discover";
20
+ import {
21
+ isLexiconPlugin,
22
+ type LexiconPlugin,
23
+ type ComponentPipelineOptions,
24
+ type ScheduledOpSpec,
25
+ type OpPipelineFile,
26
+ type OpPipelineJob,
27
+ } from "../lexicon";
28
+
29
+ /**
30
+ * Load a lexicon's plugin (`@intentius/chant-lexicon-<name>`) to reach its
31
+ * `generateOpPipeline`. Tolerant: returns null when the package can't be
32
+ * resolved. Kept local (rather than imported from
33
+ * `../components/cli-support.ts`) so this module carries no dependency on
34
+ * `components/`.
35
+ */
36
+ async function loadLexiconPlugin(name: string): Promise<LexiconPlugin | null> {
37
+ let mod: Record<string, unknown>;
38
+ try {
39
+ mod = (await import(`@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
40
+ } catch {
41
+ return null;
42
+ }
43
+ for (const value of Object.values(mod)) {
44
+ if (isLexiconPlugin(value)) return value;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** Result of generating scheduled CI for a set of Ops (generate mode's Op counterpart, #927). */
50
+ export interface GenerateOpsPipelineResult {
51
+ success: boolean;
52
+ /** One generated CI file per scheduled Op, when `success` is true. */
53
+ files?: OpPipelineFile[];
54
+ /** Every generated job, for a machine-readable view. */
55
+ jobs?: OpPipelineJob[];
56
+ error?: string;
57
+ }
58
+
59
+ /**
60
+ * Synthesize scheduled CI for `ops` targeting `lexicon`: validate every named
61
+ * Op exists (`discoverOps`, under the git root — mirrors `chant run <name>`'s
62
+ * own resolution), then dispatch to the lexicon's `generateOpPipeline`. No
63
+ * deploy/audit logic is inlined into the generated YAML — the finding-mode
64
+ * behavior lives in the Op's own activity args, set by the composite that
65
+ * built it (`WorkflowAuditOp`, `PipelineAuditOp`, `ReconcileOp`, …); this
66
+ * function only wires the cron trigger and the token/permission surface the
67
+ * declared `findingMode` needs.
68
+ */
69
+ export async function generateOpsPipeline(
70
+ ops: ScheduledOpSpec[],
71
+ lexicon: string,
72
+ options?: ComponentPipelineOptions,
73
+ cwd?: string,
74
+ ): Promise<GenerateOpsPipelineResult> {
75
+ const plugin = await loadLexiconPlugin(lexicon);
76
+ if (!plugin?.generateOpPipeline) {
77
+ return {
78
+ success: false,
79
+ error: `Lexicon "${lexicon}" does not support Op generate mode (no generateOpPipeline). GitLab, GitHub, and Forgejo are supported today.`,
80
+ };
81
+ }
82
+
83
+ const discovered = await discoverOps({ cwd });
84
+ if (discovered.errors.length > 0) {
85
+ return { success: false, error: discovered.errors.join("\n") };
86
+ }
87
+
88
+ const unknown = ops.filter((spec) => !discovered.ops.has(spec.name));
89
+ if (unknown.length > 0) {
90
+ const known = [...discovered.ops.keys()].sort().join(", ");
91
+ return {
92
+ success: false,
93
+ error: `Unknown Op(s): ${unknown.map((s) => s.name).join(", ")}.${known ? ` Known Ops: ${known}` : " No Ops discovered."}`,
94
+ };
95
+ }
96
+
97
+ const { files, jobs } = plugin.generateOpPipeline(ops, options);
98
+ return { success: true, files, jobs };
99
+ }
package/src/op/index.ts CHANGED
@@ -23,8 +23,38 @@ export type {
23
23
  } from "./receipt-store";
24
24
  export { discoverOps } from "./discover";
25
25
  export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
26
+ export { generateOpsPipeline } from "./generate-pipeline";
27
+ export type { GenerateOpsPipelineResult } from "./generate-pipeline";
26
28
  export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
27
29
  export type { ActivityFn, ActivityProfile } from "./activity-registry";
28
30
  export { runOpLocally, parseDuration, findGate, LocalGateUnsupportedError, OpRunFailure } from "./local-executor";
29
31
  export type { StepRecord, OpRunResult } from "./local-executor";
30
32
  export { renderHuman, renderJson } from "./local-output";
33
+ export {
34
+ activityContract, isActivityContract, collectActivityContracts, validateActivitySteps, KNOWN_ACTIVITY_PROFILES,
35
+ pathExistsInSchema,
36
+ } from "./activity-contract";
37
+ export type { ActivityContract, ActivityContractIssue } from "./activity-contract";
38
+ export { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope, makeOutProxy } from "./step-output-ref";
39
+ export type { StepOutputRef, WithStepRefs } from "./step-output-ref";
40
+ export type { NamedActivityStep } from "./builders";
41
+ export {
42
+ eq, neq, gt, gte, lt, lte, truthy, falsy, allOf, anyOf,
43
+ evaluatePredicate, isWellFormedPredicate, predicateReferencesField,
44
+ run, report,
45
+ when, duplicateRuleIds,
46
+ DEFAULT_FLAP_THRESHOLD,
47
+ } from "./converge-rule";
48
+ export type {
49
+ FieldComparisonOp, FieldTruthinessOp,
50
+ FieldComparisonPredicate, FieldTruthinessPredicate, AllOfPredicate, AnyOfPredicate, SymptomPredicate,
51
+ RunAction, ReportAction, RuleAction, ConvergeRule,
52
+ } from "./converge-rule";
53
+ export {
54
+ discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
55
+ DEFAULT_OPERATOR_INTERVAL_MS,
56
+ } from "./operator";
57
+ export type { OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions } from "./operator";
58
+ export { classifyOpVerbClass, isGated } from "./op-verb-class";
59
+ export type { OpVerbClass } from "./op-verb-class";
60
+ export { takeProfileAndId } from "./builders";
@@ -8,6 +8,7 @@ import {
8
8
  LocalGateUnsupportedError,
9
9
  OpRunFailure,
10
10
  } from "./local-executor";
11
+ import { stepOutput } from "./step-output-ref";
11
12
 
12
13
  // Fast profiles so retry/timeout tests run in milliseconds.
13
14
  const PROFILES: Record<string, ActivityProfile> = {
@@ -261,3 +262,94 @@ describe("runOpLocally — gate rejection", () => {
261
262
  expect(gated?.signalName).toBe("g");
262
263
  });
263
264
  });
265
+
266
+ describe("runOpLocally — step-output references (#1290)", () => {
267
+ test("a later step receives the producer's resolved value, not the placeholder", async () => {
268
+ const received: unknown[] = [];
269
+ const activities = new Map<string, ActivityFn>([
270
+ ["produce", async () => ({ driftedStacks: ["a", "b"], count: 2 })],
271
+ ["consume", async (args) => { received.push(args); }],
272
+ ]);
273
+ const config = op({
274
+ phases: [{ name: "P", steps: [
275
+ { kind: "activity", fn: "produce", id: "diff" },
276
+ { kind: "activity", fn: "consume", args: { stacks: stepOutput("diff", "driftedStacks") } },
277
+ ] }],
278
+ });
279
+ const result = await runOpLocally(config, activities, PROFILES);
280
+ expect(result.ok).toBe(true);
281
+ expect(received).toEqual([{ stacks: ["a", "b"] }]);
282
+ // The recorded step also shows the resolved value, not the raw ref object.
283
+ expect(result.records[1].args).toEqual({ stacks: ["a", "b"] });
284
+ });
285
+
286
+ test("a whole-value reference (no path) passes the entire producer result", async () => {
287
+ const received: unknown[] = [];
288
+ const activities = new Map<string, ActivityFn>([
289
+ ["produce", async () => ({ ok: true, detail: "x" })],
290
+ ["consume", async (args) => { received.push(args); }],
291
+ ]);
292
+ const config = op({
293
+ phases: [{ name: "P", steps: [
294
+ { kind: "activity", fn: "produce", id: "p" },
295
+ { kind: "activity", fn: "consume", args: { result: stepOutput("p") } },
296
+ ] }],
297
+ });
298
+ await runOpLocally(config, activities, PROFILES);
299
+ expect(received).toEqual([{ result: { ok: true, detail: "x" } }]);
300
+ });
301
+
302
+ test("resolves a reference nested inside an object and an array", async () => {
303
+ const received: unknown[] = [];
304
+ const activities = new Map<string, ActivityFn>([
305
+ ["produce", async () => ({ name: "prod" })],
306
+ ["consume", async (args) => { received.push(args); }],
307
+ ]);
308
+ const config = op({
309
+ phases: [{ name: "P", steps: [
310
+ { kind: "activity", fn: "produce", id: "p" },
311
+ {
312
+ kind: "activity",
313
+ fn: "consume",
314
+ args: { config: { tags: ["static", stepOutput("p", "name")] } },
315
+ },
316
+ ] }],
317
+ });
318
+ await runOpLocally(config, activities, PROFILES);
319
+ expect(received).toEqual([{ config: { tags: ["static", "prod"] } }]);
320
+ });
321
+
322
+ test("an unresolvable path resolves to undefined instead of throwing", async () => {
323
+ const received: unknown[] = [];
324
+ const activities = new Map<string, ActivityFn>([
325
+ ["produce", async () => ({})],
326
+ ["consume", async (args) => { received.push(args); }],
327
+ ]);
328
+ const config = op({
329
+ phases: [{ name: "P", steps: [
330
+ { kind: "activity", fn: "produce", id: "p" },
331
+ { kind: "activity", fn: "consume", args: { v: stepOutput("p", "a.b") } },
332
+ ] }],
333
+ });
334
+ const result = await runOpLocally(config, activities, PROFILES);
335
+ expect(result.ok).toBe(true);
336
+ expect(received).toEqual([{ v: undefined }]);
337
+ });
338
+
339
+ test("e2e: chant run (non-temporal) end to end — producer object flows to consumer", async () => {
340
+ const activities = new Map<string, ActivityFn>([
341
+ ["lifecycleDiff", async () => ({ driftedStacks: ["stack-a"], drifted: true })],
342
+ ["applyStacks", async (args) => ({ applied: args.stacks })],
343
+ ]);
344
+ const config = op({
345
+ phases: [
346
+ { name: "Diff", steps: [{ kind: "activity", fn: "lifecycleDiff", id: "diff", args: { env: "prod" } }] },
347
+ { name: "Apply", steps: [{ kind: "activity", fn: "applyStacks", args: { stacks: stepOutput("diff", "driftedStacks") } }] },
348
+ ],
349
+ });
350
+ const result = await runOpLocally(config, activities, PROFILES);
351
+ expect(result.ok).toBe(true);
352
+ expect(result.records.every((r) => r.status === "ok")).toBe(true);
353
+ expect(result.records[1].args).toEqual({ stacks: ["stack-a"] });
354
+ });
355
+ });
@@ -14,13 +14,20 @@
14
14
  import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
15
15
  import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
16
16
  import type { ReceiptReadResult } from "./receipt-store";
17
+ import { isStepOutputRef } from "./step-output-ref";
17
18
 
18
19
  // ── Records ─────────────────────────────────────────────────────────────────
19
20
 
20
21
  export interface StepRecord {
21
22
  phase: string;
22
23
  fn: string;
23
- args: Record<string, unknown>;
24
+ /**
25
+ * Present for a local-executor record. Absent for one reconstructed from
26
+ * Temporal workflow history (op-progress.ts) — an activity's scheduled
27
+ * input isn't decoded there, so the field is simply omitted rather than
28
+ * populated with a guess.
29
+ */
30
+ args?: Record<string, unknown>;
24
31
  status: "ok" | "fail" | "skipped";
25
32
  durationMs: number;
26
33
  outcome?: { name: string; value: unknown };
@@ -94,6 +101,25 @@ function resolvePath(value: unknown, path?: string): unknown {
94
101
  );
95
102
  }
96
103
 
104
+ /**
105
+ * Deep-walk `value`, replacing every {@link StepOutputRef} with the recorded
106
+ * result of its producer step, resolved through the ref's optional dot-path
107
+ * exactly like the serializer's compiled `__rN?.path?.segments` (chant #1290)
108
+ * — `resolvePath` gives the same "undefined intermediate resolves to
109
+ * undefined, never throws" semantics. A reference to a step whose result
110
+ * isn't in `resultsById` (never ran, or ran out of the scope
111
+ * `validateStepOutputRefs` allows) resolves to `undefined`, the same way a
112
+ * producer whose declared field is absent would.
113
+ */
114
+ function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string, unknown>): unknown {
115
+ if (isStepOutputRef(value)) return resolvePath(resultsById.get(value.step), value.path);
116
+ if (Array.isArray(value)) return value.map((v) => resolveStepOutputRefs(v, resultsById));
117
+ if (value && typeof value === "object") {
118
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveStepOutputRefs(v, resultsById)]));
119
+ }
120
+ return value;
121
+ }
122
+
97
123
  /** Find the first gate step anywhere in the Op (phases + onFailure, including
98
124
  * gates nested inside effect steps), if any. */
99
125
  export function findGate(config: OpConfig): GateStep | undefined {
@@ -160,15 +186,22 @@ interface RanStep {
160
186
  result?: unknown;
161
187
  }
162
188
 
163
- /** Run one activity step with retry + timeout. Never throws — returns a record. */
189
+ /**
190
+ * Run one activity step with retry + timeout. Never throws — returns a
191
+ * record. Any {@link StepOutputRef} in `step.args` is resolved against
192
+ * `resultsById` before the activity is called (#1290) — the local executor
193
+ * is a first-class peer of the Temporal path, so an unresolved placeholder
194
+ * must never reach an activity function; see `resolveStepOutputRefs`.
195
+ */
164
196
  async function runStep(
165
197
  step: ActivityStep,
166
198
  phaseName: string,
167
199
  activities: Map<string, ActivityFn>,
168
200
  profiles: Record<string, ActivityProfile>,
201
+ resultsById: Map<string, unknown>,
169
202
  signal?: AbortSignal,
170
203
  ): Promise<RanStep> {
171
- const args = step.args ?? {};
204
+ const args = resolveStepOutputRefs(step.args ?? {}, resultsById) as Record<string, unknown>;
172
205
  const base = { phase: phaseName, fn: step.fn, args };
173
206
  const start = Date.now();
174
207
 
@@ -198,6 +231,7 @@ async function runStep(
198
231
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
199
232
  try {
200
233
  const result = await callWithTimeout(fn, args, timeoutMs, signal);
234
+ if (step.id) resultsById.set(step.id, result);
201
235
  const record: StepRecord = { ...base, status: "ok", durationMs: Date.now() - start };
202
236
  if (step.outcomeAttribute) {
203
237
  record.outcome = {
@@ -258,11 +292,12 @@ async function runEffectStep(
258
292
  phaseName: string,
259
293
  activities: Map<string, ActivityFn>,
260
294
  profiles: Record<string, ActivityProfile>,
295
+ resultsById: Map<string, unknown>,
261
296
  signal?: AbortSignal,
262
297
  ): Promise<{ records: StepRecord[]; failed: boolean }> {
263
298
  const records: StepRecord[] = [];
264
299
 
265
- const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, signal);
300
+ const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
266
301
  records.push(read.record);
267
302
  if (read.record.status === "fail") return { records, failed: true };
268
303
 
@@ -291,7 +326,7 @@ async function runEffectStep(
291
326
  // Gates are pre-flighted by findGate; only activities remain here.
292
327
  const nestedActivities = step.steps.filter(isActivity);
293
328
  for (let i = 0; i < nestedActivities.length; i++) {
294
- const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, signal);
329
+ const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, resultsById, signal);
295
330
  records.push(ran.record);
296
331
  if (ran.record.status === "fail") {
297
332
  // Receipt left untouched (stale) — the next run re-proposes the effect.
@@ -314,6 +349,7 @@ async function runEffectStep(
314
349
  phaseName,
315
350
  activities,
316
351
  profiles,
352
+ resultsById,
317
353
  signal,
318
354
  );
319
355
  records.push(wrote.record);
@@ -325,6 +361,7 @@ async function runPhase(
325
361
  phase: PhaseDefinition,
326
362
  activities: Map<string, ActivityFn>,
327
363
  profiles: Record<string, ActivityProfile>,
364
+ resultsById: Map<string, unknown>,
328
365
  signal?: AbortSignal,
329
366
  ): Promise<StepRecord[]> {
330
367
  // Defensive: gates are pre-flighted, but never execute one if it slips
@@ -343,7 +380,7 @@ async function runPhase(
343
380
  }
344
381
  const steps = phase.steps.filter(isActivity);
345
382
  const records = (
346
- await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, signal)))
383
+ await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)))
347
384
  ).map((r) => r.record);
348
385
  if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
349
386
  return records;
@@ -365,7 +402,7 @@ async function runPhase(
365
402
  for (let i = 0; i < steps.length; i++) {
366
403
  const step = steps[i];
367
404
  if (isEffect(step)) {
368
- const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, signal);
405
+ const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, resultsById, signal);
369
406
  records.push(...effRecords);
370
407
  if (failed) {
371
408
  skipRemaining(i + 1);
@@ -373,7 +410,7 @@ async function runPhase(
373
410
  }
374
411
  continue;
375
412
  }
376
- const { record } = await runStep(step, phase.name, activities, profiles, signal);
413
+ const { record } = await runStep(step, phase.name, activities, profiles, resultsById, signal);
377
414
  records.push(record);
378
415
  if (record.status === "fail") {
379
416
  // Mark the remaining steps in this phase as skipped, then abort.
@@ -416,10 +453,15 @@ export async function runOpLocally(
416
453
  const records: StepRecord[] = [];
417
454
  const start = Date.now();
418
455
 
456
+ // Completed step id → its activity result (#1290). Populated as main-phase
457
+ // steps finish, so a later step's step-output references resolve to real
458
+ // values before reaching an activity function — see `runStep`.
459
+ const resultsById = new Map<string, unknown>();
460
+
419
461
  try {
420
462
  for (const phase of config.phases) {
421
463
  if (signal?.aborted) throw new PhaseFailure([]);
422
- records.push(...(await runPhase(phase, activities, profiles, signal)));
464
+ records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
423
465
  }
424
466
  } catch (err) {
425
467
  if (err instanceof PhaseFailure) records.push(...err.records);
@@ -429,7 +471,7 @@ export async function runOpLocally(
429
471
  if (!signal?.aborted) {
430
472
  for (const phase of [...(config.onFailure ?? [])].reverse()) {
431
473
  try {
432
- records.push(...(await runPhase(phase, activities, profiles, signal)));
474
+ records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
433
475
  } catch (compErr) {
434
476
  if (compErr instanceof PhaseFailure) records.push(...compErr.records);
435
477
  }
@@ -33,7 +33,7 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
33
33
  write(`[phase] ${currentPhase}`);
34
34
  }
35
35
  const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
36
- const call = `${record.fn}(${formatArgs(record.args)})`;
36
+ const call = `${record.fn}(${formatArgs(record.args ?? {})})`;
37
37
  if (record.status === "skipped") {
38
38
  write(` ${mark} ${call} skipped`);
39
39
  } else {
@@ -0,0 +1,126 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { classifyOpVerbClass, isGated } from "./op-verb-class";
3
+ import type { OpConfig } from "./types";
4
+
5
+ function cfg(phases: OpConfig["phases"], onFailure?: OpConfig["onFailure"]): Pick<OpConfig, "phases" | "onFailure"> {
6
+ return { phases, ...(onFailure ? { onFailure } : {}) };
7
+ }
8
+
9
+ describe("classifyOpVerbClass", () => {
10
+ test("read-only for a WatchOp-shaped composition (snapshot + diff)", () => {
11
+ const config = cfg([
12
+ { name: "Snapshot", steps: [{ kind: "activity", fn: "lifecycleSnapshot", args: { env: "prod" } }] },
13
+ { name: "Diff", steps: [{ kind: "activity", fn: "lifecycleDiff", args: { env: "prod" } }] },
14
+ ]);
15
+ expect(classifyOpVerbClass(config)).toBe("read-only");
16
+ });
17
+
18
+ test("read-only for an empty phase list", () => {
19
+ expect(classifyOpVerbClass(cfg([]))).toBe("read-only");
20
+ });
21
+
22
+ test("mutating for nativeApply with deleteMode never", () => {
23
+ const config = cfg([
24
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "never" } }] },
25
+ ]);
26
+ expect(classifyOpVerbClass(config)).toBe("mutating");
27
+ });
28
+
29
+ test("mutating for nativeApply with no deleteMode at all", () => {
30
+ const config = cfg([
31
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist" } }] },
32
+ ]);
33
+ expect(classifyOpVerbClass(config)).toBe("mutating");
34
+ });
35
+
36
+ test("destructive for nativeApply with deleteMode owned-only", () => {
37
+ const config = cfg([
38
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "owned-only" } }] },
39
+ ]);
40
+ expect(classifyOpVerbClass(config)).toBe("destructive");
41
+ });
42
+
43
+ test("destructive for nativeApply with deleteMode gated", () => {
44
+ const config = cfg([
45
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "gated" } }] },
46
+ ]);
47
+ expect(classifyOpVerbClass(config)).toBe("destructive");
48
+ });
49
+
50
+ test("destructive for envTeardown regardless of args", () => {
51
+ const config = cfg([{ name: "Teardown", steps: [{ kind: "activity", fn: "envTeardown", args: { env: "staging" } }] }]);
52
+ expect(classifyOpVerbClass(config)).toBe("destructive");
53
+ });
54
+
55
+ test("destructive for awsDelete/azDelete/gcpDelete/chantTeardown", () => {
56
+ for (const fn of ["awsDelete", "azDelete", "gcpDelete", "chantTeardown"]) {
57
+ const config = cfg([{ name: "P", steps: [{ kind: "activity", fn, args: {} }] }]);
58
+ expect(classifyOpVerbClass(config)).toBe("destructive");
59
+ }
60
+ });
61
+
62
+ test("fail-closed: an unrecognized activity fn is mutating, not read-only", () => {
63
+ const config = cfg([{ name: "P", steps: [{ kind: "activity", fn: "someBespokeCustomActivity", args: {} }] }]);
64
+ expect(classifyOpVerbClass(config)).toBe("mutating");
65
+ });
66
+
67
+ test("a single destructive step outranks other read-only/mutating steps in the same Op", () => {
68
+ const config = cfg([
69
+ { name: "Diff", steps: [{ kind: "activity", fn: "lifecycleDiff", args: { env: "prod" } }] },
70
+ { name: "Teardown", steps: [{ kind: "activity", fn: "envTeardown", args: { env: "prod" } }] },
71
+ ]);
72
+ expect(classifyOpVerbClass(config)).toBe("destructive");
73
+ });
74
+
75
+ test("checks onFailure phases too", () => {
76
+ const config = cfg(
77
+ [{ name: "Apply", steps: [{ kind: "activity", fn: "lifecycleDiff", args: { env: "prod" } }] }],
78
+ [{ name: "Rollback", steps: [{ kind: "activity", fn: "envTeardown", args: { env: "prod" } }] }],
79
+ );
80
+ expect(classifyOpVerbClass(config)).toBe("destructive");
81
+ });
82
+
83
+ test("looks inside an effect step's nested activity steps", () => {
84
+ const config = cfg([
85
+ {
86
+ name: "Seed",
87
+ steps: [
88
+ {
89
+ kind: "effect",
90
+ receipt: { name: "seeded", lexicon: "aws" } as never,
91
+ steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "gated" } }],
92
+ },
93
+ ],
94
+ },
95
+ ]);
96
+ expect(classifyOpVerbClass(config)).toBe("destructive");
97
+ });
98
+ });
99
+
100
+ describe("isGated", () => {
101
+ test("false with no gate step", () => {
102
+ expect(isGated(cfg([{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] }]))).toBe(false);
103
+ });
104
+
105
+ test("true with a gate step in the main phases", () => {
106
+ expect(
107
+ isGated(
108
+ cfg([
109
+ { name: "Approve", steps: [{ kind: "gate", signalName: "approve-x" }] },
110
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] },
111
+ ]),
112
+ ),
113
+ ).toBe(true);
114
+ });
115
+
116
+ test("true with a gate step in onFailure", () => {
117
+ expect(
118
+ isGated(
119
+ cfg(
120
+ [{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] }],
121
+ [{ name: "Rollback", steps: [{ kind: "gate", signalName: "approve-rollback" }] }],
122
+ ),
123
+ ),
124
+ ).toBe(true);
125
+ });
126
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Op verb classification (#1484) — generalizes `delete: "never" | "owned-only"
3
+ * | "gated"` (`../lifecycle` teardown/apply's `DeleteMode`) up one level, to
4
+ * the whole Op: read-only, mutating, or destructive. `ConvergeOp`'s
5
+ * dial × verb-class matrix (the issue's "Autonomy" table) needs to answer
6
+ * "what kind of thing is this dispatched Op, from the outside" for an
7
+ * arbitrary declared Op — including one this project's authors wrote, not
8
+ * just the pre-built composites — so classification walks `OpConfig`'s
9
+ * steps rather than trusting a self-reported tag.
10
+ *
11
+ * There is no existing per-Op verb-class field (`searchAttributes` is a free
12
+ * `Record<string,string>` an author may or may not set — `ApplyOp` tags
13
+ * `{ Apply: "true" }` but nothing requires it, and it carries no verb-class
14
+ * semantics on its own) and no existing "which activity names are
15
+ * destructive" registry (`../lint/pipeline-change-gate.ts`'s classifier is
16
+ * shaped for a `Component` composition's capability-verb steps, not an
17
+ * `OpConfig`'s activity steps — a genuinely different graph). This module is
18
+ * that registry's Op-shaped sibling: a small, explicit set of known
19
+ * mutating/destructive activity function names, plus the one piece of
20
+ * structured signal that already exists (`nativeApply`'s `deleteMode` arg).
21
+ *
22
+ * Fail-closed by construction: an activity `fn` this module doesn't
23
+ * recognize is classified `mutating`, never `read-only` — an unknown step
24
+ * might do anything, and "unknown never remediates" (the issue's own honesty
25
+ * requirement for symptoms) applies here too: this module never silently
26
+ * clears an Op to free-run just because its steps aren't in the known-safe
27
+ * list.
28
+ */
29
+
30
+ import { findGate } from "./local-executor";
31
+ import type { ActivityStep, EffectStep, OpConfig, StepDefinition } from "./types";
32
+
33
+ export type OpVerbClass = "read-only" | "mutating" | "destructive";
34
+
35
+ /** Activity function names known to be read-only — verification, observation, audit. Anything not in this set is treated as at least `mutating` (fail-closed). */
36
+ const READ_ONLY_ACTIVITY_FNS: ReadonlySet<string> = new Set([
37
+ "lifecycleSnapshot",
38
+ "lifecycleDiff",
39
+ "httpCheck",
40
+ "policyGate",
41
+ "workflowSupplyChainAudit",
42
+ "pipelineSupplyChainAudit",
43
+ "receiptStaleness",
44
+ "waitForStack",
45
+ "waitForReady",
46
+ "waitForArgoSync",
47
+ "spriteReadFile",
48
+ "spriteListDir",
49
+ "listCheckpoints",
50
+ "convergeTick",
51
+ ]);
52
+
53
+ /** Activity function names that always delete/destroy, regardless of args. */
54
+ const ALWAYS_DESTRUCTIVE_ACTIVITY_FNS: ReadonlySet<string> = new Set([
55
+ "envTeardown",
56
+ "chantTeardown",
57
+ "azDelete",
58
+ "awsDelete",
59
+ "gcpDelete",
60
+ "spriteDestroy",
61
+ "k3dDown",
62
+ "k3sUninstall",
63
+ ]);
64
+
65
+ function stepsOf(config: Pick<OpConfig, "phases" | "onFailure">): ActivityStep[] {
66
+ const all: StepDefinition[] = [...config.phases, ...(config.onFailure ?? [])].flatMap((p) => p.steps);
67
+ const flat: ActivityStep[] = [];
68
+ for (const step of all) {
69
+ if (step.kind === "activity") flat.push(step);
70
+ else if (step.kind === "effect") flat.push(...(step as EffectStep).steps.filter((s): s is ActivityStep => s.kind === "activity"));
71
+ }
72
+ return flat;
73
+ }
74
+
75
+ /**
76
+ * Classify an Op's own composition. Pure — reads only the passed config, no
77
+ * I/O. `nativeApply`'s `deleteMode` (`"never" | "owned-only" | "gated"`) is
78
+ * read straight off the step's `args` — `"owned-only"`/`"gated"` are
79
+ * destructive (both can delete a live resource), `"never"`/absent is merely
80
+ * mutating (creates/updates only). A step whose `fn` matches neither the
81
+ * read-only nor the destructive set is `mutating` by default (fail-closed).
82
+ */
83
+ export function classifyOpVerbClass(config: Pick<OpConfig, "phases" | "onFailure">): OpVerbClass {
84
+ let sawMutating = false;
85
+
86
+ for (const step of stepsOf(config)) {
87
+ if (ALWAYS_DESTRUCTIVE_ACTIVITY_FNS.has(step.fn)) return "destructive";
88
+
89
+ if (step.fn === "nativeApply") {
90
+ const deleteMode = step.args?.deleteMode;
91
+ if (deleteMode === "owned-only" || deleteMode === "gated") return "destructive";
92
+ sawMutating = true;
93
+ continue;
94
+ }
95
+
96
+ if (step.fn === "compensateApply") {
97
+ // Rollback compensation — mutating (it re-applies a prior state), not
98
+ // itself a fresh destructive act.
99
+ sawMutating = true;
100
+ continue;
101
+ }
102
+
103
+ if (READ_ONLY_ACTIVITY_FNS.has(step.fn)) continue;
104
+
105
+ // Unknown or known-mutating fn: fail-closed to mutating.
106
+ sawMutating = true;
107
+ }
108
+
109
+ return sawMutating ? "mutating" : "read-only";
110
+ }
111
+
112
+ /** Does this Op have its own approval gate anywhere in its phases (main or `onFailure`)? Thin re-export of `findGate` under the name this module's callers reach for. */
113
+ export function isGated(config: Pick<OpConfig, "phases" | "onFailure">): boolean {
114
+ return findGate(config as OpConfig) !== undefined;
115
+ }