@intentius/chant 0.0.18 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/chant +4 -1
  2. package/package.json +20 -1
  3. package/src/build.test.ts +4 -2
  4. package/src/build.ts +3 -0
  5. package/src/builder.test.ts +3 -0
  6. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
  7. package/src/cli/commands/build.ts +5 -12
  8. package/src/cli/commands/diff.test.ts +2 -1
  9. package/src/cli/commands/diff.ts +2 -1
  10. package/src/cli/commands/init-lexicon.test.ts +0 -9
  11. package/src/cli/commands/init-lexicon.ts +0 -94
  12. package/src/cli/commands/init.ts +2 -20
  13. package/src/cli/handlers/build.ts +3 -3
  14. package/src/cli/handlers/lint.ts +2 -2
  15. package/src/cli/handlers/spell.ts +396 -0
  16. package/src/cli/handlers/state.ts +230 -0
  17. package/src/cli/lsp/server.test.ts +4 -0
  18. package/src/cli/main.ts +37 -3
  19. package/src/cli/mcp/server.test.ts +13 -9
  20. package/src/cli/mcp/server.ts +220 -6
  21. package/src/cli/mcp/tools/build.ts +2 -1
  22. package/src/cli/plugins.ts +1 -1
  23. package/src/cli/reporters/stylish.test.ts +2 -2
  24. package/src/cli/reporters/stylish.ts +1 -1
  25. package/src/composite.test.ts +1 -1
  26. package/src/config.ts +4 -0
  27. package/src/declarable.test.ts +2 -1
  28. package/src/declarable.ts +1 -1
  29. package/src/discovery/graph.test.ts +40 -0
  30. package/src/discovery/import.test.ts +5 -5
  31. package/src/discovery/resolve.test.ts +20 -0
  32. package/src/discovery/resolve.ts +2 -2
  33. package/src/index.ts +2 -0
  34. package/src/lexicon.ts +24 -0
  35. package/src/lint/rule-options.test.ts +3 -3
  36. package/src/lint/rule-registry.test.ts +1 -1
  37. package/src/lint/rules/composite-scope.ts +1 -1
  38. package/src/serializer-walker.ts +2 -1
  39. package/src/spell/discovery.ts +183 -0
  40. package/src/spell/index.ts +3 -0
  41. package/src/spell/prompt.ts +133 -0
  42. package/src/spell/types.ts +89 -0
  43. package/src/state/digest.ts +88 -0
  44. package/src/state/git.ts +317 -0
  45. package/src/state/index.ts +4 -0
  46. package/src/state/snapshot.ts +179 -0
  47. package/src/state/types.ts +59 -0
  48. package/src/types.ts +2 -1
  49. package/src/utils.test.ts +16 -3
  50. package/src/utils.ts +31 -1
  51. package/src/validation.test.ts +11 -0
  52. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
  53. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
  54. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
  55. package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
  56. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  57. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
  58. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
  59. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
  60. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Spell types and factory functions.
