@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
@@ -10,6 +10,10 @@ function createMockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
10
10
  return {
11
11
  name: "mock",
12
12
  serializer: { name: "mock", serialize: () => "" } as unknown as Serializer,
13
+ generate: async () => {},
14
+ validate: async () => {},
15
+ coverage: async () => {},
16
+ package: async () => {},
13
17
  ...overrides,
14
18
  };
15
19
  }
@@ -401,11 +405,11 @@ describe("McpServer", () => {
401
405
  test("passes template name to initTemplates", async () => {
402
406
  const plugin = createMockPlugin({
403
407
  name: "test-lex",
404
- initTemplates: (template?: string) => {
405
- if (template === "special") {
406
- return { src: { "special.ts": "export const special = {};" } };
407
- }
408
- return { src: { "default.ts": "export const def = {};" } };
408
+ initTemplates: (template?: string | undefined) => {
409
+ const src: Record<string, string> = template === "special"
410
+ ? { "special.ts": "export const special = {};" }
411
+ : { "default.ts": "export const def = {};" };
412
+ return { src };
409
413
  },
410
414
  });
411
415
 
@@ -958,19 +962,19 @@ describe("McpServer", () => {
958
962
 
959
963
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
960
964
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
961
- expect(tools).toHaveLength(6);
962
- expect(tools.map((t) => t.name).sort()).toEqual(["build", "explain", "import", "lint", "scaffold", "search"]);
965
+ expect(tools).toHaveLength(9);
966
+ expect(tools.map((t) => t.name).sort()).toEqual(["build", "explain", "import", "lint", "scaffold", "search", "spell-done", "state-diff", "state-snapshot"]);
963
967
 
964
968
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
965
969
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
966
- expect(resources).toHaveLength(2);
970
+ expect(resources).toHaveLength(7);
967
971
  });
968
972
 
969
973
  test("server with empty plugins array works", async () => {
970
974
  const s = new McpServer([]);
971
975
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
972
976
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
973
- expect(tools).toHaveLength(6);
977
+ expect(tools).toHaveLength(9);
974
978
  });
975
979
  });
976
980
  });
@@ -9,6 +9,14 @@ import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import { getContext } from "./resources/context";
10
10
  import type { LexiconPlugin } from "../../lexicon";
11
11
  import type { McpToolContribution, McpResourceContribution } from "../../mcp/types";
12
+ import { readSnapshot, readEnvironmentSnapshots } from "../../state/git";
13
+ import { build } from "../../build";
14
+ import { computeBuildDigest, diffDigests } from "../../state/digest";
15
+ import { takeSnapshot } from "../../state/snapshot";
16
+ import type { StateSnapshot } from "../../state/types";
17
+ import { discoverSpells } from "../../spell/discovery";
18
+ import { generatePrompt } from "../../spell/prompt";
19
+ import { getRuntime } from "../../runtime-adapter";
12
20
 
13
21
  /**
14
22
  * MCP message types
@@ -31,12 +39,6 @@ interface McpResponse {
31
39
  };
32
40
  }
33
41
 
34
- interface McpNotification {
35
- jsonrpc: "2.0";
36
- method: string;
37
- params?: Record<string, unknown>;
38
- }
39
-
40
42
  /**
41
43
  * Tool definition for MCP
42
44
  */
@@ -77,6 +79,117 @@ export class McpServer {
77
79
  this.registerTool(scaffoldTool, createScaffoldHandler(plugins ?? []));
78
80
  this.registerTool(searchTool, createSearchHandler(plugins ?? []));
79
81
 
82
+ // Register state tools
83
+ this.registerTool(
84
+ {
85
+ name: "state-snapshot",
86
+ description: "Capture deployed state for an environment",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ environment: { type: "string", description: "Target environment" },
91
+ lexicon: { type: "string", description: "Optional — snapshot all lexicons if omitted" },
92
+ },
93
+ required: ["environment"],
94
+ },
95
+ },
96
+ async (params) => {
97
+ const env = params.environment as string;
98
+ const lexiconFilter = params.lexicon as string | undefined;
99
+ const targetPlugins = lexiconFilter
100
+ ? (plugins ?? []).filter((p) => p.name === lexiconFilter)
101
+ : (plugins ?? []);
102
+ const pluginsWithDescribe = targetPlugins.filter((p) => p.describeResources);
103
+ if (pluginsWithDescribe.length === 0) return "No plugins implement describeResources";
104
+ const serializers = (plugins ?? []).map((p) => p.serializer);
105
+ const buildResult = await build(resolve("."), serializers);
106
+ if (buildResult.errors.length > 0) return "Build failed";
107
+ const result = await takeSnapshot(env, pluginsWithDescribe, buildResult);
108
+ return { snapshots: result.snapshots.length, warnings: result.warnings, errors: result.errors };
109
+ },
110
+ );
111
+
112
+ this.registerTool(
113
+ {
114
+ name: "state-diff",
115
+ description: "Compare current build declarations against last snapshot's digest",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ environment: { type: "string", description: "Target environment" },
120
+ lexicon: { type: "string", description: "Optional — diff all lexicons if omitted" },
121
+ },
122
+ required: ["environment"],
123
+ },
124
+ },
125
+ async (params) => {
126
+ const env = params.environment as string;
127
+ const lexiconFilter = params.lexicon as string | undefined;
128
+ const serializers = (plugins ?? []).map((p) => p.serializer);
129
+ const buildResult = await build(resolve("."), serializers);
130
+ if (buildResult.errors.length > 0) return "Build failed";
131
+ const currentDigest = computeBuildDigest(buildResult);
132
+ const lexicons = lexiconFilter ? [lexiconFilter] : buildResult.manifest.lexicons;
133
+ const results: Record<string, unknown> = {};
134
+ for (const lex of lexicons) {
135
+ const content = await readSnapshot(env, lex);
136
+ let previousDigest = undefined;
137
+ if (content) {
138
+ const snapshot: StateSnapshot = JSON.parse(content);
139
+ previousDigest = snapshot.digest;
140
+ }
141
+ results[lex] = diffDigests(currentDigest, previousDigest);
142
+ }
143
+ return results;
144
+ },
145
+ );
146
+
147
+ // Register spell tools
148
+ this.registerTool(
149
+ {
150
+ name: "spell-done",
151
+ description: "Mark a spell task as done",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: {
155
+ name: { type: "string", description: "Spell name" },
156
+ taskNumber: { type: "number", description: "Task number (1-based)" },
157
+ },
158
+ required: ["name", "taskNumber"],
159
+ },
160
+ },
161
+ async (params) => {
162
+ const { readFileSync, writeFileSync } = await import("node:fs");
163
+ const { spells } = await discoverSpells();
164
+ const name = params.name as string;
165
+ const taskNumber = params.taskNumber as number;
166
+ const spell = spells.get(name);
167
+ if (!spell) return `Spell "${name}" not found`;
168
+ if (taskNumber < 1 || taskNumber > spell.definition.tasks.length) {
169
+ return `Invalid task number ${taskNumber}`;
170
+ }
171
+ const task = spell.definition.tasks[taskNumber - 1];
172
+ if (task.done) return `Task ${taskNumber} is already done`;
173
+
174
+ const content = readFileSync(spell.filePath, "utf-8");
175
+ let count = 0;
176
+ const rewritten = content.replace(
177
+ /task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
178
+ (match, desc, opts) => {
179
+ count++;
180
+ if (count !== taskNumber) return match;
181
+ if (opts && opts.includes("done:")) {
182
+ return match.replace(/done:\s*false/, "done: true");
183
+ }
184
+ return `task(${desc}, { done: true })`;
185
+ },
186
+ );
187
+ if (rewritten === content) return `Could not rewrite task ${taskNumber}`;
188
+ writeFileSync(spell.filePath, rewritten);
189
+ return `Task ${taskNumber} marked done: "${task.description}"`;
190
+ },
191
+ );
192
+
80
193
  // Register plugin contributions
