@intentius/chant 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +47 -3
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +134 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +17 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +6 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +7 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +30 -5
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +144 -0
- package/src/cli/handlers/run.ts +40 -18
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +81 -1
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +47 -3
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/run-progress.ts +9 -5
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +154 -2
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.ts +35 -3
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +6 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +52 -10
- package/src/op/local-output.ts +1 -1
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
- package/src/toml.test.ts +157 -384
- 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
|
-
|
|
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,
|
package/src/op/builders.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
):
|
|
42
|
-
|
|
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
|
+
});
|