@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
@@ -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,6 +14,7 @@
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
 
@@ -94,6 +95,25 @@ function resolvePath(value: unknown, path?: string): unknown {
94
95
  );
95
96
  }
96
97
 
98
+ /**
99
+ * Deep-walk `value`, replacing every {@link StepOutputRef} with the recorded
100
+ * result of its producer step, resolved through the ref's optional dot-path
101
+ * exactly like the serializer's compiled `__rN?.path?.segments` (chant #1290)
102
+ * — `resolvePath` gives the same "undefined intermediate resolves to
103
+ * undefined, never throws" semantics. A reference to a step whose result
104
+ * isn't in `resultsById` (never ran, or ran out of the scope
105
+ * `validateStepOutputRefs` allows) resolves to `undefined`, the same way a
106
+ * producer whose declared field is absent would.
107
+ */
108
+ function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string, unknown>): unknown {
109
+ if (isStepOutputRef(value)) return resolvePath(resultsById.get(value.step), value.path);
110
+ if (Array.isArray(value)) return value.map((v) => resolveStepOutputRefs(v, resultsById));
111
+ if (value && typeof value === "object") {
112
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveStepOutputRefs(v, resultsById)]));
113
+ }
114
+ return value;
115
+ }
116
+
97
117
  /** Find the first gate step anywhere in the Op (phases + onFailure, including
98
118
  * gates nested inside effect steps), if any. */
99
119
  export function findGate(config: OpConfig): GateStep | undefined {
@@ -160,15 +180,22 @@ interface RanStep {
160
180
  result?: unknown;
161
181
  }
162
182
 
163
- /** Run one activity step with retry + timeout. Never throws — returns a record. */
183
+ /**
184
+ * Run one activity step with retry + timeout. Never throws — returns a
185
+ * record. Any {@link StepOutputRef} in `step.args` is resolved against
186
+ * `resultsById` before the activity is called (#1290) — the local executor
187
+ * is a first-class peer of the Temporal path, so an unresolved placeholder
188
+ * must never reach an activity function; see `resolveStepOutputRefs`.
189
+ */
164
190
  async function runStep(
165
191
  step: ActivityStep,
166
192
  phaseName: string,
167
193
  activities: Map<string, ActivityFn>,
168
194
  profiles: Record<string, ActivityProfile>,
195
+ resultsById: Map<string, unknown>,
169
196
  signal?: AbortSignal,
170
197
  ): Promise<RanStep> {
171
- const args = step.args ?? {};
198
+ const args = resolveStepOutputRefs(step.args ?? {}, resultsById) as Record<string, unknown>;
172
199
  const base = { phase: phaseName, fn: step.fn, args };
173
200
  const start = Date.now();
174
201
 
@@ -198,6 +225,7 @@ async function runStep(
198
225
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
199
226
  try {
200
227
  const result = await callWithTimeout(fn, args, timeoutMs, signal);
228
+ if (step.id) resultsById.set(step.id, result);
201
229
  const record: StepRecord = { ...base, status: "ok", durationMs: Date.now() - start };
202
230
  if (step.outcomeAttribute) {
203
231
  record.outcome = {
@@ -258,11 +286,12 @@ async function runEffectStep(
258
286
  phaseName: string,
259
287
  activities: Map<string, ActivityFn>,
260
288
  profiles: Record<string, ActivityProfile>,
289
+ resultsById: Map<string, unknown>,
261
290
  signal?: AbortSignal,
262
291
  ): Promise<{ records: StepRecord[]; failed: boolean }> {
263
292
  const records: StepRecord[] = [];
264
293
 
265
- const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, signal);
294
+ const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
266
295
  records.push(read.record);
267
296
  if (read.record.status === "fail") return { records, failed: true };
268
297
 
@@ -291,7 +320,7 @@ async function runEffectStep(
291
320
  // Gates are pre-flighted by findGate; only activities remain here.
292
321
  const nestedActivities = step.steps.filter(isActivity);
293
322
  for (let i = 0; i < nestedActivities.length; i++) {
294
- const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, signal);
323
+ const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, resultsById, signal);
295
324
  records.push(ran.record);
296
325
  if (ran.record.status === "fail") {
297
326
  // Receipt left untouched (stale) — the next run re-proposes the effect.
@@ -314,6 +343,7 @@ async function runEffectStep(
314
343
  phaseName,
315
344
  activities,
316
345
  profiles,
346
+ resultsById,
317
347
  signal,
318
348
  );
319
349
  records.push(wrote.record);
@@ -325,6 +355,7 @@ async function runPhase(
325
355
  phase: PhaseDefinition,
326
356
  activities: Map<string, ActivityFn>,
327
357
  profiles: Record<string, ActivityProfile>,
358
+ resultsById: Map<string, unknown>,
328
359
  signal?: AbortSignal,
329
360
  ): Promise<StepRecord[]> {
330
361
  // Defensive: gates are pre-flighted, but never execute one if it slips
@@ -343,7 +374,7 @@ async function runPhase(
343
374
  }
344
375
  const steps = phase.steps.filter(isActivity);
345
376
  const records = (
346
- await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, signal)))
377
+ await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)))
347
378
  ).map((r) => r.record);
348
379
  if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
349
380
  return records;
@@ -365,7 +396,7 @@ async function runPhase(
365
396
  for (let i = 0; i < steps.length; i++) {
366
397
  const step = steps[i];
367
398
  if (isEffect(step)) {
368
- const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, signal);
399
+ const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, resultsById, signal);
369
400
  records.push(...effRecords);
370
401
  if (failed) {
371
402
  skipRemaining(i + 1);
@@ -373,7 +404,7 @@ async function runPhase(
373
404
  }
374
405
  continue;
375
406
  }
376
- const { record } = await runStep(step, phase.name, activities, profiles, signal);
407
+ const { record } = await runStep(step, phase.name, activities, profiles, resultsById, signal);
377
408
  records.push(record);
378
409
  if (record.status === "fail") {
379
410
  // Mark the remaining steps in this phase as skipped, then abort.
@@ -416,10 +447,15 @@ export async function runOpLocally(
416
447
  const records: StepRecord[] = [];
417
448
  const start = Date.now();
418
449
 
450
+ // Completed step id → its activity result (#1290). Populated as main-phase
451
+ // steps finish, so a later step's step-output references resolve to real
452
+ // values before reaching an activity function — see `runStep`.
453
+ const resultsById = new Map<string, unknown>();
454
+
419
455
  try {
420
456
  for (const phase of config.phases) {
421
457
  if (signal?.aborted) throw new PhaseFailure([]);
422
- records.push(...(await runPhase(phase, activities, profiles, signal)));
458
+ records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
423
459
  }
424
460
  } catch (err) {
425
461
  if (err instanceof PhaseFailure) records.push(...err.records);
@@ -429,7 +465,7 @@ export async function runOpLocally(
429
465
  if (!signal?.aborted) {
430
466
  for (const phase of [...(config.onFailure ?? [])].reverse()) {
431
467
  try {
432
- records.push(...(await runPhase(phase, activities, profiles, signal)));
468
+ records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
433
469
  } catch (compErr) {
434
470
  if (compErr instanceof PhaseFailure) records.push(...compErr.records);
435
471
  }
@@ -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
+ }