@intentius/chant 0.1.6 → 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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/build.test.ts +58 -5
  3. package/src/cli/commands/build.ts +24 -3
  4. package/src/cli/handlers/graph.test.ts +91 -0
  5. package/src/cli/handlers/graph.ts +23 -0
  6. package/src/cli/handlers/run-client.ts +134 -0
  7. package/src/cli/handlers/run-report.ts +160 -0
  8. package/src/cli/handlers/run.test.ts +448 -0
  9. package/src/cli/handlers/run.ts +453 -0
  10. package/src/cli/handlers/state.test.ts +409 -0
  11. package/src/cli/handlers/state.ts +232 -10
  12. package/src/cli/main.test.ts +65 -0
  13. package/src/cli/main.ts +32 -18
  14. package/src/cli/mcp/op-tools.ts +204 -0
  15. package/src/cli/mcp/resource-handlers.ts +69 -50
  16. package/src/cli/mcp/resources/context.ts +27 -0
  17. package/src/cli/mcp/server.test.ts +176 -3
  18. package/src/cli/mcp/server.ts +7 -3
  19. package/src/cli/mcp/state-tools.ts +0 -51
  20. package/src/cli/mcp/tools/search.ts +6 -1
  21. package/src/cli/registry.ts +3 -0
  22. package/src/composite.ts +10 -5
  23. package/src/index.ts +1 -2
  24. package/src/lexicon-plugin-helpers.ts +13 -5
  25. package/src/lexicon.ts +57 -1
  26. package/src/lint/config.test.ts +21 -0
  27. package/src/lint/config.ts +19 -3
  28. package/src/op/discover.test.ts +43 -0
  29. package/src/op/discover.ts +89 -0
  30. package/src/op/index.ts +3 -1
  31. package/src/op/types.ts +13 -6
  32. package/src/state/digest.test.ts +117 -0
  33. package/src/state/git.test.ts +191 -0
  34. package/src/state/git.ts +63 -11
  35. package/src/state/live-diff.test.ts +184 -0
  36. package/src/state/live-diff.ts +215 -0
  37. package/src/state/snapshot.test.ts +171 -0
  38. package/src/state/snapshot.ts +39 -19
  39. package/src/state/types.ts +4 -2
  40. package/src/cli/handlers/spell.ts +0 -396
  41. package/src/spell/discovery.ts +0 -183
  42. package/src/spell/index.ts +0 -3
  43. package/src/spell/prompt.ts +0 -133
  44. package/src/spell/types.ts +0 -89
@@ -195,6 +195,62 @@ describe("McpServer", () => {
195
195
  expect(props.lexicon).toBeDefined();
196
196
  expect(props.limit).toBeDefined();
197
197
  });
198
+
199
+ describe("Op tools schema", () => {
200
+ async function getToolProps(name: string): Promise<Record<string, unknown>> {
201
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
202
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
203
+ const tool = result.tools.find((t) => t.name === name)!;
204
+ return tool.inputSchema.properties as Record<string, unknown>;
205
+ }
206
+
207
+ test("op-list has profile property", async () => {
208
+ const props = await getToolProps("op-list");
209
+ expect(props.profile).toBeDefined();
210
+ });
211
+
212
+ test("op-run has name (required) and profile", async () => {
213
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
214
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
215
+ const tool = result.tools.find((t) => t.name === "op-run")!;
216
+ const props = tool.inputSchema.properties as Record<string, unknown>;
217
+ expect(props.name).toBeDefined();
218
+ expect(props.profile).toBeDefined();
219
+ expect(tool.inputSchema.required).toContain("name");
220
+ });
221
+
222
+ test("op-status has name (required) and profile", async () => {
223
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
224
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
225
+ const tool = result.tools.find((t) => t.name === "op-status")!;
226
+ const props = tool.inputSchema.properties as Record<string, unknown>;
227
+ expect(props.name).toBeDefined();
228
+ expect(props.profile).toBeDefined();
229
+ expect(tool.inputSchema.required).toContain("name");
230
+ });
231
+
232
+ test("op-signal has name and signal (both required) and profile", async () => {
233
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
234
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
235
+ const tool = result.tools.find((t) => t.name === "op-signal")!;
236
+ const props = tool.inputSchema.properties as Record<string, unknown>;
237
+ expect(props.name).toBeDefined();
238
+ expect(props.signal).toBeDefined();
239
+ expect(props.profile).toBeDefined();
240
+ expect(tool.inputSchema.required).toContain("name");
241
+ expect(tool.inputSchema.required).toContain("signal");
242
+ });
243
+
244
+ test("op-report has name (required) and profile", async () => {
245
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
246
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
247
+ const tool = result.tools.find((t) => t.name === "op-report")!;
248
+ const props = tool.inputSchema.properties as Record<string, unknown>;
249
+ expect(props.name).toBeDefined();
250
+ expect(props.profile).toBeDefined();
251
+ expect(tool.inputSchema.required).toContain("name");
252
+ });
253
+ });
198
254
  });
