@intentius/chant 0.45.0 → 0.49.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 (223) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -0,0 +1,74 @@
1
+ import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
2
+ import type { OkfBundle } from "../okf-read";
3
+ import { bindConcepts } from "../okf-read";
4
+
5
+ /**
6
+ * COR026: Stale Knowledge Binding (#1865, design #1059, epic #1057)
7
+ *
8
+ * A concept's `binds` frontmatter key names a discovered entity's logical
9
+ * name (#1864, `../okf-read.ts`'s `bindConcepts`). A name that resolves to
10
+ * nothing is a stale binding — the entity was renamed or removed, or the
11
+ * concept was authored against a name that never existed — and per #1059's
12
+ * stated failure posture ("the spec permits broken links, which argues for
13
+ * warn") that is a warning, never a build failure: knowledge is deliberately
14
+ * softer than the typed graph it describes, and OKF itself tolerates broken
15
+ * links (epic #1057).
16
+ *
17
+ * The converse is deliberately silent: a concept with no `binds` at all is
18
+ * orphaned knowledge, not an omission (a runbook, a decision about the
19
+ * project as a whole), and `bindConcepts` never reports it as unresolved in
20
+ * the first place — see its own doc comment. A project with no knowledge
21
+ * bundle loads an empty one (`loadOkfBundle`), so this check produces no
22
+ * diagnostics either.
23
+ *
24
+ * Wired into `chant build` (`../cli/commands/build.ts`) alongside
25
+ * `coreReceiptChecks` — same reasons COR023/COR024 are build-owned rather
26
+ * than a per-file `LintRule`: the check needs the full discovered entity map
27
+ * (`ctx.entities`), not one `ts.SourceFile`, and that map only exists after
28
+ * discovery has run. Unlike COR023/024 it never reads `ctx.outputs` — the
29
+ * bundle is loaded once, up front (async — `loadOkfBundle` reads the
30
+ * filesystem), and closed over here so the check itself stays the same sync
31
+ * shape every other `PostSynthCheck` has.
32
+ */
33
+
34
+ export const STALE_KNOWLEDGE_BINDING_CHECK_ID = "COR026";
35
+
36
+ /**
37
+ * Build the COR026 check for one loaded bundle. A factory, not a bare
38
+ * constant, because — unlike `coreReceiptChecks`, which needs nothing but
39
+ * `ctx.entities` — this check needs the bundle loaded from disk, and
40
+ * `loadOkfBundle` is async while `PostSynthCheck#check` is not. The caller
41
+ * loads the bundle once (`resolveKnowledgeDir` + `loadOkfBundle`) and passes
42
+ * it in; see `../cli/commands/build.ts`.
43
+ */
44
+ export function staleKnowledgeBindingCheck(bundle: OkfBundle): PostSynthCheck {
45
+ return {
46
+ id: STALE_KNOWLEDGE_BINDING_CHECK_ID,
47
+ description:
48
+ "A knowledge concept's `binds` entry must name a discovered entity — a name that resolves to nothing is a stale binding",
49
+ check(ctx) {
50
+ const { unresolved } = bindConcepts(bundle, ctx.entities);
51
+ return unresolved.map(
52
+ ({ concept, name }): PostSynthDiagnostic => ({
53
+ checkId: STALE_KNOWLEDGE_BINDING_CHECK_ID,
54
+ severity: "warning",
55
+ message:
56
+ `Knowledge concept "${concept.path}" binds "${name}", but no discovered entity has that name — ` +
57
+ `a stale binding. The entity may have been renamed or removed, or the concept was authored ` +
58
+ `against a name that never existed. Update the concept's \`binds\` frontmatter, or remove the ` +
59
+ `entry if the concept no longer applies.`,
60
+ }),
61
+ );
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Core's own post-synth check over knowledge bindings, run by `chant build`
68
+ * over the full build result — unscoped, like `coreReceiptChecks`, since a
69
+ * concept's `binds` can name any discovered entity regardless of which
70
+ * lexicon owns it.
71
+ */
72
+ export function coreKnowledgeChecks(bundle: OkfBundle): PostSynthCheck[] {
73
+ return [staleKnowledgeBindingCheck(bundle)];
74
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { coreOutputChecks, STRINGIFIED_REFERENCE_CHECK_ID } from "./output-checks";
3
+ import { runPostSynthChecks } from "./post-synth";
4
+ import type { SerializerResult } from "../serializer";
5
+ import type { Declarable } from "../declarable";
6
+
7
+ function run(outputs: Map<string, string | SerializerResult>) {
8
+ return runPostSynthChecks(coreOutputChecks(), {
9
+ outputs,
10
+ entities: new Map<string, Declarable>(),
11
+ warnings: [],
12
+ errors: [],
13
+ sourceFileCount: 1,
14
+ });
15
+ }
16
+
17
+ describe("COR025: stringified reference in serialized output (#1526)", () => {
18
+ test("check id", () => {
19
+ expect(STRINGIFIED_REFERENCE_CHECK_ID).toBe("COR025");
20
+ expect(coreOutputChecks().map((c) => c.id)).toContain("COR025");
21
+ });
22
+
23
+ test("fires when a string output contains the [object Object] marker", () => {
24
+ const outputs = new Map<string, string | SerializerResult>([
25
+ ["aws", '{"Resource": "arn:aws:s3:::bucket-[object Object]/*"}'],
26
+ ]);
27
+ const diags = run(outputs);
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].checkId).toBe("COR025");
30
+ expect(diags[0].severity).toBe("error");
31
+ expect(diags[0].lexicon).toBe("aws");
32
+ expect(diags[0].message).toContain("[object Object]");
33
+ });
34
+
35
+ test("fires when a SerializerResult's primary content contains the marker", () => {
36
+ const outputs = new Map<string, string | SerializerResult>([
37
+ ["aws", { primary: 'Resource: "[object Object]/*"' } satisfies SerializerResult],
38
+ ]);
39
+ const diags = run(outputs);
40
+ expect(diags).toHaveLength(1);
41
+ expect(diags[0].message).toContain('Resource: "[object Object]/*"');
42
+ });
43
+
44
+ test("fires when a SerializerResult's nested file contains the marker, and names the file", () => {
45
+ const outputs = new Map<string, string | SerializerResult>([
46
+ [
47
+ "aws",
48
+ {
49
+ primary: "clean template",
50
+ files: { "network.template.json": '{"Resource": "[object Object]"}' },
51
+ } satisfies SerializerResult,
52
+ ],
53
+ ]);
54
+ const diags = run(outputs);
55
+ expect(diags).toHaveLength(1);
56
+ expect(diags[0].message).toContain("network.template.json");
57
+ expect(diags[0].message).toContain("[object Object]");
58
+ });
59
+
60
+ test("dedupes repeated occurrences of the same line within a file", () => {
61
+ const line = 'Resource: "[object Object]/*"';
62
+ const outputs = new Map<string, string | SerializerResult>([["aws", `${line}\n${line}\n${line}`]]);
63
+ const diags = run(outputs);
64
+ expect(diags).toHaveLength(1);
65
+ });
66
+
67
+ test("reports one diagnostic per distinct offending line", () => {
68
+ const outputs = new Map<string, string | SerializerResult>([
69
+ ["aws", 'Resource: "[object Object]/*"\nPolicy: "[object Object]"'],
70
+ ]);
71
+ const diags = run(outputs);
72
+ expect(diags).toHaveLength(2);
73
+ });
74
+
75
+ test("passes: output with no stringified reference", () => {
76
+ const outputs = new Map<string, string | SerializerResult>([
77
+ ["aws", '{"Resource": "arn:aws:s3:::my-bucket/*"}'],
78
+ ]);
79
+ expect(run(outputs)).toHaveLength(0);
80
+ });
81
+
82
+ test("passes: empty outputs", () => {
83
+ expect(run(new Map())).toHaveLength(0);
84
+ });
85
+ });
@@ -0,0 +1,99 @@
1
+ import type { SerializerResult } from "../serializer";
2
+ import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
3
+ import { getPrimaryOutput } from "./post-synth";
4
+
5
+ /**
6
+ * COR025: Stringified Reference in Serialized Output (#1526)
7
+ *
8
+ * kubemicrovm-ops composed a bucket policy's resource ARN with a template
9
+ * literal — `` `${bucket.Arn}/*` `` — over an `AttrRef`, not a string.
10
+ * JavaScript stringifies any non-primitive interpolated into a template
11
+ * literal via `Object.prototype.toString`, so the emitted policy carried the
12
+ * literal substring `"[object Object]/*"` in place of the resolved ARN. That
13
+ * built clean and linted clean — nothing upstream of serialization sees a
14
+ * type error, because `AttrRef` IS an object and a template literal accepts
15
+ * any coercible value — and it deployed clean on floci, which does no
16
+ * policy validation. It failed the first real deploy with S3 rejecting the
17
+ * policy's `Resource` and rolling back the stack.
18
+ *
19
+ * `[object Object]` (and its array-of-objects sibling `[object Object,object
20
+ * Object]`) never appears in a serialized template/manifest on purpose: no
21
+ * lexicon's serializer ever emits it, and no cloud provider's schema ever
22
+ * expects it as a literal value. Its presence is only ever a reference that
23
+ * got coerced to a string instead of resolved. That makes this check cheap,
24
+ * universal across lexicons (it scans the emitted text, not any one
25
+ * lexicon's schema), and free of false positives worth caring about — see
26
+ * `stringifiedReferenceCheck` below.
27
+ *
28
+ * Scans `ctx.outputs` (the SYNTHESIZED text) rather than `ctx.entities`
29
+ * because the coercion already happened in the user's own declaration code,
30
+ * upstream of anything chant's model retains structurally — by the time an
31
+ * entity reaches `ctx.entities` its prop is already a plain string containing
32
+ * the marker, indistinguishable from any other string prop. The only place
33
+ * left to catch it lexicon-independently is the text chant actually emits.
34
+ */
35
+
36
+ const MARKER = "[object Object]";
37
+
38
+ export const STRINGIFIED_REFERENCE_CHECK_ID = "COR025";
39
+
40
+ /** Trim a matched line down to a readable snippet without truncating mid-marker. */
41
+ const MAX_SNIPPET_LENGTH = 200;
42
+
43
+ function lineContaining(text: string, index: number): string {
44
+ const lineStart = text.lastIndexOf("\n", index) + 1;
45
+ const nextNewline = text.indexOf("\n", index);
46
+ const lineEnd = nextNewline === -1 ? text.length : nextNewline;
47
+ const line = text.slice(lineStart, lineEnd).trim();
48
+ return line.length > MAX_SNIPPET_LENGTH ? `${line.slice(0, MAX_SNIPPET_LENGTH)}…` : line;
49
+ }
50
+
51
+ /** Every serialized file for one lexicon's output: the primary plus any nested files. */
52
+ function serializedFiles(output: string | SerializerResult): Record<string, string> {
53
+ const files: Record<string, string> = { primary: getPrimaryOutput(output) };
54
+ if (typeof output !== "string" && output.files) {
55
+ Object.assign(files, output.files);
56
+ }
57
+ return files;
58
+ }
59
+
60
+ const stringifiedReferenceCheck: PostSynthCheck = {
61
+ id: STRINGIFIED_REFERENCE_CHECK_ID,
62
+ description:
63
+ 'A serialized output contains the literal substring "[object Object]" — a reference that was ' +
64
+ "stringified (e.g. through a template literal or string concatenation) instead of resolved. " +
65
+ "Always an authoring error, never intentional output.",
66
+ check(ctx) {
67
+ const diagnostics: PostSynthDiagnostic[] = [];
68
+ for (const [lexicon, output] of ctx.outputs) {
69
+ for (const [fileLabel, content] of Object.entries(serializedFiles(output))) {
70
+ const seenLines = new Set<string>();
71
+ let index = content.indexOf(MARKER);
72
+ while (index !== -1) {
73
+ const line = lineContaining(content, index);
74
+ const dedupeKey = `${fileLabel}::${line}`;
75
+ if (!seenLines.has(dedupeKey)) {
76
+ seenLines.add(dedupeKey);
77
+ const location = fileLabel === "primary" ? "" : ` (${fileLabel})`;
78
+ diagnostics.push({
79
+ checkId: STRINGIFIED_REFERENCE_CHECK_ID,
80
+ severity: "error",
81
+ lexicon,
82
+ message:
83
+ `Serialized output${location} contains "${MARKER}" — a reference was stringified ` +
84
+ `instead of resolved (a template literal or string concatenation over an object, ` +
85
+ `not the raw value/ref). Emitted line: ${line}`,
86
+ });
87
+ }
88
+ index = content.indexOf(MARKER, index + MARKER.length);
89
+ }
90
+ }
91
+ }
92
+ return diagnostics;
93
+ },
94
+ };
95
+
96
+ /** Core-owned post-synth checks over serialized output text (#1526). */
97
+ export function coreOutputChecks(): PostSynthCheck[] {
98
+ return [stringifiedReferenceCheck];
99
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { Component, Phase } from "../components/component";
3
+ import {
4
+ classifyComponentPipelineChange,
5
+ classifyPipelineChange,
6
+ componentVerbSet,
7
+ evaluatePipelineChangeGate,
8
+ } from "./pipeline-change-gate";
9
+
10
+ const KNOWN: ReadonlySet<string> = new Set(["cfn-deploy", "publish-image", "wait-for-stack"]);
11
+
12
+ const component = (deploy: Phase[]): Component => ({
13
+ name: "widget",
14
+ dependsOn: [],
15
+ deploy,
16
+ });
17
+
18
+ const ordinary = (): Component =>
19
+ component([{ phase: "Deploy", steps: [{ kind: "cfn-deploy" }, { kind: "wait-for-stack" }] }]);
20
+
21
+ const withShell = (): Component =>
22
+ component([{ phase: "Deploy", steps: [{ kind: "shell", cmd: "echo hi", reason: "no capability yet" }] }]);
23
+
24
+ const withUnregisteredVerb = (): Component =>
25
+ component([{ phase: "Deploy", steps: [{ kind: "some-new-verb" }] }]);
26
+
27
+ describe("classifyComponentPipelineChange (#1569)", () => {
28
+ test("a composition of only registry verbs is not a pipeline change", () => {
29
+ const result = classifyComponentPipelineChange(ordinary(), { knownKinds: KNOWN });
30
+ expect(result).toEqual({ pipelineChange: false, reasons: [] });
31
+ });
32
+
33
+ test("any shell step trips the class unconditionally, even with a declared reason", () => {
34
+ const result = classifyComponentPipelineChange(withShell(), { knownKinds: KNOWN });
35
+ expect(result.pipelineChange).toBe(true);
36
+ expect(result.reasons).toEqual([{ kind: "shell-step", phaseName: "Deploy" }]);
37
+ });
38
+
39
+ test("a verb outside the known registry trips the class", () => {
40
+ const result = classifyComponentPipelineChange(withUnregisteredVerb(), { knownKinds: KNOWN });
41
+ expect(result.pipelineChange).toBe(true);
42
+ expect(result.reasons).toEqual([{ kind: "unregistered-verb", stepKind: "some-new-verb", phaseName: "Deploy" }]);
43
+ });
44
+
45
+ test("gate steps are never mistaken for an escape hatch", () => {
46
+ const withGate = component([
47
+ { phase: "Approve", steps: [{ kind: "gate", signalName: "release-approved" }] },
48
+ ]);
49
+ expect(classifyComponentPipelineChange(withGate, { knownKinds: KNOWN })).toEqual({
50
+ pipelineChange: false,
51
+ reasons: [],
52
+ });
53
+ });
54
+
55
+ test("nested fan-out phases are walked, same as COMP005/COMP006", () => {
56
+ const nested = component([
57
+ {
58
+ phase: "Fanout",
59
+ steps: [{ phase: "per-region", steps: [{ kind: "shell", cmd: "aws sts get-caller-identity", reason: "no capability" }] }],
60
+ },
61
+ ]);
62
+ const result = classifyComponentPipelineChange(nested, { knownKinds: KNOWN });
63
+ expect(result.pipelineChange).toBe(true);
64
+ expect(result.reasons).toEqual([{ kind: "shell-step", phaseName: "per-region" }]);
65
+ });
66
+
67
+ test("without an explicit registry, falls back to core's starter verbs (same fallback as COMP005)", () => {
68
+ // cfn-deploy is not in core's starter set (it's an aws-lexicon leaf), so
69
+ // this asserts the fallback is core's own set, not "everything passes".
70
+ const result = classifyComponentPipelineChange(ordinary());
71
+ expect(result.pipelineChange).toBe(true);
72
+ expect(result.reasons.some((r) => r.kind === "unregistered-verb" && r.stepKind === "cfn-deploy")).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe("componentVerbSet (#1569)", () => {
77
+ test("collects every non-gate step kind, excluding gate steps", () => {
78
+ const c = component([
79
+ {
80
+ phase: "Deploy",
81
+ steps: [{ kind: "cfn-deploy" }, { kind: "gate", signalName: "go" }, { kind: "wait-for-stack" }],
82
+ },
83
+ ]);
84
+ expect(componentVerbSet(c)).toEqual(new Set(["cfn-deploy", "wait-for-stack"]));
85
+ });
86
+ });
87
+
88
+ describe("classifyPipelineChange (#1569)", () => {
89
+ test("no `before` (a brand-new component): reports only the single-composition findings, no verb-set-changed", () => {
90
+ const result = classifyPipelineChange(undefined, ordinary(), { knownKinds: KNOWN });
91
+ expect(result).toEqual({ pipelineChange: false, reasons: [] });
92
+ });
93
+
94
+ test("identical verb set before and after is not a pipeline change even with `before` present", () => {
95
+ const result = classifyPipelineChange(ordinary(), ordinary(), { knownKinds: KNOWN });
96
+ expect(result).toEqual({ pipelineChange: false, reasons: [] });
97
+ });
98
+
99
+ test("adding a verb to the composition trips verb-set-changed", () => {
100
+ const after = component([
101
+ { phase: "Deploy", steps: [{ kind: "cfn-deploy" }, { kind: "wait-for-stack" }, { kind: "publish-image" }] },
102
+ ]);
103
+ const result = classifyPipelineChange(ordinary(), after, { knownKinds: KNOWN });
104
+ expect(result.pipelineChange).toBe(true);
105
+ expect(result.reasons).toEqual([{ kind: "verb-set-changed", added: ["publish-image"], removed: [] }]);
106
+ });
107
+
108
+ test("removing a verb from the composition trips verb-set-changed", () => {
109
+ const after = component([{ phase: "Deploy", steps: [{ kind: "cfn-deploy" }] }]);
110
+ const result = classifyPipelineChange(ordinary(), after, { knownKinds: KNOWN });
111
+ expect(result.reasons).toEqual([{ kind: "verb-set-changed", added: [], removed: ["wait-for-stack"] }]);
112
+ });
113
+
114
+ test("a verb-set change composes with a shell finding on the same change, both reported", () => {
115
+ const after = withShell();
116
+ const result = classifyPipelineChange(ordinary(), after, { knownKinds: KNOWN });
117
+ expect(result.pipelineChange).toBe(true);
118
+ expect(result.reasons).toContainEqual({ kind: "shell-step", phaseName: "Deploy" });
119
+ expect(result.reasons).toContainEqual({
120
+ kind: "verb-set-changed",
121
+ added: ["shell"],
122
+ removed: ["cfn-deploy", "wait-for-stack"],
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("evaluatePipelineChangeGate (#1569)", () => {
128
+ test("an ordinary composition routes nowhere", () => {
129
+ const verdict = evaluatePipelineChangeGate(undefined, ordinary(), "human-always", { knownKinds: KNOWN });
130
+ expect(verdict.route).toBe("none");
131
+ expect(verdict.pipelineChange).toBe(false);
132
+ });
133
+
134
+ test('defaults to "human-always" — a pipeline change never free-runs unless the caller opts into "stricter-gate"', () => {
135
+ const verdict = evaluatePipelineChangeGate(undefined, withShell(), undefined, { knownKinds: KNOWN });
136
+ expect(verdict.route).toBe("human-always");
137
+ expect(verdict.pipelineChange).toBe(true);
138
+ });
139
+
140
+ test('"stricter-gate" routes a pipeline change to the named stricter class instead', () => {
141
+ const verdict = evaluatePipelineChangeGate(undefined, withShell(), "stricter-gate", { knownKinds: KNOWN });
142
+ expect(verdict.route).toBe("stricter-gate");
143
+ });
144
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The pipeline-change gate class (#1569): changes to the pipeline itself
3
+ * route to a stricter gate.
4
+ *
5
+ * The honest concession in the PR-flow teardown (rubric-product-research,
6
+ * 06-objections §1): chant Ops have a `shell` step, so a governed plane is
7
+ * not automatically immune to "the change edits the machinery that applies
8
+ * changes." What chant *can* claim — and a plain PR flow structurally cannot
9
+ * — is that pipeline changes are distinguishable: a composition is data over
10
+ * a bounded verb registry, and the COMP rules already police the escape
11
+ * hatches (COMP006 — every `shell` step carries a declared reason;
12
+ * COMP005 — no noun-shaped escape hatches, `./rules/comp/`). What was missing
13
+ * was the routing: nothing classified a change as "touches the pipeline" and
14
+ * required a stricter gate for it, so a change to a composition rode through
15
+ * the same gate as a replica-count bump.
16
+ *
17
+ * This is the cheap first cut #1569 asks for: the registry-only predicate.
18
+ * A composition that contains only registry verbs is ordinary; a `shell`
19
+ * step (the deliberate escape hatch) or a change to the set of verbs a
20
+ * composition invokes trips the stricter class. comp005/comp006 already make
21
+ * both decidable from the text — this module reuses the same walk
22
+ * (`./rules/comp/support.ts`) rather than re-deriving it.
23
+ *
24
+ * Like #1568's `evaluateUnobservedGate`
25
+ * (../lifecycle/unobserved-gate.ts), this is a reusable predicate, not a
26
+ * runtime: classify a change and get back a verdict; the caller (a
27
+ * `lint.policies` check routed through `policyGate`, an Op step, #1487's
28
+ * gate-as-fact once it lands) decides what "stricter" means in its own
29
+ * context. The stricter class composes with #1487's principle that
30
+ * destructive verbs always wait for a person — pipeline changes are the
31
+ * second category that should never free-run, because they change what
32
+ * "gated" means for everything after them. That is why the default policy
33
+ * below is `"human-always"`, not `"stricter-gate"`.
34
+ */
35
+ import type { Component } from "../components/component";
36
+ import { STARTER_VERB_FAMILIES } from "../components/starter-plugin";
37
+ import { walkComponent } from "./rules/comp/support";
38
+
39
+ /**
40
+ * Fallback registry when no project registry is supplied: core's own starter
41
+ * verbs, the same fallback COMP005 uses for a direct unit test
42
+ * (`./rules/comp/comp005-capability-kind-is-noun.ts`). A real `chant lint` /
43
+ * gate run passes the project's resolved `knownKinds` (core's starter set
44
+ * plus the active lexicons' leaves) so a real project's cloud verbs are never
45
+ * mistaken for an out-of-registry escape hatch.
46
+ */
47
+ const CORE_STARTER_KINDS: ReadonlySet<string> = new Set(Object.values(STARTER_VERB_FAMILIES).flat());
48
+
49
+ /** Why a composition (or a change to one) tripped the pipeline-change class. */
50
+ export type PipelineChangeReason =
51
+ | { kind: "shell-step"; phaseName: string }
52
+ | { kind: "unregistered-verb"; stepKind: string; phaseName: string }
53
+ | { kind: "verb-set-changed"; added: string[]; removed: string[] };
54
+
55
+ export interface PipelineChangeClassification {
56
+ /** True when any reason below applies. */
57
+ pipelineChange: boolean;
58
+ reasons: PipelineChangeReason[];
59
+ }
60
+
61
+ /** Every non-gate step kind a component's composition invokes, across `deploy` and `rollback` (nested fan-out phases included, via `walkComponent`). `gate` steps are excluded — they are not a capability verb. */
62
+ export function componentVerbSet(component: Component): Set<string> {
63
+ const { steps } = walkComponent(component);
64
+ return new Set(steps.map((s) => s.step.kind));
65
+ }
66
+
67
+ /**
68
+ * Classify a single component's composition (the "cheap first cut", no
69
+ * before/after needed): a `shell` step trips the class unconditionally — it
70
+ * is the escape hatch by construction (COMP006) — and any step whose `kind`
71
+ * is outside `knownKinds` trips it too, since an unregistered verb is,
72
+ * structurally, the same kind of hole a `shell` step is: something the
73
+ * bounded registry does not account for.
74
+ */
75
+ export function classifyComponentPipelineChange(
76
+ component: Component,
77
+ opts?: { knownKinds?: ReadonlySet<string> },
78
+ ): PipelineChangeClassification {
79
+ const knownKinds = opts?.knownKinds ?? CORE_STARTER_KINDS;
80
+ const { steps } = walkComponent(component);
81
+
82
+ const reasons: PipelineChangeReason[] = [];
83
+ for (const { step, phaseName } of steps) {
84
+ if (step.kind === "shell") {
85
+ reasons.push({ kind: "shell-step", phaseName });
86
+ } else if (!knownKinds.has(step.kind)) {
87
+ reasons.push({ kind: "unregistered-verb", stepKind: step.kind, phaseName });
88
+ }
89
+ }
90
+
91
+ return { pipelineChange: reasons.length > 0, reasons };
92
+ }
93
+
94
+ /**
95
+ * Classify a *change* to a component's composition between two revisions
96
+ * (#1569's "add or modify a shell step ... or the composition graph
97
+ * itself"): on top of `classifyComponentPipelineChange`'s single-composition
98
+ * check on `after`, a change also trips the class when the verb set itself
99
+ * changed — a verb added or removed changes what the composition can invoke,
100
+ * which is the routing signal even when every individual verb, before and
101
+ * after, is registry-clean. `before: undefined` (a brand-new component) never
102
+ * reports a verb-set change — there is nothing to diff against, and the
103
+ * single-composition check already covers a new component's own shell/
104
+ * unregistered-verb findings.
105
+ */
106
+ export function classifyPipelineChange(
107
+ before: Component | undefined,
108
+ after: Component,
109
+ opts?: { knownKinds?: ReadonlySet<string> },
110
+ ): PipelineChangeClassification {
111
+ const base = classifyComponentPipelineChange(after, opts);
112
+ if (!before) return base;
113
+
114
+ const beforeVerbs = componentVerbSet(before);
115
+ const afterVerbs = componentVerbSet(after);
116
+ const added = [...afterVerbs].filter((v) => !beforeVerbs.has(v)).sort();
117
+ const removed = [...beforeVerbs].filter((v) => !afterVerbs.has(v)).sort();
118
+
119
+ if (added.length === 0 && removed.length === 0) return base;
120
+
121
+ return {
122
+ pipelineChange: true,
123
+ reasons: [...base.reasons, { kind: "verb-set-changed", added, removed }],
124
+ };
125
+ }
126
+
127
+ /**
128
+ * How the gate routes a classified pipeline change.
129
+ *
130
+ * - `"human-always"` (the recommended default — see this module's doc
131
+ * comment) — a pipeline change never free-runs, mirroring #1487's "a
132
+ * destructive verb always waits for a person."
133
+ * - `"stricter-gate"` — route to a named stricter gate class rather than
134
+ * requiring a person on every run; the caller supplies what "stricter"
135
+ * means (an additional approval, a narrower blast-radius budget, ...).
136
+ */
137
+ export type PipelineChangeGatePolicy = "stricter-gate" | "human-always";
138
+
139
+ export interface PipelineChangeGateVerdict extends PipelineChangeClassification {
140
+ /** What the policy says to do about it. `"none"` when the change is not a pipeline change at all. */
141
+ route: PipelineChangeGatePolicy | "none";
142
+ }
143
+
144
+ /** Evaluate a component composition change against a routing policy. Pure — no I/O. */
145
+ export function evaluatePipelineChangeGate(
146
+ before: Component | undefined,
147
+ after: Component,
148
+ policy: PipelineChangeGatePolicy = "human-always",
149
+ opts?: { knownKinds?: ReadonlySet<string> },
150
+ ): PipelineChangeGateVerdict {
151
+ const classification = classifyPipelineChange(before, after, opts);
152
+ return { ...classification, route: classification.pipelineChange ? policy : "none" };
153
+ }
@@ -33,6 +33,21 @@ export function getPrimaryOutput(output: string | SerializerResult): string {
33
33
  return typeof output === "string" ? output : output.primary;
34
34
  }
35
35
 
36
+ /**
37
+ * Extract the ADDITIONAL files from a serializer output — everything
38
+ * {@link getPrimaryOutput} discards.
39
+ *
40
+ * Every post-synth check shipped before this one reads the primary output
41
+ * only, which means a sidecar file (a nested stack template, committed SOPS
42
+ * ciphertext) is invisible to all of them. That is the right default — the
43
+ * primary output is what appliers read — but a rule ABOUT a sidecar has to
44
+ * be able to see it, and `PostSynthContext.outputs` has carried the data all
45
+ * along. WK8504 (k8s) is the first caller.
46
+ */
47
+ export function getAdditionalFiles(output: string | SerializerResult): Record<string, string> {
48
+ return typeof output === "string" ? {} : (output.files ?? {});
49
+ }
50
+
36
51
  /**
37
52
  * A diagnostic from a post-synthesis check.
38
53
  *
@@ -0,0 +1,101 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { coreReceiptChecks, RECEIPT_PLAIN_STORE_CHECK_ID } from "./receipt-checks";
3
+ import { runPostSynthChecks } from "./post-synth";
4
+ import { EffectReceipt, EFFECT_RECEIPT_MARKER } from "../effect-receipt";
5
+ import { DECLARABLE_MARKER, type Declarable } from "../declarable";
6
+
7
+ /** A lexicon-materialized receipt row: its own entityType, the receipt marker. */
8
+ function materializedReceipt(opts: {
9
+ lexicon: string;
10
+ entityType: string;
11
+ props?: Record<string, unknown>;
12
+ }): Declarable {
13
+ return {
14
+ [DECLARABLE_MARKER]: true,
15
+ [EFFECT_RECEIPT_MARKER]: true,
16
+ lexicon: opts.lexicon,
17
+ entityType: opts.entityType,
18
+ ...(opts.props ? { props: opts.props } : {}),
19
+ } as unknown as Declarable;
20
+ }
21
+
22
+ function run(entities: Map<string, Declarable>) {
23
+ return runPostSynthChecks(coreReceiptChecks(), {
24
+ outputs: new Map(),
25
+ entities,
26
+ warnings: [],
27
+ errors: [],
28
+ sourceFileCount: 1,
29
+ });
30
+ }
31
+
32
+ describe("COR023: receipt materializes into a plain store (#1833)", () => {
33
+ test("check id", () => {
34
+ expect(RECEIPT_PLAIN_STORE_CHECK_ID).toBe("COR023");
35
+ expect(coreReceiptChecks().map((c) => c.id)).toContain("COR023");
36
+ });
37
+
38
+ test("fires when a receipt is materialized into a Secret kind", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["seededReceipt", materializedReceipt({ lexicon: "k8s", entityType: "K8s::Core::Secret" })],
41
+ ]);
42
+ const diags = run(entities);
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].checkId).toBe("COR023");
45
+ expect(diags[0].severity).toBe("error");
46
+ expect(diags[0].entity).toBe("seededReceipt");
47
+ expect(diags[0].message).toContain("K8s::Core::Secret");
48
+ expect(diags[0].message).toContain("plain store");
49
+ });
50
+
51
+ test("fires when a plain kind selects a secret-capable variant (SSM SecureString)", () => {
52
+ const entities = new Map<string, Declarable>([
53
+ [
54
+ "migratedReceipt",
55
+ materializedReceipt({
56
+ lexicon: "aws",
57
+ entityType: "AWS::SSM::Parameter",
58
+ props: { Name: "/receipts/migrated", Type: "SecureString" },
59
+ }),
60
+ ],
61
+ ]);
62
+ const diags = run(entities);
63
+ expect(diags).toHaveLength(1);
64
+ expect(diags[0].message).toContain('Type: "SecureString"');
65
+ expect(diags[0].entity).toBe("migratedReceipt");
66
+ });
67
+
68
+ test("passes: a receipt materialized into a plain store", () => {
69
+ const entities = new Map<string, Declarable>([
70
+ [
71
+ "migratedReceipt",
72
+ materializedReceipt({
73
+ lexicon: "aws",
74
+ entityType: "AWS::SSM::Parameter",
75
+ props: { Name: "/receipts/migrated", Type: "String" },
76
+ }),
77
+ ],
78
+ ]);
79
+ expect(run(entities)).toHaveLength(0);
80
+ });
81
+
82
+ test("passes: the core Chant::EffectReceipt declaration itself", () => {
83
+ const receipt = EffectReceipt("seeded", {
84
+ effect: "db-seed",
85
+ flavor: "hash",
86
+ inputs: { schema: "v3" },
87
+ });
88
+ const entities = new Map<string, Declarable>([["seeded", receipt]]);
89
+ expect(run(entities)).toHaveLength(0);
90
+ });
91
+
92
+ test("passes: a Secret-kind entity that is NOT a receipt is not this check's concern", () => {
93
+ const secret: Declarable = {
94
+ [DECLARABLE_MARKER]: true,
95
+ lexicon: "k8s",
96
+ entityType: "K8s::Core::Secret",
97
+ } as unknown as Declarable;
98
+ const entities = new Map<string, Declarable>([["dbSecret", secret]]);
99
+ expect(run(entities)).toHaveLength(0);
100
+ });
101
+ });