@intentius/chant 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/operator.d.ts +32 -0
  28. package/dist/cli/handlers/operator.d.ts.map +1 -0
  29. package/dist/cli/handlers/scenario.d.ts +39 -0
  30. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/mcp/server.d.ts +35 -2
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/types.d.ts +29 -1
  35. package/dist/cli/mcp/types.d.ts.map +1 -1
  36. package/dist/cli/registry.d.ts +14 -2
  37. package/dist/cli/registry.d.ts.map +1 -1
  38. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  39. package/dist/components/capability.d.ts +17 -2
  40. package/dist/components/capability.d.ts.map +1 -1
  41. package/dist/components/cli-support.d.ts +7 -0
  42. package/dist/components/cli-support.d.ts.map +1 -1
  43. package/dist/components/component.d.ts +15 -0
  44. package/dist/components/component.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts.map +1 -1
  46. package/dist/components/verbs/index.d.ts +6 -1
  47. package/dist/components/verbs/index.d.ts.map +1 -1
  48. package/dist/components/verbs/run-agent.d.ts +499 -0
  49. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  50. package/dist/components/verbs/sign.d.ts +30 -0
  51. package/dist/components/verbs/sign.d.ts.map +1 -1
  52. package/dist/composite.d.ts +6 -1
  53. package/dist/composite.d.ts.map +1 -1
  54. package/dist/discovery/collect.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -1
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/discovery/fold-rank.d.ts +66 -0
  58. package/dist/discovery/fold-rank.d.ts.map +1 -0
  59. package/dist/discovery/index.d.ts +15 -0
  60. package/dist/discovery/index.d.ts.map +1 -1
  61. package/dist/discovery/param-deps.d.ts +17 -0
  62. package/dist/discovery/param-deps.d.ts.map +1 -0
  63. package/dist/fold/fold.d.ts +55 -2
  64. package/dist/fold/fold.d.ts.map +1 -1
  65. package/dist/fold/subset.d.ts +21 -14
  66. package/dist/fold/subset.d.ts.map +1 -1
  67. package/dist/lexicon-schema.d.ts +2 -0
  68. package/dist/lexicon-schema.d.ts.map +1 -1
  69. package/dist/lexicon.d.ts +93 -0
  70. package/dist/lexicon.d.ts.map +1 -1
  71. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  72. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  73. package/dist/lifecycle/deep-diff.d.ts +18 -0
  74. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  75. package/dist/lifecycle/deep-observe.d.ts +9 -1
  76. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  77. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  78. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  79. package/dist/lifecycle/git.d.ts +145 -21
  80. package/dist/lifecycle/git.d.ts.map +1 -1
  81. package/dist/lifecycle/index.d.ts +4 -0
  82. package/dist/lifecycle/index.d.ts.map +1 -1
  83. package/dist/lifecycle/lease.d.ts +113 -0
  84. package/dist/lifecycle/lease.d.ts.map +1 -0
  85. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  86. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  87. package/dist/lifecycle/scenario.d.ts +163 -0
  88. package/dist/lifecycle/scenario.d.ts.map +1 -0
  89. package/dist/lifecycle/symptoms.d.ts +63 -0
  90. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  91. package/dist/lint/output-docs.d.ts +94 -0
  92. package/dist/lint/output-docs.d.ts.map +1 -0
  93. package/dist/lint/post-synth.d.ts +29 -0
  94. package/dist/lint/post-synth.d.ts.map +1 -1
  95. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  96. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  97. package/dist/lsp/lexicon-providers.d.ts +7 -0
  98. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  99. package/dist/op/activity-contract.d.ts +139 -0
  100. package/dist/op/activity-contract.d.ts.map +1 -0
  101. package/dist/op/builders.d.ts +42 -2
  102. package/dist/op/builders.d.ts.map +1 -1
  103. package/dist/op/converge-rule.d.ts +161 -0
  104. package/dist/op/converge-rule.d.ts.map +1 -0
  105. package/dist/op/generate-pipeline.d.ts +39 -0
  106. package/dist/op/generate-pipeline.d.ts.map +1 -0
  107. package/dist/op/index.d.ts +14 -0
  108. package/dist/op/index.d.ts.map +1 -1
  109. package/dist/op/local-executor.d.ts.map +1 -1
  110. package/dist/op/op-verb-class.d.ts +42 -0
  111. package/dist/op/op-verb-class.d.ts.map +1 -0
  112. package/dist/op/operator.d.ts +128 -0
  113. package/dist/op/operator.d.ts.map +1 -0
  114. package/dist/op/step-output-ref.d.ts +187 -0
  115. package/dist/op/step-output-ref.d.ts.map +1 -0
  116. package/dist/op/types.d.ts +18 -1
  117. package/dist/op/types.d.ts.map +1 -1
  118. package/dist/provenance.d.ts +73 -3
  119. package/dist/provenance.d.ts.map +1 -1
  120. package/dist/runtime-adapter.d.ts +7 -1
  121. package/dist/runtime-adapter.d.ts.map +1 -1
  122. package/dist/serializer.d.ts +18 -0
  123. package/dist/serializer.d.ts.map +1 -1
  124. package/dist/toml.d.ts +40 -5
  125. package/dist/toml.d.ts.map +1 -1
  126. package/package.json +1 -1
  127. package/src/audit/catalog.test.ts +1 -1
  128. package/src/audit/catalog.ts +75 -3
  129. package/src/audit/core.ts +9 -0
  130. package/src/audit/discover.ts +29 -2
  131. package/src/audit/fetch.test.ts +216 -3
  132. package/src/audit/fetch.ts +270 -59
  133. package/src/audit/report-html.ts +5 -2
  134. package/src/audit/report-model.ts +9 -0
  135. package/src/audit/report.test.ts +22 -0
  136. package/src/audit/report.ts +3 -2
  137. package/src/audit/rules-doc.ts +2 -0
  138. package/src/audit/secrets.test.ts +303 -0
  139. package/src/audit/secrets.ts +406 -0
  140. package/src/audit/wrangler.test.ts +230 -0
  141. package/src/audit/wrangler.ts +290 -0
  142. package/src/build.ts +8 -3
  143. package/src/cli/command-group.ts +1 -1
  144. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  145. package/src/cli/commands/audit.test.ts +215 -1
  146. package/src/cli/commands/audit.ts +86 -17
  147. package/src/cli/commands/build.test.ts +167 -2
  148. package/src/cli/commands/build.ts +114 -23
  149. package/src/cli/handlers/build.ts +2 -0
  150. package/src/cli/handlers/components.test.ts +199 -1
  151. package/src/cli/handlers/components.ts +160 -3
  152. package/src/cli/handlers/graph.test.ts +20 -0
  153. package/src/cli/handlers/graph.ts +10 -1
  154. package/src/cli/handlers/lifecycle.ts +12 -4
  155. package/src/cli/handlers/operator.test.ts +255 -0
  156. package/src/cli/handlers/operator.ts +240 -0
  157. package/src/cli/handlers/scenario.test.ts +456 -0
  158. package/src/cli/handlers/scenario.ts +330 -0
  159. package/src/cli/main.test.ts +23 -0
  160. package/src/cli/main.ts +72 -1
  161. package/src/cli/mcp/server.test.ts +265 -2
  162. package/src/cli/mcp/server.ts +84 -7
  163. package/src/cli/mcp/types.ts +27 -1
  164. package/src/cli/registry.ts +14 -2
  165. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  166. package/src/codegen/docs-rule-scanning.ts +25 -2
  167. package/src/components/README.md +7 -0
  168. package/src/components/capability.ts +17 -2
  169. package/src/components/cli-support.test.ts +17 -0
  170. package/src/components/cli-support.ts +13 -1
  171. package/src/components/component-schema.test.ts +32 -0
  172. package/src/components/component.schema.json +6 -0
  173. package/src/components/component.test.ts +21 -0
  174. package/src/components/component.ts +15 -0
  175. package/src/components/driver.ts +12 -4
  176. package/src/components/verbs/index.ts +6 -1
  177. package/src/components/verbs/run-agent.test.ts +683 -0
  178. package/src/components/verbs/run-agent.ts +786 -0
  179. package/src/components/verbs/sign.test.ts +19 -0
  180. package/src/components/verbs/sign.ts +34 -2
  181. package/src/composite.ts +31 -2
  182. package/src/discovery/collect.ts +11 -2
  183. package/src/discovery/fold-import.test.ts +54 -0
  184. package/src/discovery/fold-import.ts +178 -38
  185. package/src/discovery/fold-rank.test.ts +197 -0
  186. package/src/discovery/fold-rank.ts +346 -0
  187. package/src/discovery/index.ts +16 -1
  188. package/src/discovery/param-deps.test.ts +118 -0
  189. package/src/discovery/param-deps.ts +170 -0
  190. package/src/fold/fold.test.ts +6 -2
  191. package/src/fold/fold.ts +184 -3
  192. package/src/fold/subset.test.ts +82 -19
  193. package/src/fold/subset.ts +79 -41
  194. package/src/lexicon-schema.ts +3 -0
  195. package/src/lexicon.ts +103 -2
  196. package/src/lifecycle/converge-ledger.test.ts +199 -0
  197. package/src/lifecycle/converge-ledger.ts +179 -0
  198. package/src/lifecycle/deep-diff.test.ts +79 -1
  199. package/src/lifecycle/deep-diff.ts +23 -0
  200. package/src/lifecycle/deep-observe.ts +13 -2
  201. package/src/lifecycle/gate-ledger.test.ts +103 -0
  202. package/src/lifecycle/gate-ledger.ts +140 -0
  203. package/src/lifecycle/git.test.ts +430 -0
  204. package/src/lifecycle/git.ts +446 -84
  205. package/src/lifecycle/index.ts +4 -0
  206. package/src/lifecycle/lease.test.ts +343 -0
  207. package/src/lifecycle/lease.ts +270 -0
  208. package/src/lifecycle/scenario-eval.test.ts +199 -0
  209. package/src/lifecycle/scenario-eval.ts +158 -0
  210. package/src/lifecycle/scenario.test.ts +195 -0
  211. package/src/lifecycle/scenario.ts +321 -0
  212. package/src/lifecycle/symptoms.test.ts +116 -0
  213. package/src/lifecycle/symptoms.ts +126 -0
  214. package/src/lint/output-docs.test.ts +220 -0
  215. package/src/lint/output-docs.ts +204 -0
  216. package/src/lint/post-synth.test.ts +97 -0
  217. package/src/lint/post-synth.ts +45 -0
  218. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  219. package/src/lint/rules/comp/comp.test.ts +49 -1
  220. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  221. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  222. package/src/lsp/lexicon-providers.test.ts +44 -0
  223. package/src/lsp/lexicon-providers.ts +11 -1
  224. package/src/op/activity-contract.test.ts +180 -0
  225. package/src/op/activity-contract.ts +278 -0
  226. package/src/op/builders-exports.test.ts +17 -1
  227. package/src/op/builders.ts +59 -5
  228. package/src/op/converge-rule.test.ts +179 -0
  229. package/src/op/converge-rule.ts +311 -0
  230. package/src/op/generate-pipeline.test.ts +53 -0
  231. package/src/op/generate-pipeline.ts +99 -0
  232. package/src/op/index.ts +30 -0
  233. package/src/op/local-executor.test.ts +92 -0
  234. package/src/op/local-executor.ts +45 -9
  235. package/src/op/op-verb-class.test.ts +126 -0
  236. package/src/op/op-verb-class.ts +115 -0
  237. package/src/op/operator.test.ts +346 -0
  238. package/src/op/operator.ts +213 -0
  239. package/src/op/step-output-ref.test.ts +334 -0
  240. package/src/op/step-output-ref.ts +453 -0
  241. package/src/op/types.ts +18 -1
  242. package/src/provenance.test.ts +151 -4
  243. package/src/provenance.ts +118 -4
  244. package/src/runtime-adapter.ts +31 -10
  245. package/src/serializer.ts +18 -0
  246. package/src/toml.test.ts +157 -384
  247. package/src/toml.ts +371 -5
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { parseOutputDocs, pick, get } from "./output-docs";
3
+ import type { SerializerResult } from "../serializer";
4
+
5
+ describe("parseOutputDocs", () => {
6
+ test("parses a single JSON output as one document", () => {
7
+ const outputs = new Map<string, string | SerializerResult>([
8
+ ["aws", JSON.stringify({ AWSTemplateFormatVersion: "2010-09-09", Resources: {} })],
9
+ ]);
10
+ const docs = parseOutputDocs(outputs);
11
+ expect(docs).toHaveLength(1);
12
+ expect(docs[0]).toMatchObject({
13
+ lexicon: "aws",
14
+ index: 0,
15
+ format: "json",
16
+ });
17
+ expect(docs[0].error).toBeUndefined();
18
+ expect((docs[0].value as { Resources: unknown }).Resources).toEqual({});
19
+ });
20
+
21
+ test("splits multi-document YAML on `---` into one OutputDoc per document", () => {
22
+ const yaml = [
23
+ "apiVersion: v1",
24
+ "kind: Namespace",
25
+ "metadata:",
26
+ " name: ns-a",
27
+ "---",
28
+ "apiVersion: apps/v1",
29
+ "kind: Deployment",
30
+ "metadata:",
31
+ " name: web",
32
+ ].join("\n");
33
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
34
+ const docs = parseOutputDocs(outputs);
35
+ expect(docs).toHaveLength(2);
36
+ expect(docs[0]).toMatchObject({ lexicon: "k8s", index: 0, format: "yaml" });
37
+ expect(docs[1]).toMatchObject({ lexicon: "k8s", index: 1, format: "yaml" });
38
+ expect((docs[0].value as { kind: string }).kind).toBe("Namespace");
39
+ expect((docs[1].value as { kind: string }).kind).toBe("Deployment");
40
+ });
41
+
42
+ test("handles a leading `---` document separator before any content", () => {
43
+ const yaml = ["---", "kind: Namespace", "---", "kind: Deployment"].join("\n");
44
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
45
+ const docs = parseOutputDocs(outputs);
46
+ expect(docs).toHaveLength(2);
47
+ expect((docs[0].value as { kind: string }).kind).toBe("Namespace");
48
+ expect((docs[1].value as { kind: string }).kind).toBe("Deployment");
49
+ });
50
+
51
+ test("drops empty documents between/around separators without erroring", () => {
52
+ const yaml = ["---", "", "---", "kind: Deployment", "---", ""].join("\n");
53
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
54
+ const docs = parseOutputDocs(outputs);
55
+ expect(docs).toHaveLength(1);
56
+ expect((docs[0].value as { kind: string }).kind).toBe("Deployment");
57
+ });
58
+
59
+ test("parses SerializerResult.files as additional, separately-indexed documents", () => {
60
+ const output: SerializerResult = {
61
+ primary: JSON.stringify({ kind: "root" }),
62
+ files: {
63
+ "nested.template.json": JSON.stringify({ kind: "nested-a" }),
64
+ "sidecar.yaml": "kind: nested-b\n---\nkind: nested-c",
65
+ },
66
+ };
67
+ const outputs = new Map<string, string | SerializerResult>([["aws", output]]);
68
+ const docs = parseOutputDocs(outputs);
69
+
70
+ const primaryDocs = docs.filter((d) => d.file === undefined);
71
+ expect(primaryDocs).toHaveLength(1);
72
+ expect((primaryDocs[0].value as { kind: string }).kind).toBe("root");
73
+
74
+ const nestedJson = docs.filter((d) => d.file === "nested.template.json");
75
+ expect(nestedJson).toHaveLength(1);
76
+ expect((nestedJson[0].value as { kind: string }).kind).toBe("nested-a");
77
+
78
+ // The sidecar YAML file is itself multi-document — index resets to 0
79
+ // within that file, since index is scoped to its own source.
80
+ const sidecarDocs = docs.filter((d) => d.file === "sidecar.yaml");
81
+ expect(sidecarDocs).toHaveLength(2);
82
+ expect(sidecarDocs[0].index).toBe(0);
83
+ expect(sidecarDocs[1].index).toBe(1);
84
+ expect((sidecarDocs[0].value as { kind: string }).kind).toBe("nested-b");
85
+ expect((sidecarDocs[1].value as { kind: string }).kind).toBe("nested-c");
86
+
87
+ expect(docs.every((d) => d.lexicon === "aws")).toBe(true);
88
+ });
89
+
90
+ test("skips a source with no files entries and no primary content", () => {
91
+ const outputs = new Map<string, string | SerializerResult>([["empty", ""]]);
92
+ expect(parseOutputDocs(outputs)).toEqual([]);
93
+ });
94
+
95
+ test("parses every ctx.outputs entry, tagging each with its own lexicon key", () => {
96
+ const outputs = new Map<string, string | SerializerResult>([
97
+ ["aws", JSON.stringify({ kind: "aws-thing" })],
98
+ ["k8s", "kind: k8s-thing"],
99
+ ]);
100
+ const docs = parseOutputDocs(outputs);
101
+ expect(docs.map((d) => d.lexicon).sort()).toEqual(["aws", "k8s"]);
102
+ });
103
+
104
+ describe("malformed documents — marker, not throw", () => {
105
+ test("a bare scalar document is marked with an error instead of being treated as a manifest", () => {
106
+ // "42" is syntactically fine (valid YAML/JSON) but is a scalar, not an
107
+ // object/array — not something a manifest-shaped check can walk.
108
+ const yaml = ["kind: Deployment", "---", "42", "---", "kind: Service"].join("\n");
109
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
110
+ const docs = parseOutputDocs(outputs);
111
+
112
+ expect(docs).toHaveLength(3);
113
+ expect(docs[0].error).toBeUndefined();
114
+ expect(docs[2].error).toBeUndefined();
115
+
116
+ // The malformed middle document is a marker: present, flagged, no value.
117
+ expect(docs[1].error).toBeDefined();
118
+ expect(docs[1].value).toBeUndefined();
119
+ expect(docs[1].format).toBe("yaml");
120
+
121
+ // Filtering it out is one line, and leaves the good documents intact.
122
+ const usable = docs.filter((d) => !d.error);
123
+ expect(usable).toHaveLength(2);
124
+ expect((usable[0].value as { kind: string }).kind).toBe("Deployment");
125
+ expect((usable[1].value as { kind: string }).kind).toBe("Service");
126
+ });
127
+
128
+ test("a lone scalar as the entire output is marked with an error, not thrown", () => {
129
+ // Valid JSON ("true" parses fine) but not an object/array — the
130
+ // whole-content JSON path hits the same isUsableDoc guard.
131
+ const outputs = new Map<string, string | SerializerResult>([["weird", "true"]]);
132
+ expect(() => parseOutputDocs(outputs)).not.toThrow();
133
+ const docs = parseOutputDocs(outputs);
134
+ expect(docs).toHaveLength(1);
135
+ expect(docs[0].format).toBe("json");
136
+ expect(docs[0].error).toBeDefined();
137
+ expect(docs[0].value).toBeUndefined();
138
+ });
139
+
140
+ test("a malformed document in one files entry does not affect the others", () => {
141
+ const output: SerializerResult = {
142
+ primary: "kind: root",
143
+ files: {
144
+ "good.yaml": "kind: good",
145
+ "bad.yaml": "null",
146
+ },
147
+ };
148
+ const outputs = new Map<string, string | SerializerResult>([["k8s", output]]);
149
+ const docs = parseOutputDocs(outputs);
150
+
151
+ const good = docs.find((d) => d.file === "good.yaml")!;
152
+ const bad = docs.find((d) => d.file === "bad.yaml")!;
153
+ expect(good.error).toBeUndefined();
154
+ expect((good.value as { kind: string }).kind).toBe("good");
155
+ expect(bad.error).toBeDefined();
156
+ expect(bad.value).toBeUndefined();
157
+ });
158
+ });
159
+ });
160
+
161
+ describe("pick", () => {
162
+ interface Widget {
163
+ kind: string;
164
+ name: string;
165
+ extra: unknown;
166
+ }
167
+
168
+ test("returns only the named keys that are present", () => {
169
+ const value = { kind: "Deployment", name: "web", other: "ignored" };
170
+ expect(pick<Widget>(value, ["kind", "name"])).toEqual({ kind: "Deployment", name: "web" });
171
+ });
172
+
173
+ test("omits a named key that is absent — no invented default", () => {
174
+ const value = { kind: "Deployment" };
175
+ expect(pick<Widget>(value, ["kind", "name"])).toEqual({ kind: "Deployment" });
176
+ });
177
+
178
+ test("returns an empty object for a non-object value", () => {
179
+ expect(pick<Widget>(null, ["kind"])).toEqual({});
180
+ expect(pick<Widget>("a string", ["kind"])).toEqual({});
181
+ expect(pick<Widget>(undefined, ["kind"])).toEqual({});
182
+ });
183
+
184
+ test("copies values through unvalidated, whatever their actual shape", () => {
185
+ const value = { kind: 42 }; // wrong runtime type for `kind: string`
186
+ expect(pick<Widget>(value, ["kind"])).toEqual({ kind: 42 });
187
+ });
188
+ });
189
+
190
+ describe("get", () => {
191
+ test("walks a dotted path through nested objects", () => {
192
+ const value = { spec: { template: { spec: { containers: [] } } } };
193
+ expect(get(value, "spec.template.spec")).toEqual({ containers: [] });
194
+ });
195
+
196
+ test("returns undefined as soon as a segment is missing", () => {
197
+ const value = { spec: {} };
198
+ expect(get(value, "spec.template.spec")).toBeUndefined();
199
+ });
200
+
201
+ test("returns undefined when an intermediate value is not indexable", () => {
202
+ const value = { spec: "not-an-object" };
203
+ expect(get(value, "spec.template")).toBeUndefined();
204
+ });
205
+
206
+ test("indexes into arrays with a numeric path segment", () => {
207
+ const value = { items: [{ name: "first" }, { name: "second" }] };
208
+ expect(get(value, "items.1.name")).toBe("second");
209
+ });
210
+
211
+ test("returns the value itself for an empty path", () => {
212
+ const value = { a: 1 };
213
+ expect(get(value, "")).toBe(value);
214
+ });
215
+
216
+ test("does not throw on null/undefined input", () => {
217
+ expect(get(null, "a.b")).toBeUndefined();
218
+ expect(get(undefined, "a.b")).toBeUndefined();
219
+ });
220
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Parsed-output view for post-synth checks (chant #975).
3
+ *
4
+ * `PostSynthContext.outputs` carries raw serialized strings — a `chant build`
5
+ * consumer has to parse YAML/JSON itself before it can reason about
6
+ * structure. Every lexicon that needs this today rolls its own splitter
7
+ * (`parseK8sManifests` in k8s, hand-rolled `JSON.parse` in aws's
8
+ * `cf-refs.ts`), and each of the ~30 k8s post-synth checks reparses the same
9
+ * output independently. This module is the shared, parse-once primitive:
10
+ * `parseOutputDocs` turns `ctx.outputs` into a flat list of `OutputDoc`s,
11
+ * and `PostSynthContext.docs` (see `./post-synth.ts`) memoizes one call to it
12
+ * per build so every check — lexicon-shipped or project-authored policy —
13
+ * shares the same parse.
14
+ *
15
+ * `pick`/`get` are the "look at only the fields you care about" ergonomic
16
+ * from the issue's `,remain`-style framing — a partial, unvalidated view of
17
+ * an already-parsed tree, not a query DSL. Selector languages over the
18
+ * *source* AST stay in `./declarative.ts`; this module is output-side only.
19
+ */
20
+
21
+ import type { SerializerResult } from "../serializer";
22
+ import { parseYAML } from "../yaml";
23
+
24
+ /**
25
+ * One parsed document from a build's serialized output.
26
+ *
27
+ * A single `ctx.outputs` entry can yield more than one `OutputDoc`: a
28
+ * multi-document YAML stream (`---`-separated) yields one per document, and
29
+ * a `SerializerResult` with `files` yields further docs for each nested
30
+ * file (a CloudFormation nested stack template, a sidecar manifest).
31
+ */
32
+ export interface OutputDoc {
33
+ /** The `ctx.outputs` map key this document came from. */
34
+ lexicon: string;
35
+ /** Position of this document within its source (primary output or one
36
+ * `files` entry) — 0 for a single-document source, 0..n-1 for a
37
+ * multi-document YAML stream. */
38
+ index: number;
39
+ /** Whether this document was decoded as YAML or as whole-content JSON. */
40
+ format: "yaml" | "json";
41
+ /** The parsed tree. `undefined` when parsing failed — see `error`. */
42
+ value: unknown;
43
+ /**
44
+ * Set when this document came from a `SerializerResult.files` entry rather
45
+ * than the primary output — the file's key in that map (e.g. a nested
46
+ * stack template's filename).
47
+ */
48
+ file?: string;
49
+ /**
50
+ * Set when this document could not be parsed into a usable tree. The
51
+ * document is still returned (a marker, not a throw) so a caller can see
52
+ * that something in the build output failed to parse, rather than the
53
+ * failure disappearing silently the way `parseK8sManifests` has always
54
+ * swallowed it. `value` is `undefined` on an errored document. Filter with
55
+ * `ctx.docs.filter((d) => !d.error)` to get only usable documents.
56
+ */
57
+ error?: string;
58
+ }
59
+
60
+ /**
61
+ * Parse every output in `outputs` into a flat list of `OutputDoc`s: one call,
62
+ * shared by every post-synth check via `PostSynthContext.docs`.
63
+ *
64
+ * Format detection is whole-source, not per-document: if the entire source
65
+ * parses as JSON, it is one `"json"` document (CloudFormation, most non-k8s
66
+ * serializers). Otherwise the source is treated as a YAML stream — split on
67
+ * `---` document separators — and each resulting document is parsed as YAML
68
+ * (`format: "yaml"`), even one that happens to also be valid JSON, since
69
+ * JSON is a YAML subset. `SerializerResult.files` entries (nested templates,
70
+ * sidecar manifests) are parsed the same way, tagged with `file`.
71
+ *
72
+ * A document that fails to parse into a non-null object/array — malformed
73
+ * input, or a bare scalar where a manifest was expected — is not thrown:
74
+ * it is included with `error` set and `value: undefined`. See `OutputDoc`.
75
+ */
76
+ export function parseOutputDocs(outputs: Map<string, string | SerializerResult>): OutputDoc[] {
77
+ const docs: OutputDoc[] = [];
78
+ for (const [lexicon, output] of outputs) {
79
+ const primary = typeof output === "string" ? output : output.primary;
80
+ docs.push(...parseSource(primary, lexicon));
81
+
82
+ const files = typeof output === "string" ? undefined : output.files;
83
+ for (const [file, content] of Object.entries(files ?? {})) {
84
+ docs.push(...parseSource(content, lexicon, file));
85
+ }
86
+ }
87
+ return docs;
88
+ }
89
+
90
+ /** Split a YAML stream on `---` document-separator lines (own line, optional
91
+ * trailing whitespace) — including a leading separator before any content,
92
+ * which `"\n---\n"`-style splitting misses. Empty documents (two separators
93
+ * back to back, or a leading/trailing one) are dropped. */
94
+ function splitYamlDocuments(source: string): string[] {
95
+ return source
96
+ .split(/^---[ \t]*$/m)
97
+ .map((doc) => doc.trim())
98
+ .filter((doc) => doc.length > 0);
99
+ }
100
+
101
+ /** True for a value a document is usable as — a non-null object or array.
102
+ * A bare scalar ("just a string") parses without error but is not a
103
+ * document a check can walk, so it is treated as malformed. */
104
+ function isUsableDoc(value: unknown): boolean {
105
+ return typeof value === "object" && value !== null;
106
+ }
107
+
108
+ /**
109
+ * Parse one source string (a primary output, or one `files` entry) into its
110
+ * `OutputDoc`s, trying whole-content JSON first and falling back to a
111
+ * (possibly multi-document) YAML stream. Never throws — a parse failure or
112
+ * an unusable result becomes a marker document with `error` set.
113
+ */
114
+ function parseSource(source: string, lexicon: string, file?: string): OutputDoc[] {
115
+ const trimmed = source.trim();
116
+ if (trimmed === "") return [];
117
+
118
+ try {
119
+ const value = JSON.parse(trimmed);
120
+ if (isUsableDoc(value)) {
121
+ return [{ lexicon, index: 0, format: "json", value, ...(file && { file }) }];
122
+ }
123
+ return [
124
+ {
125
+ lexicon,
126
+ index: 0,
127
+ format: "json",
128
+ value: undefined,
129
+ error: "parsed JSON is not an object or array",
130
+ ...(file && { file }),
131
+ },
132
+ ];
133
+ } catch {
134
+ // Not whole-content JSON — fall through to the YAML stream path.
135
+ }
136
+
137
+ return splitYamlDocuments(trimmed).map((segment, index) => {
138
+ try {
139
+ const value = parseYAML(segment);
140
+ if (isUsableDoc(value)) {
141
+ return { lexicon, index, format: "yaml" as const, value, ...(file && { file }) };
142
+ }
143
+ return {
144
+ lexicon,
145
+ index,
146
+ format: "yaml" as const,
147
+ value: undefined,
148
+ error: "parsed YAML document is not an object or array",
149
+ ...(file && { file }),
150
+ };
151
+ } catch (err) {
152
+ return {
153
+ lexicon,
154
+ index,
155
+ format: "yaml" as const,
156
+ value: undefined,
157
+ error: err instanceof Error ? err.message : String(err),
158
+ ...(file && { file }),
159
+ };
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Partial typed decode — the `,remain`-style ergonomic from `ma91n/tfpolicy`:
166
+ * decode the fields you care about, leave everything else in place,
167
+ * unvalidated. Returns a shallow, top-level view of `value` narrowed to the
168
+ * keys named in `shape`. A key absent from `value` (or `value` not being a
169
+ * plain object) is simply absent from the result — no default is invented.
170
+ *
171
+ * This is NOT runtime validation: a key that IS present is copied as-is,
172
+ * whatever its actual shape, and cast to `T`'s declared type for that key.
173
+ * Use `get` for a deeper walk into one of the picked values.
174
+ */
175
+ export function pick<T extends object>(value: unknown, shape: (keyof T)[]): Partial<T> {
176
+ const result: Partial<T> = {};
177
+ if (typeof value !== "object" || value === null) return result;
178
+ const source = value as Record<string, unknown>;
179
+ for (const key of shape) {
180
+ const propertyName = key as string;
181
+ if (propertyName in source) {
182
+ (result as Record<string, unknown>)[propertyName] = source[propertyName];
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * Dotted-path getter for the common walk over a parsed doc, e.g.
190
+ * `get(doc.value, "spec.template.spec")`. Not a query DSL — no wildcards,
191
+ * no predicates, no array-flattening. An array index is just a numeric path
192
+ * segment (`get(value, "items.0.name")`), since JS indexes arrays by string
193
+ * key underneath. Returns `undefined` as soon as any segment is missing or
194
+ * the value at that point isn't indexable — it never throws on a bad path.
195
+ */
196
+ export function get(value: unknown, path: string): unknown {
197
+ if (path === "") return value;
198
+ let current: unknown = value;
199
+ for (const segment of path.split(".")) {
200
+ if (typeof current !== "object" || current === null) return undefined;
201
+ current = (current as Record<string, unknown>)[segment];
202
+ }
203
+ return current;
204
+ }
@@ -145,3 +145,100 @@ describe("isPostSynthCheck", () => {
145
145
  // chant #1138 — `applyConfiguredSeverity` (the `lint.rules` severity-override
146
146
  // pass over `PostSynthDiagnostic`s) is tested in `./config.test.ts`, where the
147
147
  // function itself now lives — see `./config.ts`'s doc comment for why.
148
+
149
+ // ── chant #975 — ctx.docs: lazy, memoized, shared across every check ───────
150
+ describe("ctx.docs (chant #975)", () => {
151
+ /** A Map subclass that counts how many times it was iterated — the only
152
+ * way `parseOutputDocs` reads its outputs — so the tests below can prove
153
+ * "parsed once" without reaching into module internals. */
154
+ class CountingOutputs extends Map<string, string> {
155
+ iterations = 0;
156
+ [Symbol.iterator](): IterableIterator<[string, string]> {
157
+ this.iterations++;
158
+ return super[Symbol.iterator]();
159
+ }
160
+ }
161
+
162
+ test("is not computed until first accessed", () => {
163
+ const outputs = new CountingOutputs([["k8s", "kind: Namespace"]]);
164
+ const check: PostSynthCheck = {
165
+ id: "PS-DOCS-1",
166
+ description: "never touches ctx.docs",
167
+ check() {
168
+ return [];
169
+ },
170
+ };
171
+ runPostSynthChecks([check], createBuildResult({ outputs: outputs as never }));
172
+ expect(outputs.iterations).toBe(0);
173
+ });
174
+
175
+ test("is parsed exactly once even when read by multiple checks", () => {
176
+ const outputs = new CountingOutputs([
177
+ ["k8s", "apiVersion: v1\nkind: Namespace\nmetadata:\n name: ns-a"],
178
+ ]);
179
+ let seenByFirst: unknown;
180
+ let seenBySecond: unknown;
181
+ const checks: PostSynthCheck[] = [
182
+ {
183
+ id: "PS-DOCS-2A",
184
+ description: "reads ctx.docs once",
185
+ check(ctx) {
186
+ seenByFirst = ctx.docs;
187
+ return [];
188
+ },
189
+ },
190
+ {
191
+ id: "PS-DOCS-2B",
192
+ description: "reads ctx.docs again, and a second time in the same check",
193
+ check(ctx) {
194
+ seenBySecond = ctx.docs;
195
+ void ctx.docs; // a second read within the same check — still no reparse
196
+ return [];
197
+ },
198
+ },
199
+ ];
200
+ runPostSynthChecks(checks, createBuildResult({ outputs: outputs as never }));
201
+ expect(outputs.iterations).toBe(1);
202
+ // Every reader gets the exact same array instance, not an equal copy.
203
+ expect(seenByFirst).toBe(seenBySecond);
204
+ });
205
+
206
+ test("parses ctx.outputs into the expected OutputDoc shape", () => {
207
+ const outputs = new Map<string, string>([
208
+ ["k8s", "apiVersion: v1\nkind: Namespace\nmetadata:\n name: ns-a"],
209
+ ]);
210
+ const check: PostSynthCheck = {
211
+ id: "PS-DOCS-3",
212
+ description: "reads a manifest field off ctx.docs",
213
+ check(ctx) {
214
+ const doc = (ctx.docs ?? [])[0];
215
+ return [
216
+ {
217
+ checkId: "PS-DOCS-3",
218
+ severity: "info",
219
+ message: `kind=${(doc.value as { kind?: string }).kind}`,
220
+ },
221
+ ];
222
+ },
223
+ };
224
+ const diags = runPostSynthChecks([check], createBuildResult({ outputs: outputs as never }));
225
+ expect(diags[0].message).toBe("kind=Namespace");
226
+ });
227
+
228
+ test("two independent runPostSynthChecks calls each get their own cache", () => {
229
+ const outputsA = new CountingOutputs([["k8s", "kind: Namespace"]]);
230
+ const outputsB = new CountingOutputs([["k8s", "kind: Deployment"]]);
231
+ const reader: PostSynthCheck = {
232
+ id: "PS-DOCS-4",
233
+ description: "reads ctx.docs",
234
+ check(ctx) {
235
+ void ctx.docs;
236
+ return [];
237
+ },
238
+ };
239
+ runPostSynthChecks([reader], createBuildResult({ outputs: outputsA as never }));
240
+ runPostSynthChecks([reader], createBuildResult({ outputs: outputsB as never }));
241
+ expect(outputsA.iterations).toBe(1);
242
+ expect(outputsB.iterations).toBe(1);
243
+ });
244
+ });
@@ -1,6 +1,9 @@
1
1
  import type { Declarable } from "../declarable";
2
2
  import type { SerializerResult } from "../serializer";
3
3
  import type { Severity } from "./rule";
4
+ import { parseOutputDocs, type OutputDoc } from "./output-docs";
5
+
6
+ export { parseOutputDocs, pick, get, type OutputDoc } from "./output-docs";
4
7
 
5
8
  /**
6
9
  * Context provided to post-synthesis checks.
@@ -16,6 +19,25 @@ export interface PostSynthContext {
16
19
  * e.g. "no public buckets in prod". Undefined when no environment is set.
17
20
  */
18
21
  env?: string;
22
+ /**
23
+ * Parsed output documents (chant #975) — `ctx.outputs` run through
24
+ * `parseOutputDocs` once and cached. A lazy `readonly` getter, not a plain
25
+ * field: computed on first access and shared across every check in the
26
+ * run, so a check that only reads `entities` pays nothing, and no two
27
+ * checks re-parse the same YAML/JSON. See `./output-docs.ts`.
28
+ *
29
+ * Optional at the type level — NOT because it can be absent from a real
30
+ * build. Every context chant itself constructs (`runPostSynthChecks`
31
+ * below, `@intentius/chant-test-utils`'s `createPostSynthContext` and
32
+ * `makePostSynthCtx*`) wires it up via `createDocsAccessor` and it is
33
+ * always present there. It is typed optional only so the many lexicon
34
+ * tests that build a `PostSynthContext` object literal by hand (predating
35
+ * this field) keep compiling unchanged, per this issue's own "existing
36
+ * checks compile unchanged" constraint — a new check that wants `ctx.docs`
37
+ * should still get a real array from every context chant builds; guard
38
+ * with `ctx.docs ?? []` only when a context's provenance is unknown.
39
+ */
40
+ readonly docs?: OutputDoc[];
19
41
  /** Raw build result object */
20
42
  buildResult: {
21
43
  outputs: Map<string, string | SerializerResult>;
@@ -26,6 +48,25 @@ export interface PostSynthContext {
26
48
  };
27
49
  }
28
50
 
51
+ /**
52
+ * Build the lazy, memoized `docs` accessor shared by `runPostSynthChecks`
53
+ * (below) and `@intentius/chant-test-utils`'s `createPostSynthContext` — the
54
+ * two places a `PostSynthContext` gets constructed. Returns a zero-arg
55
+ * function suitable for a `get docs()` object-literal accessor; the first
56
+ * call parses, every later call returns the same cached array.
57
+ */
58
+ export function createDocsAccessor(
59
+ outputs: Map<string, string | SerializerResult>,
60
+ ): () => OutputDoc[] {
61
+ let cached: OutputDoc[] | undefined;
62
+ return () => {
63
+ if (cached === undefined) {
64
+ cached = parseOutputDocs(outputs);
65
+ }
66
+ return cached;
67
+ };
68
+ }
69
+
29
70
  /**
30
71
  * Extract the primary content string from a serializer output.
31
72
  */
@@ -115,11 +156,15 @@ export function runPostSynthChecks(
115
156
  buildResult: PostSynthContext["buildResult"],
116
157
  env?: string,
117
158
  ): PostSynthDiagnostic[] {
159
+ const getDocs = createDocsAccessor(buildResult.outputs);
118
160
  const ctx: PostSynthContext = {
119
161
  outputs: buildResult.outputs,
120
162
  entities: buildResult.entities,
121
163
  env,
122
164
  buildResult,
165
+ get docs(): OutputDoc[] {
166
+ return getDocs();
167
+ },
123
168
  };
124
169
 
125
170
  const diagnostics: PostSynthDiagnostic[] = [];
@@ -0,0 +1,26 @@
1
+ import type { Component } from "../../../../../../components/component";
2
+ import { phase } from "../../../../../../components/component";
3
+
4
+ /**
5
+ * COMP003 pass case (#1944): a bare "run-agent" step with no "noRollback"
6
+ * opt-out and no component-level "rollback" — must pass COMP003 because
7
+ * "run-agent"'s rollbackPolicy is "native" (#1941), not "needs-opt-out". This
8
+ * is the regression test proving the registry's declared rollbackPolicy for
9
+ * "run-agent" is wired correctly into ctx.rollbackPolicies, the same seam
10
+ * COMP005 uses for ctx.knownKinds — see comp.test.ts's FIXTURE_ROLLBACK_POLICIES.
11
+ */
12
+ export const agentTurn: Component = {
13
+ name: "agent-turn",
14
+ archetype: "infra",
15
+ dependsOn: [],
16
+ deploy: [
17
+ phase("Run", [
18
+ {
19
+ kind: "run-agent",
20
+ agent: "claude",
21
+ task: { prompt: "run the migration script and report the result" },
22
+ workspace: {},
23
+ },
24
+ ]),
25
+ ],
26
+ };