81
194
  if (plugins) {
82
195
  for (const plugin of plugins) {
@@ -268,6 +381,36 @@ export class McpServer {
268
381
  description: "List of available chant examples",
269
382
  mimeType: "application/json",
270
383
  },
384
+ {
385
+ uri: "chant://spells",
386
+ name: "Spells",
387
+ description: "List all spells with status, tasks, and lexicon",
388
+ mimeType: "application/json",
389
+ },
390
+ {
391
+ uri: "chant://spell/{name}",
392
+ name: "Spell details",
393
+ description: "Show spell definition and status",
394
+ mimeType: "application/json",
395
+ },
396
+ {
397
+ uri: "chant://spell/{name}/prompt",
398
+ name: "Spell bootstrap prompt",
399
+ description: "Bootstrap prompt for agent consumption",
400
+ mimeType: "text/markdown",
401
+ },
402
+ {
403
+ uri: "chant://state/{environment}",
404
+ name: "State (all lexicons)",
405
+ description: "All lexicon snapshots for an environment",
406
+ mimeType: "application/json",
407
+ },
408
+ {
409
+ uri: "chant://state/{environment}/{lexicon}",
410
+ name: "State (single lexicon)",
411
+ description: "Single lexicon snapshot for an environment",
412
+ mimeType: "application/json",
413
+ },
271
414
  ];
272
415
 
273
416
  // Merge plugin resources
@@ -322,6 +465,77 @@ export class McpServer {
322
465
  };
323
466
  }
