@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
@@ -50,7 +50,23 @@ describe("op builders are all reachable (#1715)", () => {
50
50
  "spriteApplyServices", "spriteTaskCreate", "spriteTaskRefresh",
51
51
  "spriteTaskRelease", "spritesUp", "spritesDown",
52
52
  ]);
53
- const expected = Object.keys(builders).filter((n) => !FLY_SURFACE.has(n));
53
+ // chant #1288 Stage 2 — these are the temporal lexicon's OWN activities
54
+ // (build.ts, shell.ts, wait.ts, ...), so its barrel re-exports a fully
55
+ // typed twin from its own `./op/builders` instead of core's untyped
56
+ // original — same name, same import path, deliberately not sourced from
57
+ // `@intentius/chant/op` any more. See `lexicons/temporal/src/index.ts`
58
+ // and `lexicons/temporal/src/op/builders.ts`.
59
+ const TEMPORAL_TYPED_SURFACE = new Set([
60
+ "build", "shell", "waitForStack", "lifecycleSnapshot", "teardown", "envTeardown", "httpCheck", "policyGate",
61
+ ]);
62
+ // chant #1288 Stage 2 — an internal helper (`takeProfile`'s sibling) for
63
+ // the lexicon-owned typed step-builder wrappers to reuse, not an
64
+ // Op-authoring primitive itself; nothing authors an Op with it directly,
65
+ // so it has no place in the Op-authoring barrel.
66
+ const INTERNAL_HELPERS = new Set(["takeProfileAndId"]);
67
+ const expected = Object.keys(builders).filter(
68
+ (n) => !FLY_SURFACE.has(n) && !TEMPORAL_TYPED_SURFACE.has(n) && !INTERNAL_HELPERS.has(n),
69
+ );
54
70
  const missing = expected.filter((name) => !reExported.has(name));
55
71
  expect(
56
72
  missing,
@@ -2,6 +2,18 @@ import { OpResource } from "./resource";
2
2
  import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep } from "./types";
3
3
  import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receipt";
4
4
  import { receiptCheckInput } from "./receipt-store";
5
+ import { makeOutProxy, type StepOutputRef } from "./step-output-ref";
6
+
7
+ /** An `activity()` result — the plain `ActivityStep` shape plus the `.out` reference sugar (#1290). */
8
+ export interface NamedActivityStep extends ActivityStep {
9
+ /**
10
+ * `step.out.someField` builds a {@link StepOutputRef} into this step's
11
+ * declared return value, for use in a later step's `args`. Throws on
12
+ * access if this step has no `id` (pass one via `activity()`'s third
13
+ * argument) — there is nothing for a reference to name otherwise.
14
+ */
15
+ readonly out: Record<string, StepOutputRef>;
16
+ }
5
17
 
6
18
  // ── Core builders ─────────────────────────────────────────────────────────────
7
19
 
@@ -33,18 +45,44 @@ export function phase(
33
45
  return { name, steps, ...(opts?.parallel ? { parallel: true } : {}) };
34
46
  }
35
47
 
36
- /** Reference a pre-built or custom activity by function name. */
48
+ /**
49
+ * Reference a pre-built or custom activity by function name.
50
+ *
51
+ * The third argument is a profile string (unchanged) or, to name the step
52
+ * so a later step can reference its output (#1290), an options object:
53
+ * `activity("lifecycleDiff", { env: "prod" }, { id: "diff" })`. Named or
54
+ * not, the result carries `.out` — `diff.out.driftedStacks` builds a
55
+ * reference to this step's declared return value; see `stepOutput()` in
56
+ * `@intentius/chant/op` for the equivalent on a step authored as a plain
57
+ * object literal instead of via this builder.
58
+ */
37
59
  export function activity(
38
60
  fn: string,
39
61
  args?: Record<string, unknown>,
40
- profile?: ActivityStep["profile"],
41
- ): ActivityStep {
42
- return {
62
+ opts?: ActivityStep["profile"] | { profile?: ActivityStep["profile"]; id?: string },
63
+ ): NamedActivityStep {
64
+ const profile = typeof opts === "string" ? opts : opts?.profile;
65
+ const id = typeof opts === "string" ? undefined : opts?.id;
66
+ const step = {
43
67
  kind: "activity",
44
68
  fn,
45
69
  ...(args && Object.keys(args).length > 0 ? { args } : {}),
46
70
  ...(profile ? { profile } : {}),
47
- };
71
+ ...(id ? { id } : {}),
72
+ } as NamedActivityStep;
73
+ Object.defineProperty(step, "out", {
74
+ enumerable: false,
75
+ get(): Record<string, StepOutputRef> {
76
+ if (!step.id) {
77
+ throw new Error(
78
+ `activity(${JSON.stringify(fn)}, ...).out: this step has no id — pass one via ` +
79
+ `activity(fn, args, { id: "..." }) before referencing its output.`,
80
+ );
81
+ }
82
+ return makeOutProxy(step.id);
83
+ },
84
+ });
85
+ return step;
48
86
  }
