@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,453 @@
1
+ /**
2
+ * Step-output references — chant #1290 ("A step cannot reference a prior
3
+ * step's output, so values leave an Op only as search attributes").
4
+ *
5
+ * An Op's steps run in a fixed sequence chant already knows: phases in
6
+ * order, and (within a non-parallel phase) steps in order. Today the only
7
+ * way a value escapes a step is `outcomeAttribute`, which stringifies it
8
+ * into a Temporal search attribute — a UI filter, not something a later
9
+ * step can consume. `StepOutputRef` is the mechanism that lets a later
10
+ * step's `args` hold a *reference* to an earlier step's declared return
11
+ * value instead.
12
+ *
13
+ * Deliberately a reference, not an expression: `diff.out.driftedStacks` is
14
+ * a value placeholder the build resolves and the serializer compiles into a
15
+ * real local variable in the generated workflow — never `diff.out.count >
16
+ * 0` or a `.map()` over a reference. That property (an Op is data you can
17
+ * read and know what it does, not a program) is exactly what makes it safe
18
+ * to add this without Ops becoming programs; see the issue's "line not to
19
+ * cross".
20
+ *
21
+ * Scope, kept deliberately tight (see the issue's "Open questions"):
22
+ * - same-Op only — no cross-Op plumbing (that needs a durable place to put
23
+ * the value, which #1290 explicitly defers).
24
+ * - main `phases` only — a step inside `onFailure` or nested inside an
25
+ * `EffectStep` can neither produce nor consume a step-output reference.
26
+ * `onFailure` compensation runs only when something upstream already
27
+ * failed, so a main-phase step's captured result isn't reliably
28
+ * available there; an effect step's nested steps run only when the
29
+ * receipt comparison mismatches, so a value it produces isn't reliably
30
+ * available to anything outside it either. Both are conditional
31
+ * execution paths, which is exactly the class of thing #1290 keeps out
32
+ * of an Op's inert-data model.
33
+ * - producer must run to completion before the consumer starts: an earlier
34
+ * phase, or an earlier step within the same non-parallel phase. Two
35
+ * steps in the same parallel phase run concurrently (`Promise.all`) with
36
+ * no ordering guarantee, so neither may reference the other.
37
+ * - the producer needs a registered `ActivityContract` with a `returns`
38
+ * schema — without one there is nothing to validate the reference
39
+ * against, and it degrades into the same stringly path `outcomeAttribute`
40
+ * already had (chant #1288 comment on this issue).
41
+ *
42
+ * Cross-contract type compatibility (chant #1950 pre-merge review, finding
43
+ * 3): `validateStepOutputRefs` checks the producer side (a registered
44
+ * contract with a `returns` schema, and — when `path` is set — that the path
45
+ * resolves on it) and, separately, `validateActivitySteps`/TMP012 checks a
46
+ * step's `args` against its own contract's `args` schema — but a
47
+ * {@link StepOutputRef} sitting in `args` is a placeholder object at build
48
+ * time, not the value it resolves to, so TMP012 skips it (see
49
+ * `activity-contract.ts`'s `isStepOutputRefValue` guard) rather than
50
+ * false-positive on it. Nothing before this compared the *consumer's*
51
+ * declared type at that position against the *producer's* declared type at
52
+ * `path` — a string-returning path could feed a number-typed arg and both
53
+ * checks would pass. `validateStepOutputRefs` now adds one more check for
54
+ * exactly this: when `path` is set, and both the producer's and the
55
+ * consumer's contracts are registered, it compares the zod schema at the
56
+ * producer's `path` against the zod schema at the consumer's arg position by
57
+ * primitive shape (`string`/`number`/`boolean`/`object`/`array` — see
58
+ * `activity-contract.ts`'s `primitiveKindOf`) and flags a mismatch. This is
59
+ * deliberately shallow, the same "hard stop rather than guess" posture
60
+ * `pathExistsInSchema`'s doc above takes for a record/array root: a union, an
61
+ * enum, a literal, `z.any()`/`z.unknown()`, a transform, or a ref sitting
62
+ * inside an array in `args` (no stable position to look up an arg schema
63
+ * against) all bail silently — no issue raised, not a false negative
64
+ * reported as clean, just genuinely out of this check's reach. A more
65
+ * complete version (real zod-schema subtyping/union-aware comparison) is
66
+ * exactly the kind of thing #1288 Stage 2 (typed step builders, deriving
67
+ * from contracts instead of restating them) would need anyway; deferred
68
+ * there rather than grown ad hoc here. A whole-value reference (no `path`)
69
+ * is skipped by this check for the same reason it's skipped by the
70
+ * path-existence check: there's no single field to compare.
71
+ *
72
+ * References only, no expressions: the issue asks for this to be "enforced
73
+ * by a lint rule rather than left as a convention." What's here instead is
74
+ * a runtime-on-load guard — `diff.out.count > 0` or a template literal over
75
+ * a reference coerces it to a primitive, and the reference throws when that
76
+ * happens, at the moment chant loads the `.op.ts` module to build the
77
+ * entity graph (i.e. still before anything runs). A real static lint rule
78
+ * would need to parse-inspect the author's TypeScript for expression syntax
79
+ * around a reference — a genuine ESLint-rule-shaped feature, and a bigger
80
+ * one than this issue's scope. The coercion guard catches the same misuse
81
+ * (arithmetic, comparisons, template literals, `String(...)`) without it;
82
+ * a `.map()`-style structural misuse already throws on its own (`StepOutputRef`
83
+ * has no array methods).
84
+ */
85
+
86
+ import { z } from "zod";
87
+ import type { ActivityStep, OpConfig, PhaseDefinition } from "./types";
88
+ import { pathExistsInSchema, schemaAtPath, primitiveKindOf, type ActivityContract, type ActivityContractIssue } from "./activity-contract";
89
+
90
+ const STEP_OUTPUT_REF_BRAND = Symbol.for("chant.op.stepOutputRef");
91
+
92
+ /**
93
+ * A typed reference to a prior step's declared return value. Inert by
94
+ * construction — it carries a producer step id and an optional dot-path
95
+ * into that producer's return schema, resolved by `chant build`
96
+ * (`validateStepOutputRefs`) and compiled by the temporal serializer into a
97
+ * local variable holding the awaited activity result.
98
+ */
99
+ export interface StepOutputRef {
100
+ readonly [STEP_OUTPUT_REF_BRAND]: true;
101
+ readonly kind: "step-output-ref";
102
+ /** `ActivityStep.id` of the producing step. */
103
+ readonly step: string;
104
+ /**
105
+ * Dot-path into the producer's declared return value (e.g.
106
+ * `"driftedStacks"`, `"result.healthy"`). Omitted references the whole
107
+ * return value.
108
+ */
109
+ readonly path?: string;
110
+ }
111
+
112
+ /**
113
+ * `T` with every own property additionally accepting a {@link StepOutputRef}
114
+ * in its place (chant #1288 Stage 2) — the authoring-time counterpart of
115
+ * `args` accepting a reference anywhere in the structure (#1290): a typed
116
+ * step-builder wrapper whose opts type is `WithStepRefs<SomeActivityArgs>`
117
+ * lets an author pass `diff.out.driftedStacks` for any field without an
118
+ * `as` cast, while runtime validation of the reference itself is still
119
+ * `validateStepOutputRefs`' job, not this type's.
120
+ */
121
+ export type WithStepRefs<T> = { [K in keyof T]: T[K] | StepOutputRef };
122
+
123
+ /** Structural guard for a value produced by {@link stepOutput} (or `activity()`'s `.out`). */
124
+ export function isStepOutputRef(value: unknown): value is StepOutputRef {
125
+ return typeof value === "object" && value !== null && (value as Record<symbol, unknown>)[STEP_OUTPUT_REF_BRAND] === true;
126
+ }
127
+
128
+ function makeStepOutputRef(step: string, path?: string): StepOutputRef {
129
+ const ref = { [STEP_OUTPUT_REF_BRAND]: true, kind: "step-output-ref", step, ...(path ? { path } : {}) } as StepOutputRef;
130
+ // References only, no expressions (see module doc): coercing a reference
131
+ // to a primitive — `diff.out.count > 0`, `` `${diff.out.name}` ``,
132
+ // `String(diff.out.x)` — is exactly the "Op becomes a program" failure
133
+ // mode the issue calls out, so it throws immediately on load instead of
134
+ // silently producing a wrong value (NaN comparisons, "[object Object]").
135
+ const rejectExpression = (): never => {
136
+ throw new Error(
137
+ `step-output reference (step "${step}"${path ? `, path "${path}"` : ""}) was coerced to a primitive — ` +
138
+ "references only, no expressions: use it as a plain arg value, never inside a comparison, arithmetic, or template literal.",
139
+ );
140
+ };
141
+ Object.defineProperty(ref, Symbol.toPrimitive, { value: rejectExpression, enumerable: false });
142
+ Object.defineProperty(ref, "toString", { value: rejectExpression, enumerable: false });
143
+ Object.defineProperty(ref, "valueOf", { value: rejectExpression, enumerable: false });
144
+ return ref;
145
+ }
146
+
147
+ /**
148
+ * Reference a named step's output from a later step's `args`. Works with
149
+ * any step that has an `id` — an `activity()` builder result (which also
150
+ * gets the `.out` proxy sugar below) or a plain `{ kind: "activity", ...,
151
+ * id: "..." }` object literal, the shape composites author directly.
152
+ *
153
+ * `stepOutput(diff, "driftedStacks")` and `diff.out.driftedStacks` produce
154
+ * an identical reference; `stepOutput` is the explicit form for authors who
155
+ * build steps as object literals, or whose return schema has a top-level
156
+ * field literally named `step`, `path`, or `kind` (the `.out` proxy
157
+ * reserves those three property names for its own introspection).
158
+ */
159
+ export function stepOutput(step: string | Pick<ActivityStep, "id">, path?: string): StepOutputRef {
160
+ const id = typeof step === "string" ? step : step.id;
161
+ if (!id) {
162
+ throw new Error(
163
+ "stepOutput(): the step has no `id` — pass one via activity(fn, args, { id: \"...\" }) or set `id` on the step object literal first.",
164
+ );
165
+ }
166
+ return makeStepOutputRef(id, path);
167
+ }
168
+
169
+ /**
170
+ * `.out` proxy attached to `activity()`'s result: `diff.out.driftedStacks`
171
+ * builds a {@link StepOutputRef} without the explicit `stepOutput()` call.
172
+ *
173
+ * A single property-access level, matching `outcomeAttribute.from`'s
174
+ * existing convention — the property name IS the dot-path, so a nested
175
+ * field is `diff.out["result.healthy"]`, not `diff.out.result.healthy`.
176
+ * `kind`, `step`, and `path` are reserved (the ref's own introspection
177
+ * fields); a return schema with a top-level field by one of those names
178
+ * needs `stepOutput(diff, "step")` instead.
179
+ */
180
+ export function makeOutProxy(stepId: string): Record<string, StepOutputRef> {
181
+ const whole = makeStepOutputRef(stepId);
182
+ return new Proxy(whole as unknown as Record<string, StepOutputRef>, {
183
+ get(target, prop, receiver) {
184
+ if (typeof prop === "symbol" || prop === "kind" || prop === "step" || prop === "path" || prop === "toString" || prop === "valueOf") {
185
+ return Reflect.get(target, prop, receiver);
186
+ }
187
+ return makeStepOutputRef(stepId, prop);
188
+ },
189
+ });
190
+ }
191
+
192
+ /** Every {@link StepOutputRef} found anywhere inside `value` (recursing through plain objects and arrays). */
193
+ export function collectStepOutputRefs(value: unknown): StepOutputRef[] {
194
+ if (isStepOutputRef(value)) return [value];
195
+ if (Array.isArray(value)) return value.flatMap(collectStepOutputRefs);
196
+ if (value && typeof value === "object") return Object.values(value).flatMap(collectStepOutputRefs);
197
+ return [];
198
+ }
199
+
200
+ /**
201
+ * The property-key path from `value`'s root down to `target` (found by
202
+ * reference identity), or `undefined` if it isn't found or sits inside an
203
+ * array — an array index isn't a stable position to look up a declared arg
204
+ * schema against (chant #1950-3's cross-contract type check skips these,
205
+ * same "bail on anything fancier" policy as the rest of that check).
206
+ */
207
+ function locateStepOutputRefPath(value: unknown, target: StepOutputRef, path: string[] = []): string[] | undefined {
208
+ if (value === target) return path;
209
+ if (Array.isArray(value)) return undefined;
210
+ if (value && typeof value === "object" && !isStepOutputRef(value)) {
211
+ for (const [k, v] of Object.entries(value)) {
212
+ const found = locateStepOutputRefPath(v, target, [...path, k]);
213
+ if (found) return found;
214
+ }
215
+ }
216
+ return undefined;
217
+ }
218
+
219
+ // ── Validation ──────────────────────────────────────────────────────────────
220
+
221
+ interface StepLocation {
222
+ phaseIndex: number;
223
+ stepIndex: number;
224
+ parallel: boolean;
225
+ step: ActivityStep;
226
+ }
227
+
228
+ /** Top-level `ActivityStep`s of `phases`, in authored (phase, then step) order — gates, effects, and steps nested inside an effect are not producers or consumers (see the module doc's scope note). */
229
+ function locateActivitySteps(phases: PhaseDefinition[]): StepLocation[] {
230
+ const locations: StepLocation[] = [];
231
+ phases.forEach((phase, phaseIndex) => {
232
+ let stepIndex = 0;
233
+ for (const step of phase.steps) {
234
+ if (step.kind !== "activity") continue;
235
+ locations.push({ phaseIndex, stepIndex, parallel: phase.parallel === true, step });
236
+ stepIndex++;
237
+ }
238
+ });
239
+ return locations;
240
+ }
241
+
242
+ /** True when `producer` is guaranteed to have completed before `consumer` starts. */
243
+ function producerPrecedes(producer: StepLocation, consumer: StepLocation): boolean {
244
+ if (producer.phaseIndex !== consumer.phaseIndex) return producer.phaseIndex < consumer.phaseIndex;
245
+ if (producer.parallel) return false; // same parallel phase — Promise.all, no ordering guarantee
246
+ return producer.stepIndex < consumer.stepIndex;
247
+ }
248
+
249
+ function effectNestedActivitySteps(phase: PhaseDefinition): ActivityStep[] {
250
+ return phase.steps.flatMap((s) => (s.kind === "effect" ? s.steps.filter((n) => n.kind === "activity") : []));
251
+ }
252
+
253
+ /** `byId`/`duplicateIds` over an Op's top-level main-phase activity steps — shared by {@link validateStepOutputRefScope} and {@link validateStepOutputRefs}. */
254
+ function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>; duplicateIds: Set<string> } {
255
+ const byId = new Map<string, StepLocation>();
256
+ const duplicateIds = new Set<string>();
257
+ for (const loc of locations) {
258
+ if (!loc.step.id) continue;
259
+ if (byId.has(loc.step.id)) duplicateIds.add(loc.step.id);
260
+ else byId.set(loc.step.id, loc);
261
+ }
262
+ return { byId, duplicateIds };
263
+ }
264
+
265
+ /**
266
+ * Validate every step-output reference in an Op against scope and ordering
267
+ * alone — no contract needed. This is the subset of {@link
268
+ * validateStepOutputRefs}'s checks that don't depend on a contract map, kept
269
+ * as its own export so a caller with no contracts on hand (or that wants to
270
+ * defend against exactly this class of bug regardless of what contracts are
271
+ * registered) can still refuse a scope-invalid reference. The temporal
272
+ * lexicon's serializer (`serializeOps`) is exactly this caller: TMP013 (which
273
+ * calls the fuller {@link validateStepOutputRefs}) protects `chant build`,
274
+ * but `serializeOps` is a public export a caller can invoke directly,
275
+ * bypassing that lint pass — this is its own defense-in-depth.
276
+ *
277
+ * Flags:
278
+ * - a reference authored inside `onFailure`, or inside an `EffectStep`'s
279
+ * nested steps — out of scope by design (see the module doc); flagged
280
+ * explicitly here rather than left to fall through as "unknown producer",
281
+ * - a reference to an unregistered step id (no step in scope declares it),
282
+ * - a duplicate step id (ambiguous producer),
283
+ * - a reference to a step that does not precede the referencing step
284
+ * (a later phase, a later step in the same phase, itself, or a step in
285
+ * the same parallel phase).
286
+ */
287
+ export function validateStepOutputRefScope(
288
+ config: Pick<OpConfig, "name" | "phases" | "onFailure">,
289
+ ): ActivityContractIssue[] {
290
+ const issues: ActivityContractIssue[] = [];
291
+
292
+ const flagOutOfScope = (steps: ActivityStep[], phaseName: string, reason: string) => {
293
+ for (const step of steps) {
294
+ for (const ref of collectStepOutputRefs(step.args)) {
295
+ issues.push({
296
+ opName: config.name,
297
+ phase: phaseName,
298
+ fn: step.fn,
299
+ message: `references step "${ref.step}"'s output, but ${reason}`,
300
+ });
301
+ }
302
+ }
303
+ };
304
+ for (const phase of config.onFailure ?? []) {
305
+ const steps = phase.steps.filter((s): s is ActivityStep => s.kind === "activity").concat(effectNestedActivitySteps(phase));
306
+ flagOutOfScope(steps, phase.name, "step-output references are not supported in onFailure compensation phases");
307
+ }
308
+ for (const phase of config.phases) {
309
+ flagOutOfScope(effectNestedActivitySteps(phase), phase.name, "step-output references are not supported for a step nested inside an effect step");
310
+ }
311
+
312
+ const locations = locateActivitySteps(config.phases);
313
+ const { byId, duplicateIds } = indexById(locations);
314
+ const phaseNameOf = (loc: StepLocation): string => config.phases[loc.phaseIndex]!.name;
315
+
316
+ for (const consumer of locations) {
317
+ const refs = collectStepOutputRefs(consumer.step.args);
318
+ for (const ref of refs) {
319
+ if (duplicateIds.has(ref.step)) {
320
+ issues.push({
321
+ opName: config.name,
322
+ phase: phaseNameOf(consumer),
323
+ fn: consumer.step.fn,
324
+ message: `references step id "${ref.step}", but that id is used by more than one step in this Op — ids must be unique`,
325
+ });
326
+ continue;
327
+ }
328
+
329
+ const producer = byId.get(ref.step);
330
+ if (!producer) {
331
+ issues.push({
332
+ opName: config.name,
333
+ phase: phaseNameOf(consumer),
334
+ fn: consumer.step.fn,
335
+ message: `references unknown step id "${ref.step}" — no step in this Op's main phases declares that id`,
336
+ });
337
+ continue;
338
+ }
339
+
340
+ if (!producerPrecedes(producer, consumer)) {
341
+ const reason =
342
+ producer.phaseIndex > consumer.phaseIndex
343
+ ? `step "${ref.step}" is in a later phase`
344
+ : producer.parallel
345
+ ? `step "${ref.step}" is in the same parallel phase (steps there run concurrently, with no ordering guarantee)`
346
+ : `step "${ref.step}" does not run before this step`;
347
+ issues.push({
348
+ opName: config.name,
349
+ phase: phaseNameOf(consumer),
350
+ fn: consumer.step.fn,
351
+ message: `references ${reason} — a step can only reference an earlier step's output`,
352
+ });
353
+ }
354
+ }
355
+ }
356
+
357
+ return issues;
358
+ }
359
+
360
+ /**
361
+ * Validate every step-output reference in an Op's main phases against a
362
+ * contract map and the Op's own step ordering. Scope: `config.phases` only
363
+ * (never `onFailure`, never a step nested inside an `EffectStep` — see the
364
+ * module doc). Layers contract-based checks on top of {@link
365
+ * validateStepOutputRefScope}'s scope/ordering checks.
366
+ *
367
+ * Flags everything {@link validateStepOutputRefScope} does, plus:
368
+ * - a reference into a step whose `fn` has no registered
369
+ * {@link ActivityContract}, or whose contract declares no `returns`
370
+ * schema — nothing to validate the reference against,
371
+ * - a `path` that does not resolve on the producer's declared return
372
+ * schema (an empty `path` — the whole return value — is always valid).
373
+ */
374
+ export function validateStepOutputRefs(
375
+ config: Pick<OpConfig, "name" | "phases" | "onFailure">,
376
+ contracts: ReadonlyMap<string, ActivityContract>,
377
+ ): ActivityContractIssue[] {
378
+ const issues = validateStepOutputRefScope(config);
379
+
380
+ const locations = locateActivitySteps(config.phases);
381
+ const { byId, duplicateIds } = indexById(locations);
382
+ const phaseNameOf = (loc: StepLocation): string => config.phases[loc.phaseIndex]!.name;
383
+
384
+ for (const consumer of locations) {
385
+ const refs = collectStepOutputRefs(consumer.step.args);
386
+ for (const ref of refs) {
387
+ // Already flagged by validateStepOutputRefScope — nothing to validate
388
+ // a contract-based check against.
389
+ if (duplicateIds.has(ref.step)) continue;
390
+ const producer = byId.get(ref.step);
391
+ if (!producer || !producerPrecedes(producer, consumer)) continue;
392
+
393
+ const producerContract = contracts.get(producer.step.fn);
394
+ if (!producerContract) {
395
+ issues.push({
396
+ opName: config.name,
397
+ phase: phaseNameOf(consumer),
398
+ fn: consumer.step.fn,
399
+ message: `references step "${ref.step}"'s output, but "${producer.step.fn}" has no registered activity contract to validate the reference against`,
400
+ });
401
+ continue;
402
+ }
403
+ if (!producerContract.returns) {
404
+ issues.push({
405
+ opName: config.name,
406
+ phase: phaseNameOf(consumer),
407
+ fn: consumer.step.fn,
408
+ message: `references step "${ref.step}"'s output, but "${producer.step.fn}"'s contract declares no return schema`,
409
+ });
410
+ continue;
411
+ }
412
+ if (ref.path && !pathExistsInSchema(producerContract.returns as z.ZodTypeAny, ref.path)) {
413
+ issues.push({
414
+ opName: config.name,
415
+ phase: phaseNameOf(consumer),
416
+ fn: consumer.step.fn,
417
+ message: `references step "${ref.step}"'s output path "${ref.path}", which does not exist on "${producer.step.fn}"'s declared return type`,
418
+ });
419
+ continue;
420
+ }
421
+
422
+ // Finding #1950-3: cross-contract primitive-type compatibility. Only
423
+ // meaningful for a field reference (`ref.path` set) against a
424
+ // registered consumer contract's arg schema at the same position in
425
+ // `args` — a whole-value reference (no path) is intentionally left
426
+ // unchecked (see the "whole-value reference... passes even without
427
+ // checking the producer's return shape" test/doc above). Bails
428
+ // silently (no issue) on anything not a plain string/number/boolean/
429
+ // object/array — a union, enum, literal, transform, `z.any()`, etc. —
430
+ // there's no cheap structural comparison for those.
431
+ if (ref.path) {
432
+ const consumerContract = contracts.get(consumer.step.fn);
433
+ const argPath = consumerContract && locateStepOutputRefPath(consumer.step.args, ref);
434
+ if (consumerContract && argPath) {
435
+ const consumerFieldSchema = schemaAtPath(consumerContract.args as z.ZodTypeAny, argPath);
436
+ const producerFieldSchema = schemaAtPath(producerContract.returns as z.ZodTypeAny, ref.path.split("."));
437
+ const consumerKind = consumerFieldSchema && primitiveKindOf(consumerFieldSchema);
438
+ const producerKind = producerFieldSchema && primitiveKindOf(producerFieldSchema);
439
+ if (consumerKind && producerKind && consumerKind !== producerKind) {
440
+ issues.push({
441
+ opName: config.name,
442
+ phase: phaseNameOf(consumer),
443
+ fn: consumer.step.fn,
444
+ message: `references step "${ref.step}"'s output path "${ref.path}" (${producerKind}), but arg "${argPath.join(".")}" on "${consumer.step.fn}" declares ${consumerKind} — type mismatch`,
445
+ });
446
+ }
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ return issues;
453
+ }
package/src/op/types.ts CHANGED
@@ -39,9 +39,26 @@ export type StepDefinition = ActivityStep | GateStep | EffectStep;
39
39
 
40
40
  export interface ActivityStep {
41
41
  kind: "activity";
42
+ /**
43
+ * Identifies this step so a later step can reference its output (#1290)
44
+ * via `stepOutput(id, path?)` or, when built with `activity()`, the
45
+ * `.out` proxy sugar. Only steps in an Op's main `phases` — not
46
+ * `onFailure`, not one nested inside an `EffectStep` — can be referenced.
47
+ */
48
+ id?: string;
42
49
  /** Name of the exported activity function in the pre-built activity library. */
43
50
  fn: string;
44
- /** Arguments passed to the activity function. */
51
+ /**
52
+ * Arguments passed to the activity function. A value may be a
53
+ * {@link StepOutputRef} (anywhere in the structure, including nested
54
+ * inside a plain object or array) — a reference to an earlier step's
55
+ * declared return value, resolved at build time and compiled by the
56
+ * serializer into a local variable holding that step's result. Never an
57
+ * expression over one: `diff.out.count > 0` or a template literal
58
+ * coerces the reference to a primitive, which throws (see
59
+ * `step-output-ref.ts`'s module doc for why this is a runtime-on-load
60
+ * guard, not a static lint rule).
61
+ */
45
62
  args?: Record<string, unknown>;
46
63
  /**
47
64
  * Key from TEMPORAL_ACTIVITY_PROFILES controlling timeout + retry.
@@ -1,11 +1,19 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { setProvenance, getProvenance } from "./provenance";
3
- import { Composite, expandComposite } from "./composite";
2
+ import {
3
+ describePathOrigin,
4
+ getPathProvenance,
5
+ getProvenance,
6
+ originOfPath,
7
+ setPathProvenance,
8
+ setProvenance,
9
+ type PathOrigin,
10
+ } from "./provenance";
11
+ import { Composite, expandComposite, propagate } from "./composite";
4
12
  import { collectEntities } from "./discovery/collect";
5
13
  import { DECLARABLE_MARKER, type Declarable } from "./declarable";
6
14
 
7
- const decl = (entityType: string): Declarable =>
8
- ({ [DECLARABLE_MARKER]: true, lexicon: "test", entityType, kind: "resource", props: {} }) as unknown as Declarable;
15
+ const decl = (entityType: string, props: Record<string, unknown> = {}): Declarable =>
16
+ ({ [DECLARABLE_MARKER]: true, lexicon: "test", entityType, kind: "resource", props }) as unknown as Declarable;
9
17
 
10
18
  const Pair = Composite<{ n: string }>((props) => ({
11
19
  first: decl(`Test::First:${props.n}`),
@@ -77,3 +85,142 @@ describe("collectEntities stamps the source file", () => {
77
85
  expect(prov?.composite).toBe("Pair");
78
86
  });
79
87
  });
88
+
89
+ describe("path provenance storage (#1443)", () => {
90
+ test("first writer wins per path, independently of other paths", () => {
91
+ const e = decl("Test::Thing");
92
+ setPathProvenance(e, "spec.replicas", { kind: "build-param", params: ["tier"] });
93
+ setPathProvenance(e, "spec.replicas", { kind: "authored" });
94
+ setPathProvenance(e, "", { kind: "authored" });
95
+ expect(getPathProvenance(e)).toEqual({
96
+ "": { kind: "authored" },
97
+ "spec.replicas": { kind: "build-param", params: ["tier"] },
98
+ });
99
+ });
100
+
101
+ test("keys come back sorted, whatever order they were written in", () => {
102
+ const e = decl("Test::Thing");
103
+ setPathProvenance(e, "spec.z", { kind: "authored" });
104
+ setPathProvenance(e, "metadata.a", { kind: "authored" });
105
+ setPathProvenance(e, "", { kind: "authored" });
106
+ expect(Object.keys(getPathProvenance(e)!)).toEqual(["", "metadata.a", "spec.z"]);
107
+ });
108
+
109
+ test("path origins stay off the serialized entity", () => {
110
+ const e = decl("Test::Thing");
111
+ setPathProvenance(e, "spec.replicas", { kind: "authored" });
112
+ expect(JSON.stringify(e)).not.toContain("replicas");
113
+ });
114
+
115
+ test("nothing recorded reads as undefined, not an empty record", () => {
116
+ expect(getPathProvenance(decl("Test::Thing"))).toBeUndefined();
117
+ expect(originOfPath(undefined, "spec.replicas")).toBeUndefined();
118
+ });
119
+ });
120
+
121
+ describe("originOfPath resolution", () => {
122
+ const paths: Record<string, PathOrigin> = {
123
+ "": { kind: "authored" },
124
+ spec: { kind: "composite", composite: "WebService", instance: "web" },
125
+ "spec.template.spec.containers": { kind: "build-param", params: ["image", "tier"] },
126
+ };
127
+
128
+ test("the longest matching prefix wins", () => {
129
+ expect(originOfPath(paths, "spec.template.spec.containers")).toEqual(paths["spec.template.spec.containers"]);
130
+ expect(originOfPath(paths, "spec.replicas")).toEqual(paths.spec);
131
+ expect(originOfPath(paths, "metadata.name")).toEqual(paths[""]);
132
+ });
133
+
134
+ test("a keyed list element inherits the origin recorded for the list (#1441 grammar)", () => {
135
+ expect(originOfPath(paths, "spec.template.spec.containers[#app].image")).toEqual(
136
+ paths["spec.template.spec.containers"],
137
+ );
138
+ expect(originOfPath(paths, "spec.template.spec.containers[0].image")).toEqual(
139
+ paths["spec.template.spec.containers"],
140
+ );
141
+ });
142
+
143
+ test("prefixes only match on a segment boundary", () => {
144
+ // `spec` must not claim `specialCase`, and a deeper recorded key must not
145
+ // claim a shallower query.
146
+ expect(originOfPath(paths, "specialCase")).toEqual(paths[""]);
147
+ expect(originOfPath({ "spec.containers.image": { kind: "authored" } }, "spec.containers")).toBeUndefined();
148
+ });
149
+ });
150
+
151
+ describe("describePathOrigin", () => {
152
+ test("renders each kind", () => {
153
+ expect(describePathOrigin({ kind: "authored" })).toBe("authored");
154
+ expect(describePathOrigin({ kind: "composite", composite: "WebService", instance: "web" })).toBe(
155
+ "composite WebService (web)",
156
+ );
157
+ expect(describePathOrigin({ kind: "build-param", params: ["region", "tier"] })).toBe("param region, tier");
158
+ });
159
+ });
160
+
161
+ describe("composite expansion records path origins (#1443)", () => {
162
+ test("each member's whole-entity origin names the composite and the instance", () => {
163
+ const expanded = expandComposite("web", Pair({ n: "x" }));
164
+ for (const [, entity] of expanded) {
165
+ expect(originOfPath(getPathProvenance(entity), "anything.at.all")).toEqual({
166
+ kind: "composite",
167
+ composite: "Pair",
168
+ instance: "web",
169
+ });
170
+ }
171
+ });
172
+
173
+ test("a nested member keeps the innermost composite but the outer instance", () => {
174
+ const Wrapper = Composite<{ n: string }>((props) => ({
175
+ inner: Pair({ n: props.n }) as unknown as Declarable,
176
+ }), "Wrapper");
177
+ const expanded = expandComposite("stack", Wrapper({ n: "y" }));
178
+ for (const [, entity] of expanded) {
179
+ expect(getPathProvenance(entity)?.[""]).toEqual({
180
+ kind: "composite",
181
+ composite: "Pair",
182
+ instance: "stack",
183
+ });
184
+ }
185
+ });
186
+
187
+ test("a propagated key the member never set is attributed to the instance", () => {
188
+ const Tagged = Composite<{ n: string }>(() => ({
189
+ only: decl("Test::Only", { name: "fixed" }),
190
+ }), "Tagged");
191
+ const expanded = expandComposite("env", propagate(Tagged({ n: "x" }), { tags: [{ key: "env", value: "prod" }] }));
192
+ const entity = expanded.get("envOnly")!;
193
+ expect(getPathProvenance(entity)?.tags).toEqual({
194
+ kind: "composite",
195
+ composite: "Tagged",
196
+ instance: "env",
197
+ });
198
+ });
199
+
200
+ test("a key both the member and the propagation wrote is left to the whole-entity origin", () => {
201
+ const Tagged = Composite<{ n: string }>(() => ({
202
+ only: decl("Test::Only", { tags: [{ key: "own", value: "1" }] }),
203
+ }), "Tagged");
204
+ const expanded = expandComposite("env", propagate(Tagged({ n: "x" }), { tags: [{ key: "env", value: "prod" }] }));
205
+ const paths = getPathProvenance(expanded.get("envOnly")!);
206
+ expect(paths?.tags).toBeUndefined();
207
+ expect(paths?.[""]).toBeDefined();
208
+ });
209
+ });
210
+
211
+ describe("collectEntities records the authored origin", () => {
212
+ test("a directly exported declarable is authored at the root", () => {
213
+ const a = decl("Test::A");
214
+ const entities = collectEntities([{ file: "/proj/src/infra.ts", exports: { a } }]);
215
+ expect(getPathProvenance(entities.get("a")!)?.[""]).toEqual({ kind: "authored" });
216
+ });
217
+
218
+ test("a composite member is not relabelled authored", () => {
219
+ const entities = collectEntities([{ file: "/proj/src/pipe.ts", exports: { p: Pair({ n: "z" }) } }]);
220
+ expect(getPathProvenance(entities.get("pFirst")!)?.[""]).toEqual({
221
+ kind: "composite",
222
+ composite: "Pair",
223
+ instance: "p",
224
+ });
225
+ });
226
+ });