3
+ *
4
+ * A spell is a structured task definition for agent orchestration.
5
+ */
6
+
7
+ // ── Types ────────────────────────────────────────────────────────
8
+
9
+ export interface ContextItem {
10
+ type: "file" | "cmd";
11
+ value: string;
12
+ }
13
+
14
+ export interface Task {
15
+ description: string;
16
+ done: boolean;
17
+ }
18
+
19
+ export type Status = "blocked" | "ready" | "done";
20
+
21
+ export interface SpellDefinition {
22
+ name: string;
23
+ lexicon?: string;
24
+ overview: string;
25
+ context?: (string | ContextItem)[];
26
+ tasks: Task[];
27
+ depends?: string[];
28
+ afterAll?: string[];
29
+ }
30
+
31
+ // ── Validation ───────────────────────────────────────────────────
32
+
33
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
34
+ const MAX_NAME_LENGTH = 64;
35
+ const RESERVED_NAMES = new Set([
36
+ "add", "list", "show", "cast", "done", "rm", "graph",
37
+ ]);
38
+
39
+ function validateName(name: string): void {
40
+ if (!name) {
41
+ throw new Error("Spell name must not be empty");
42
+ }
43
+ if (name.length > MAX_NAME_LENGTH) {
44
+ throw new Error(`Spell name must be at most ${MAX_NAME_LENGTH} characters: "${name}"`);
45
+ }
46
+ if (!NAME_PATTERN.test(name)) {
47
+ throw new Error(`Spell name must be kebab-case (lowercase letters, numbers, hyphens): "${name}"`);
48
+ }
49
+ if (RESERVED_NAMES.has(name)) {
50
+ throw new Error(`Spell name "${name}" is reserved (conflicts with CLI subcommand)`);
51
+ }
52
+ }
53
+
54
+ // ── Factory functions ────────────────────────────────────────────
55
+
56
+ /**
57
+ * Define a spell. Validates the name and freezes the object.
58
+ */
59
+ export function spell(def: SpellDefinition): SpellDefinition {
60
+ validateName(def.name);
61
+ if (!def.overview) {
62
+ throw new Error(`Spell "${def.name}" must have a non-empty overview`);
63
+ }
64
+ if (!def.tasks || def.tasks.length === 0) {
65
+ throw new Error(`Spell "${def.name}" must have at least one task`);
66
+ }
67
+ return Object.freeze({ ...def });
68
+ }
69
+
70
+ /**
71
+ * Define a task within a spell.
72
+ */
73
+ export function task(description: string, opts?: { done?: boolean }): Task {
74
+ return { description, done: opts?.done ?? false };
75
+ }
76
+
77
+ /**
78
+ * File context item — contents inlined at cast time.
79
+ */
80
+ export function file(path: string): ContextItem {
81
+ return { type: "file", value: path };
82
+ }
83
+
84
+ /**
85
+ * Command context item — stdout inlined at cast time.
86
+ */
87
+ export function cmd(command: string): ContextItem {
88
+ return { type: "cmd", value: command };
89
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Build digest: fingerprints of resource declarations + dependency graph.
3
+ *
4
+ * The digest captures *what was declared* at a point in time, enabling
5
+ * diff operations without re-parsing templates.
6
+ */
7
+ import type { BuildResult } from "../build";
8
+ import type { Declarable } from "../declarable";
9
+ import type { BuildDigest, ResourceDigest, DigestDiff } from "./types";
10
+ import { sortedJsonReplacer } from "../utils";
11
+ import { getRuntime } from "../runtime-adapter";
12
+
13
+ /**
14
+ * Hash an entity's props deterministically.
15
+ */
16
+ export function hashProps(props: unknown): string {
17
+ const json = JSON.stringify(props, sortedJsonReplacer);
18
+ return getRuntime().hash(json);
19
+ }
20
+
21
+ /**
22
+ * Compute a full build digest from a BuildResult.
23
+ */
24
+ export function computeBuildDigest(buildResult: BuildResult): BuildDigest {
25
+ const resources: Record<string, ResourceDigest> = {};
26
+
27
+ for (const [name, entity] of buildResult.entities) {
28
+ const props = "props" in entity && entity.props != null ? entity.props : {};
29
+ resources[name] = {
30
+ type: entity.entityType,
31
+ lexicon: entity.lexicon,
32
+ propsHash: hashProps(props),
33
+ };
34
+ }
35
+
36
+ // Convert dependency Map<string, Set<string>> to Record<string, string[]>
37
+ const dependencies: Record<string, string[]> = {};
38
+ for (const [name, deps] of buildResult.dependencies) {
39
+ dependencies[name] = Array.from(deps);
40
+ }
41
+
42
+ return {
43
+ resources,
44
+ dependencies,
45
+ outputs: buildResult.manifest.outputs,
46
+ deployOrder: buildResult.manifest.deployOrder,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Compare two digests and categorize resources.
52
+ */
53
+ export function diffDigests(
54
+ current: BuildDigest,
55
+ previous: BuildDigest | undefined,
56
+ ): DigestDiff {
57
+ const added: string[] = [];
58
+ const removed: string[] = [];
59
+ const changed: string[] = [];
60
+ const unchanged: string[] = [];
61
+
62
+ if (!previous) {
63
+ // No previous digest — everything is added
64
+ added.push(...Object.keys(current.resources));
65
+ return { added, removed, changed, unchanged };
66
+ }
67
+
68
+ // Check current resources against previous
69
+ for (const name of Object.keys(current.resources)) {
70
+ const prev = previous.resources[name];
71
+ if (!prev) {
72
+ added.push(name);
73
+ } else if (current.resources[name].propsHash !== prev.propsHash) {
74
+ changed.push(name);
75
+ } else {
76
+ unchanged.push(name);
77
+ }
78
+ }
79
+
80
+ // Check for removed resources
81
+ for (const name of Object.keys(previous.resources)) {
82
+ if (!(name in current.resources)) {
83
+ removed.push(name);
84
+ }
85
+ }
86
+
87
+ return { added, removed, changed, unchanged };
88
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Git plumbing operations for the chant/state orphan branch.
3
+ *
4
+ * All operations use git plumbing commands — no checkout, no branch switching,
5
+ * no working tree changes.
6
+ */
7
+ import { getRuntime } from "../runtime-adapter";
8
+
9
+ const STATE_BRANCH = "chant/state";
10
+
11
+ /**
12
+ * Write a state snapshot JSON to the orphan branch.
13
+ *
14
+ * Pipeline: hash-object → mktree → commit-tree → update-ref
15
+ */
16
+ export async function writeSnapshot(
17
+ environment: string,
18
+ lexicon: string,
19
+ json: string,
20
+ opts?: { cwd?: string },
21
+ ): Promise<string> {
22
+ const rt = getRuntime();
23
+ const cwd = opts?.cwd;
24
+
25
+ // 1. Write blob
26
+ const hashResult = await rt.spawn(
27
+ ["git", "hash-object", "-w", "--stdin"],
28
+ { cwd },
29
+ );
30
+ // hash-object reads from stdin, but spawn doesn't support piping directly.
31
+ // Use a shell pipeline instead.
32
+ const blobResult = await rt.spawn(
33
+ ["sh", "-c", `echo '${json.replace(/'/g, "'\\''")}' | git hash-object -w --stdin`],
34
+ { cwd },
35
+ );
36
+ if (blobResult.exitCode !== 0) {
37
+ throw new Error(`git hash-object failed: ${blobResult.stderr}`);
38
+ }
39
+ const blobSha = blobResult.stdout.trim();
40
+
41
+ // 2. Read existing tree (if branch exists) to preserve other env/lexicon entries
42
+ const existingTree = await readTree(cwd);
43
+
44
+ // 3. Build new tree entries
45
+ const path = `${environment}/${lexicon}.json`;
46
+ const entries = mergeTreeEntry(existingTree, path, blobSha);
47
+ const treeInput = entries.map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`).join("\n");
48
+
49
+ // mktree needs a nested tree structure. Build env subtree first, then root tree.
50
+ // Build env subtree
51
+ const envEntries = entries
52
+ .filter((e) => e.env === environment)
53
+ .map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`)
54
+ .join("\n");
55
+
56
+ const envTreeResult = await rt.spawn(
57
+ ["sh", "-c", `printf '%s\\n' ${shellQuoteLines(envEntries)} | git mktree`],
58
+ { cwd },
59
+ );
60
+ if (envTreeResult.exitCode !== 0) {
61
+ throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
62
+ }
63
+ const envTreeSha = envTreeResult.stdout.trim();
64
+
65
+ // Build root tree: collect env subtrees
66
+ const rootEntries: string[] = [];
67
+ const envsSeen = new Set<string>();
68
+ for (const e of entries) {
69
+ if (!envsSeen.has(e.env)) {
70
+ envsSeen.add(e.env);
71
+ if (e.env === environment) {
72
+ rootEntries.push(`040000 tree ${envTreeSha}\t${environment}`);
73
+ } else {
74
+ rootEntries.push(`040000 tree ${e.envTreeSha!}\t${e.env}`);
75
+ }
76
+ }
77
+ }
78
+
79
+ const rootTreeResult = await rt.spawn(
80
+ ["sh", "-c", `printf '%s\\n' ${shellQuoteLines(rootEntries.join("\n"))} | git mktree`],
81
+ { cwd },
82
+ );
83
+ if (rootTreeResult.exitCode !== 0) {
84
+ throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
85
+ }
86
+ const rootTreeSha = rootTreeResult.stdout.trim();
87
+
88
+ // 4. Create commit
89
+ const parentRef = await getStateBranchTip(cwd);
90
+ const parentArgs = parentRef ? ["-p", parentRef] : [];
91
+ const commitResult = await rt.spawn(
92
+ ["git", "commit-tree", ...parentArgs, "-m", "State snapshot", rootTreeSha],
93
+ { cwd },
94
+ );
95
+ if (commitResult.exitCode !== 0) {
96
+ throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
97
+ }
98
+ const commitSha = commitResult.stdout.trim();
99
+
100
+ // 5. Update ref
101
+ const updateResult = await rt.spawn(
102
+ ["git", "update-ref", `refs/heads/${STATE_BRANCH}`, commitSha],
103
+ { cwd },
104
+ );
105
+ if (updateResult.exitCode !== 0) {
106
+ throw new Error(`git update-ref failed: ${updateResult.stderr}`);
107
+ }
108
+
109
+ return commitSha;
110
+ }
111
+
112
+ /**
113
+ * Read a snapshot from the orphan branch.
114
+ */
115
+ export async function readSnapshot(
116
+ environment: string,
117
+ lexicon: string,
118
+ opts?: { cwd?: string },
119
+ ): Promise<string | null> {
120
+ const rt = getRuntime();
121
+ const result = await rt.spawn(
122
+ ["git", "show", `${STATE_BRANCH}:${environment}/${lexicon}.json`],
123
+ { cwd: opts?.cwd },
124
+ );
125
+ if (result.exitCode !== 0) return null;
126
+ return result.stdout;
127
+ }
128
+
129
+ /**
130
+ * Read all snapshots for an environment (all lexicons).
131
+ */
132
+ export async function readEnvironmentSnapshots(
133
+ environment: string,
134
+ opts?: { cwd?: string },
135
+ ): Promise<Map<string, string>> {
136
+ const rt = getRuntime();
137
+ const snapshots = new Map<string, string>();
138
+
139
+ // List files in the environment directory
140
+ const lsResult = await rt.spawn(
141
+ ["git", "ls-tree", "--name-only", `${STATE_BRANCH}:${environment}/`],
142
+ { cwd: opts?.cwd },
143
+ );
144
+ if (lsResult.exitCode !== 0) return snapshots;
145
+
146
+ const files = lsResult.stdout.trim().split("\n").filter(Boolean);
147
+ for (const file of files) {
148
+ if (file.endsWith(".json")) {
149
+ const lexicon = file.replace(/\.json$/, "");
150
+ const content = await readSnapshot(environment, lexicon, opts);
151
+ if (content) snapshots.set(lexicon, content);
152
+ }
153
+ }
154
+
155
+ return snapshots;
156
+ }
157
+
158
+ /**
159
+ * List snapshot history from the orphan branch.
160
+ */
161
+ export async function listSnapshots(
162
+ opts?: { cwd?: string; environment?: string },
163
+ ): Promise<Array<{ commit: string; date: string; message: string }>> {
164
+ const rt = getRuntime();
165
+ const result = await rt.spawn(
166
+ ["git", "log", "--format=%H %aI %s", STATE_BRANCH],
167
+ { cwd: opts?.cwd },
168
+ );
169
+ if (result.exitCode !== 0) return [];
170
+
171
+ return result.stdout
172
+ .trim()
173
+ .split("\n")
174
+ .filter(Boolean)
175
+ .map((line) => {
176
+ const [commit, date, ...rest] = line.split(" ");
177
+ return { commit, date, message: rest.join(" ") };
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Push the state branch to remote.
183
+ */
184
+ export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
185
+ const rt = getRuntime();
186
+ // Check if remote exists
187
+ const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
188
+ if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
189
+
190
+ const remote = remoteResult.stdout.trim().split("\n")[0];
191
+ const pushResult = await rt.spawn(
192
+ ["git", "push", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
193
+ { cwd: opts?.cwd },
194
+ );
195
+ return pushResult.exitCode === 0;
196
+ }
197
+
198
+ /**
199
+ * Fetch the state branch from remote.
200
+ */
201
+ export async function fetchState(opts?: { cwd?: string }): Promise<boolean> {
202
+ const rt = getRuntime();
203
+ const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
204
+ if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
205
+
206
+ const remote = remoteResult.stdout.trim().split("\n")[0];
207
+ const fetchResult = await rt.spawn(
208
+ ["git", "fetch", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
209
+ { cwd: opts?.cwd },
210
+ );
211
+ return fetchResult.exitCode === 0;
212
+ }
213
+
214
+ /**
215
+ * Get the current HEAD commit SHA of the main working branch.
216
+ */
217
+ export async function getHeadCommit(opts?: { cwd?: string }): Promise<string> {
218
+ const rt = getRuntime();
219
+ const result = await rt.spawn(["git", "rev-parse", "HEAD"], { cwd: opts?.cwd });
220
+ if (result.exitCode !== 0) {
221
+ throw new Error(`git rev-parse HEAD failed: ${result.stderr}`);
222
+ }
223
+ return result.stdout.trim();
224
+ }
225
+
226
+ // ── Internal helpers ────────────────────────────────────────────
227
+
228
+ interface TreeEntry {
229
+ mode: string;
230
+ type: string;
231
+ sha: string;
232
+ name: string;
233
+ env: string;
234
+ envTreeSha?: string;
235
+ }
236
+
237
+ async function getStateBranchTip(cwd?: string): Promise<string | null> {
238
+ const rt = getRuntime();
239
+ const result = await rt.spawn(
240
+ ["git", "rev-parse", "--verify", `refs/heads/${STATE_BRANCH}`],
241
+ { cwd },
242
+ );
243
+ if (result.exitCode !== 0) return null;
244
+ return result.stdout.trim();
245
+ }
246
+
247
+ async function readTree(cwd?: string): Promise<TreeEntry[]> {
248
+ const rt = getRuntime();
249
+ const tip = await getStateBranchTip(cwd);
250
+ if (!tip) return [];
251
+
252
+ // List root tree to get env directories
253
+ const rootResult = await rt.spawn(
254
+ ["git", "ls-tree", STATE_BRANCH],
255
+ { cwd },
256
+ );
257
+ if (rootResult.exitCode !== 0) return [];
258
+
259
+ const entries: TreeEntry[] = [];
260
+ const lines = rootResult.stdout.trim().split("\n").filter(Boolean);
261
+
262
+ for (const line of lines) {
263
+ // Format: mode type sha\tname
264
+ const match = line.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\t(.+)$/);
265
+ if (!match) continue;
266
+ const [, mode, type, sha, name] = match;
267
+
268
+ if (type === "tree") {
269
+ // This is an env directory — list its contents
270
+ const envResult = await rt.spawn(
271
+ ["git", "ls-tree", `${STATE_BRANCH}:${name}/`],
272
+ { cwd },
273
+ );
274
+ if (envResult.exitCode !== 0) continue;
275
+
276
+ const envLines = envResult.stdout.trim().split("\n").filter(Boolean);
277
+ for (const envLine of envLines) {
278
+ const envMatch = envLine.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\t(.+)$/);
279
+ if (!envMatch) continue;
280
+ entries.push({
281
+ mode: envMatch[1],
282
+ type: envMatch[2],
283
+ sha: envMatch[3],
284
+ name: envMatch[4],
285
+ env: name,
286
+ envTreeSha: sha,
287
+ });
288
+ }
289
+ }
290
+ }
291
+
292
+ return entries;
293
+ }
294
+
295
+ function mergeTreeEntry(
296
+ existing: TreeEntry[],
297
+ path: string,
298
+ blobSha: string,
299
+ ): TreeEntry[] {
300
+ const [env, filename] = path.split("/");
301
+ const entries = existing.filter(
302
+ (e) => !(e.env === env && e.name === filename),
303
+ );
304
+ entries.push({
305
+ mode: "100644",
306
+ type: "blob",
307
+ sha: blobSha,
308
+ name: filename,
309
+ env,
310
+ });
311
+ return entries;
312
+ }
313
+
314
+ function shellQuoteLines(input: string): string {
315
+ // Escape for printf in shell
316
+ return `'${input.replace(/'/g, "'\\''")}'`;
317
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./types";
2
+ export * from "./git";
3
+ export * from "./digest";
4
+ export * from "./snapshot";
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Snapshot orchestration: queries plugins for deployed resource metadata,
3
+ * assembles StateSnapshots, computes build digests, and writes to git.
4
+ */
5
+ import type { LexiconPlugin, ResourceMetadata } from "../lexicon";
6
+ import type { BuildResult } from "../build";
7
+ import type { SerializerResult } from "../serializer";
8
+ import type { StateSnapshot } from "./types";
9
+ import { computeBuildDigest } from "./digest";
10
+ import { writeSnapshot, getHeadCommit, pushState } from "./git";
11
+ import { sortedJsonReplacer } from "../utils";
12
+
13
+ /** Patterns in attribute names that suggest sensitive data. */
14
+ const SENSITIVE_PATTERNS = [
15
+ /password/i,
16
+ /secret/i,
17
+ /token/i,
18
+ /private.?key/i,
19
+ /credential/i,
20
+ /connection.?string/i,
21
+ ];
22
+
23
+ /**
24
+ * Check for potential sensitive data in resource attributes and return warnings.
25
+ */
26
+ function checkSensitiveData(
27
+ resources: Record<string, ResourceMetadata>,
28
+ ): string[] {
29
+ const warnings: string[] = [];
30
+ for (const [name, meta] of Object.entries(resources)) {
31
+ if (!meta.attributes) continue;
32
+ for (const attrName of Object.keys(meta.attributes)) {
33
+ if (SENSITIVE_PATTERNS.some((p) => p.test(attrName))) {
34
+ warnings.push(
35
+ `Potential sensitive data in ${name}.attributes.${attrName} — ensure it is scrubbed`,
36
+ );
37
+ }
38
+ }
39
+ }
40
+ return warnings;
41
+ }
42
+
43
+ /**
44
+ * Validate ResourceMetadata entries — resources must have at least type and status.
45
+ * Returns { valid, dropped, warnings }.
46
+ */
47
+ function validateResources(
48
+ resources: Record<string, ResourceMetadata>,
49
+ ): {
50
+ valid: Record<string, ResourceMetadata>;
51
+ dropped: string[];
52
+ warnings: string[];
53
+ } {
54
+ const valid: Record<string, ResourceMetadata> = {};
55
+ const dropped: string[] = [];
56
+ const warnings: string[] = [];
57
+
58
+ for (const [name, meta] of Object.entries(resources)) {
59
+ if (!meta.type || !meta.status) {
60
+ dropped.push(name);
61
+ warnings.push(`Dropped ${name}: missing type or status`);
62
+ continue;
63
+ }
64
+ valid[name] = meta;
65
+ }
66
+
67
+ // Check for sensitive data in valid resources
68
+ warnings.push(...checkSensitiveData(valid));
69
+
70
+ return { valid, dropped, warnings };
71
+ }
72
+
73
+ export interface TakeSnapshotResult {
74
+ snapshots: StateSnapshot[];
75
+ commit: string;
76
+ warnings: string[];
77
+ errors: string[];
78
+ }
79
+
80
+ /**
81
+ * Take state snapshots for all plugins that implement describeResources.
82
+ */
83
+ export async function takeSnapshot(
84
+ environment: string,
85
+ plugins: LexiconPlugin[],
86
+ buildResult: BuildResult,
87
+ opts?: { cwd?: string },
88
+ ): Promise<TakeSnapshotResult> {
89
+ const warnings: string[] = [];
90
+ const errors: string[] = [];
91
+ const snapshots: StateSnapshot[] = [];
92
+
93
+ const headCommit = await getHeadCommit(opts);
94
+ const timestamp = new Date().toISOString();
95
+ const digest = computeBuildDigest(buildResult);
96
+
97
+ for (const plugin of plugins) {
98
+ if (!plugin.describeResources) continue;
99
+
100
+ // Get serialized build output for this lexicon
101
+ const rawOutput = buildResult.outputs.get(plugin.name);
102
+ const buildOutput =
103
+ rawOutput === undefined
104
+ ? ""
105
+ : typeof rawOutput === "string"
106
+ ? rawOutput
107
+ : (rawOutput as SerializerResult).primary;
108
+
109
+ // Get entity names for this lexicon
110
+ const entityNames: string[] = [];
111
+ for (const [name, entity] of buildResult.entities) {
112
+ if (entity.lexicon === plugin.name) {
113
+ entityNames.push(name);
114
+ }
115
+ }
116
+
117
+ try {
118
+ const resources = await plugin.describeResources({
119
+ environment,
120
+ buildOutput,
121
+ entityNames,
122
+ });
123
+
124
+ const { valid, dropped, warnings: validationWarnings } =
125
+ validateResources(resources);
126
+ warnings.push(...validationWarnings);
127
+
128
+ if (dropped.length > 0) {
129
+ warnings.push(
130
+ `${plugin.name}: dropped ${dropped.length} invalid resource(s)`,
131
+ );
132
+ }
133
+
134
+ if (Object.keys(valid).length === 0) {
135
+ errors.push(`${plugin.name}: no valid resources returned`);
136
+ continue;
137
+ }
138
+
139
+ const snapshot: StateSnapshot = {
140
+ lexicon: plugin.name,
141
+ environment,
142
+ commit: headCommit,
143
+ timestamp,
144
+ resources: valid,
145
+ digest,
146
+ };
147
+
148
+ snapshots.push(snapshot);
149
+ } catch (err) {
150
+ errors.push(
151
+ `${plugin.name}: ${err instanceof Error ? err.message : String(err)}`,
152
+ );
153
+ }
154
+ }
155
+
156
+ // Write all successful snapshots to git
157
+ let commitSha = "";
158
+ for (const snapshot of snapshots) {
159
+ const json = JSON.stringify(snapshot, sortedJsonReplacer, 2);
160
+ commitSha = await writeSnapshot(
161
+ snapshot.environment,
162
+ snapshot.lexicon,
163
+ json,
164
+ opts,
165
+ );
166
+ }
167
+
168
+ // Push to remote
169
+ if (snapshots.length > 0) {
170
+ await pushState(opts);
171
+ }
172
+
173
+ return {
174
+ snapshots,
175
+ commit: commitSha,
176
+ warnings,
177
+ errors,
178
+ };
179
+ }