@intentius/chant 0.0.18 → 0.0.24
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/bin/chant +4 -1
- package/package.json +20 -1
- package/src/build.test.ts +4 -2
- package/src/build.ts +3 -0
- package/src/builder.test.ts +3 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
- package/src/cli/commands/build.ts +5 -12
- package/src/cli/commands/diff.test.ts +2 -1
- package/src/cli/commands/diff.ts +2 -1
- package/src/cli/commands/init-lexicon/templates/codegen.ts +188 -0
- package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
- package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
- package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
- package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
- package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
- package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
- package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
- package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
- package/src/cli/commands/init-lexicon.test.ts +0 -9
- package/src/cli/commands/init-lexicon.ts +12 -868
- package/src/cli/commands/init.ts +2 -20
- package/src/cli/conflict-check.test.ts +43 -0
- package/src/cli/handlers/build.ts +3 -3
- package/src/cli/handlers/lint.ts +2 -2
- package/src/cli/handlers/spell.ts +396 -0
- package/src/cli/handlers/state.ts +230 -0
- package/src/cli/lsp/server.test.ts +4 -0
- package/src/cli/main.ts +37 -3
- package/src/cli/mcp/resource-handlers.ts +227 -0
- package/src/cli/mcp/server.test.ts +13 -9
- package/src/cli/mcp/server.ts +24 -199
- package/src/cli/mcp/state-tools.ts +138 -0
- package/src/cli/mcp/tools/build.ts +2 -1
- package/src/cli/mcp/types.ts +45 -0
- package/src/cli/plugins.ts +1 -1
- package/src/cli/reporters/stylish.test.ts +2 -2
- package/src/cli/reporters/stylish.ts +1 -1
- package/src/codegen/docs-file-markers.ts +69 -0
- package/src/codegen/docs-rule-scanning.ts +159 -0
- package/src/codegen/docs-sections.ts +159 -0
- package/src/codegen/docs-sidebar.ts +56 -0
- package/src/codegen/docs-types.ts +79 -0
- package/src/codegen/docs.ts +9 -495
- package/src/composite.test.ts +76 -1
- package/src/composite.ts +37 -0
- package/src/config.ts +4 -0
- package/src/declarable.test.ts +2 -1
- package/src/declarable.ts +1 -1
- package/src/discovery/collect.test.ts +34 -0
- package/src/discovery/collect.ts +12 -0
- package/src/discovery/graph.test.ts +40 -0
- package/src/discovery/import.test.ts +5 -5
- package/src/discovery/resolve.test.ts +20 -0
- package/src/discovery/resolve.ts +2 -2
- package/src/index.ts +2 -0
- package/src/lexicon-plugin-helpers.ts +130 -0
- package/src/lexicon.ts +24 -0
- package/src/lint/rule-options.test.ts +3 -3
- package/src/lint/rule-registry.test.ts +1 -1
- package/src/lint/rules/composite-scope.ts +1 -1
- package/src/serializer-walker.ts +2 -1
- package/src/spell/discovery.ts +183 -0
- package/src/spell/index.ts +3 -0
- package/src/spell/prompt.ts +133 -0
- package/src/spell/types.ts +89 -0
- package/src/state/digest.ts +88 -0
- package/src/state/git.ts +317 -0
- package/src/state/index.ts +4 -0
- package/src/state/snapshot.ts +179 -0
- package/src/state/types.ts +59 -0
- package/src/toml-emit.ts +182 -0
- package/src/toml-parse.ts +370 -0
- package/src/toml-utils.ts +60 -0
- package/src/toml.ts +5 -602
- package/src/types.ts +2 -1
- package/src/utils.test.ts +16 -3
- package/src/utils.ts +31 -1
- package/src/validation.test.ts +11 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
package/src/state/git.ts
ADDED
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
2
|
+
|
|
3
|
+
export type { ResourceMetadata } from "../lexicon";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* State snapshot for a single lexicon in an environment.
|
|
7
|
+
*/
|
|
8
|
+
export interface StateSnapshot {
|
|
9
|
+
lexicon: string;
|
|
10
|
+
environment: string;
|
|
11
|
+
/** Main branch commit this corresponds to */
|
|
12
|
+
commit: string;
|
|
13
|
+
/** ISO timestamp when the snapshot was taken */
|
|
14
|
+
timestamp: string;
|
|
15
|
+
/** Resource metadata keyed by logical name */
|
|
16
|
+
resources: Record<string, ResourceMetadata>;
|
|
17
|
+
/** Build digest at snapshot time — what was declared when this snapshot was taken */
|
|
18
|
+
digest?: BuildDigest;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Digest of a single resource declaration.
|
|
23
|
+
*/
|
|
24
|
+
export interface ResourceDigest {
|
|
25
|
+
/** Entity type (e.g. AWS::S3::Bucket) */
|
|
26
|
+
type: string;
|
|
27
|
+
/** Which lexicon owns this resource */
|
|
28
|
+
lexicon: string;
|
|
29
|
+
/** Hash of deterministically-serialized declaration props */
|
|
30
|
+
propsHash: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Digest of the entire build at a point in time.
|
|
35
|
+
*/
|
|
36
|
+
export interface BuildDigest {
|
|
37
|
+
/** Per-resource digest keyed by logical name */
|
|
38
|
+
resources: Record<string, ResourceDigest>;
|
|
39
|
+
/** Resource-level dependency graph */
|
|
40
|
+
dependencies: Record<string, string[]>;
|
|
41
|
+
/** Cross-lexicon output bridges from BuildManifest */
|
|
42
|
+
outputs: Record<string, { source: string; entity: string; attribute: string }>;
|
|
43
|
+
/** Lexicon-level deploy order */
|
|
44
|
+
deployOrder: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Result of comparing two build digests.
|
|
49
|
+
*/
|
|
50
|
+
export interface DigestDiff {
|
|
51
|
+
/** Resources in current build but not in previous digest */
|
|
52
|
+
added: string[];
|
|
53
|
+
/** Resources in previous digest but not in current build */
|
|
54
|
+
removed: string[];
|
|
55
|
+
/** Resources where propsHash differs */
|
|
56
|
+
changed: string[];
|
|
57
|
+
/** Resources where propsHash matches */
|
|
58
|
+
unchanged: string[];
|
|
59
|
+
}
|