199
255
 
200
256
  describe("tools/call", () => {
@@ -302,6 +358,74 @@ describe("McpServer", () => {
302
358
  expect(parsed.total).toBe(0);
303
359
  expect(parsed.results).toEqual([]);
304
360
  });
361
+
362
+ describe("Op tool handlers", () => {
363
+ test("op-list returns list without throwing when Temporal unavailable", async () => {
364
+ const response = await server.handleRequest({
365
+ jsonrpc: "2.0",
366
+ id: 1,
367
+ method: "tools/call",
368
+ params: { name: "op-list", arguments: {} },
369
+ });
370
+ expect(response.error).toBeUndefined();
371
+ const result = response.result as { content: Array<{ type: string; text: string }>; isError?: boolean };
372
+ expect(result.content[0].type).toBe("text");
373
+ // May be empty list or error-degraded — but no thrown error
374
+ expect(result.isError).toBeUndefined();
375
+ });
376
+
377
+ test("op-run returns isError when Temporal unavailable", async () => {
378
+ const response = await server.handleRequest({
379
+ jsonrpc: "2.0",
380
+ id: 1,
381
+ method: "tools/call",
382
+ params: { name: "op-run", arguments: { name: "nonexistent-op" } },
383
+ });
384
+ expect(response.error).toBeUndefined();
385
+ const result = response.result as { content: Array<{ text: string }>; isError?: boolean };
386
+ // Either "not found" or Temporal error — either way should not be a protocol error
387
+ expect(result.content[0].text.length).toBeGreaterThan(0);
388
+ });
389
+
390
+ test("op-status returns isError when Temporal unavailable", async () => {
391
+ const response = await server.handleRequest({
392
+ jsonrpc: "2.0",
393
+ id: 1,
394
+ method: "tools/call",
395
+ params: { name: "op-status", arguments: { name: "nonexistent-op" } },
396
+ });
397
+ expect(response.error).toBeUndefined();
398
+ const result = response.result as { content: Array<{ text: string }>; isError: boolean };
399
+ expect(result.isError).toBe(true);
400
+ expect(result.content[0].text).toContain("Error:");
401
+ });
402
+
403
+ test("op-signal returns isError when Temporal unavailable", async () => {
404
+ const response = await server.handleRequest({
405
+ jsonrpc: "2.0",
406
+ id: 1,
407
+ method: "tools/call",
408
+ params: { name: "op-signal", arguments: { name: "nonexistent-op", signal: "gate" } },
409
+ });
410
+ expect(response.error).toBeUndefined();
411
+ const result = response.result as { content: Array<{ text: string }>; isError: boolean };
412
+ expect(result.isError).toBe(true);
413
+ expect(result.content[0].text).toContain("Error:");
414
+ });
415
+
416
+ test("op-report returns content without throwing when op not found", async () => {
417
+ const response = await server.handleRequest({
418
+ jsonrpc: "2.0",
419
+ id: 1,
420
+ method: "tools/call",
421
+ params: { name: "op-report", arguments: { name: "nonexistent-op" } },
422
+ });
423
+ expect(response.error).toBeUndefined();
424
+ const result = response.result as { content: Array<{ text: string }>; isError?: boolean };
425
+ // Op not found → returns a "not found" message or Temporal error, not a protocol error
426
+ expect(result.content[0].text.length).toBeGreaterThan(0);
427
+ });
428
+ });
305
429
  });