49
87
 
50
88
  /** Insert a human gate — the workflow pauses until the named signal is received. */
@@ -136,6 +174,22 @@ function takeProfile(
136
174
  return { args, profile };
137
175
  }
138
176
 
177
+ /**
178
+ * {@link takeProfile}'s sibling for the typed, lexicon-owned step-builder
179
+ * wrappers added in chant #1288 Stage 2 (e.g. `kubectlApply` in
180
+ * `@intentius/chant-lexicon-k8s`): also pulls `id` out of the opts bag so it
181
+ * routes to the step's `id` field (enabling `.out` — #1290) instead of
182
+ * leaking into the activity's args, the same way `profile` would without
183
+ * this split.
184
+ */
185
+ export function takeProfileAndId(
186
+ opts: (Record<string, unknown> & { profile?: ActivityStep["profile"]; id?: string }) | undefined,
187
+ ): { args: Record<string, unknown>; profile?: ActivityStep["profile"]; id?: string } {
188
+ if (!opts) return { args: {} };
189
+ const { profile, id, ...args } = opts as { profile?: ActivityStep["profile"]; id?: string } & Record<string, unknown>;
190
+ return { args, profile, id };
191
+ }
192
+
139
193
  /** Run an npm build script in the given project directory. `opts.script` selects the script (default `build`, e.g. `build:aws`); `opts.env` adds env vars. */
