@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
@@ -23,11 +23,14 @@ const checks = loadComponentChecks();
23
23
  /**
24
24
  * Rollback dispositions the COMP003 fixtures reference, standing in for what
25
25
  * `chant lint` derives from the project's registry (`cfn-deploy` has a native
26
- * `rollback`; `run-migration` declares `needs-opt-out` in the aws lexicon).
26
+ * `rollback`; `run-migration` declares `needs-opt-out` in the aws lexicon;
27
+ * `run-agent` declares `native` from phase 1 (#1941) — see
28
+ * `../../../components/verbs/run-agent.ts`'s `rollbackPolicy: "native"`).
27
29
  */
28
30
  const FIXTURE_ROLLBACK_POLICIES = new Map<string, RollbackPolicy>([
29
31
  ["cfn-deploy", "native"],
30
32
  ["run-migration", "needs-opt-out"],
33
+ ["run-agent", "native"],
31
34
  ]);
32
35
 
33
36
  async function lintFixture(
@@ -124,6 +127,16 @@ describe("COMP003: mutating-no-rollback", () => {
124
127
  expect(diagnostics.filter((d) => d.checkId === "COMP003")).toHaveLength(0);
125
128
  });
126
129
 
130
+ it("does not flag a bare run-agent step — its registry-declared rollbackPolicy is \"native\" (#1941), never needing a noRollback opt-out (#1944)", async () => {
131
+ // Regression test: proves the registry's rollbackPolicy for "run-agent"
132
+ // is wired correctly into ctx.rollbackPolicies (the same seam COMP005
133
+ // uses for ctx.knownKinds), not just that COMP003 happens to stay quiet.
134
+ // See ../../../components/verbs/run-agent.ts's "rollbackPolicy: 'native'".
135
+ const diagnostics = await lintFixture("comp003", "pass", { rollbackPolicies: FIXTURE_ROLLBACK_POLICIES });
136
+ const hits = diagnostics.filter((d) => d.checkId === "COMP003" && d.component === "agent-turn");
137
+ expect(hits).toHaveLength(0);
138
+ });
139
+
127
140
  it("flags a needs-opt-out step nested inside a fan-out phase, not just top-level phases", () => {
128
141
  // Regression test: the rule used to iterate component.deploy directly and
129
142
  // never recursed into nested Phase entries (a fan-out unit, e.g. the
@@ -163,6 +176,41 @@ describe("COMP003: mutating-no-rollback", () => {
163
176
  expect(diagnostics[0].message).toContain("run-migration");
164
177
  });
165
178
 
179
+ it("DOES flag a \"run-agent\"-kind step when a synthetic registry declares it needs-opt-out — proves the rule is conditional on the registry, not a hard-coded allowance for this one verb name (#1944)", () => {
180
+ // Companion negative case to the "does not flag a bare run-agent step"
181
+ // test above: same step kind, same shape, but with a synthetic
182
+ // rollbackPolicies map that declares "run-agent" as "needs-opt-out"
183
+ // instead of the real registry's "native". If this rule silently
184
+ // special-cased "run-agent" by name, this would incorrectly pass; it must
185
+ // flag exactly like any other needs-opt-out verb.
186
+ const [comp003] = checks.filter((c) => c.id === "COMP003");
187
+ const ctx = {
188
+ rollbackPolicies: new Map<string, RollbackPolicy>([["run-agent", "needs-opt-out"]]),
189
+ components: new Map([
190
+ [
191
+ "agent-turn",
192
+ {
193
+ component: {
194
+ name: "agent-turn",
195
+ dependsOn: [],
196
+ deploy: [
197
+ {
198
+ phase: "Run",
199
+ steps: [{ kind: "run-agent", agent: "claude", task: { prompt: "..." }, workspace: {} }],
200
+ },
201
+ ],
202
+ },
203
+ filePath: "agent-turn.component.ts",
204
+ },
205
+ ],
206
+ ]),
207
+ };
208
+ const diagnostics = comp003.check(ctx as never);
209
+ expect(diagnostics).toHaveLength(1);
210
+ expect(diagnostics[0].component).toBe("agent-turn");
211
+ expect(diagnostics[0].message).toContain("run-agent");
212
+ });
213
+
166
214
  it("a compensation sibling only counts within the same nested phase, not a same-named sibling phase elsewhere in the component", () => {
167
215
  // Two different "Node" phases both contain a run-migration step; only the
168
216
  // second one has a rollback-previous sibling. The first must still be
@@ -90,11 +90,16 @@ describe("EVL001: non-literal-expression", () => {
90
90
  expect(diags[0].message).toContain("statically evaluable");
91
91
  });
92
92
 
93
- test("flags method call", () => {
93
+ // chant #1966 — `fold()` widened to fold a method call when its receiver
94
+ // resolves (`github.actor.toString()`, `[...].join(",")`); `findSubsetViolation`
95
+ // (./subset.ts) accepts the SHAPE unconditionally, the same way it already
96
+ // does for `.step` and a nested `new` — whether the receiver actually
97
+ // resolves is resolution-dependent and out of reach here, so a method call
98
+ // no longer trips this rule on shape alone.
99
+ test("does not flag a method call — resolution-dependent, out of scope for a syntax-only rule (chant #1966)", () => {
94
100
  const ctx = createContext(`new Bucket({ name: config.getName() });`);
95
101
  const diags = evl001NonLiteralExpressionRule.check(ctx);
96
- expect(diags).toHaveLength(1);
97
- expect(diags[0].ruleId).toBe("EVL001");
102
+ expect(diags).toHaveLength(0);
98
103
  });
99
104
 
100
105
  test("flags await expression", () => {
@@ -31,14 +31,14 @@ function checkNode(node: ts.Node, context: LintContext, diagnostics: LintDiagnos
31
31
  const firstArg = node.arguments[0];
32
32
  if (ts.isObjectLiteralExpression(firstArg)) {
33
33
  for (const prop of firstArg.properties) {
34
- // chant #1544 — `allowCompositeStepAccess: true` opts EVL001 (and
35
- // only EVL001; `fold()` never sets this) into treating
36
34
  // `Checkout({...}).step` — the single-action Composite()-wrapper
37
35
  // idiom every lexicon's own docs/examples embed inline inside a
38
- // Job's `steps:` array — as shape-valid. See findSubsetViolation's
39
- // doc comment (../../fold/subset.ts) for why this is a documented,
40
- // EVL-only, more-permissive divergence rather than a shared one.
41
- const violation = checkObjectMember(prop, context.intrinsics, true);
36
+ // Job's `steps:` array — is shape-valid unconditionally (chant
37
+ // #1544 introduced this as an EVL-only opt-in while `fold()`
38
+ // still rejected the shape; chant #1174 closed that divergence by
39
+ // making `fold()` accept it too, so `findSubsetViolation` no
40
+ // longer needs a flag to say so).
41
+ const violation = checkObjectMember(prop, context.intrinsics);
42
42
  if (violation) {
43
43
  const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
44
44
  violation.node.getStart(context.sourceFile),
@@ -53,3 +53,47 @@ describe("lexiconCompletions resource ranking (#600)", () => {
53
53
  expect(items.length).toBe(100);
54
54
  });
55
55
  });
56
+
57
+ describe("lexiconCompletions deprecation marking (#1701)", () => {
58
+ /** One resource with a declared deprecation and an inferred one. */
59
+ function bucketIndex(): LexiconIndex {
60
+ return new LexiconIndex({
61
+ Bucket: {
62
+ resourceType: "Test::S3::Bucket",
63
+ kind: "resource",
64
+ lexicon: "test",
65
+ createOnly: ["AccessControl", "Runtime", "BucketName"],
66
+ deprecatedProperties: ["AccessControl", "Runtime"],
67
+ inferredDeprecations: ["Runtime"],
68
+ },
69
+ });
70
+ }
71
+
72
+ const propCtx = (): CompletionContext =>
73
+ ({
74
+ uri: "file:///t.ts",
75
+ content: "const b = new Bucket({\n ",
76
+ linePrefix: " ",
77
+ wordAtCursor: "",
78
+ position: { line: 1, character: 2 },
79
+ }) as unknown as CompletionContext;
80
+
81
+ test("a declared deprecation is marked deprecated", () => {
82
+ const items = lexiconCompletions(propCtx(), bucketIndex(), "Test resource");
83
+ const access = items.find((i) => i.label === "AccessControl");
84
+ expect(access?.deprecated).toBe(true);
85
+ });
86
+
87
+ test("an inferred deprecation is not marked deprecated", () => {
88
+ const items = lexiconCompletions(propCtx(), bucketIndex(), "Test resource");
89
+ const runtime = items.find((i) => i.label === "Runtime");
90
+ expect(runtime).toBeDefined();
91
+ expect(runtime?.deprecated).toBeUndefined();
92
+ });
93
+
94
+ test("a property with no deprecation signal is not marked", () => {
95
+ const items = lexiconCompletions(propCtx(), bucketIndex(), "Test resource");
96
+ const name = items.find((i) => i.label === "BucketName");
97
+ expect(name?.deprecated).toBeUndefined();
98
+ });
99
+ });
@@ -20,6 +20,13 @@ export interface LexiconEntry {
20
20
  writeOnly?: string[];
21
21
  primaryIdentifier?: string[];
22
22
  deprecatedProperties?: string[];
23
+ /**
24
+ * Subset of `deprecatedProperties` that only prose supports — a lexicon
25
+ * generator matched the property description against a deprecation regex
26
+ * rather than reading an upstream declaration (#1701). Editors do not strike
27
+ * these through; the basis is too weak to mark a property dead.
28
+ */
29
+ inferredDeprecations?: string[];
23
30
  conditionalCreateOnly?: string[];
24
31
  replacementStrategy?: "delete_then_create" | "create_then_delete";
25
32
  tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean };
@@ -127,7 +134,10 @@ export function lexiconCompletions(
127
134
  const entry = index.getEntry(className);
128
135
  const props = index.getPropertyNames(className);
129
136
  if (props.length > 0) {
130
- const deprecatedSet = new Set(entry?.deprecatedProperties ?? []);
137
+ const inferred = new Set(entry?.inferredDeprecations ?? []);
138
+ const deprecatedSet = new Set(
139
+ (entry?.deprecatedProperties ?? []).filter((p) => !inferred.has(p)),
140
+ );
131
141
  const filtered = wordAtCursor
132
142
  ? props.filter((p) => p.toLowerCase().startsWith(wordAtCursor.toLowerCase()))
133
143
  : props;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Activity contract tests (chant #1288 Stage 1).
3
+ *
4
+ * Exercises the four failure classes named in the issue, using a small
5
+ * lifecycleDiff-shaped contract so the test doesn't need a real lexicon.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { z } from "zod";
10
+ import { activity, phase, effect } from "./builders";
11
+ import type { OpConfig } from "./types";
12
+ import {
13
+ activityContract,
14
+ isActivityContract,
15
+ collectActivityContracts,
16
+ validateActivitySteps,
17
+ type ActivityContract,
18
+ } from "./activity-contract";
19
+ import { stepOutput } from "./step-output-ref";
20
+ import { EffectReceipt } from "../effect-receipt";
21
+
22
+ // ── activityContract() / isActivityContract() ──────────────────────────────
23
+
24
+ describe("activityContract()", () => {
25
+ it("brands its result so isActivityContract recognizes it", () => {
26
+ const contract = activityContract("noop", z.strictObject({}));
27
+ expect(isActivityContract(contract)).toBe(true);
28
+ expect(contract.name).toBe("noop");
29
+ });
30
+
31
+ it("isActivityContract rejects a plain object with the same shape", () => {
32
+ expect(isActivityContract({ name: "noop", args: z.strictObject({}) })).toBe(false);
33
+ expect(isActivityContract(null)).toBe(false);
34
+ expect(isActivityContract(undefined)).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("collectActivityContracts()", () => {
39
+ it("collects every exported contract, keyed by its declared name — ignores non-contract exports", () => {
40
+ const mod = {
41
+ shellCmdContract: activityContract("shellCmd", z.strictObject({ cmd: z.string() })),
42
+ httpCheckContract: activityContract("httpCheck", z.strictObject({ url: z.string() })),
43
+ unrelatedExport: "not a contract",
44
+ SOME_CONSTANT: 42,
45
+ };
46
+ const into = new Map<string, ActivityContract>();
47
+ collectActivityContracts(mod, into);
48
+ expect([...into.keys()].sort()).toEqual(["httpCheck", "shellCmd"]);
49
+ expect(into.get("shellCmd")!.name).toBe("shellCmd");
50
+ });
51
+ });
52
+
53
+ // ── validateActivitySteps() ─────────────────────────────────────────────────
54
+
55
+ const lifecycleDiffContract = activityContract(
56
+ "lifecycleDiff",
57
+ z.strictObject({ env: z.string(), live: z.boolean().optional() }),
58
+ z.object({ output: z.string(), exitCode: z.number(), drifted: z.boolean() }),
59
+ );
60
+
61
+ const helmInstallContract = activityContract(
62
+ "helmInstall",
63
+ z.strictObject({ name: z.string(), chart: z.string().optional(), namespace: z.string().optional() }),
64
+ );
65
+
66
+ function opWith(config: Partial<OpConfig> & Pick<OpConfig, "phases">): Pick<OpConfig, "name" | "phases" | "onFailure"> {
67
+ return { name: "test-op", ...config };
68
+ }
69
+
70
+ describe("validateActivitySteps() — passing Ops", () => {
71
+ it("returns no issues for a step matching its contract exactly", () => {
72
+ const config = opWith({ phases: [phase("Diff", [activity("lifecycleDiff", { env: "prod", live: true })])] });
73
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
74
+ expect(issues).toEqual([]);
75
+ });
76
+
77
+ it("skips a step whose fn has no registered contract — non-breaking by design", () => {
78
+ const config = opWith({ phases: [phase("Deploy", [activity("somethingUnregistered", { anything: "goes" })])] });
79
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
80
+ expect(issues).toEqual([]);
81
+ });
82
+
83
+ it("a valid outcomeAttribute.from path against the declared return schema passes", () => {
84
+ const step = activity("lifecycleDiff", { env: "prod" });
85
+ step.outcomeAttribute = { name: "Drift", from: "drifted" };
86
+ const config = opWith({ phases: [phase("Diff", [step])] });
87
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
88
+ expect(issues).toEqual([]);
89
+ });
90
+
91
+ it("a step-output reference standing in for a typed arg does not trip a false-positive type mismatch (#1290)", () => {
92
+ // args.env is z.string() — a StepOutputRef placeholder there is not a
93
+ // string, but it's not a real error either; TMP013 validates it against
94
+ // the producer's return schema separately.
95
+ const step = activity("lifecycleDiff", { env: stepOutput("diff", "name") as unknown as string });
96
+ const config = opWith({ phases: [phase("Diff", [step])] });
97
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
98
+ expect(issues).toEqual([]);
99
+ });
100
+
101
+ it("a step-output reference elsewhere in args doesn't mask a genuinely unrecognized key", () => {
102
+ const step = activity("lifecycleDiff", { env: stepOutput("diff", "name") as unknown as string, typo: "oops" });
103
+ const config = opWith({ phases: [phase("Diff", [step])] });
104
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
105
+ expect(issues.some((i) => i.message.includes("Unrecognized key") && i.message.includes("typo"))).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe("validateActivitySteps() — the four failure classes from #1288", () => {
110
+ it("flags an unknown profile — e.g. kubectlApply(..., { profile: \"longInfa\" })", () => {
111
+ const step = activity("kubectlApply", { manifest: "dist/k8s.yaml" }, "longInfa" as never);
112
+ const config = opWith({ phases: [phase("Deploy", [step])] });
113
+ const issues = validateActivitySteps(config, new Map());
114
+ expect(issues).toHaveLength(1);
115
+ expect(issues[0].message).toContain('unknown profile "longInfa"');
116
+ expect(issues[0].message).toContain("fastIdempotent");
117
+ });
118
+
119
+ it("flags an arg key the schema doesn't recognize — e.g. helmInstall(..., { nameSpace: \"prod\" })", () => {
120
+ const step = activity("helmInstall", { name: "api", chart: "./chart", nameSpace: "prod" });
121
+ const config = opWith({ phases: [phase("Deploy", [step])] });
122
+ const issues = validateActivitySteps(config, new Map([["helmInstall", helmInstallContract]]));
123
+ expect(issues.some((i) => i.message.includes("Unrecognized key") && i.message.includes("nameSpace"))).toBe(true);
124
+ });
125
+
126
+ it("flags a wrong/missing arg key — e.g. activity(\"lifecycleDiff\", { environment: \"prod\" })", () => {
127
+ const step = activity("lifecycleDiff", { environment: "prod" });
128
+ const config = opWith({ phases: [phase("Diff", [step])] });
129
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
130
+ // `env` (required) is missing, and `environment` is an unrecognized key.
131
+ expect(issues.some((i) => i.message.includes("args.env"))).toBe(true);
132
+ expect(issues.some((i) => i.message.includes("Unrecognized key") && i.message.includes("environment"))).toBe(true);
133
+ });
134
+
135
+ it("flags an outcomeAttribute.from path that doesn't exist on the declared return type", () => {
136
+ const step = activity("lifecycleDiff", { env: "prod" });
137
+ step.outcomeAttribute = { name: "Drift", from: "drifed" }; // typo: drifted
138
+ const config = opWith({ phases: [phase("Diff", [step])] });
139
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
140
+ expect(issues.some((i) => i.message.includes('outcomeAttribute.from "drifed"'))).toBe(true);
141
+ });
142
+
143
+ it("flags a wrong-typed arg value even when the key is right", () => {
144
+ const step = activity("lifecycleDiff", { env: 42 as unknown as string });
145
+ const config = opWith({ phases: [phase("Diff", [step])] });
146
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
147
+ expect(issues.some((i) => i.message.includes("args.env"))).toBe(true);
148
+ });
149
+ });
150
+
151
+ describe("validateActivitySteps() — structural coverage", () => {
152
+ it("walks onFailure phases too", () => {
153
+ const step = activity("lifecycleDiff", { environment: "prod" });
154
+ const config = opWith({ phases: [], onFailure: [phase("Rollback", [step])] });
155
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
156
+ expect(issues.length).toBeGreaterThan(0);
157
+ });
158
+
159
+ it("walks activity steps nested inside an effect step", () => {
160
+ const receipt = EffectReceipt("seeded", {
161
+ effect: "db-seed",
162
+ flavor: "hash",
163
+ inputs: { file: "seed.sql", version: 3 },
164
+ });
165
+ const badStep = activity("lifecycleDiff", { environment: "prod" });
166
+ const effectStep = effect(receipt, [badStep]);
167
+ const config = opWith({ phases: [phase("Seed", [effectStep])] });
168
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
169
+ expect(issues.length).toBeGreaterThan(0);
170
+ });
171
+
172
+ it("reports every issue across multiple steps, not just the first", () => {
173
+ const step1 = activity("lifecycleDiff", { environment: "prod" });
174
+ const step2 = activity("kubectlApply", {}, "longInfa" as never);
175
+ const config = opWith({ phases: [phase("P", [step1, step2])] });
176
+ const issues = validateActivitySteps(config, new Map([["lifecycleDiff", lifecycleDiffContract]]));
177
+ expect(issues.some((i) => i.fn === "lifecycleDiff")).toBe(true);
178
+ expect(issues.some((i) => i.fn === "kubectlApply")).toBe(true);
179
+ });
180
+ });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Activity contracts — the Stage 1 fix for chant #1288 ("Ops are the one
3
+ * place chant accepts untyped arguments and resolves names at runtime").
4
+ *
5
+ * Maintainer decision (#1288, 2026-08-25): a staged approach. Stage 1 (this
6
+ * module) is registered args/return schemas per activity, validated by
7
+ * `chant build` against every step — non-breaking, no change to
8
+ * {@link ActivityStep.args}'s `Record<string, unknown>` shape or the step
9
+ * builders. Stage 2 (a separate PR) regenerates the step builders as fully
10
+ * typed wrappers for editor completion and go-to-definition. #1289 (op.json
11
+ * IR) and #1290 (step-output references) build on these contracts — #1290
12
+ * specifically needs a declared `returns` schema to validate a later step's
13
+ * reference into an earlier one's output against, which is why `returns` is
14
+ * part of the shape here even though nothing in Stage 1 reads it for that.
15
+ *
16
+ * An activity declares a contract alongside its implementation — the same
17
+ * "registration surface" the issue asked for (`activity-registry.ts`'s
18
+ * `collectActivities` already does this for implementations; this is the
19
+ * schema-shaped sibling). `chant build` — via a lexicon's own post-synth
20
+ * check, e.g. the temporal lexicon's TMP012 — resolves each step's `fn`
21
+ * against a contract map built the same way and validates `args` and
22
+ * `outcomeAttribute.from`. A step whose `fn` has no registered contract is
23
+ * skipped (not an error): this is deliberately incremental — a lexicon opts
24
+ * an activity in by declaring a contract for it, and the k8s/aws/azure/gcp/
25
+ * fly activity sets are expected to pick this up lexicon by lexicon rather
26
+ * than all at once (see the issue's "worth checking this lands cleanly"
27
+ * note).
28
+ *
29
+ * Ownership is decentralized on purpose: each lexicon declares contracts for
30
+ * the activities it implements and validates them with its own post-synth
31
+ * check (the same `rulePrefix`-per-lexicon pattern every other check in
32
+ * chant already uses), rather than a shared cross-lexicon registry. A
33
+ * `Temporal::Op` step can call an activity contributed by any lexicon, and
34
+ * `PostSynthContext.entities` already carries the whole resolved graph to
35
+ * every lexicon's checks, so no new plumbing is needed for that to work.
36
+ */
37
+
38
+ import { z } from "zod";
39
+ import type { OpConfig, PhaseDefinition, ActivityStep, StepDefinition } from "./types";
40
+
41
+ /** Every literal value {@link ActivityStep.profile} may hold. Kept in sync with `types.ts`'s `ActivityStep["profile"]`. */
42
+ export const KNOWN_ACTIVITY_PROFILES = [
43
+ "fastIdempotent",
44
+ "longInfra",
45
+ "k8sWait",
46
+ "humanGate",
47
+ "argoSync",
48
+ "policyCheck",
49
+ ] as const;
50
+
51
+ const CONTRACT_BRAND = Symbol.for("chant.op.activityContract");
52
+
53
+ /**
54
+ * A registered activity's args/return schemas — the declaration that lets
55
+ * `chant build` catch a typo'd or mistyped step before it ever reaches a
56
+ * cluster.
57
+ *
58
+ * Author `args` with `z.strictObject(...)` (or an equivalent that rejects
59
+ * unrecognized keys), not `z.object(...)`. Zod's default `.object()` silently
60
+ * drops a key it doesn't recognize instead of failing — exactly the
61
+ * `helmInstall("api", "./chart", { nameSpace: "prod" })` failure class the
62
+ * issue names, where the misspelled key vanishes instead of erroring. A
63
+ * strict schema turns that into a build error.
64
+ */
65
+ export interface ActivityContract<Args = unknown, Return = unknown> {
66
+ readonly [CONTRACT_BRAND]: true;
67
+ /** The activity's registered name — must match a step's `fn`. */
68
+ name: string;
69
+ /** Schema every step's `args` (defaulted to `{}` when omitted) must satisfy. */
70
+ args: z.ZodType<Args>;
71
+ /**
72
+ * Schema the activity resolves to. Optional: an activity that returns
73
+ * nothing meaningful (or hasn't had its return type written down yet) can
74
+ * omit it. Needed to validate a step's `outcomeAttribute.from` path, and —
75
+ * per #1290 — a later step's reference into this one's output.
76
+ */
77
+ returns?: z.ZodType<Return>;
78
+ }
79
+
80
+ /** Declare an activity contract. */
81
+ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema extends z.ZodTypeAny = never>(
82
+ name: string,
83
+ args: ArgsSchema,
84
+ returns?: ReturnSchema,
85
+ ): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
86
+ return {
87
+ [CONTRACT_BRAND]: true,
88
+ name,
89
+ args,
90
+ ...(returns ? { returns } : {}),
91
+ } as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
92
+ }
93
+
94
+ /** Structural guard for a value produced by {@link activityContract}. */
95
+ export function isActivityContract(value: unknown): value is ActivityContract {
96
+ return typeof value === "object" && value !== null && (value as Record<symbol, unknown>)[CONTRACT_BRAND] === true;
97
+ }
98
+
99
+ /**
100
+ * Add every {@link ActivityContract} exported from an activity-contracts
101
+ * module to `into`, keyed by its declared `name` — the schema-shaped sibling
102
+ * of `activity-registry.ts`'s `collectActivities`.
103
+ */
104
+ export function collectActivityContracts(mod: Record<string, unknown>, into: Map<string, ActivityContract>): void {
105
+ for (const value of Object.values(mod)) {
106
+ if (isActivityContract(value)) into.set(value.name, value);
107
+ }
108
+ }
109
+
110
+ // ── Validation ──────────────────────────────────────────────────────────────
111
+
112
+ export interface ActivityContractIssue {
113
+ /** The Op the offending step belongs to. */
114
+ opName: string;
115
+ /** The phase the offending step belongs to. */
116
+ phase: string;
117
+ /** The activity name the offending step calls. */
118
+ fn: string;
119
+ /** Human-readable description of the mismatch. */
120
+ message: string;
121
+ }
122
+
123
+ /** Every `ActivityStep` in a phase, including ones nested inside an `EffectStep`. */
124
+ function activityStepsOf(steps: StepDefinition[]): ActivityStep[] {
125
+ return steps.flatMap((s) => (s.kind === "activity" ? [s] : s.kind === "effect" ? s.steps.filter((n) => n.kind === "activity") : []));
126
+ }
127
+
128
+ // Same global symbol `step-output-ref.ts` brands a `StepOutputRef` with —
129
+ // `Symbol.for(...)` interns by string key, so this recognizes one without
130
+ // importing that module (which itself imports `pathExistsInSchema` below;
131
+ // importing the other way would make the two files a cycle).
132
+ const STEP_OUTPUT_REF_BRAND = Symbol.for("chant.op.stepOutputRef");
133
+ function isStepOutputRefValue(value: unknown): boolean {
134
+ return typeof value === "object" && value !== null && (value as Record<symbol, unknown>)[STEP_OUTPUT_REF_BRAND] === true;
135
+ }
136
+
137
+ /** The value at `path` (a zod issue's `.path`) inside `obj`, or `undefined` if any segment doesn't resolve. */
138
+ function valueAtPath(obj: unknown, path: ReadonlyArray<PropertyKey>): unknown {
139
+ let current = obj;
140
+ for (const segment of path) {
141
+ if (current === null || typeof current !== "object") return undefined;
142
+ current = (current as Record<PropertyKey, unknown>)[segment];
143
+ }
144
+ return current;
145
+ }
146
+
147
+ /** Unwrap `ZodOptional`/`ZodNullable`/`ZodDefault` (and similar) down to the schema they wrap. */
148
+ function unwrap(schema: z.ZodTypeAny): z.ZodTypeAny {
149
+ let current = schema;
150
+ while (typeof (current as unknown as { unwrap?: () => z.ZodTypeAny }).unwrap === "function") {
151
+ current = (current as unknown as { unwrap: () => z.ZodTypeAny }).unwrap();
152
+ }
153
+ return current;
154
+ }
155
+
156
+ /**
157
+ * Does dot-path `path` resolve to a field that exists on `schema`? Object
158
+ * shapes only — a return schema whose root (or an intermediate segment) is
159
+ * a `z.record(...)`/`z.array(...)` rather than a `z.object(...)` hard-errors
160
+ * (returns `false`) instead of skipping, deliberately (chant #1290 comment
161
+ * on #1288's pre-merge review): a record's keys are dynamic and an array's
162
+ * elements are index-addressed, neither of which a dot-path segment can
163
+ * check against in any way that's more meaningful than "the author probably
164
+ * meant something else." No declared `returns` schema needs this today, so
165
+ * there's no live case to design against yet. The escape hatch is an empty
166
+ * path — `outcomeAttribute.from` omitted, or a {@link StepOutputRef}'s
167
+ * `path` omitted — which references the whole return value and never calls
168
+ * this function; a record/array-returning activity's whole value is always
169
+ * a valid reference target.
170
+ */
171
+ export function pathExistsInSchema(schema: z.ZodTypeAny, path: string): boolean {
172
+ return schemaAtPath(schema, path.split(".")) !== undefined;
173
+ }
174
+
175
+ /**
176
+ * The zod schema at property-key path `path` inside `schema`, walking
177
+ * through `z.ZodObject` shapes only (unwrapping optional/nullable/default at
178
+ * each level, same as {@link pathExistsInSchema}). `undefined` when a
179
+ * segment doesn't resolve, or an intermediate schema isn't a `z.ZodObject` —
180
+ * same record/array hard-stop {@link pathExistsInSchema} documents. An empty
181
+ * `path` returns `schema` itself (unwrapped).
182
+ */
183
+ export function schemaAtPath(schema: z.ZodTypeAny, path: ReadonlyArray<string>): z.ZodTypeAny | undefined {
184
+ let current = unwrap(schema);
185
+ for (const segment of path) {
186
+ if (!(current instanceof z.ZodObject)) return undefined;
187
+ const shape = current.shape as Record<string, z.ZodTypeAny>;
188
+ if (!(segment in shape)) return undefined;
189
+ current = unwrap(shape[segment]);
190
+ }
191
+ return current;
192
+ }
193
+
194
+ /**
195
+ * A primitive-shape classification of a zod schema — `string`/`number`/
196
+ * `boolean`/`object`/`array`, or `undefined` for anything else (a union,
197
+ * enum, literal, `z.any()`/`z.unknown()`, a transform, …). Used by the
198
+ * step-output-ref cross-contract type check (chant #1950-3) to compare a
199
+ * producer's declared return type against a consumer's declared arg type at
200
+ * the same structural position — deliberately shallow: it bails (returns
201
+ * `undefined`) on anything fancier than these five kinds rather than trying
202
+ * to reason about it, per that check's "bail out silently" design.
203
+ */
204
+ export type PrimitiveSchemaKind = "string" | "number" | "boolean" | "object" | "array";
205
+
206
+ export function primitiveKindOf(schema: z.ZodTypeAny): PrimitiveSchemaKind | undefined {
207
+ const s = unwrap(schema);
208
+ if (s instanceof z.ZodString) return "string";
209
+ if (s instanceof z.ZodNumber) return "number";
210
+ if (s instanceof z.ZodBoolean) return "boolean";
211
+ if (s instanceof z.ZodObject) return "object";
212
+ if (s instanceof z.ZodArray) return "array";
213
+ return undefined;
214
+ }
215
+
216
+ /**
217
+ * Validate every activity step in an Op's phases (main and `onFailure`)
218
+ * against a contract map. A step whose `fn` has no entry in `contracts` is
219
+ * skipped — Stage 1 is opt-in per activity, not a hard requirement that
220
+ * every activity have a declared contract.
221
+ *
222
+ * Catches the four failure classes chant #1288 names:
223
+ * - an unrecognized `profile` (checked against {@link KNOWN_ACTIVITY_PROFILES}, independent of whether `fn` has a contract),
224
+ * - an args key the declared schema doesn't recognize,
225
+ * - an args value of the wrong type (including a required key that's missing),
226
+ * - an `outcomeAttribute.from` path that can't exist on the declared return type.
227
+ */
228
+ export function validateActivitySteps(
229
+ config: Pick<OpConfig, "name" | "phases" | "onFailure">,
230
+ contracts: ReadonlyMap<string, ActivityContract>,
231
+ ): ActivityContractIssue[] {
232
+ const issues: ActivityContractIssue[] = [];
233
+ const phasesToWalk: PhaseDefinition[] = [...config.phases, ...(config.onFailure ?? [])];
234
+
235
+ for (const phase of phasesToWalk) {
236
+ for (const step of activityStepsOf(phase.steps)) {
237
+ if (step.profile && !(KNOWN_ACTIVITY_PROFILES as readonly string[]).includes(step.profile)) {
238
+ issues.push({
239
+ opName: config.name,
240
+ phase: phase.name,
241
+ fn: step.fn,
242
+ message: `unknown profile "${step.profile}" (known: ${KNOWN_ACTIVITY_PROFILES.join(", ")})`,
243
+ });
244
+ }
245
+
246
+ const contract = contracts.get(step.fn);
247
+ if (!contract) continue;
248
+
249
+ const parsed = contract.args.safeParse(step.args ?? {});
250
+ if (!parsed.success) {
251
+ for (const issue of parsed.error.issues) {
252
+ // A step-output reference (#1290) sitting at this path is a
253
+ // placeholder object at build time, not the value it will
254
+ // resolve to — so an args-schema type mismatch here is a false
255
+ // positive; TMP013 (`validateStepOutputRefs`) is what validates
256
+ // a reference, against the *producer's* declared return schema.
257
+ // An unrecognized-key issue's path is the parent object (`[]`
258
+ // for a top-level extra key), which is never itself a reference,
259
+ // so a genuinely misspelled key is still caught either way.
260
+ if (isStepOutputRefValue(valueAtPath(step.args, issue.path))) continue;
261
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(args)";
262
+ issues.push({ opName: config.name, phase: phase.name, fn: step.fn, message: `args.${path}: ${issue.message}` });
263
+ }
264
+ }
265
+
266
+ if (step.outcomeAttribute?.from && contract.returns && !pathExistsInSchema(contract.returns, step.outcomeAttribute.from)) {
267
+ issues.push({
268
+ opName: config.name,
269
+ phase: phase.name,
270
+ fn: step.fn,
271
+ message: `outcomeAttribute.from "${step.outcomeAttribute.from}" does not exist on "${step.fn}"'s declared return type`,
272
+ });
273
+ }
274
+ }
275
+ }
276
+
277
+ return issues;
278
+ }