306
430
 
307
431
  // -----------------------------------------------------------------------
@@ -446,6 +570,9 @@ describe("McpServer", () => {
446
570
  const uris = result.resources.map((r) => r.uri);
447
571
  expect(uris).toContain("chant://context");
448
572
  expect(uris).toContain("chant://examples/list");
573
+ expect(uris).toContain("chant://ops");
574
+ expect(uris).toContain("chant://ops/{name}/runs");
575
+ expect(uris).toContain("chant://ops/{name}/runs/latest");
449
576
 
450
577
  // Each resource has required fields
451
578
  for (const resource of result.resources) {
@@ -549,6 +676,48 @@ describe("McpServer", () => {
549
676
  expect(response.error).toBeDefined();
550
677
  expect(response.error?.message).toContain("Unknown resource");
551
678
  });
679
+
680
+ describe("Op resources", () => {
681
+ test("chant://ops returns an array", async () => {
682
+ const response = await server.handleRequest({
683
+ jsonrpc: "2.0",
684
+ id: 1,
685
+ method: "resources/read",
686
+ params: { uri: "chant://ops" },
687
+ });
688
+ expect(response.error).toBeUndefined();
689
+ const result = response.result as { contents: Array<{ text: string; mimeType: string }> };
690
+ expect(result.contents[0].mimeType).toBe("application/json");
691
+ const ops = JSON.parse(result.contents[0].text);
692
+ expect(Array.isArray(ops)).toBe(true);
693
+ });
694
+
695
+ test("chant://ops/{name}/runs degrades gracefully when Temporal unavailable", async () => {
696
+ const response = await server.handleRequest({
697
+ jsonrpc: "2.0",
698
+ id: 1,
699
+ method: "resources/read",
700
+ params: { uri: "chant://ops/nonexistent/runs" },
701
+ });
702
+ expect(response.error).toBeUndefined();
703
+ const result = response.result as { contents: Array<{ text: string }> };
704
+ const data = JSON.parse(result.contents[0].text);
705
+ expect(data.error).toBeDefined();
706
+ });
707
+
708
+ test("chant://ops/{name}/runs/latest degrades gracefully when Temporal unavailable", async () => {
709
+ const response = await server.handleRequest({
710
+ jsonrpc: "2.0",
711
+ id: 1,
712
+ method: "resources/read",
713
+ params: { uri: "chant://ops/nonexistent/runs/latest" },
714
+ });
715
+ expect(response.error).toBeUndefined();
716
+ const result = response.result as { contents: Array<{ text: string }> };
717
+ const data = JSON.parse(result.contents[0].text);
718
+ expect(data.error).toBeDefined();
719
+ });
720
+ });
552
721
  });
553
722
 
554
723
  // -----------------------------------------------------------------------
@@ -962,8 +1131,12 @@ describe("McpServer", () => {
962
1131
 
963
1132
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
964
1133
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
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"]);
1134
+ expect(tools).toHaveLength(13);
1135
+ expect(tools.map((t) => t.name).sort()).toEqual([
1136
+ "build", "explain", "import", "lint",
1137
+ "op-list", "op-report", "op-run", "op-signal", "op-status",
1138
+ "scaffold", "search", "state-diff", "state-snapshot",
1139
+ ]);
967
1140
 
968
1141
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
969
1142
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
@@ -974,7 +1147,7 @@ describe("McpServer", () => {
974
1147
  const s = new McpServer([]);
975
1148
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
976
1149
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
977
- expect(tools).toHaveLength(9);
1150
+ expect(tools).toHaveLength(13);
978
1151
  });
979
1152
  });
980
1153
  });
