@intentius/chant 0.1.7 → 0.1.8

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.
@@ -0,0 +1,184 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { diffLive, diffLiveArtifacts } from "./live-diff";
3
+ import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
4
+
5
+ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
6
+ type: "AWS::S3::Bucket",
7
+ status: "CREATE_COMPLETE",
8
+ physicalId: "bucket-1",
9
+ ...overrides,
10
+ });
11
+
12
+ describe("diffLive", () => {
13
+ test("empty inputs produce empty result", () => {
14
+ const result = diffLive({
15
+ declared: new Set(),
16
+ observedNow: {},
17
+ observedThen: undefined,
18
+ });
19
+ expect(result).toEqual({
20
+ missing: [],
21
+ orphan: [],
22
+ disappeared: [],
23
+ newlyObserved: [],
24
+ driftedSinceSnapshot: [],
25
+ unchanged: [],
26
+ });
27
+ });
28
+
29
+ test("declared but not observed → missing", () => {
30
+ const result = diffLive({
31
+ declared: new Set(["bucket"]),
32
+ observedNow: {},
33
+ observedThen: undefined,
34
+ });
35
+ expect(result.missing).toEqual(["bucket"]);
36
+ });
37
+
38
+ test("observed but not declared → orphan", () => {
39
+ const result = diffLive({
40
+ declared: new Set(),
41
+ observedNow: { abandoned: meta() },
42
+ observedThen: undefined,
43
+ });
44
+ expect(result.orphan).toEqual(["abandoned"]);
45
+ });
46
+
47
+ test("in previous snapshot but not observed now → disappeared", () => {
48
+ const result = diffLive({
49
+ declared: new Set(["bucket"]),
50
+ observedNow: {},
51
+ observedThen: { bucket: meta() },
52
+ });
53
+ expect(result.missing).toEqual(["bucket"]);
54
+ expect(result.disappeared).toEqual(["bucket"]);
55
+ });
56
+
57
+ test("observed for the first time (declared, no previous snapshot) → newlyObserved", () => {
58
+ const result = diffLive({
59
+ declared: new Set(["bucket"]),
60
+ observedNow: { bucket: meta() },
61
+ observedThen: {},
62
+ });
63
+ expect(result.newlyObserved).toEqual(["bucket"]);
64
+ expect(result.unchanged).toEqual([]);
65
+ expect(result.driftedSinceSnapshot).toEqual([]);
66
+ });
67
+
68
+ test("attribute changed between snapshots → driftedSinceSnapshot with attribute path", () => {
69
+ const result = diffLive({
70
+ declared: new Set(["bucket"]),
71
+ observedNow: { bucket: meta({ status: "UPDATE_COMPLETE", attributes: { tags: { env: "prod" } } }) },
72
+ observedThen: { bucket: meta({ status: "CREATE_COMPLETE", attributes: { tags: { env: "stage" } } }) },
73
+ });
74
+ expect(result.driftedSinceSnapshot).toHaveLength(1);
75
+ const drift = result.driftedSinceSnapshot[0];
76
+ expect(drift.name).toBe("bucket");
77
+ expect(drift.type).toBe("AWS::S3::Bucket");
78
+ const paths = drift.changes.map((c) => c.path).sort();
79
+ expect(paths).toEqual(["attributes.tags", "status"]);
80
+ });
81
+
82
+ test("identical metadata between snapshots → unchanged", () => {
83
+ const sameMeta = meta({ attributes: { tags: { env: "prod" } } });
84
+ const result = diffLive({
85
+ declared: new Set(["bucket"]),
86
+ observedNow: { bucket: sameMeta },
87
+ observedThen: { bucket: sameMeta },
88
+ });
89
+ expect(result.unchanged).toEqual(["bucket"]);
90
+ expect(result.driftedSinceSnapshot).toEqual([]);
91
+ });
92
+
93
+ test("mixed: counts add up across all six categories", () => {
94
+ const result = diffLive({
95
+ declared: new Set(["a", "b", "c", "d"]),
96
+ observedNow: {
97
+ b: meta(), // unchanged
98
+ c: meta({ status: "UPDATE_COMPLETE" }), // drift
99
+ d: meta(), // newlyObserved
100
+ e: meta(), // orphan
101
+ },
102
+ observedThen: {
103
+ a: meta(), // disappeared (and missing, since declared)
104
+ b: meta(), // unchanged
105
+ c: meta(), // drift
106
+ },
107
+ });
108
+ expect(result.missing).toEqual(["a"]);
109
+ expect(result.orphan).toEqual(["e"]);
110
+ expect(result.disappeared).toEqual(["a"]);
111
+ expect(result.newlyObserved).toEqual(["d"]);
112
+ expect(result.driftedSinceSnapshot.map((d) => d.name)).toEqual(["c"]);
113
+ expect(result.unchanged).toEqual(["b"]);
114
+ });
115
+ });
116
+
117
+ describe("diffLiveArtifacts", () => {
118
+ const a = (overrides: Partial<ArtifactMetadata> = {}): ArtifactMetadata => ({
119
+ type: "Helm::Release",
120
+ physicalId: "default/foo",
121
+ status: "deployed",
122
+ ...overrides,
123
+ });
124
+
125
+ test("empty inputs produce empty result", () => {
126
+ expect(diffLiveArtifacts({ observedNow: {}, observedThen: undefined })).toEqual({
127
+ added: [], removed: [], changed: [], unchanged: [],
128
+ });
129
+ });
130
+
131
+ test("observed now, no previous snapshot → added", () => {
132
+ const result = diffLiveArtifacts({
133
+ observedNow: { "release/default/foo": a() },
134
+ observedThen: undefined,
135
+ });
136
+ expect(result.added).toEqual(["release/default/foo"]);
137
+ });
138
+
139
+ test("in previous snapshot, gone now → removed", () => {
140
+ const result = diffLiveArtifacts({
141
+ observedNow: {},
142
+ observedThen: { "release/default/foo": a() },
143
+ });
144
+ expect(result.removed).toEqual(["release/default/foo"]);
145
+ });
146
+
147
+ test("metadata differs between snapshots → changed", () => {
148
+ const result = diffLiveArtifacts({
149
+ observedNow: { "release/default/foo": a({ status: "failed" }) },
150
+ observedThen: { "release/default/foo": a({ status: "deployed" }) },
151
+ });
152
+ expect(result.changed).toHaveLength(1);
153
+ expect(result.changed[0].name).toBe("release/default/foo");
154
+ expect(result.changed[0].changes.map((c) => c.path)).toContain("status");
155
+ });
156
+
157
+ test("identical metadata → unchanged", () => {
158
+ const same = a();
159
+ const result = diffLiveArtifacts({
160
+ observedNow: { "release/default/foo": same },
161
+ observedThen: { "release/default/foo": same },
162
+ });
163
+ expect(result.unchanged).toEqual(["release/default/foo"]);
164
+ });
165
+
166
+ test("mixed: counts add up across all four categories", () => {
167
+ const result = diffLiveArtifacts({
168
+ observedNow: {
169
+ "release/default/b": a(), // unchanged
170
+ "release/default/c": a({ status: "failed" }), // changed
171
+ "release/default/d": a(), // added
172
+ },
173
+ observedThen: {
174
+ "release/default/a": a(), // removed
175
+ "release/default/b": a(), // unchanged
176
+ "release/default/c": a(), // changed
177
+ },
178
+ });
179
+ expect(result.added).toEqual(["release/default/d"]);
180
+ expect(result.removed).toEqual(["release/default/a"]);
181
+ expect(result.changed.map((c) => c.name)).toEqual(["release/default/c"]);
182
+ expect(result.unchanged).toEqual(["release/default/b"]);
183
+ });
184
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Live-state diff: compares declared vs observed-now vs observed-then.
3
+ *
4
+ * Produces structured drift signal — *what is in the cloud right now* against
5
+ * both *what was declared in source* and *what was observed at the last
6
+ * snapshot*. Pure function; all I/O happens in the caller.
7
+ *
8
+ * Two diff flavors:
9
+ * - diffLive — entity-keyed (declared ↔ observedNow ↔ observedThen)
10
+ * - diffLiveArtifacts — context-keyed (observedNow ↔ observedThen only;
11
+ * no `declared` axis since artifacts aren't declared
12
+ * as chant entities — they're created by tooling
13
+ * outside chant's entity model)
14
+ */
15
+ import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
16
+
17
+ export interface AttributeChange {
18
+ /** Attribute path (e.g. "status", "physicalId", "attributes.tags.env"). */
19
+ path: string;
20
+ oldValue: unknown;
21
+ newValue: unknown;
22
+ }
23
+
24
+ export interface ResourceDrift {
25
+ name: string;
26
+ type: string;
27
+ changes: AttributeChange[];
28
+ }
29
+
30
+ export interface LiveDiffResult {
31
+ /** Declared in current build, but not observed in cloud right now. */
32
+ missing: string[];
33
+ /** Observed in cloud right now, but not declared. */
34
+ orphan: string[];
35
+ /** Was in last snapshot but isn't observed now. */
36
+ disappeared: string[];
37
+ /** Observed now and declared, but not in the previous snapshot. */
38
+ newlyObserved: string[];
39
+ /** Observed both then and now; metadata changed. */
40
+ driftedSinceSnapshot: ResourceDrift[];
41
+ /** Observed both then and now; metadata identical. */
42
+ unchanged: string[];
43
+ }
44
+
45
+ export interface DiffLiveInput {
46
+ /** Entity names from the current build. */
47
+ declared: Set<string>;
48
+ /** Resources returned by `plugin.describeResources()` right now. */
49
+ observedNow: Record<string, ResourceMetadata>;
50
+ /** Resources captured by the previous snapshot, if any. */
51
+ observedThen: Record<string, ResourceMetadata> | undefined;
52
+ }
53
+
54
+ const TRACKED_FIELDS: Array<keyof ResourceMetadata> = [
55
+ "status",
56
+ "physicalId",
57
+ "lastUpdated",
58
+ ];
59
+
60
+ function compareMetadata(
61
+ oldMeta: ResourceMetadata,
62
+ newMeta: ResourceMetadata,
63
+ ): AttributeChange[] {
64
+ const changes: AttributeChange[] = [];
65
+
66
+ for (const field of TRACKED_FIELDS) {
67
+ if (oldMeta[field] !== newMeta[field]) {
68
+ changes.push({ path: field, oldValue: oldMeta[field], newValue: newMeta[field] });
69
+ }
70
+ }
71
+
72
+ const oldAttrs = oldMeta.attributes ?? {};
73
+ const newAttrs = newMeta.attributes ?? {};
74
+ const allAttrKeys = new Set([...Object.keys(oldAttrs), ...Object.keys(newAttrs)]);
75
+ for (const key of allAttrKeys) {
76
+ const oldValue = oldAttrs[key];
77
+ const newValue = newAttrs[key];
78
+ if (!shallowEqual(oldValue, newValue)) {
79
+ changes.push({ path: `attributes.${key}`, oldValue, newValue });
80
+ }
81
+ }
82
+
83
+ return changes;
84
+ }
85
+
86
+ function shallowEqual(a: unknown, b: unknown): boolean {
87
+ if (a === b) return true;
88
+ if (a == null || b == null) return false;
89
+ if (typeof a !== "object" || typeof b !== "object") return false;
90
+ return JSON.stringify(a) === JSON.stringify(b);
91
+ }
92
+
93
+ export function diffLive(input: DiffLiveInput): LiveDiffResult {
94
+ const { declared, observedNow, observedThen } = input;
95
+ const observedThenMap = observedThen ?? {};
96
+ const observedNowNames = new Set(Object.keys(observedNow));
97
+ const observedThenNames = new Set(Object.keys(observedThenMap));
98
+
99
+ const missing: string[] = [];
100
+ const orphan: string[] = [];
101
+ const disappeared: string[] = [];
102
+ const newlyObserved: string[] = [];
103
+ const driftedSinceSnapshot: ResourceDrift[] = [];
104
+ const unchanged: string[] = [];
105
+
106
+ // Declared but not observed in cloud right now → missing
107
+ for (const name of declared) {
108
+ if (!observedNowNames.has(name)) {
109
+ missing.push(name);
110
+ }
111
+ }
112
+
113
+ // In cloud right now but not declared → orphan
114
+ for (const name of observedNowNames) {
115
+ if (!declared.has(name)) {
116
+ orphan.push(name);
117
+ }
118
+ }
119
+
120
+ // In previous snapshot but not observed now → disappeared
121
+ for (const name of observedThenNames) {
122
+ if (!observedNowNames.has(name)) {
123
+ disappeared.push(name);
124
+ }
125
+ }
126
+
127
+ // Observed now: classify drift relative to previous snapshot
128
+ for (const name of observedNowNames) {
129
+ const now = observedNow[name];
130
+ const then = observedThenMap[name];
131
+ if (!then) {
132
+ if (declared.has(name)) {
133
+ newlyObserved.push(name);
134
+ }
135
+ // else: orphan, already classified above
136
+ continue;
137
+ }
138
+ const changes = compareMetadata(then, now);
139
+ if (changes.length === 0) {
140
+ unchanged.push(name);
141
+ } else {
142
+ driftedSinceSnapshot.push({
143
+ name,
144
+ type: now.type,
145
+ changes,
146
+ });
147
+ }
148
+ }
149
+
150
+ return {
151
+ missing: missing.sort(),
152
+ orphan: orphan.sort(),
153
+ disappeared: disappeared.sort(),
154
+ newlyObserved: newlyObserved.sort(),
155
+ driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
156
+ unchanged: unchanged.sort(),
157
+ };
158
+ }
159
+
160
+ // ── Artifact diff (no `declared` axis) ──────────────────────────────────────
161
+
162
+ export interface LiveArtifactDiffResult {
163
+ /** Observed now, not in previous snapshot. */
164
+ added: string[];
165
+ /** In previous snapshot, not observed now. */
166
+ removed: string[];
167
+ /** In both; metadata changed. */
168
+ changed: ResourceDrift[];
169
+ /** In both; metadata identical. */
170
+ unchanged: string[];
171
+ }
172
+
173
+ export interface DiffLiveArtifactsInput {
174
+ /** Artifacts returned by `plugin.listArtifacts()` right now. */
175
+ observedNow: Record<string, ArtifactMetadata>;
176
+ /** Artifacts captured by the previous snapshot, if any. */
177
+ observedThen: Record<string, ArtifactMetadata> | undefined;
178
+ }
179
+
180
+ export function diffLiveArtifacts(input: DiffLiveArtifactsInput): LiveArtifactDiffResult {
181
+ const observedThenMap = input.observedThen ?? {};
182
+ const nowNames = new Set(Object.keys(input.observedNow));
183
+ const thenNames = new Set(Object.keys(observedThenMap));
184
+
185
+ const added: string[] = [];
186
+ const removed: string[] = [];
187
+ const changed: ResourceDrift[] = [];
188
+ const unchanged: string[] = [];
189
+
190
+ for (const name of nowNames) {
191
+ if (!thenNames.has(name)) {
192
+ added.push(name);
193
+ continue;
194
+ }
195
+ const now = input.observedNow[name];
196
+ const then = observedThenMap[name];
197
+ const diffs = compareMetadata(then, now);
198
+ if (diffs.length === 0) {
199
+ unchanged.push(name);
200
+ } else {
201
+ changed.push({ name, type: now.type, changes: diffs });
202
+ }
203
+ }
204
+
205
+ for (const name of thenNames) {
206
+ if (!nowNames.has(name)) removed.push(name);
207
+ }
208
+
209
+ return {
210
+ added: added.sort(),
211
+ removed: removed.sort(),
212
+ changed: changed.sort((a, b) => a.name.localeCompare(b.name)),
213
+ unchanged: unchanged.sort(),
214
+ };
215
+ }
@@ -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
+ });
@@ -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
- try {
118
- const resources = await plugin.describeResources({
119
- environment,
120
- buildOutput,
121
- entityNames,
122
- });
124
+ let resources: Record<string, ResourceMetadata> = {};
125
+ let artifacts: Record<string, ArtifactMetadata> = {};
123
126
 
124
- const { valid, dropped, warnings: validationWarnings } =
125
- validateResources(resources);
126
- warnings.push(...validationWarnings);
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 (dropped.length > 0) {
129
- warnings.push(
130
- `${plugin.name}: dropped ${dropped.length} invalid resource(s)`,
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(valid).length === 0) {
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: valid,
163
+ resources,
164
+ ...(Object.keys(artifacts).length > 0 && { artifacts }),
145
165
  digest,
146
166
  };
147
167
 
@@ -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
  }