324
467
 
468
+ // Spell resources
469
+ if (uri === "chant://spells") {
470
+ const { spells } = await discoverSpells();
471
+ const list = Array.from(spells.entries()).map(([name, s]) => ({
472
+ name,
473
+ status: s.status,
474
+ tasks: `${s.definition.tasks.filter((t) => t.done).length}/${s.definition.tasks.length}`,
475
+ lexicon: s.definition.lexicon ?? null,
476
+ overview: s.definition.overview,
477
+ }));
478
+ return {
479
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(list, null, 2) }],
480
+ };
481
+ }
482
+
483
+ if (uri.startsWith("chant://spell/") && uri.endsWith("/prompt")) {
484
+ const name = uri.replace("chant://spell/", "").replace("/prompt", "");
485
+ const { spells } = await discoverSpells();
486
+ const spell = spells.get(name);
487
+ if (!spell) throw new Error(`Spell "${name}" not found`);
488
+ const rt = getRuntime();
489
+ const gitRootResult = await rt.spawn(["git", "rev-parse", "--show-toplevel"]);
490
+ const gitRoot = gitRootResult.stdout.trim();
491
+ const prompt = await generatePrompt(spell.definition, { gitRoot });
492
+ return {
493
+ contents: [{ uri, mimeType: "text/markdown", text: prompt }],
494
+ };
495
+ }
496
+
497
+ if (uri.startsWith("chant://spell/")) {
498
+ const name = uri.replace("chant://spell/", "");
499
+ const { spells } = await discoverSpells();
500
+ const spell = spells.get(name);
501
+ if (!spell) throw new Error(`Spell "${name}" not found`);
502
+ return {
503
+ contents: [{
504
+ uri,
505
+ mimeType: "application/json",
506
+ text: JSON.stringify({
507
+ ...spell.definition,
508
+ status: spell.status,
509
+ filePath: spell.filePath,
510
+ }, null, 2),
511
+ }],
512
+ };
513
+ }
514
+
515
+ // State resources: chant://state/{environment} and chant://state/{environment}/{lexicon}
516
+ if (uri.startsWith("chant://state/")) {
517
+ const parts = uri.replace("chant://state/", "").split("/");
518
+ const environment = parts[0];
519
+ const lexicon = parts[1];
520
+
521
+ if (lexicon) {
522
+ const content = await readSnapshot(environment, lexicon);
523
+ if (!content) throw new Error(`No snapshot found for ${environment}/${lexicon}`);
524
+ return {
525
+ contents: [{ uri, mimeType: "application/json", text: content }],
526
+ };
527
+ } else {
528
+ const snapshots = await readEnvironmentSnapshots(environment);
529
+ const result: Record<string, unknown> = {};
530
+ for (const [lex, content] of snapshots) {
531
+ result[lex] = JSON.parse(content);
532
+ }
533
+ return {
534
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
535
+ };
536
+ }
537
+ }
538
+
325
539
  if (uri.startsWith("chant://examples/")) {
326
540
  // Look up example in plugin resources
327
541
  const name = uri.replace("chant://examples/", "");
@@ -58,7 +58,8 @@ export async function handleBuild(params: Record<string, unknown>): Promise<unkn
58
58
  // Combine all lexicon outputs
59
59
  const combined: Record<string, unknown> = {};
60
60
  for (const [lexiconName, lexiconOutput] of result.outputs) {
61
- combined[lexiconName] = JSON.parse(lexiconOutput);
61
+ const raw = lexiconOutput;
62
+ combined[lexiconName] = JSON.parse(typeof raw === "string" ? raw : raw.primary);
62
63
  }
63
64
 
64
65
  let output = JSON.stringify(combined, null, 2);
@@ -33,7 +33,7 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
33
33
  validate: notSupported("validate"),
34
34
  coverage: notSupported("coverage"),
35
35
  package: notSupported("package"),
36
- rollback: notSupported("rollback"),
36
+
37
37
  };