@@ -8,7 +8,8 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import type { LexiconPlugin } from "../../lexicon";
10
10
  import type { McpRequest, McpResponse, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
- import { createSnapshotTool, createDiffTool, createSpellDoneTool } from "./state-tools";
11
+ import { createSnapshotTool, createDiffTool } from "./state-tools";
12
+ import { createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool } from "./op-tools";
12
13
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
13
14
 
14
15
  /**
@@ -35,8 +36,11 @@ export class McpServer {
35
36
  const diff = createDiffTool(plugins ?? []);
36
37
  this.registerTool(diff.definition, diff.handler);
37
38
 
38
- const spellDone = createSpellDoneTool();
39
- this.registerTool(spellDone.definition, spellDone.handler);
39
+ // Register Op tools
40
+ for (const factory of [createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool]) {
41
+ const t = factory();
42
+ this.registerTool(t.definition, t.handler);
43
+ }
40
44
 
41
45
  // Register plugin contributions
42
46
  if (plugins) {
@@ -6,8 +6,6 @@ import { build } from "../../build";
6
6
  import { computeBuildDigest, diffDigests } from "../../state/digest";
7
7
  import { takeSnapshot } from "../../state/snapshot";
8
8
  import type { StateSnapshot } from "../../state/types";
9
- import { discoverSpells } from "../../spell/discovery";
10
-
11
9
  export interface ToolRegistration {
12
10
  definition: ToolDefinition;
13
11
  handler: ToolHandler;
@@ -87,52 +85,3 @@ export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
87
85
  };
88
86
  }
89
87
 
90
- /**
91
- * Create spell-done tool definition and handler
92
- */
93
- export function createSpellDoneTool(): ToolRegistration {
94
- return {
95
- definition: {
96
- name: "spell-done",
97
- description: "Mark a spell task as done",
98
- inputSchema: {
99
- type: "object",
100
- properties: {
101
- name: { type: "string", description: "Spell name" },
102
- taskNumber: { type: "number", description: "Task number (1-based)" },
103
- },
104
- required: ["name", "taskNumber"],
105
- },
106
- },
107
- handler: async (params) => {
108
- const { readFileSync, writeFileSync } = await import("node:fs");
109
- const { spells } = await discoverSpells();
110
- const name = params.name as string;
111
- const taskNumber = params.taskNumber as number;
112
- const spell = spells.get(name);
113
- if (!spell) return `Spell "${name}" not found`;
114
- if (taskNumber < 1 || taskNumber > spell.definition.tasks.length) {
115
- return `Invalid task number ${taskNumber}`;
116
- }
117
- const task = spell.definition.tasks[taskNumber - 1];
118
- if (task.done) return `Task ${taskNumber} is already done`;
119
-
120
- const content = readFileSync(spell.filePath, "utf-8");
121
- let count = 0;
122
- const rewritten = content.replace(
123
- /task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
124
- (match, desc, opts) => {
125
- count++;
126
- if (count !== taskNumber) return match;
127
- if (opts && opts.includes("done:")) {
128
- return match.replace(/done:\s*false/, "done: true");
129
- }
130
- return `task(${desc}, { done: true })`;
131
- },
132
- );
133
- if (rewritten === content) return `Could not rewrite task ${taskNumber}`;
134
- writeFileSync(spell.filePath, rewritten);
135
- return `Task ${taskNumber} marked done: "${task.description}"`;
136
- },
137
- };
138
- }
@@ -52,7 +52,12 @@ export function createSearchHandler(
52
52
 
53
53
  for (const plugin of candidates) {
54
54
  const resources = plugin.mcpResources?.() ?? [];
55
- const catalog = resources.find((r) => r.uri === "resource-catalog");
55
+ // Catalog URIs were unscoped ("resource-catalog") before lexicon
56
+ // namespacing landed; new lexicons emit "<lexicon>:resource-catalog"
57
+ // to avoid cross-lexicon collision. Support both.
58
+ const catalog = resources.find(
59
+ (r) => r.uri === "resource-catalog" || r.uri.endsWith(":resource-catalog"),
60
+ );
56
61
  if (!catalog) continue;
57
62
 
58
63
  let entries: CatalogEntry[];
@@ -18,6 +18,9 @@ export interface ParsedArgs {
18
18
  watch: boolean;
19
19
  verbose: boolean;
20
20
  help: boolean;
21
+ profile?: string;
22
+ report?: boolean;
23
+ live: boolean;
21
24
  }
22
25
 
23
26
  /**
package/src/composite.ts CHANGED
@@ -95,11 +95,16 @@ export function Composite<P, M extends CompositeMembers = CompositeMembers>(
95
95
  }
96
96
  }
97
97
 
98
- const instance: CompositeInstance<M> = {
99
- [COMPOSITE_MARKER]: true,
100
- members,
101
- _definition: definition,
102
- };
98
+ // Define `members` and `_definition` as non-enumerable so spreading a
99
+ // composite instance (`...someComposite`) only exposes the actual member
100
+ // resources, not the framework's bookkeeping properties. Without this, a
101
+ // parent composite that does `...childResult` ends up with a `members` key
102
+ // pointing at the child's CompositeMembers record — not a Declarable —
103
+ // which then trips the parent's own member validation.
104
+ const instance = {} as CompositeInstance<M>;
105
+ Object.defineProperty(instance, COMPOSITE_MARKER, { value: true, enumerable: false });
106
+ Object.defineProperty(instance, "members", { value: members, enumerable: false });
107
+ Object.defineProperty(instance, "_definition", { value: definition, enumerable: false });
103
108
 
104
109
  return Object.assign(instance, members) as CompositeInstance<M> & M;
105
110
  }) as CompositeDefinition<P, M>;
package/src/index.ts CHANGED
@@ -60,8 +60,7 @@ export * from "./lsp/types";
60
60
  export * from "./lsp/lexicon-providers";
61
61
  export * from "./mcp/types";
62
62
  export * from "./state/index";
63
- export * from "./spell/index";
64
63
  // Op builders — use explicit exports to avoid collision with the core `build` function
65
64
  export { Op, phase, activity, gate, kubectlApply, helmInstall, waitForStack,
66
65
  gitlabPipeline, stateSnapshot, shell, teardown, OpResource } from "./op/index";
67
- export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, RetryPolicy } from "./op/index";
66
+ export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./op/index";
@@ -61,15 +61,18 @@ export function createSkillsLoader(
61
61
  /**
62
62
  * Create an MCP diff tool contribution for a lexicon.
63
63
  *
64
- * All lexicons (except Azure) expose an identical "diff" tool that compares
65
- * current build output against previous output using the lexicon's serializer.
64
+ * All lexicons (except Azure) expose an identical-shape "diff" tool that
65
+ * compares current build output against previous output using the lexicon's
66
+ * serializer. The tool name is namespaced by `lexiconName` (e.g. `gcp:diff`)
67
+ * so multiple lexicons loaded together don't collide on the same tool key.
66
68
  */
67
69
  export function createDiffTool(
68
70
  serializer: Serializer,
69
71
  description: string,
72
+ lexiconName: string,
70
73
  ): McpToolContribution {
71
74
  return {
72
- name: "diff",
75
+ name: `${lexiconName}:diff`,
73
76
  description,
74
77
  inputSchema: {
75
78
  type: "object" as const,
@@ -96,21 +99,26 @@ export function createDiffTool(
96
99
  /**
97
100
  * Create an MCP resource that serves the lexicon's meta.json as a catalog.
98
101
  *
99
- * Most lexicons expose a "resource-catalog" resource with identical structure.
102
+ * Most lexicons expose a "resource-catalog" resource with identical
103
+ * structure. The URI is namespaced by `lexiconName` (e.g.
104
+ * `gcp:resource-catalog`) so multiple lexicons loaded together don't
105
+ * collide on the same resource key.
100
106
  *
101
107
  * @param importMetaUrl — The plugin's import.meta.url (used to locate generated JSON)
102
108
  * @param name — Display name (e.g. "AWS Resource Catalog")
103
109
  * @param description — Resource description
104
110
  * @param lexiconJsonFile — Filename of the generated lexicon JSON (e.g. "lexicon-aws.json")
111
+ * @param lexiconName — Short lexicon name (e.g. "aws", "gcp"). Becomes the URI prefix.
105
112
  */
106
113
  export function createCatalogResource(
107
114
  importMetaUrl: string,
108
115
  name: string,
109
116
  description: string,
110
117
  lexiconJsonFile: string,
118
+ lexiconName: string,
111
119
  ): McpResourceContribution {
112
120
  return {
113
- uri: "resource-catalog",
121
+ uri: `${lexiconName}:resource-catalog`,
114
122
  name,
115
123
  description,
116
124
  mimeType: "application/json",
package/src/lexicon.ts CHANGED
@@ -195,12 +195,48 @@ export interface LexiconPlugin {
195
195
  mcpResources?(): McpResourceContribution[];
196
196
 
197
197
  // State
198
- /** Query deployed resources and return API metadata. Opt-in. */
198
+ /**
199
+ * Query deployed resources and return API metadata. Opt-in.
200
+ *
201
+ * Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
202
+ * AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
203
+ *
204
+ * `entities` carries the chant-side entity declarations for this lexicon,
205
+ * keyed by chant entity name (e.g. the export name from a `*.ts` file).
206
+ * Implementations that need to map cloud-side names back to chant entity
207
+ * names (e.g. Temporal — server-side namespace `prod` ↔ chant entity `ns`
208
+ * declared with `name: "prod"`) read this; implementations that already
209
+ * have name parity (e.g. AWS CloudFormation logical IDs == chant entity
210
+ * names) can ignore it.
211
+ *
212
+ * `entityNames` is preserved as a convenience for the simple case.
213
+ */
199
214
  describeResources?(options: {
200
215
  environment: string;
201
216
  buildOutput: string;
202
217
  entityNames: string[];
218
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
203
219
  }): Promise<Record<string, ResourceMetadata>>;
220
+
221
+ /**
222
+ * List runtime artifacts in the given environment. Opt-in.
223
+ *
224
+ * Use this for lexicons whose chant entities describe *authoring*
225
+ * primitives rather than 1:1 cloud resources — e.g. Helm (charts vs
226
+ * releases), Docker (Compose vs running containers), Flyway (migration
227
+ * scripts vs applied migrations). The contract is context-keyed: given an
228
+ * environment, list all artifacts visible there. There is no `declared`
229
+ * comparison axis — `state diff --live` reports added/removed/changed
230
+ * between snapshots, not vs. declared.
231
+ *
232
+ * `entities` is passed for cases where the lexicon needs to know what
233
+ * was declared in order to enumerate (e.g. Flyway needs the declared
234
+ * `Flyway::Environment` entities to know which DBs to query).
235
+ */
236
+ listArtifacts?(options: {
237
+ environment: string;
238
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
239
+ }): Promise<Record<string, ArtifactMetadata>>;
204
240
  }
205
241
 
206
242
  /**
@@ -219,6 +255,26 @@ export interface ResourceMetadata {
219
255
  attributes?: Record<string, unknown>;
220
256
  }
221
257
 
258
+ /**
259
+ * Metadata about a runtime artifact, returned by listArtifacts. Same shape
260
+ * as ResourceMetadata; the conceptual distinction is whether the lexicon's
261
+ * chant entities have 1:1 runtime equivalents (resources) or whether the
262
+ * runtime artifacts are created by tooling outside chant's entity model
263
+ * (artifacts — e.g. Helm releases, Docker containers, Flyway migrations).
264
+ */
265
+ export interface ArtifactMetadata {
266
+ /** Artifact type (e.g. Helm::Release, Docker::Container) */
267
+ type: string;
268
+ /** Server-side identifier */
269
+ physicalId?: string;
270
+ /** Provider-specific status string */
271
+ status: string;
272
+ /** ISO timestamp of last update */
273
+ lastUpdated?: string;
274
+ /** Provider-specific properties */
275
+ attributes?: Record<string, unknown>;
276
+ }
277
+
222
278
  /**
223
279
  * Type guard to check if a value is a LexiconPlugin.
224
280
  * Checks for required lifecycle methods in addition to name/serializer.
@@ -641,4 +641,25 @@ describe("loadConfig", () => {
641
641
  const config = loadConfig(subDir);
642
642
  expect(config).toEqual(DEFAULT_CONFIG);
643
643
  });
644
+
645
+ test("accepts ChantConfig-shape nested lint key in chant.config.json", () => {
646
+ const configPath = join(TEST_DIR, "chant.config.json");
647
+ writeFileSync(
648
+ configPath,
649
+ JSON.stringify({
650
+ lexicons: ["aws"],
651
+ lint: {
652
+ rules: {
653
+ "test-rule": "error",
654
+ "noisy-rule": "off",
655
+ },
656
+ },
657
+ }),
658
+ );
659
+
660
+ const config = loadConfig(TEST_DIR);
661
+
662
+ expect(config.rules?.["test-rule"]).toBe("error");
663
+ expect(config.rules?.["noisy-rule"]).toBe("off");
664
+ });
644
665
  });
@@ -223,18 +223,34 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
223
223
  throw new Error(`Failed to read config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
224
224
  }
225
225
 
226
- let config: LintConfig;
226
+ let raw: unknown;
227
227
  try {
228
- config = JSON.parse(content);
228
+ raw = JSON.parse(content);
229
229
  } catch (err) {
230
230
  throw new Error(`Failed to parse config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
231
231
  }
232
232
 
233
233
  // Validate config structure
234
- if (typeof config !== "object" || config === null || Array.isArray(config)) {
234
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
235
235
  throw new Error(`Invalid config file ${configPath}: must be an object`);
236
236
  }
237
237
 
238
+ // Accept both shapes: lint fields at top level (legacy) OR nested under a
239
+ // "lint" key (matches chant.config.ts ChantConfig shape). When the JSON has
240
+ // both a top-level "rules"/"extends"/etc AND a "lint": {...}, prefer the
241
+ // nested one — that matches the explicit ChantConfig contract.
242
+ const rawObj = raw as Record<string, unknown>;
243
+ let config: LintConfig;
244
+ if (
245
+ rawObj.lint &&
246
+ typeof rawObj.lint === "object" &&
247
+ !Array.isArray(rawObj.lint)
248
+ ) {
249
+ config = rawObj.lint as LintConfig;
250
+ } else {
251
+ config = rawObj as LintConfig;
252
+ }
253
+
238
254
  // Validate with Zod schema
239
255
  const parseResult = LintConfigSchema.safeParse(config);
240
256
  if (!parseResult.success) {
@@ -0,0 +1,43 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import { discoverOps } from "./discover";
3
+
4
+ // Mock getRuntime to return git root pointing at the repo root
5
+ vi.mock("../runtime-adapter", () => ({
6
+ getRuntime: () => ({
7
+ spawn: async (cmd: string[]) => {
8
+ if (cmd[0] === "git" && cmd[1] === "rev-parse") {
9
+ // Return the actual repo root so the test can find the example op file
10
+ const { execFile } = await import("node:child_process");
11
+ const { promisify } = await import("node:util");
12
+ const execFileAsync = promisify(execFile);
13
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
14
+ return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
15
+ }
16
+ return { stdout: "", stderr: "", exitCode: 0 };
17
+ },
18
+ }),
19
+ }));
20
+
21
+ describe("discoverOps", () => {
22
+ test("discovers alb-deploy.op.ts from examples/", async () => {
23
+ const { ops, errors } = await discoverOps();
24
+ expect(errors).toHaveLength(0);
25
+ expect(ops.has("alb-deploy")).toBe(true);
26
+ });
27
+
28
+ test("discovered Op has correct config shape", async () => {
29
+ const { ops } = await discoverOps();
30
+ const op = ops.get("alb-deploy");
31
+ expect(op).toBeDefined();
32
+ expect(op!.config.name).toBe("alb-deploy");
33
+ expect(Array.isArray(op!.config.phases)).toBe(true);
34
+ expect(op!.config.phases.length).toBeGreaterThan(0);
35
+ expect(typeof op!.config.overview).toBe("string");
36
+ });
37
+
38
+ test("filePath points to the .op.ts source file", async () => {
39
+ const { ops } = await discoverOps();
40
+ const op = ops.get("alb-deploy");
41
+ expect(op!.filePath).toMatch(/alb-deploy\.op\.ts$/);
42
+ });
43
+ });