140
194
  export const build = (path: string, opts?: Record<string, unknown>): ActivityStep => {
141
195
  const { args, profile } = takeProfile(opts);
@@ -0,0 +1,179 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ eq, neq, gt, gte, lt, lte, truthy, falsy, allOf, anyOf,
4
+ evaluatePredicate, isWellFormedPredicate, predicateReferencesField,
5
+ run, report, when, duplicateRuleIds,
6
+ DEFAULT_FLAP_THRESHOLD,
7
+ } from "./converge-rule";
8
+
9
+ interface TestSymptom {
10
+ status: string;
11
+ count: number;
12
+ flag: boolean;
13
+ }
14
+
15
+ const symptom: TestSymptom = { status: "drifted", count: 3, flag: true };
16
+
17
+ describe("evaluatePredicate", () => {
18
+ test("eq matches / doesn't match", () => {
19
+ expect(evaluatePredicate(eq<TestSymptom>("status", "drifted"), symptom)).toBe(true);
20
+ expect(evaluatePredicate(eq<TestSymptom>("status", "reconciled"), symptom)).toBe(false);
21
+ });
22
+
23
+ test("neq", () => {
24
+ expect(evaluatePredicate(neq<TestSymptom>("status", "reconciled"), symptom)).toBe(true);
25
+ expect(evaluatePredicate(neq<TestSymptom>("status", "drifted"), symptom)).toBe(false);
26
+ });
27
+
28
+ test("numeric comparisons", () => {
29
+ expect(evaluatePredicate(gt<TestSymptom>("count", 2), symptom)).toBe(true);
30
+ expect(evaluatePredicate(gt<TestSymptom>("count", 3), symptom)).toBe(false);
31
+ expect(evaluatePredicate(gte<TestSymptom>("count", 3), symptom)).toBe(true);
32
+ expect(evaluatePredicate(lt<TestSymptom>("count", 4), symptom)).toBe(true);
33
+ expect(evaluatePredicate(lt<TestSymptom>("count", 3), symptom)).toBe(false);
34
+ expect(evaluatePredicate(lte<TestSymptom>("count", 3), symptom)).toBe(true);
35
+ });
36
+
37
+ test("numeric comparison against a non-number field never throws, just doesn't match", () => {
38
+ expect(evaluatePredicate(gt<TestSymptom>("status" as never, 1), symptom)).toBe(false);
39
+ });
40
+
41
+ test("truthy / falsy", () => {
42
+ expect(evaluatePredicate(truthy<TestSymptom>("flag"), symptom)).toBe(true);
43
+ expect(evaluatePredicate(falsy<TestSymptom>("flag"), symptom)).toBe(false);
44
+ expect(evaluatePredicate(truthy<TestSymptom>("count"), { ...symptom, count: 0 })).toBe(false);
45
+ });
46
+
47
+ test("allOf requires every predicate to match", () => {
48
+ expect(evaluatePredicate(allOf<TestSymptom>(eq("status", "drifted"), gt("count", 2)), symptom)).toBe(true);
49
+ expect(evaluatePredicate(allOf<TestSymptom>(eq("status", "drifted"), gt("count", 10)), symptom)).toBe(false);
50
+ });
51
+
52
+ test("anyOf requires at least one predicate to match", () => {
53
+ expect(evaluatePredicate(anyOf<TestSymptom>(eq("status", "reconciled"), gt("count", 2)), symptom)).toBe(true);
54
+ expect(evaluatePredicate(anyOf<TestSymptom>(eq("status", "reconciled"), gt("count", 10)), symptom)).toBe(false);
55
+ });
56
+
57
+ test("nested allOf/anyOf compose", () => {
58
+ const p = allOf<TestSymptom>(truthy("flag"), anyOf(eq("status", "reconciled"), gt("count", 1)));
59
+ expect(evaluatePredicate(p, symptom)).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("predicateReferencesField", () => {
64
+ test("a field comparison references its own field, not another", () => {
65
+ expect(predicateReferencesField(eq<TestSymptom>("status", "drifted"), "status")).toBe(true);
66
+ expect(predicateReferencesField(eq<TestSymptom>("status", "drifted"), "count")).toBe(false);
67
+ });
68
+
69
+ test("a field truthiness predicate references its own field", () => {
70
+ expect(predicateReferencesField(truthy<TestSymptom>("flag"), "flag")).toBe(true);
71
+ expect(predicateReferencesField(truthy<TestSymptom>("flag"), "status")).toBe(false);
72
+ });
73
+
74
+ test("allOf/anyOf reference a field if any nested predicate does", () => {
75
+ const p = allOf<TestSymptom>(eq("status", "drifted"), gt("count", 2));
76
+ expect(predicateReferencesField(p, "count")).toBe(true);
77
+ expect(predicateReferencesField(p, "flag")).toBe(false);
78
+ });
79
+
80
+ test("recurses through nested allOf/anyOf", () => {
81
+ const p = allOf<TestSymptom>(truthy("flag"), anyOf(eq("status", "reconciled"), gt("count", 1)));
82
+ expect(predicateReferencesField(p, "count")).toBe(true);
83
+ expect(predicateReferencesField(p, "status")).toBe(true);
84
+ expect(predicateReferencesField(p, "flag")).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe("isWellFormedPredicate", () => {
89
+ test("accepts every builder's output", () => {
90
+ expect(isWellFormedPredicate(eq<TestSymptom>("status", "x"))).toBe(true);
91
+ expect(isWellFormedPredicate(neq<TestSymptom>("status", "x"))).toBe(true);
92
+ expect(isWellFormedPredicate(gt<TestSymptom>("count", 1))).toBe(true);
93
+ expect(isWellFormedPredicate(truthy<TestSymptom>("flag"))).toBe(true);
94
+ expect(isWellFormedPredicate(falsy<TestSymptom>("flag"))).toBe(true);
95
+ expect(isWellFormedPredicate(allOf<TestSymptom>(eq("status", "x"), truthy("flag")))).toBe(true);
96
+ expect(isWellFormedPredicate(anyOf<TestSymptom>(eq("status", "x")))).toBe(true);
97
+ });
98
+
99
+ test("rejects a malformed shape", () => {
100
+ expect(isWellFormedPredicate(null)).toBe(false);
101
+ expect(isWellFormedPredicate("eq(status, drifted)")).toBe(false);
102
+ expect(isWellFormedPredicate({ kind: "field-comparison", field: "status", op: "regex", value: "x" })).toBe(false);
103
+ expect(isWellFormedPredicate({ kind: "all-of", predicates: [{ kind: "bogus" }] })).toBe(false);
104
+ expect(isWellFormedPredicate(() => true)).toBe(false);
105
+ });
106
+
107
+ test("respects a field whitelist", () => {
108
+ const whitelist = new Set(["status"]);
109
+ expect(isWellFormedPredicate(eq<TestSymptom>("status", "x"), whitelist)).toBe(true);
110
+ expect(isWellFormedPredicate(eq<TestSymptom>("count", 1), whitelist)).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("run / report", () => {
115
+ test("run() builds a RunAction", () => {
116
+ expect(run("fountain-apply")).toEqual({ kind: "run", op: "fountain-apply" });
117
+ });
118
+ test("report() builds a ReportAction", () => {
119
+ expect(report("unowned resources present")).toEqual({ kind: "report", reason: "unowned resources present" });
120
+ });
121
+ });
122
+
123
+ describe("when()", () => {
124
+ test("builds a well-formed rule", () => {
125
+ const rule = when<TestSymptom>(eq("status", "drifted"), run("fountain-apply"), {
126
+ id: "drift-apply",
127
+ why: "Re-apply on drift.",
128
+ });
129
+ expect(rule).toEqual({
130
+ id: "drift-apply",
131
+ when: { kind: "field-comparison", field: "status", op: "eq", value: "drifted" },
132
+ then: { kind: "run", op: "fountain-apply" },
133
+ why: "Re-apply on drift.",
134
+ });
135
+ });
136
+
137
+ test("throws without an id", () => {
138
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "", why: "because" })).toThrow(/non-empty `id`/);
139
+ });
140
+
141
+ test("throws without a why", () => {
142
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "" })).toThrow(/carry its `why`/);
143
+ });
144
+
145
+ test("throws without a why (whitespace only)", () => {
146
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: " " })).toThrow(/carry its `why`/);
147
+ });
148
+
149
+ test("throws on a non-positive-integer flapThreshold", () => {
150
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "y", flapThreshold: 0 })).toThrow(/flapThreshold/);
151
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "y", flapThreshold: 1.5 })).toThrow(/flapThreshold/);
152
+ });
153
+
154
+ test("flapThreshold defaults are left to the caller (DEFAULT_FLAP_THRESHOLD is exported for that)", () => {
155
+ expect(DEFAULT_FLAP_THRESHOLD).toBeGreaterThan(0);
156
+ const rule = when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "y" });
157
+ expect(rule.flapThreshold).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ describe("duplicateRuleIds", () => {
162
+ test("empty when every id is unique", () => {
163
+ const rules = [
164
+ when<TestSymptom>(eq("status", "a"), report("x"), { id: "r1", why: "y" }),
165
+ when<TestSymptom>(eq("status", "b"), report("x"), { id: "r2", why: "y" }),
166
+ ];
167
+ expect(duplicateRuleIds(rules)).toEqual([]);
168
+ });
169
+
170
+ test("reports every id used more than once, sorted", () => {
171
+ const rules = [
172
+ when<TestSymptom>(eq("status", "a"), report("x"), { id: "r2", why: "y" }),
173
+ when<TestSymptom>(eq("status", "b"), report("x"), { id: "r1", why: "y" }),
174
+ when<TestSymptom>(eq("status", "c"), report("x"), { id: "r1", why: "y" }),
175
+ when<TestSymptom>(eq("status", "d"), report("x"), { id: "r2", why: "y" }),
176
+ ];
177
+ expect(duplicateRuleIds(rules)).toEqual(["r1", "r2"]);
178
+ });
179
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Converge rules (#1484) — the typed, JSON-serializable predicate language a
3
+ * `ConvergeOp` rule table is built from.
4
+ *
5
+ * The issue's illustrative sketch writes a rule's condition as a JS arrow
6
+ * function (`s => s.status === "drifted"`). That reads well but can't
7
+ * actually ship: a rule is evaluated per-tick against live, freshly-observed
8
+ * data inside a Temporal activity, and an activity's arguments — including
9
+ * whatever a `ConvergeOp` composite bakes into its generated workflow — must
10
+ * be plain JSON (see `step-output-ref.ts`'s module doc on why a value
11
+ * crossing the workflow/activity boundary can't carry a closure). A rule
12
+ * "outside the evaluable subset" is one of the build-time refusals #1484
13
+ * names explicitly, which only makes sense if there IS an evaluable subset
14
+ * to be outside of — so rules here are data: a small comparison language
15
+ * over one typed symptom record, interpreted by {@link evaluatePredicate}
16
+ * (pure, no I/O, matching Accessible Ops factor I — "predictable from the
17
+ * text alone").
18
+ *
19
+ * `S` is the symptom record type a `ConvergeOp` instance evaluates against
20
+ * (`ConvergeSymptom`, ../lifecycle/symptoms.ts, for the real thing — kept
21
+ * generic here so the predicate language and its tests don't depend on the
22
+ * lifecycle package). Since a predicate's `field` is typed `keyof S`, a rule
23
+ * that names a field the symptom record doesn't have is refused by
24
+ * TypeScript itself at the author's call site — the "symptom field nothing
25
+ * produces" refusal, satisfied for free. {@link isWellFormedPredicate} is the
26
+ * runtime backstop for a rule assembled by hand (bypassing `when()`/the
27
+ * comparison builders) rather than authored through them, and is what a
28
+ * build-time check (TMP014) re-validates the serialized rule table against.
29
+ */
30
+
31
+ // ── Predicates ───────────────────────────────────────────────────────────────
32
+
33
+ /** Comparison operators over a single symptom field. */
34
+ export type FieldComparisonOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
35
+
36
+ /** Truthiness operators over a single symptom field — no `value` needed. */
37
+ export type FieldTruthinessOp = "truthy" | "falsy";
38
+
39
+ export interface FieldComparisonPredicate<S> {
40
+ kind: "field-comparison";
41
+ field: keyof S & string;
42
+ op: FieldComparisonOp;
43
+ value: string | number | boolean;
44
+ }
45
+
46
+ export interface FieldTruthinessPredicate<S> {
47
+ kind: "field-truthiness";
48
+ field: keyof S & string;
49
+ op: FieldTruthinessOp;
50
+ }
51
+
52
+ export interface AllOfPredicate<S> {
53
+ kind: "all-of";
54
+ predicates: SymptomPredicate<S>[];
55
+ }
56
+
57
+ export interface AnyOfPredicate<S> {
58
+ kind: "any-of";
59
+ predicates: SymptomPredicate<S>[];
60
+ }
61
+
62
+ /**
63
+ * A rule's `when` — the evaluable subset #1484 requires: field comparisons
64
+ * and truthiness checks over `S`, composed with `allOf`/`anyOf`. Every
65
+ * variant is plain JSON; there is deliberately no escape hatch to an
66
+ * arbitrary expression.
67
+ */
68
+ export type SymptomPredicate<S> =
69
+ | FieldComparisonPredicate<S>
70
+ | FieldTruthinessPredicate<S>
71
+ | AllOfPredicate<S>
72
+ | AnyOfPredicate<S>;
73
+
74
+ const FIELD_COMPARISON_OPS: ReadonlySet<string> = new Set(["eq", "neq", "gt", "gte", "lt", "lte"]);
75
+ const FIELD_TRUTHINESS_OPS: ReadonlySet<string> = new Set(["truthy", "falsy"]);
76
+
77
+ /**
78
+ * Structural validator for a value claiming to be a {@link SymptomPredicate}
79
+ * — the runtime backstop `TMP014` (lexicons/temporal's post-synth check)
80
+ * re-checks a serialized rule table against, since nothing forces a rule
81
+ * assembled by hand (rather than through the builders below) to match this
82
+ * shape. Recurses through `allOf`/`anyOf`; `fieldWhitelist`, when given,
83
+ * additionally refuses a field name the symptom record doesn't actually
84
+ * produce (defense in depth over TypeScript's `keyof S` check, which only
85
+ * applies at the author's original call site — not to a rule table replayed
86
+ * from JSON).
87
+ */
88
+ export function isWellFormedPredicate(value: unknown, fieldWhitelist?: ReadonlySet<string>): boolean {
89
+ if (typeof value !== "object" || value === null) return false;
90
+ const v = value as Record<string, unknown>;
91
+ switch (v.kind) {
92
+ case "field-comparison":
93
+ return (
94
+ typeof v.field === "string" &&
95
+ (!fieldWhitelist || fieldWhitelist.has(v.field)) &&
96
+ typeof v.op === "string" &&
97
+ FIELD_COMPARISON_OPS.has(v.op) &&
98
+ (typeof v.value === "string" || typeof v.value === "number" || typeof v.value === "boolean")
99
+ );
100
+ case "field-truthiness":
101
+ return (
102
+ typeof v.field === "string" &&
103
+ (!fieldWhitelist || fieldWhitelist.has(v.field)) &&
104
+ typeof v.op === "string" &&
105
+ FIELD_TRUTHINESS_OPS.has(v.op)
106
+ );
107
+ case "all-of":
108
+ case "any-of":
109
+ return Array.isArray(v.predicates) && v.predicates.every((p) => isWellFormedPredicate(p, fieldWhitelist));
110
+ default:
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /** Evaluate a {@link SymptomPredicate} against a symptom record. Pure — no I/O, no ambient reads. */
116
+ export function evaluatePredicate<S>(predicate: SymptomPredicate<S>, symptom: S): boolean {
117
+ switch (predicate.kind) {
118
+ case "field-comparison": {
119
+ const actual = symptom[predicate.field];
120
+ switch (predicate.op) {
121
+ case "eq":
122
+ return actual === predicate.value;
123
+ case "neq":
124
+ return actual !== predicate.value;
125
+ case "gt":
126
+ return typeof actual === "number" && typeof predicate.value === "number" && actual > predicate.value;
127
+ case "gte":
128
+ return typeof actual === "number" && typeof predicate.value === "number" && actual >= predicate.value;
129
+ case "lt":
130
+ return typeof actual === "number" && typeof predicate.value === "number" && actual < predicate.value;
131
+ case "lte":
132
+ return typeof actual === "number" && typeof predicate.value === "number" && actual <= predicate.value;
133
+ }
134
+ break;
135
+ }
136
+ case "field-truthiness": {
137
+ const actual = symptom[predicate.field];
138
+ return predicate.op === "truthy" ? !!actual : !actual;
139
+ }
140
+ case "all-of":
141
+ return predicate.predicates.every((p) => evaluatePredicate(p, symptom));
142
+ case "any-of":
143
+ return predicate.predicates.some((p) => evaluatePredicate(p, symptom));
144
+ }
145
+ // Unreachable for a well-formed predicate; a malformed one (bypassing the
146
+ // builders and isWellFormedPredicate) never matches rather than throwing
147
+ // mid-tick — the build-time check is what refuses it before this runs.
148
+ return false;
149
+ }
150
+
151
+ /**
152
+ * Does a predicate reference the given symptom field anywhere in it,
153
+ * recursing through `allOf`/`anyOf`? The build-time backstop for a claim
154
+ * about a *category* of rule (e.g. "a rule reading `adoptCount`") rather
155
+ * than a single comparison — `TMP014`'s adopt-safety check
156
+ * (../../../lexicons/temporal/src/lint/post-synth/tmp014-converge-rule-refusals.ts)
157
+ * uses this to refuse a rule that both reads `adoptCount` and dispatches a
158
+ * mutating op, rather than merely documenting that authors shouldn't write
159
+ * one.
160
+ */
161
+ export function predicateReferencesField<S>(predicate: SymptomPredicate<S>, field: string): boolean {
162
+ switch (predicate.kind) {
163
+ case "field-comparison":
164
+ case "field-truthiness":
165
+ return predicate.field === field;
166
+ case "all-of":
167
+ case "any-of":
168
+ return predicate.predicates.some((p) => predicateReferencesField(p, field));
169
+ }
170
+ }
171
+
172
+ // ── Predicate builders ──────────────────────────────────────────────────────
173
+
174
+ export function eq<S>(field: keyof S & string, value: string | number | boolean): FieldComparisonPredicate<S> {
175
+ return { kind: "field-comparison", field, op: "eq", value };
176
+ }
177
+ export function neq<S>(field: keyof S & string, value: string | number | boolean): FieldComparisonPredicate<S> {
178
+ return { kind: "field-comparison", field, op: "neq", value };
179
+ }
180
+ export function gt<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
181
+ return { kind: "field-comparison", field, op: "gt", value };
182
+ }
183
+ export function gte<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
184
+ return { kind: "field-comparison", field, op: "gte", value };
185
+ }
186
+ export function lt<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
187
+ return { kind: "field-comparison", field, op: "lt", value };
188
+ }
189
+ export function lte<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
190
+ return { kind: "field-comparison", field, op: "lte", value };
191
+ }
192
+ export function truthy<S>(field: keyof S & string): FieldTruthinessPredicate<S> {
193
+ return { kind: "field-truthiness", field, op: "truthy" };
194
+ }
195
+ export function falsy<S>(field: keyof S & string): FieldTruthinessPredicate<S> {
196
+ return { kind: "field-truthiness", field, op: "falsy" };
197
+ }
198
+ export function allOf<S>(...predicates: SymptomPredicate<S>[]): AllOfPredicate<S> {
199
+ return { kind: "all-of", predicates };
200
+ }
201
+ export function anyOf<S>(...predicates: SymptomPredicate<S>[]): AnyOfPredicate<S> {
202
+ return { kind: "any-of", predicates };
203
+ }
204
+
205
+ // ── Actions ──────────────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Dispatch a declared Op by name. `chant run <op>` — the existing local
209
+ * runner (../op's `runOpLocally` under the hood via the CLI) — takes no
210
+ * caller-supplied arguments today, so a dispatched Op reads whatever it
211
+ * needs from its own declared `env`/config rather than from this rule.
212
+ */
213
+ export interface RunAction {
214
+ kind: "run";
215
+ op: string;
216
+ }
217
+
218
+ /**
219
+ * Report only — never a mutation. The log-and-ledger channel is the only one
220
+ * v1 implements (issue open question 5): reusing `ReconcileOp`'s
221
+ * `onDrift: "pull-request" | "issue" | "report"` channels is "obviously
222
+ * right" per the issue, but wiring `pull-request`/`issue` through requires
223
+ * the same reconcile plumbing `ReconcileOp` already owns — out of scope for
224
+ * the smallest honest `ConvergeOp`. A future `channel` field can extend this
225
+ * without a shape change.
226
+ */
227
+ export interface ReportAction {
228
+ kind: "report";
229
+ reason: string;
230
+ }
231
+
232
+ export type RuleAction = RunAction | ReportAction;
233
+
234
+ export function run(op: string): RunAction {
235
+ return { kind: "run", op };
236
+ }
237
+
238
+ export function report(reason: string): ReportAction {
239
+ return { kind: "report", reason };
240
+ }
241
+
242
+ // ── Rules ────────────────────────────────────────────────────────────────────
243
+
244
+ /** A rule table's default flap-damping threshold (issue: "with a default"). */
245
+ export const DEFAULT_FLAP_THRESHOLD = 3;
246
+
247
+ /**
248
+ * One rule: a symptom predicate, the action to take when it matches, and its
249
+ * mandatory rationale. "Every rule carries its why" (Accessible Ops factor
250
+ * III) — `why` is a required field, not an optional one, and `when()` below
251
+ * throws at construction (build time — the same "throw in the factory" shape
252
+ * `ApplyOp`'s `compensate` refusal already uses) if it's missing or blank.
253
+ */
254
+ export interface ConvergeRule<S> {
255
+ /** Stable identifier — used for flap-damping counters, so it must not change across a rule's lifetime just because the table was reordered. */
256
+ id: string;
257
+ when: SymptomPredicate<S>;
258
+ then: RuleAction;
259
+ /** Why this rule exists — refused at build if blank. */
260
+ why: string;
261
+ /** Consecutive ticks the symptom may fire before this rule escalates to report-and-stop. @default {@link DEFAULT_FLAP_THRESHOLD} */
262
+ flapThreshold?: number;
263
+ }
264
+
265
+ /**
266
+ * Declare one converge rule. Throws immediately (build time — this runs
267
+ * while `chant build` evaluates the authoring `*.op.ts` file, exactly like
268
+ * `ApplyOp`'s `compensate` refusal) when `id` or `why` is missing or blank,
269
+ * or `flapThreshold` isn't a positive integer.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * when(eq("status", "drifted"), run("fountain-apply"), {
274
+ * id: "drift-apply",
275
+ * why: "Live config drifted from declared source; re-apply converges it back.",
276
+ * })
277
+ * ```
278
+ */
279
+ export function when<S>(
280
+ predicate: SymptomPredicate<S>,
281
+ action: RuleAction,
282
+ opts: { id: string; why: string; flapThreshold?: number },
283
+ ): ConvergeRule<S> {
284
+ if (!opts.id || opts.id.trim().length === 0) {
285
+ throw new Error("when(): a rule must have a non-empty `id` (used for flap-damping and diagnostics)");
286
+ }
287
+ if (!opts.why || opts.why.trim().length === 0) {
288
+ throw new Error(`when("${opts.id}"): a rule must carry its \`why\` — every rule's rationale is required, refused at build otherwise`);
289
+ }
290
+ if (opts.flapThreshold !== undefined && (!Number.isInteger(opts.flapThreshold) || opts.flapThreshold < 1)) {
291
+ throw new Error(`when("${opts.id}"): flapThreshold must be a positive integer`);
292
+ }
293
+ return {
294
+ id: opts.id,
295
+ when: predicate,
296
+ then: action,
297
+ why: opts.why,
298
+ ...(opts.flapThreshold !== undefined ? { flapThreshold: opts.flapThreshold } : {}),
299
+ };
300
+ }
301
+
302
+ /** Every rule id in a table must be unique — flap-damping counters and dispatch/report accounting are keyed by id. */
303
+ export function duplicateRuleIds<S>(rules: ConvergeRule<S>[]): string[] {
304
+ const seen = new Set<string>();
305
+ const dupes = new Set<string>();
306
+ for (const r of rules) {
307
+ if (seen.has(r.id)) dupes.add(r.id);
308
+ seen.add(r.id);
309
+ }
310
+ return [...dupes].sort();
311
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tests for `generateOpsPipeline` (#927) — generate mode's Op counterpart to
3
+ * `../components/cli-support.ts`'s `generateComponentsPipeline`. Mirrors that
4
+ * module's own test style (`cli-support.test.ts`): a minimal mocked lexicon
5
+ * plugin satisfying `isLexiconPlugin`, real `discoverOps()` resolution
6
+ * against this repo's actual `*.op.ts` fixtures (`examples/alb-deploy.op.ts`
7
+ * — see `./discover.test.ts`) so Op-name validation exercises the real
8
+ * discovery path rather than a stub.
9
+ */
10
+
11
+ import { describe, test, expect, vi } from "vitest";
12
+ import { generateOpsPipeline } from "./generate-pipeline";
13
+ import type { ScheduledOpSpec, OpPipelineResult } from "../lexicon";
14
+
15
+ vi.mock("@intentius/chant-lexicon-gitlab", () => ({
16
+ gitlab: {
17
+ name: "gitlab",
18
+ serializer: { name: "gitlab", rulePrefix: "GL", serialize: () => "" },
19
+ generate: () => {},
20
+ validate: () => [],
21
+ coverage: () => ({ total: 0, covered: 0 }),
22
+ package: () => "gitlab",
23
+ generateOpPipeline: (ops: ScheduledOpSpec[]): OpPipelineResult => ({
24
+ files: ops.map((o) => ({ name: `${o.name}.yml`, yaml: `# ${o.name} @ ${o.schedule}` })),
25
+ jobs: ops.map((o) => ({ jobName: o.name, op: o.name, schedule: o.schedule, findingMode: o.findingMode ?? "report" })),
26
+ }),
27
+ },
28
+ }));
29
+
30
+ describe("generateOpsPipeline", () => {
31
+ test("errors when the target lexicon has no generateOpPipeline", async () => {
32
+ const result = await generateOpsPipeline([{ name: "alb-deploy", schedule: "0 6 * * *" }], "aws");
33
+ expect(result.success).toBe(false);
34
+ expect(result.error).toMatch(/does not support Op generate mode/);
35
+ });
36
+
37
+ test("errors on an Op name that isn't discovered", async () => {
38
+ const result = await generateOpsPipeline(
39
+ [{ name: "definitely-not-a-real-op", schedule: "0 6 * * *" }],
40
+ "gitlab",
41
+ );
42
+ expect(result.success).toBe(false);
43
+ expect(result.error).toMatch(/Unknown Op\(s\): definitely-not-a-real-op/);
44
+ });
45
+
46
+ test("validates every named Op and delegates to the lexicon plugin", async () => {
47
+ const specs: ScheduledOpSpec[] = [{ name: "alb-deploy", schedule: "0 6 * * *", findingMode: "issue" }];
48
+ const result = await generateOpsPipeline(specs, "gitlab");
49
+ expect(result.success).toBe(true);
50
+ expect(result.files).toEqual([{ name: "alb-deploy.yml", yaml: "# alb-deploy @ 0 6 * * *" }]);
51
+ expect(result.jobs).toEqual([{ jobName: "alb-deploy", op: "alb-deploy", schedule: "0 6 * * *", findingMode: "issue" }]);
52
+ });
53
+ });