38
38
  }
39
39
 
@@ -17,9 +17,9 @@ describe("formatStylish", () => {
17
17
  }
18
18
  });
19
19
 
20
- test("returns empty string for no diagnostics", () => {
20
+ test("returns summary line for no diagnostics", () => {
21
21
  const result = formatStylish([]);
22
- expect(result).toBe("");
22
+ expect(result).toBe("\u2713 No problems found");
23
23
  });
24
24
 
25
25
  test("formats single diagnostic", () => {
@@ -36,7 +36,7 @@ function color(text: string, colorCode: string): string {
36
36
  */
37
37
  export function formatStylish(diagnostics: LintDiagnostic[]): string {
38
38
  if (diagnostics.length === 0) {
39
- return "";
39
+ return formatSummary(0, 0);
40
40
  }
41
41
 
42
42
  // Group by file
@@ -116,7 +116,7 @@ describe("Composite", () => {
116
116
  });
117
117
 
118
118
  const instance = MyComp({});
119
- const roleProps = instance.members.role.props as Record<string, unknown>;
119
+ const roleProps = (instance.members.role as MockResource).props;
120
120
  expect(roleProps.bucketArn).toBeInstanceOf(AttrRef);
121
121
  // The AttrRef's parent should be the bucket instance
122
122
  expect((roleProps.bucketArn as AttrRef).attribute).toBe("Arn");
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@ import type { LintConfig } from "./lint/config";
9
9
  export const ChantConfigSchema = z.object({
10
10
  runtime: z.enum(["bun", "node"]).optional(),
11
11
  lexicons: z.array(z.string().min(1)).optional(),
12
+ environments: z.array(z.string().min(1)).optional(),
12
13
  lint: z.record(z.string(), z.unknown()).optional(),
13
14
  }).passthrough();
14
15
 
@@ -24,6 +25,9 @@ export interface ChantConfig {
24
25
  /** Lexicon package names to load (e.g. ["aws"]) */
25
26
  lexicons?: string[];
26
27
 
28
+ /** Environment names (e.g. ["staging", "prod"]) */
29
+ environments?: string[];
30
+
27
31
  /** Lint configuration (rules, extends, overrides, plugins) */
28
32
  lint?: LintConfig;
29
33
  }
@@ -12,7 +12,8 @@ describe("DECLARABLE_MARKER", () => {
12
12
  });
13
13
 
14
14
  test("uses Symbol.for for global registry", () => {
15
- expect(DECLARABLE_MARKER).toBe(Symbol.for("chant.declarable"));
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ expect(DECLARABLE_MARKER).toBe(Symbol.for("chant.declarable") as any);
16
17
  });
17
18
  });
18
19
 
package/src/declarable.ts CHANGED
@@ -9,7 +9,7 @@ export const DECLARABLE_MARKER = Symbol.for("chant.declarable");
9
9
  export interface Declarable {
10
10
  readonly lexicon: string;
11
11
  readonly entityType: string;
12
- readonly kind?: "resource" | "property";
12
+ readonly kind?: "resource" | "property" | "output";
13
13
  readonly [DECLARABLE_MARKER]: true;
14
14
  }
15
15
 
@@ -12,6 +12,7 @@ describe("buildDependencyGraph", () => {
12
12
 
13
13
  test("returns graph with no dependencies for single entity", () => {
14
14
  const entity: Declarable = {
15
+ lexicon: "test",
15
16
  entityType: "test",
16
17
  [DECLARABLE_MARKER]: true,
17
18
  };
@@ -25,11 +26,13 @@ describe("buildDependencyGraph", () => {
25
26
 
26
27
  test("returns graph with no dependencies for multiple unrelated entities", () => {
27
28
  const entity1: Declarable = {
29
+ lexicon: "test",
28
30
  entityType: "test",
29
31
  [DECLARABLE_MARKER]: true,
30
32
  };
31
33
 
32
34
  const entity2: Declarable = {
35
+ lexicon: "test",
33
36
  entityType: "test",
34
37
  [DECLARABLE_MARKER]: true,
35
38
  };
@@ -47,11 +50,13 @@ describe("buildDependencyGraph", () => {
47
50
 
48
51
  test("detects dependency from AttrRef", () => {
49
52
  const parent: Declarable = {
53
+ lexicon: "test",
50
54
  entityType: "parent",
51
55
  [DECLARABLE_MARKER]: true,
52
56
  };
53
57
 
54
58
  const child: Declarable & { ref: AttrRef } = {
59
+ lexicon: "test",
55
60
  entityType: "child",
56
61
  [DECLARABLE_MARKER]: true,
57
62
  ref: new AttrRef(parent, "someAttr"),
@@ -71,11 +76,13 @@ describe("buildDependencyGraph", () => {
71
76
 
72
77
  test("detects dependency from direct Declarable reference", () => {
73
78
  const entity1: Declarable = {
79
+ lexicon: "test",
74
80
  entityType: "type1",
75
81
  [DECLARABLE_MARKER]: true,
76
82
  };
77
83
 
78
84
  const entity2: Declarable & { dependency: Declarable } = {
85
+ lexicon: "test",
79
86
  entityType: "type2",
80
87
  [DECLARABLE_MARKER]: true,
81
88
  dependency: entity1,
@@ -95,16 +102,19 @@ describe("buildDependencyGraph", () => {
95
102
 
96
103
  test("detects multiple dependencies from one entity", () => {
97
104
  const entity1: Declarable = {
105
+ lexicon: "test",
98
106
  entityType: "type1",
99
107
  [DECLARABLE_MARKER]: true,
100
108
  };
101
109
 
102
110
  const entity2: Declarable = {
111
+ lexicon: "test",
103
112
  entityType: "type2",
104
113
  [DECLARABLE_MARKER]: true,
105
114
  };
106
115
 
107
116
  const entity3: Declarable & { dep1: Declarable; dep2: Declarable } = {
117
+ lexicon: "test",
108
118
  entityType: "type3",
109
119
  [DECLARABLE_MARKER]: true,
110
120
  dep1: entity1,
@@ -126,11 +136,13 @@ describe("buildDependencyGraph", () => {
126
136
 
127
137
  test("detects dependencies in nested objects", () => {
128
138
  const entity1: Declarable = {
139
+ lexicon: "test",
129
140
  entityType: "type1",
130
141
  [DECLARABLE_MARKER]: true,
131
142
  };
132
143
 
133
144
  const entity2: Declarable & { nested: { deep: Declarable } } = {
145
+ lexicon: "test",
134
146
  entityType: "type2",
135
147
  [DECLARABLE_MARKER]: true,
136
148
  nested: {
@@ -150,16 +162,19 @@ describe("buildDependencyGraph", () => {
150
162
 
151
163
  test("detects dependencies in arrays", () => {
152
164
  const entity1: Declarable = {
165
+ lexicon: "test",
153
166
  entityType: "type1",
154
167
  [DECLARABLE_MARKER]: true,
155
168
  };
156
169
 
157
170
  const entity2: Declarable = {
171
+ lexicon: "test",
158
172
  entityType: "type2",
159
173
  [DECLARABLE_MARKER]: true,
160
174
  };
161
175
 
162
176
  const entity3: Declarable & { deps: Declarable[] } = {
177
+ lexicon: "test",
163
178
  entityType: "type3",
164
179
  [DECLARABLE_MARKER]: true,
165
180
  deps: [entity1, entity2],
@@ -179,16 +194,19 @@ describe("buildDependencyGraph", () => {
179
194
 
180
195
  test("detects mixed AttrRef and Declarable dependencies", () => {
181
196
  const entity1: Declarable = {
197
+ lexicon: "test",
182
198
  entityType: "type1",
183
199
  [DECLARABLE_MARKER]: true,
184
200
  };
185
201
 
186
202
  const entity2: Declarable = {
203
+ lexicon: "test",
187
204
  entityType: "type2",
188
205
  [DECLARABLE_MARKER]: true,
189
206
  };
190
207
 
191
208
  const entity3: Declarable & { ref: AttrRef; dep: Declarable } = {
209
+ lexicon: "test",
192
210
  entityType: "type3",
193
211
  [DECLARABLE_MARKER]: true,
194
212
  ref: new AttrRef(entity1, "attr"),
@@ -209,17 +227,20 @@ describe("buildDependencyGraph", () => {
209
227
 
210
228
  test("handles transitive dependencies correctly", () => {
211
229
  const entity1: Declarable = {
230
+ lexicon: "test",
212
231
  entityType: "type1",
213
232
  [DECLARABLE_MARKER]: true,
214
233
  };
215
234
 
216
235
  const entity2: Declarable & { dep: Declarable } = {
236
+ lexicon: "test",
217
237
  entityType: "type2",
218
238
  [DECLARABLE_MARKER]: true,
219
239
  dep: entity1,
220
240
  };
221
241
 
222
242
  const entity3: Declarable & { dep: Declarable } = {
243
+ lexicon: "test",
223
244
  entityType: "type3",
224
245
  [DECLARABLE_MARKER]: true,
225
246
  dep: entity2,
@@ -241,16 +262,19 @@ describe("buildDependencyGraph", () => {
241
262
 
242
263
  test("ignores non-entity declarables", () => {
243
264
  const entity: Declarable = {
265
+ lexicon: "test",
244
266
  entityType: "test",
245
267
  [DECLARABLE_MARKER]: true,
246
268
  };
247
269
 
248
270
  const notInEntities: Declarable = {
271
+ lexicon: "test",
249
272
  entityType: "external",
250
273
  [DECLARABLE_MARKER]: true,
251
274
  };
252
275
 
253
276
  const entityWithExternal: Declarable & { dep: Declarable } = {
277
+ lexicon: "test",
254
278
  entityType: "test",
255
279
  [DECLARABLE_MARKER]: true,
256
280
  dep: notInEntities,
@@ -267,11 +291,13 @@ describe("buildDependencyGraph", () => {
267
291
 
268
292
  test("ignores AttrRef with parent not in entities", () => {
269
293
  const externalParent: Declarable = {
294
+ lexicon: "test",
270
295
  entityType: "external",
271
296
  [DECLARABLE_MARKER]: true,
272
297
  };
273
298
 
274
299
  const entity: Declarable & { ref: AttrRef } = {
300
+ lexicon: "test",
275
301
  entityType: "test",
276
302
  [DECLARABLE_MARKER]: true,
277
303
  ref: new AttrRef(externalParent, "attr"),
@@ -285,11 +311,13 @@ describe("buildDependencyGraph", () => {
285
311
 
286
312
  test("handles circular references without infinite loop", () => {
287
313
  const entity1: Declarable & { other?: Declarable } = {
314
+ lexicon: "test",
288
315
  entityType: "type1",
289
316
  [DECLARABLE_MARKER]: true,
290
317
  };
291
318
 
292
319
  const entity2: Declarable & { other: Declarable } = {
320
+ lexicon: "test",
293
321
  entityType: "type2",
294
322
  [DECLARABLE_MARKER]: true,
295
323
  other: entity1,
@@ -309,6 +337,7 @@ describe("buildDependencyGraph", () => {
309
337
 
310
338
  test("handles self-reference without infinite loop", () => {
311
339
  const entity: Declarable & { self?: Declarable } = {
340
+ lexicon: "test",
312
341
  entityType: "test",
313
342
  [DECLARABLE_MARKER]: true,
314
343
  };
@@ -329,6 +358,7 @@ describe("buildDependencyGraph", () => {
329
358
  bool: boolean;
330
359
  nul: null;
331
360
  } = {
361
+ lexicon: "test",
332
362
  entityType: "test",
333
363
  [DECLARABLE_MARKER]: true,
334
364
  str: "value",
@@ -348,6 +378,7 @@ describe("buildDependencyGraph", () => {
348
378
  // parent is the resource itself (e.g. bucket.arn, bucket.bucketName).
349
379
  // These are not real dependencies — they're just attribute accessors.
350
380
  const resource: Declarable & { arn?: AttrRef; bucketName?: AttrRef } = {
381
+ lexicon: "test",
351
382
  entityType: "AWS::S3::Bucket",
352
383
  [DECLARABLE_MARKER]: true,
353
384
  };
@@ -364,6 +395,7 @@ describe("buildDependencyGraph", () => {
364
395
  // A resource has its own AttrRefs (self-pointing) AND a property that
365
396
  // references a different entity. Only the cross-resource dep should appear.
366
397
  const defaults: Declarable = {
398
+ lexicon: "test",
367
399
  entityType: "AWS::S3::VersioningConfiguration",
368
400
  [DECLARABLE_MARKER]: true,
369
401
  };
@@ -372,6 +404,7 @@ describe("buildDependencyGraph", () => {
372
404
  arn?: AttrRef;
373
405
  versioningConfiguration?: Declarable;
374
406
  } = {
407
+ lexicon: "test",
375
408
  entityType: "AWS::S3::Bucket",
376
409
  [DECLARABLE_MARKER]: true,
377
410
  };
@@ -391,6 +424,7 @@ describe("buildDependencyGraph", () => {
391
424
 
392
425
  test("ignores plain objects without markers", () => {
393
426
  const entity: Declarable & { data: { key: string } } = {
427
+ lexicon: "test",
394
428
  entityType: "test",
395
429
  [DECLARABLE_MARKER]: true,
396
430
  data: { key: "value" },
@@ -404,6 +438,7 @@ describe("buildDependencyGraph", () => {
404
438
 
405
439
  test("handles AttrRef with garbage collected parent gracefully", () => {
406
440
  const entity: Declarable & { ref: AttrRef } = {
441
+ lexicon: "test",
407
442
  entityType: "test",
408
443
  [DECLARABLE_MARKER]: true,
409
444
  ref: new AttrRef({}, "attr"), // Using plain object that will be GC'd
@@ -418,6 +453,7 @@ describe("buildDependencyGraph", () => {
418
453
 
419
454
  test("detects dependencies deeply nested in arrays and objects", () => {
420
455
  const entity1: Declarable = {
456
+ lexicon: "test",
421
457
  entityType: "type1",
422
458
  [DECLARABLE_MARKER]: true,
423
459
  };
@@ -425,6 +461,7 @@ describe("buildDependencyGraph", () => {
425
461
  const entity2: Declarable & {
426
462
  complex: { nested: { array: Array<{ item: Declarable }> } };
427
463
  } = {
464
+ lexicon: "test",
428
465
  entityType: "type2",
429
466
  [DECLARABLE_MARKER]: true,
430
467
  complex: {
@@ -446,17 +483,20 @@ describe("buildDependencyGraph", () => {
446
483
 
447
484
  test("does not traverse into referenced declarables", () => {
448
485
  const entity1: Declarable = {
486
+ lexicon: "test",
449
487
  entityType: "type1",
450
488
  [DECLARABLE_MARKER]: true,
451
489
  };
452
490
 
453
491
  const entity2: Declarable & { internal: { data: string } } = {
492
+ lexicon: "test",
454
493
  entityType: "type2",
455
494
  [DECLARABLE_MARKER]: true,
456
495
  internal: { data: "should not traverse this" },
457
496
  };
458
497
 
459
498
  const entity3: Declarable & { dep: Declarable } = {
499
+ lexicon: "test",
460
500
  entityType: "type3",
461
501
  [DECLARABLE_MARKER]: true,
462
502
  dep: entity2,