@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.
- 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/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.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 +14 -2
- 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/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 +93 -0
- package/dist/lexicon.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/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 +4 -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/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.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/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.ts +12 -4
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- 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 +14 -2
- 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/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 +103 -2
- 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/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 +4 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- 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 +45 -9
- 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/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { evaluateScenario } from "./scenario-eval";
|
|
3
|
+
import type { ChangeSet, ChangeSetEntry } from "./change-set";
|
|
4
|
+
|
|
5
|
+
function entry(overrides: Partial<ChangeSetEntry> & Pick<ChangeSetEntry, "name" | "action">): ChangeSetEntry {
|
|
6
|
+
return {
|
|
7
|
+
type: "AWS::S3::Bucket",
|
|
8
|
+
evidence: { declared: true, inSnapshot: true, live: true, observed: true },
|
|
9
|
+
ownership: "unknown",
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cs(entries: ChangeSetEntry[], env = "prod"): ChangeSet {
|
|
15
|
+
return { env, entries };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("evaluateScenario — noop", () => {
|
|
19
|
+
test("passes when the plan proposes nothing", () => {
|
|
20
|
+
const verdict = evaluateScenario(cs([entry({ name: "a", action: "noop" })]), { noop: true });
|
|
21
|
+
expect(verdict.pass).toBe(true);
|
|
22
|
+
expect(verdict.checks).toEqual([{ clause: "noop", pass: true }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("fails and names the offending resource when the plan proposes a create", () => {
|
|
26
|
+
const verdict = evaluateScenario(cs([entry({ name: "newBucket", action: "create" })]), { noop: true });
|
|
27
|
+
expect(verdict.pass).toBe(false);
|
|
28
|
+
const check = verdict.checks.find((c) => c.clause === "noop")!;
|
|
29
|
+
expect(check.pass).toBe(false);
|
|
30
|
+
expect(check.detail).toMatch(/1 create/);
|
|
31
|
+
expect(check.detail).toMatch(/newBucket/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("fails on an update", () => {
|
|
35
|
+
const verdict = evaluateScenario(
|
|
36
|
+
cs([entry({ name: "web", action: "update", deltas: [{ path: "size", oldValue: 1, newValue: 2 }] })]),
|
|
37
|
+
{ noop: true },
|
|
38
|
+
);
|
|
39
|
+
expect(verdict.pass).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("fails on a delete", () => {
|
|
43
|
+
const verdict = evaluateScenario(cs([entry({ name: "legacy", action: "delete", ownership: "owned" })]), {
|
|
44
|
+
noop: true,
|
|
45
|
+
});
|
|
46
|
+
expect(verdict.pass).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ignores adopt/runtime/unobserved rows", () => {
|
|
50
|
+
const verdict = evaluateScenario(
|
|
51
|
+
cs([
|
|
52
|
+
entry({ name: "orphan", action: "adopt" }),
|
|
53
|
+
entry({ name: "child", action: "runtime", runtimeOwner: "deployment" }),
|
|
54
|
+
]),
|
|
55
|
+
{ noop: true },
|
|
56
|
+
);
|
|
57
|
+
expect(verdict.pass).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("evaluateScenario — counts", () => {
|
|
62
|
+
test("passes on an exact match across all three", () => {
|
|
63
|
+
const verdict = evaluateScenario(
|
|
64
|
+
cs([
|
|
65
|
+
entry({ name: "a", action: "create" }),
|
|
66
|
+
entry({ name: "b", action: "update" }),
|
|
67
|
+
entry({ name: "c", action: "delete", ownership: "owned" }),
|
|
68
|
+
]),
|
|
69
|
+
{ create: 1, update: 1, delete: 1 },
|
|
70
|
+
);
|
|
71
|
+
expect(verdict.pass).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("fails and reports the mismatched clause only", () => {
|
|
75
|
+
const verdict = evaluateScenario(cs([entry({ name: "a", action: "create" }), entry({ name: "b", action: "create" })]), {
|
|
76
|
+
create: 1,
|
|
77
|
+
});
|
|
78
|
+
expect(verdict.pass).toBe(false);
|
|
79
|
+
const check = verdict.checks.find((c) => c.clause === "create")!;
|
|
80
|
+
expect(check.detail).toMatch(/expected 1 create, plan proposes 2/);
|
|
81
|
+
expect(check.detail).toMatch(/a.*b|b.*a/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("an omitted clause is unconstrained", () => {
|
|
85
|
+
const verdict = evaluateScenario(cs([entry({ name: "a", action: "update" })]), { create: 0 });
|
|
86
|
+
expect(verdict.pass).toBe(true);
|
|
87
|
+
expect(verdict.checks.map((c) => c.clause)).toEqual(["create"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("zero is a real assertion, not 'omitted'", () => {
|
|
91
|
+
const verdict = evaluateScenario(cs([entry({ name: "a", action: "delete", ownership: "owned" })]), { delete: 0 });
|
|
92
|
+
expect(verdict.pass).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("evaluateScenario — deletes", () => {
|
|
97
|
+
test("passes when every named delete matches name and ownership exactly, with no extras", () => {
|
|
98
|
+
const verdict = evaluateScenario(cs([entry({ name: "legacy", action: "delete", ownership: "owned" })]), {
|
|
99
|
+
deletes: [{ name: "legacy", ownership: "owned" }],
|
|
100
|
+
});
|
|
101
|
+
expect(verdict.pass).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("fails and names the resource when an expected delete is not proposed", () => {
|
|
105
|
+
const verdict = evaluateScenario(cs([]), { deletes: [{ name: "legacy", ownership: "owned" }] });
|
|
106
|
+
expect(verdict.pass).toBe(false);
|
|
107
|
+
const check = verdict.checks.find((c) => c.clause === "deletes")!;
|
|
108
|
+
expect(check.detail).toMatch(/"legacy"/);
|
|
109
|
+
expect(check.detail).toMatch(/not proposed/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("fails and names the resource on an ownership mismatch", () => {
|
|
113
|
+
const verdict = evaluateScenario(cs([entry({ name: "legacy", action: "delete", ownership: "foreign" })]), {
|
|
114
|
+
deletes: [{ name: "legacy", ownership: "owned" }],
|
|
115
|
+
});
|
|
116
|
+
expect(verdict.pass).toBe(false);
|
|
117
|
+
const check = verdict.checks.find((c) => c.clause === "deletes")!;
|
|
118
|
+
expect(check.detail).toMatch(/"legacy"/);
|
|
119
|
+
expect(check.detail).toMatch(/expected ownership "owned", plan proposes ownership "foreign"/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fails and names the resource on an unexpected extra delete", () => {
|
|
123
|
+
const verdict = evaluateScenario(
|
|
124
|
+
cs([
|
|
125
|
+
entry({ name: "legacy", action: "delete", ownership: "owned" }),
|
|
126
|
+
entry({ name: "surprise", action: "delete", ownership: "owned" }),
|
|
127
|
+
]),
|
|
128
|
+
{ deletes: [{ name: "legacy", ownership: "owned" }] },
|
|
129
|
+
);
|
|
130
|
+
expect(verdict.pass).toBe(false);
|
|
131
|
+
const check = verdict.checks.find((c) => c.clause === "deletes")!;
|
|
132
|
+
expect(check.detail).toMatch(/"surprise"/);
|
|
133
|
+
expect(check.detail).toMatch(/not expected/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("evaluateScenario — unobserved", () => {
|
|
138
|
+
test("\"refuse\" fails on any unobserved row", () => {
|
|
139
|
+
const verdict = evaluateScenario(
|
|
140
|
+
cs([entry({ name: "web", action: "unobserved", unobservedReason: "no-credentials" })]),
|
|
141
|
+
{ unobserved: "refuse" },
|
|
142
|
+
);
|
|
143
|
+
expect(verdict.pass).toBe(false);
|
|
144
|
+
const check = verdict.checks.find((c) => c.clause === "unobserved")!;
|
|
145
|
+
expect(check.detail).toMatch(/web/);
|
|
146
|
+
expect(check.detail).toMatch(/no credentials/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("\"refuse\" passes when nothing is unobserved", () => {
|
|
150
|
+
const verdict = evaluateScenario(cs([entry({ name: "web", action: "noop" })]), { unobserved: "refuse" });
|
|
151
|
+
expect(verdict.pass).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("{ allow } tolerates only the named entities", () => {
|
|
155
|
+
const verdict = evaluateScenario(
|
|
156
|
+
cs([
|
|
157
|
+
entry({ name: "web", action: "unobserved", unobservedReason: "no-binding" }),
|
|
158
|
+
entry({ name: "db", action: "unobserved", unobservedReason: "no-binding" }),
|
|
159
|
+
]),
|
|
160
|
+
{ unobserved: { allow: ["web"] } },
|
|
161
|
+
);
|
|
162
|
+
expect(verdict.pass).toBe(false);
|
|
163
|
+
const check = verdict.checks.find((c) => c.clause === "unobserved")!;
|
|
164
|
+
expect(check.detail).toMatch(/db/);
|
|
165
|
+
expect(check.detail).not.toMatch(/web/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("{ allow } passes when every unobserved entity is named", () => {
|
|
169
|
+
const verdict = evaluateScenario(
|
|
170
|
+
cs([entry({ name: "web", action: "unobserved", unobservedReason: "no-binding" })]),
|
|
171
|
+
{ unobserved: { allow: ["web"] } },
|
|
172
|
+
);
|
|
173
|
+
expect(verdict.pass).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("omitted is unconstrained — unobserved rows neither pass nor fail it", () => {
|
|
177
|
+
const verdict = evaluateScenario(
|
|
178
|
+
cs([entry({ name: "web", action: "unobserved", unobservedReason: "read-failed" })]),
|
|
179
|
+
{ create: 0 },
|
|
180
|
+
);
|
|
181
|
+
expect(verdict.pass).toBe(true);
|
|
182
|
+
expect(verdict.checks.map((c) => c.clause)).toEqual(["create"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("evaluateScenario — composed clauses", () => {
|
|
187
|
+
test("every clause is evaluated and reported, even when only one fails", () => {
|
|
188
|
+
const verdict = evaluateScenario(
|
|
189
|
+
cs([
|
|
190
|
+
entry({ name: "legacy", action: "delete", ownership: "owned" }),
|
|
191
|
+
entry({ name: "web", action: "unobserved", unobservedReason: "read-failed" }),
|
|
192
|
+
]),
|
|
193
|
+
{ create: 0, deletes: [{ name: "legacy", ownership: "owned" }], unobserved: "refuse" },
|
|
194
|
+
);
|
|
195
|
+
expect(verdict.pass).toBe(false);
|
|
196
|
+
const byClause = Object.fromEntries(verdict.checks.map((c) => [c.clause, c.pass]));
|
|
197
|
+
expect(byClause).toEqual({ create: true, deletes: true, unobserved: false });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure evaluation of a plan scenario's `expect` clause against a `ChangeSet`
|
|
3
|
+
* (#1292).
|
|
4
|
+
*
|
|
5
|
+
* `evaluateScenario` reads only what the caller hands it — a `ChangeSet` and
|
|
6
|
+
* a `ScenarioExpect` — and does no I/O. Building the change set (reading a
|
|
7
|
+
* fixture, replaying it in place of a live observation) is the CLI handler's
|
|
8
|
+
* job (../cli/handlers/scenario.ts); this module is the part that stays
|
|
9
|
+
* testable without a filesystem, a build, or a fixture.
|
|
10
|
+
*
|
|
11
|
+
* Built on the same primitives `chant lifecycle plan` already computes from:
|
|
12
|
+
* `summarize` for counts, a filter over `cs.entries` for named deletes and
|
|
13
|
+
* ownership, and `evaluateUnobservedGate` (./unobserved-gate.ts, #1568) for
|
|
14
|
+
* the unobserved clause.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { summarize, type ChangeSet } from "./change-set";
|
|
18
|
+
import { evaluateUnobservedGate, type UnobservedGateFinding } from "./unobserved-gate";
|
|
19
|
+
import { unobservedReasonText } from "../observation";
|
|
20
|
+
import type { ScenarioDeleteExpectation, ScenarioExpect, ScenarioUnobservedPolicy } from "./scenario";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One clause's verdict — always present in {@link ScenarioVerdict.checks}, in
|
|
24
|
+
* declaration order, whether it passed or not. A scenario with no failing
|
|
25
|
+
* clauses is legible too: every check that ran, and that it held.
|
|
26
|
+
*/
|
|
27
|
+
export interface ScenarioCheckResult {
|
|
28
|
+
/** Which `expect` clause this is (`"noop"`, `"create"`, `"deletes"`, …). */
|
|
29
|
+
clause: string;
|
|
30
|
+
pass: boolean;
|
|
31
|
+
/** Present when `pass` is false — what was expected vs what the plan proposes, naming resources for delete/ownership failures. */
|
|
32
|
+
detail?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The result of evaluating one scenario's `expect` against a change set. */
|
|
36
|
+
export interface ScenarioVerdict {
|
|
37
|
+
pass: boolean;
|
|
38
|
+
/** Every clause `expect` declared, evaluated — see {@link ScenarioCheckResult}. */
|
|
39
|
+
checks: ScenarioCheckResult[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Evaluate `expect` against `cs`. Pure: reads `cs` and `expect`, computes
|
|
44
|
+
* nothing else. Every clause present on `expect` is checked independently and
|
|
45
|
+
* every one contributes to `checks`; `pass` is true only when all of them are.
|
|
46
|
+
*/
|
|
47
|
+
export function evaluateScenario(cs: ChangeSet, expect: ScenarioExpect): ScenarioVerdict {
|
|
48
|
+
const counts = summarize(cs);
|
|
49
|
+
const checks: ScenarioCheckResult[] = [];
|
|
50
|
+
|
|
51
|
+
if (expect.noop) {
|
|
52
|
+
// An effect receipt's fire (#1832) is a real proposed action — a
|
|
53
|
+
// migration or seed job about to run — even though it never counts
|
|
54
|
+
// toward the create/update/delete triad `buildChangeSet` classifies
|
|
55
|
+
// ordinary resources into. `noop: true` claims the plan does nothing;
|
|
56
|
+
// a pending effect fire is something, so it offends the claim exactly
|
|
57
|
+
// like a create/update/delete would.
|
|
58
|
+
const offenders = { create: counts.create, update: counts.update, delete: counts.delete, effect: counts.effect };
|
|
59
|
+
const clean = offenders.create === 0 && offenders.update === 0 && offenders.delete === 0 && offenders.effect === 0;
|
|
60
|
+
checks.push({
|
|
61
|
+
clause: "noop",
|
|
62
|
+
pass: clean,
|
|
63
|
+
...(clean
|
|
64
|
+
? {}
|
|
65
|
+
: {
|
|
66
|
+
detail:
|
|
67
|
+
`expected noop (no create/update/delete/effect) but the plan proposes ` +
|
|
68
|
+
`${offenders.create} create, ${offenders.update} update, ${offenders.delete} delete, ${offenders.effect} effect — ` +
|
|
69
|
+
nameList(cs, ["create", "update", "delete", "effect"]),
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const clause of ["create", "update", "delete"] as const) {
|
|
75
|
+
const want = expect[clause];
|
|
76
|
+
if (want === undefined) continue;
|
|
77
|
+
const got = counts[clause];
|
|
78
|
+
const pass = got === want;
|
|
79
|
+
checks.push({
|
|
80
|
+
clause,
|
|
81
|
+
pass,
|
|
82
|
+
...(pass ? {} : { detail: `expected ${want} ${clause}, plan proposes ${got} — ${nameList(cs, [clause])}` }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (expect.deletes) {
|
|
87
|
+
checks.push(evaluateDeletesClause(cs, expect.deletes));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (expect.unobserved !== undefined) {
|
|
91
|
+
checks.push(evaluateUnobservedClause(cs, expect.unobserved));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { pass: checks.every((c) => c.pass), checks };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Names (with type) of every entry matching one of `actions`, for a legible failure message. */
|
|
98
|
+
function nameList(cs: ChangeSet, actions: readonly string[]): string {
|
|
99
|
+
const names = cs.entries
|
|
100
|
+
.filter((e) => (actions as readonly string[]).includes(e.action))
|
|
101
|
+
.map((e) => `${e.name}${e.type ? ` (${e.type})` : ""} [${e.action}]`);
|
|
102
|
+
return names.length === 0 ? "(no matching entries)" : names.join(", ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function evaluateDeletesClause(
|
|
106
|
+
cs: ChangeSet,
|
|
107
|
+
wanted: readonly ScenarioDeleteExpectation[],
|
|
108
|
+
): ScenarioCheckResult {
|
|
109
|
+
const actualDeletes = cs.entries.filter((e) => e.action === "delete");
|
|
110
|
+
const actualByName = new Map(actualDeletes.map((e) => [e.name, e] as const));
|
|
111
|
+
const wantedNames = new Set(wanted.map((w) => w.name));
|
|
112
|
+
const problems: string[] = [];
|
|
113
|
+
|
|
114
|
+
for (const want of wanted) {
|
|
115
|
+
const match = actualByName.get(want.name);
|
|
116
|
+
if (!match) {
|
|
117
|
+
problems.push(`"${want.name}" (ownership ${want.ownership}) — not proposed for delete`);
|
|
118
|
+
} else if (match.ownership !== want.ownership) {
|
|
119
|
+
problems.push(
|
|
120
|
+
`"${want.name}" — expected ownership "${want.ownership}", plan proposes ownership "${match.ownership}"`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const e of actualDeletes) {
|
|
125
|
+
if (!wantedNames.has(e.name)) {
|
|
126
|
+
problems.push(`"${e.name}"${e.type ? ` (${e.type})` : ""} [${e.ownership}] — deleted but not expected`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
clause: "deletes",
|
|
132
|
+
pass: problems.length === 0,
|
|
133
|
+
...(problems.length > 0 ? { detail: problems.join("; ") } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function evaluateUnobservedClause(cs: ChangeSet, policy: ScenarioUnobservedPolicy): ScenarioCheckResult {
|
|
138
|
+
// evaluateUnobservedGate enumerates every unobserved row regardless of
|
|
139
|
+
// reason — "refuse" collects the full set to check against below. A
|
|
140
|
+
// scenario's allow list names ENTITIES, not reasons (./scenario.ts's
|
|
141
|
+
// ScenarioUnobservedPolicy doc explains why), so the gate's own reason-keyed
|
|
142
|
+
// policy isn't reused for the pass/fail decision — only for the enumeration.
|
|
143
|
+
const gate = evaluateUnobservedGate(cs, "refuse");
|
|
144
|
+
const allowedNames = typeof policy === "object" ? new Set(policy.allow) : undefined;
|
|
145
|
+
const unallowed = gate.findings.filter((f: UnobservedGateFinding) => !allowedNames?.has(f.name));
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
clause: "unobserved",
|
|
149
|
+
pass: unallowed.length === 0,
|
|
150
|
+
...(unallowed.length > 0
|
|
151
|
+
? {
|
|
152
|
+
detail: unallowed
|
|
153
|
+
.map((f) => `${f.name}${f.type ? ` (${f.type})` : ""} — ${unobservedReasonText(f.reason)}${f.detail ? `: ${f.detail}` : ""}`)
|
|
154
|
+
.join(", "),
|
|
155
|
+
}
|
|
156
|
+
: {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
Scenario,
|
|
4
|
+
snapshot,
|
|
5
|
+
isScenario,
|
|
6
|
+
collectScenarios,
|
|
7
|
+
SCENARIO_MARKER,
|
|
8
|
+
SCENARIO_ENTITY_TYPE,
|
|
9
|
+
} from "./scenario";
|
|
10
|
+
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
11
|
+
import { partitionByLexicon } from "../build";
|
|
12
|
+
|
|
13
|
+
describe("snapshot()", () => {
|
|
14
|
+
test("classifies a slash-containing string as a file", () => {
|
|
15
|
+
expect(snapshot("fixtures/prod-baseline.json")).toEqual({ kind: "file", path: "fixtures/prod-baseline.json" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("classifies a .json-suffixed string as a file even without a slash", () => {
|
|
19
|
+
expect(snapshot("baseline.json")).toEqual({ kind: "file", path: "baseline.json" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("classifies a bare token as an environment name", () => {
|
|
23
|
+
expect(snapshot("prod")).toEqual({ kind: "env", env: "prod" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects an empty string", () => {
|
|
27
|
+
expect(() => snapshot("")).toThrow(/non-empty/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("Scenario()", () => {
|
|
32
|
+
test("builds a Declarable-shaped, marked declaration", () => {
|
|
33
|
+
const s = Scenario("plan-neutral refactor", {
|
|
34
|
+
given: snapshot("fixtures/prod-baseline.json"),
|
|
35
|
+
expect: { noop: true },
|
|
36
|
+
});
|
|
37
|
+
expect(s[DECLARABLE_MARKER]).toBe(true);
|
|
38
|
+
expect(s[SCENARIO_MARKER]).toBe(true);
|
|
39
|
+
expect(s.entityType).toBe(SCENARIO_ENTITY_TYPE);
|
|
40
|
+
expect(s.lexicon).toBe("chant");
|
|
41
|
+
expect(s.name).toBe("plan-neutral refactor");
|
|
42
|
+
expect(s.given).toEqual({ kind: "file", path: "fixtures/prod-baseline.json" });
|
|
43
|
+
expect(s.expect).toEqual({ noop: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("isScenario recognizes it and rejects ordinary declarables", () => {
|
|
47
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { noop: true } });
|
|
48
|
+
expect(isScenario(s)).toBe(true);
|
|
49
|
+
expect(isScenario({})).toBe(false);
|
|
50
|
+
expect(isScenario(null)).toBe(false);
|
|
51
|
+
const other: Declarable = { [DECLARABLE_MARKER]: true, lexicon: "aws", entityType: "AWS::S3::Bucket" };
|
|
52
|
+
expect(isScenario(other)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("declared fields are locked but the object stays extensible", () => {
|
|
56
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { noop: true } });
|
|
57
|
+
expect(() => {
|
|
58
|
+
(s as { name: string }).name = "renamed";
|
|
59
|
+
}).toThrow();
|
|
60
|
+
// Discovery stamps its own symbol-keyed metadata — the object must accept it.
|
|
61
|
+
const extra = Symbol("chant.discovery.logical-name");
|
|
62
|
+
expect(() => {
|
|
63
|
+
(s as unknown as Record<symbol, unknown>)[extra] = "s";
|
|
64
|
+
}).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("nested given/expect structures are frozen", () => {
|
|
68
|
+
const s = Scenario("s", {
|
|
69
|
+
given: snapshot("fixtures/x.json"),
|
|
70
|
+
expect: { deletes: [{ name: "legacy", ownership: "owned" }] },
|
|
71
|
+
});
|
|
72
|
+
expect(Object.isFrozen(s.given)).toBe(true);
|
|
73
|
+
expect(Object.isFrozen(s.expect)).toBe(true);
|
|
74
|
+
expect(Object.isFrozen(s.expect.deletes)).toBe(true);
|
|
75
|
+
expect(Object.isFrozen(s.expect.deletes![0])).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects an empty name", () => {
|
|
79
|
+
expect(() => Scenario("", { given: snapshot("prod"), expect: { noop: true } })).toThrow(/non-empty/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("rejects a missing given", () => {
|
|
83
|
+
// @ts-expect-error deliberately omitting given
|
|
84
|
+
expect(() => Scenario("s", { expect: { noop: true } })).toThrow(/given/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects an empty expect", () => {
|
|
88
|
+
expect(() => Scenario("s", { given: snapshot("prod"), expect: {} })).toThrow(/at least one clause/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects an unknown expect clause", () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
// @ts-expect-error deliberately passing an unrecognized clause
|
|
94
|
+
Scenario("s", { given: snapshot("prod"), expect: { bogus: true } }),
|
|
95
|
+
).toThrow(/unknown `expect` clause/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects noop: false (not a valid literal)", () => {
|
|
99
|
+
expect(() =>
|
|
100
|
+
// @ts-expect-error deliberately passing noop: false
|
|
101
|
+
Scenario("s", { given: snapshot("prod"), expect: { noop: false } }),
|
|
102
|
+
).toThrow(/must be exactly `true`/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rejects a negative count", () => {
|
|
106
|
+
expect(() => Scenario("s", { given: snapshot("prod"), expect: { create: -1 } })).toThrow(/non-negative integer/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects a non-integer count", () => {
|
|
110
|
+
expect(() => Scenario("s", { given: snapshot("prod"), expect: { update: 1.5 } })).toThrow(/non-negative integer/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("accepts create/update/delete counts together", () => {
|
|
114
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { create: 0, update: 1, delete: 2 } });
|
|
115
|
+
expect(s.expect).toEqual({ create: 0, update: 1, delete: 2 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rejects a deletes entry with a bad ownership value", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
Scenario("s", {
|
|
121
|
+
given: snapshot("prod"),
|
|
122
|
+
// @ts-expect-error deliberately passing an invalid ownership
|
|
123
|
+
expect: { deletes: [{ name: "legacy", ownership: "nope" }] },
|
|
124
|
+
}),
|
|
125
|
+
).toThrow(/ownership/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("rejects a deletes entry with a missing name", () => {
|
|
129
|
+
expect(() =>
|
|
130
|
+
Scenario("s", {
|
|
131
|
+
given: snapshot("prod"),
|
|
132
|
+
// @ts-expect-error deliberately omitting name
|
|
133
|
+
expect: { deletes: [{ ownership: "owned" }] },
|
|
134
|
+
}),
|
|
135
|
+
).toThrow(/name/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("accepts unobserved: \"refuse\"", () => {
|
|
139
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { unobserved: "refuse" } });
|
|
140
|
+
expect(s.expect.unobserved).toBe("refuse");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("accepts unobserved: { allow: [names] }", () => {
|
|
144
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { unobserved: { allow: ["legacy-bucket"] } } });
|
|
145
|
+
expect(s.expect.unobserved).toEqual({ allow: ["legacy-bucket"] });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("rejects a malformed unobserved policy", () => {
|
|
149
|
+
expect(() =>
|
|
150
|
+
// @ts-expect-error deliberately passing a bad policy shape
|
|
151
|
+
Scenario("s", { given: snapshot("prod"), expect: { unobserved: "nope" } }),
|
|
152
|
+
).toThrow(/unobserved/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("collectScenarios()", () => {
|
|
157
|
+
test("extracts only scenario declarations, keyed by entity name", () => {
|
|
158
|
+
const s1 = Scenario("s1", { given: snapshot("prod"), expect: { noop: true } });
|
|
159
|
+
const s2 = Scenario("s2", { given: snapshot("staging"), expect: { create: 0 } });
|
|
160
|
+
const other: Declarable = { [DECLARABLE_MARKER]: true, lexicon: "aws", entityType: "AWS::S3::Bucket" };
|
|
161
|
+
const entities = new Map<string, Declarable>([
|
|
162
|
+
["scenarioOne", s1],
|
|
163
|
+
["scenarioTwo", s2],
|
|
164
|
+
["bucket", other],
|
|
165
|
+
]);
|
|
166
|
+
const found = collectScenarios(entities);
|
|
167
|
+
expect(found.size).toBe(2);
|
|
168
|
+
expect(found.get("scenarioOne")).toBe(s1);
|
|
169
|
+
expect(found.get("scenarioTwo")).toBe(s2);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("serializer exclusion", () => {
|
|
174
|
+
test("partitionByLexicon excludes scenarios from every lexicon partition", () => {
|
|
175
|
+
const s = Scenario("s", { given: snapshot("prod"), expect: { noop: true } });
|
|
176
|
+
const bucket: Declarable = {
|
|
177
|
+
[DECLARABLE_MARKER]: true,
|
|
178
|
+
lexicon: "aws",
|
|
179
|
+
entityType: "AWS::S3::Bucket",
|
|
180
|
+
kind: "resource",
|
|
181
|
+
};
|
|
182
|
+
const entities = new Map<string, Declarable>([
|
|
183
|
+
["myScenario", s],
|
|
184
|
+
["bucket", bucket],
|
|
185
|
+
]);
|
|
186
|
+
const partitions = partitionByLexicon(entities);
|
|
187
|
+
expect(partitions.has("chant")).toBe(false);
|
|
188
|
+
for (const [, group] of partitions) {
|
|
189
|
+
for (const [, entity] of group) {
|
|
190
|
+
expect(isScenario(entity)).toBe(false);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
expect(partitions.get("aws")?.has("bucket")).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|