@intentius/chant 0.1.7 → 0.1.9
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/package.json +1 -1
- package/src/cli/commands/build.test.ts +58 -5
- package/src/cli/commands/build.ts +7 -3
- package/src/cli/handlers/graph.test.ts +91 -0
- package/src/cli/handlers/run.test.ts +448 -0
- package/src/cli/handlers/state.test.ts +409 -0
- package/src/cli/handlers/state.ts +232 -10
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/mcp/tools/search.ts +6 -1
- package/src/cli/registry.ts +1 -0
- package/src/lexicon-plugin-helpers.ts +13 -5
- package/src/lexicon.ts +57 -1
- package/src/lint/config.test.ts +21 -0
- package/src/lint/config.ts +19 -3
- package/src/lint/rule-loader.test.ts +28 -0
- package/src/lint/rule-loader.ts +41 -8
- package/src/op/types.ts +13 -0
- package/src/state/digest.test.ts +117 -0
- package/src/state/git.test.ts +191 -0
- package/src/state/git.ts +63 -11
- package/src/state/live-diff.test.ts +184 -0
- package/src/state/live-diff.ts +215 -0
- package/src/state/snapshot.test.ts +171 -0
- package/src/state/snapshot.ts +39 -19
- package/src/state/types.ts +4 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
3
|
+
import type { BuildResult } from "../build";
|
|
4
|
+
|
|
5
|
+
const writeSnapshotMock = vi.fn();
|
|
6
|
+
const getHeadCommitMock = vi.fn();
|
|
7
|
+
const pushStateMock = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("./git", () => ({
|
|
10
|
+
writeSnapshot: (...args: unknown[]) => writeSnapshotMock(...args),
|
|
11
|
+
getHeadCommit: () => getHeadCommitMock(),
|
|
12
|
+
pushState: () => pushStateMock(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const { takeSnapshot } = await import("./snapshot");
|
|
16
|
+
|
|
17
|
+
function makeBuildResult(entitiesByLexicon: Record<string, string[]>): BuildResult {
|
|
18
|
+
const entities = new Map();
|
|
19
|
+
for (const [lexicon, names] of Object.entries(entitiesByLexicon)) {
|
|
20
|
+
for (const name of names) entities.set(name, { lexicon, entityType: `${lexicon}::Mock`, props: {} });
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
outputs: new Map(Object.keys(entitiesByLexicon).map((l) => [l, "{}"])),
|
|
24
|
+
entities,
|
|
25
|
+
dependencies: new Map(),
|
|
26
|
+
errors: [],
|
|
27
|
+
warnings: [],
|
|
28
|
+
manifest: { lexicons: Object.keys(entitiesByLexicon), outputs: {}, deployOrder: [] },
|
|
29
|
+
sourceFileCount: 1,
|
|
30
|
+
} as unknown as BuildResult;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("takeSnapshot", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
writeSnapshotMock.mockReset();
|
|
36
|
+
getHeadCommitMock.mockReset();
|
|
37
|
+
pushStateMock.mockReset();
|
|
38
|
+
writeSnapshotMock.mockResolvedValue("commit-sha");
|
|
39
|
+
getHeadCommitMock.mockResolvedValue("head-sha");
|
|
40
|
+
pushStateMock.mockResolvedValue(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("happy path: writes snapshot per plugin with describeResources", async () => {
|
|
44
|
+
const plugin = createMockPlugin({
|
|
45
|
+
name: "aws",
|
|
46
|
+
describeResources: staticDescribeResources({
|
|
47
|
+
bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "bucket-1" },
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
|
|
51
|
+
expect(result.snapshots).toHaveLength(1);
|
|
52
|
+
expect(result.snapshots[0]).toMatchObject({
|
|
53
|
+
lexicon: "aws",
|
|
54
|
+
environment: "prod",
|
|
55
|
+
commit: "head-sha",
|
|
56
|
+
resources: { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE" } },
|
|
57
|
+
});
|
|
58
|
+
expect(writeSnapshotMock).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("plugin without describeResources is skipped", async () => {
|
|
63
|
+
const plugin = createMockPlugin({ name: "aws" });
|
|
64
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["x"] }));
|
|
65
|
+
expect(result.snapshots).toEqual([]);
|
|
66
|
+
expect(writeSnapshotMock).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("plugin throws → captured as error, other plugins still proceed", async () => {
|
|
70
|
+
const broken = createMockPlugin({
|
|
71
|
+
name: "broken",
|
|
72
|
+
describeResources: async () => { throw new Error("boom"); },
|
|
73
|
+
});
|
|
74
|
+
const ok = createMockPlugin({
|
|
75
|
+
name: "ok",
|
|
76
|
+
describeResources: staticDescribeResources({ x: { type: "T", status: "OK" } }),
|
|
77
|
+
});
|
|
78
|
+
const result = await takeSnapshot("prod", [broken, ok], makeBuildResult({ broken: ["b"], ok: ["x"] }));
|
|
79
|
+
expect(result.errors.some((e) => e.includes("broken") && e.includes("boom"))).toBe(true);
|
|
80
|
+
expect(result.snapshots.map((s) => s.lexicon)).toEqual(["ok"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("plugin returns no valid resources → error and no snapshot", async () => {
|
|
84
|
+
const plugin = createMockPlugin({
|
|
85
|
+
name: "aws",
|
|
86
|
+
describeResources: async () => ({}), // empty
|
|
87
|
+
});
|
|
88
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: [] }));
|
|
89
|
+
expect(result.snapshots).toEqual([]);
|
|
90
|
+
expect(result.errors.some((e) => e.includes("aws") && e.includes("no valid"))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("resources missing required type/status are dropped with warning", async () => {
|
|
94
|
+
const plugin = createMockPlugin({
|
|
95
|
+
name: "aws",
|
|
96
|
+
describeResources: staticDescribeResources({
|
|
97
|
+
valid: { type: "T", status: "OK" },
|
|
98
|
+
bad: { type: "", status: "OK" } as never,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["valid"] }));
|
|
102
|
+
expect(result.snapshots[0].resources).toEqual({ valid: { type: "T", status: "OK" } });
|
|
103
|
+
expect(result.warnings.some((w) => w.includes("Dropped bad"))).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("emits sensitive-data warnings for suspect attribute names", async () => {
|
|
107
|
+
const plugin = createMockPlugin({
|
|
108
|
+
name: "aws",
|
|
109
|
+
describeResources: staticDescribeResources({
|
|
110
|
+
cred: {
|
|
111
|
+
type: "T",
|
|
112
|
+
status: "OK",
|
|
113
|
+
attributes: { connectionString: "redacted", regularAttr: "x" },
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["cred"] }));
|
|
118
|
+
expect(result.warnings.some((w) => w.toLowerCase().includes("sensitive"))).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── listArtifacts() integration (#51) ─────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
test("calls listArtifacts when implemented and stores artifacts in snapshot", async () => {
|
|
124
|
+
const plugin = createMockPlugin({
|
|
125
|
+
name: "helm",
|
|
126
|
+
listArtifacts: staticListArtifacts({
|
|
127
|
+
"release/default/web": { type: "Helm::Release", physicalId: "default/web", status: "deployed" },
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ helm: [] }));
|
|
131
|
+
expect(result.snapshots).toHaveLength(1);
|
|
132
|
+
expect(result.snapshots[0].artifacts).toEqual({
|
|
133
|
+
"release/default/web": { type: "Helm::Release", physicalId: "default/web", status: "deployed" },
|
|
134
|
+
});
|
|
135
|
+
expect(result.snapshots[0].resources).toEqual({});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("plugin can implement both describeResources and listArtifacts", async () => {
|
|
139
|
+
const plugin = createMockPlugin({
|
|
140
|
+
name: "k8s",
|
|
141
|
+
describeResources: staticDescribeResources({
|
|
142
|
+
web: { type: "K8s::Apps::Deployment", status: "READY" },
|
|
143
|
+
}),
|
|
144
|
+
listArtifacts: staticListArtifacts({
|
|
145
|
+
"release/default/proxy": { type: "Helm::Release", status: "deployed" },
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
|
|
149
|
+
expect(result.snapshots[0].resources).toEqual({
|
|
150
|
+
web: { type: "K8s::Apps::Deployment", status: "READY" },
|
|
151
|
+
});
|
|
152
|
+
expect(result.snapshots[0].artifacts).toBeDefined();
|
|
153
|
+
expect(result.snapshots[0].artifacts!["release/default/proxy"]).toBeDefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("plugin with neither method is skipped (existing behavior preserved)", async () => {
|
|
157
|
+
const plugin = createMockPlugin({ name: "noop" });
|
|
158
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ noop: ["x"] }));
|
|
159
|
+
expect(result.snapshots).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("listArtifacts only, empty result → error 'no valid resources or artifacts returned'", async () => {
|
|
163
|
+
const plugin = createMockPlugin({
|
|
164
|
+
name: "helm",
|
|
165
|
+
listArtifacts: async () => ({}),
|
|
166
|
+
});
|
|
167
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ helm: [] }));
|
|
168
|
+
expect(result.errors.some((e) => e.includes("helm") && e.includes("no valid"))).toBe(true);
|
|
169
|
+
expect(result.snapshots).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/state/snapshot.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Snapshot orchestration: queries plugins for deployed resource metadata,
|
|
3
3
|
* assembles StateSnapshots, computes build digests, and writes to git.
|
|
4
4
|
*/
|
|
5
|
-
import type { LexiconPlugin, ResourceMetadata } from "../lexicon";
|
|
5
|
+
import type { LexiconPlugin, ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
6
6
|
import type { BuildResult } from "../build";
|
|
7
7
|
import type { SerializerResult } from "../serializer";
|
|
8
8
|
import type { StateSnapshot } from "./types";
|
|
@@ -95,7 +95,7 @@ export async function takeSnapshot(
|
|
|
95
95
|
const digest = computeBuildDigest(buildResult);
|
|
96
96
|
|
|
97
97
|
for (const plugin of plugins) {
|
|
98
|
-
if (!plugin.describeResources) continue;
|
|
98
|
+
if (!plugin.describeResources && !plugin.listArtifacts) continue;
|
|
99
99
|
|
|
100
100
|
// Get serialized build output for this lexicon
|
|
101
101
|
const rawOutput = buildResult.outputs.get(plugin.name);
|
|
@@ -106,33 +106,52 @@ export async function takeSnapshot(
|
|
|
106
106
|
? rawOutput
|
|
107
107
|
: (rawOutput as SerializerResult).primary;
|
|
108
108
|
|
|
109
|
-
// Get entity names for this lexicon
|
|
109
|
+
// Get entity names + entity props for this lexicon
|
|
110
110
|
const entityNames: string[] = [];
|
|
111
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
111
112
|
for (const [name, entity] of buildResult.entities) {
|
|
112
113
|
if (entity.lexicon === plugin.name) {
|
|
113
114
|
entityNames.push(name);
|
|
115
|
+
entities.set(name, {
|
|
116
|
+
entityType: entity.entityType,
|
|
117
|
+
props: ("props" in entity && entity.props != null
|
|
118
|
+
? entity.props
|
|
119
|
+
: {}) as Record<string, unknown>,
|
|
120
|
+
});
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
environment,
|
|
120
|
-
buildOutput,
|
|
121
|
-
entityNames,
|
|
122
|
-
});
|
|
124
|
+
let resources: Record<string, ResourceMetadata> = {};
|
|
125
|
+
let artifacts: Record<string, ArtifactMetadata> = {};
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
try {
|
|
128
|
+
if (plugin.describeResources) {
|
|
129
|
+
const raw = await plugin.describeResources({
|
|
130
|
+
environment,
|
|
131
|
+
buildOutput,
|
|
132
|
+
entityNames,
|
|
133
|
+
entities,
|
|
134
|
+
});
|
|
135
|
+
const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
|
|
136
|
+
warnings.push(...validationWarnings);
|
|
137
|
+
if (dropped.length > 0) {
|
|
138
|
+
warnings.push(`${plugin.name}: dropped ${dropped.length} invalid resource(s)`);
|
|
139
|
+
}
|
|
140
|
+
resources = valid;
|
|
141
|
+
}
|
|
127
142
|
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
);
|
|
143
|
+
if (plugin.listArtifacts) {
|
|
144
|
+
const raw = await plugin.listArtifacts({ environment, entities });
|
|
145
|
+
const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
|
|
146
|
+
warnings.push(...validationWarnings);
|
|
147
|
+
if (dropped.length > 0) {
|
|
148
|
+
warnings.push(`${plugin.name}: dropped ${dropped.length} invalid artifact(s)`);
|
|
149
|
+
}
|
|
150
|
+
artifacts = valid;
|
|
132
151
|
}
|
|
133
152
|
|
|
134
|
-
if (Object.keys(
|
|
135
|
-
errors.push(`${plugin.name}: no valid resources returned`);
|
|
153
|
+
if (Object.keys(resources).length === 0 && Object.keys(artifacts).length === 0) {
|
|
154
|
+
errors.push(`${plugin.name}: no valid resources or artifacts returned`);
|
|
136
155
|
continue;
|
|
137
156
|
}
|
|
138
157
|
|
|
@@ -141,7 +160,8 @@ export async function takeSnapshot(
|
|
|
141
160
|
environment,
|
|
142
161
|
commit: headCommit,
|
|
143
162
|
timestamp,
|
|
144
|
-
resources
|
|
163
|
+
resources,
|
|
164
|
+
...(Object.keys(artifacts).length > 0 && { artifacts }),
|
|
145
165
|
digest,
|
|
146
166
|
};
|
|
147
167
|
|
package/src/state/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ResourceMetadata } from "../lexicon";
|
|
1
|
+
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
2
2
|
|
|
3
|
-
export type { ResourceMetadata } from "../lexicon";
|
|
3
|
+
export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* State snapshot for a single lexicon in an environment.
|
|
@@ -14,6 +14,8 @@ export interface StateSnapshot {
|
|
|
14
14
|
timestamp: string;
|
|
15
15
|
/** Resource metadata keyed by logical name */
|
|
16
16
|
resources: Record<string, ResourceMetadata>;
|
|
17
|
+
/** Artifact metadata keyed by server-side identifier (lexicon-specific). */
|
|
18
|
+
artifacts?: Record<string, ArtifactMetadata>;
|
|
17
19
|
/** Build digest at snapshot time — what was declared when this snapshot was taken */
|
|
18
20
|
digest?: BuildDigest;
|
|
19
21
|
}
|