@intentius/chant 0.1.7 → 0.1.9

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.
@@ -198,6 +198,7 @@ describe("resolveCommand", () => {
198
198
  watch: false,
199
199
  verbose: false,
200
200
  help: false,
201
+ live: false,
201
202
  ...overrides,
202
203
  };
203
204
  }
package/src/cli/main.ts CHANGED
@@ -36,6 +36,7 @@ export function parseArgs(args: string[]): ParsedArgs {
36
36
  help: false,
37
37
  profile: undefined,
38
38
  report: undefined,
39
+ live: false,
39
40
  };
40
41
 
41
42
  let i = 0;
@@ -64,6 +65,8 @@ export function parseArgs(args: string[]): ParsedArgs {
64
65
  result.profile = args[++i];
65
66
  } else if (arg === "--report") {
66
67
  result.report = true;
68
+ } else if (arg === "--live") {
69
+ result.live = true;
67
70
  } else if (!arg.startsWith("-")) {
68
71
  if (!result.command) {
69
72
  result.command = arg;
@@ -114,6 +117,7 @@ State:
114
117
  state snapshot <env> Query API, save metadata to orphan branch
115
118
  state show <env> Show latest state snapshot
116
119
  state diff <env> Compare current build against last snapshot
120
+ --live: query cloud now and detect drift
117
121
  state log [env] History of state snapshots
118
122
 
119
123
  Lexicon development:
@@ -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[];
@@ -20,6 +20,7 @@ export interface ParsedArgs {
20
20
  help: boolean;
21
21
  profile?: string;
22
22
  report?: boolean;
23
+ live: boolean;
23
24
  }
24
25
 
25
26
  /**
@@ -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) {
@@ -64,4 +64,32 @@ describe("rule-loader", () => {
64
64
  // Clean up
65
65
  rmSync(join(RULES_DIR, "my-rule.test.ts"));
66
66
  });
67
+
68
+ test("walks up to find .chant/rules/ when invoked from a sub-stack dir", async () => {
69
+ // Project layout: TEST_DIR (root, has .chant/rules/) -> src/east/.
70
+ const subStack = join(TEST_DIR, "src", "east");
71
+ mkdirSync(subStack, { recursive: true });
72
+ writeFileSync(join(TEST_DIR, "package.json"), `{ "name": "fixture" }`);
73
+ try {
74
+ const rules = await loadLocalRules(subStack);
75
+ expect(rules).toHaveLength(1);
76
+ expect(rules[0].id).toBe("LOCAL001");
77
+ } finally {
78
+ rmSync(join(TEST_DIR, "src"), { recursive: true });
79
+ rmSync(join(TEST_DIR, "package.json"));
80
+ }
81
+ });
82
+
83
+ test("stops at the nearest package.json — does not climb into an unrelated parent", async () => {
84
+ const innerProject = join(TEST_DIR, "inner-proj");
85
+ const innerSubDir = join(innerProject, "src");
86
+ mkdirSync(innerSubDir, { recursive: true });
87
+ writeFileSync(join(innerProject, "package.json"), `{ "name": "inner" }`);
88
+ try {
89
+ const rules = await loadLocalRules(innerSubDir);
90
+ expect(rules).toHaveLength(0);
91
+ } finally {
92
+ rmSync(innerProject, { recursive: true });
93
+ }
94
+ });
67
95
  });
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, existsSync } from "fs";
2
- import { join, resolve } from "path";
2
+ import { dirname, join, resolve } from "path";
3
3
  import type { LintRule } from "./rule";
4
4
 
5
5
  /**
@@ -19,18 +19,51 @@ function isLintRule(value: unknown): value is LintRule {
19
19
  }
20
20
 
21
21
  /**
22
- * Load local lint rules from `.chant/rules/` directory.
22
+ * Walk up from `from` until a directory containing `.chant/rules/` is found,
23
+ * or until we hit the filesystem root or a non-project boundary (a directory
24
+ * with `package.json` but no `.chant/rules/` — that's the project root and
25
+ * we stop there even if no rules dir exists).
23
26
  *
24
- * Scans for `.ts` files, dynamically imports each, and collects
25
- * all exports that conform to the LintRule interface.
27
+ * Returns the absolute path to the discovered `.chant/rules/` directory, or
28
+ * null if none was found before we crossed the project root.
29
+ */
30
+ function findRulesDir(from: string): string | null {
31
+ let cur = resolve(from);
32
+ while (true) {
33
+ const candidate = join(cur, ".chant", "rules");
34
+ if (existsSync(candidate)) {
35
+ return candidate;
36
+ }
37
+ // Stop at the nearest project root (where package.json sits) — going
38
+ // above it would pick up rules belonging to an unrelated parent project.
39
+ if (existsSync(join(cur, "package.json"))) {
40
+ return null;
41
+ }
42
+ const parent = dirname(cur);
43
+ if (parent === cur) {
44
+ return null;
45
+ }
46
+ cur = parent;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Load local lint rules from `.chant/rules/`.
52
+ *
53
+ * Scans for `.ts` files in the nearest `.chant/rules/` directory found by
54
+ * walking up from `projectDir` (stopping at the closest `package.json` to
55
+ * avoid leaking into unrelated parent projects). Dynamically imports each
56
+ * file and collects all exports that conform to the LintRule interface.
26
57
  *
27
- * @param projectDir - Root directory of the project
28
- * @returns Array of LintRule objects found in `.chant/rules/`
58
+ * @param projectDir - Directory the lint command was invoked against. May be
59
+ * a sub-stack of a larger project the rules dir is
60
+ * resolved by walking up.
61
+ * @returns Array of LintRule objects found in the discovered `.chant/rules/`.
29
62
  */
30
63
  export async function loadLocalRules(projectDir: string): Promise<LintRule[]> {
31
- const rulesDir = join(projectDir, ".chant", "rules");
64
+ const rulesDir = findRulesDir(projectDir);
32
65
 
33
- if (!existsSync(rulesDir)) {
66
+ if (rulesDir === null) {
34
67
  return [];
35
68
  }
36
69
 
package/src/op/types.ts CHANGED
@@ -46,6 +46,19 @@ export interface ActivityStep {
46
46
  * Default: "fastIdempotent"
47
47
  */
48
48
  profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate";
49
+ /**
50
+ * Surface this activity's return value as a workflow search attribute.
51
+ *
52
+ * The serializer captures the awaited result into a temporary, then emits
53
+ * `upsertSearchAttributes({ <name>: [String(<from-path>)] })` immediately
54
+ * after. Useful for filtering runs by outcome (e.g. `Drift: "true"/"false"`
55
+ * from a stateDiff activity).
56
+ *
57
+ * `from` is a dot-path into the return value (e.g. `"drifted"` for
58
+ * `{ drifted: boolean }`); when omitted, the whole return value is
59
+ * stringified.
60
+ */
61
+ outcomeAttribute?: { name: string; from?: string };
49
62
  }
50
63
 
51
64
  export interface GateStep {
@@ -0,0 +1,117 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { computeBuildDigest, diffDigests, hashProps } from "./digest";
3
+ import type { BuildResult } from "../build";
4
+ import type { BuildDigest } from "./types";
5
+
6
+ function makeBuildResult(entitiesByLexicon: Record<string, Array<{ name: string; type: string; props: unknown }>>): BuildResult {
7
+ const entities = new Map();
8
+ for (const [lexicon, list] of Object.entries(entitiesByLexicon)) {
9
+ for (const item of list) {
10
+ entities.set(item.name, { lexicon, entityType: item.type, props: item.props });
11
+ }
12
+ }
13
+ return {
14
+ outputs: new Map(Object.keys(entitiesByLexicon).map((l) => [l, "{}"])),
15
+ entities,
16
+ dependencies: new Map(),
17
+ errors: [],
18
+ warnings: [],
19
+ manifest: {
20
+ lexicons: Object.keys(entitiesByLexicon),
21
+ outputs: {},
22
+ deployOrder: Object.keys(entitiesByLexicon),
23
+ },
24
+ sourceFileCount: 1,
25
+ } as unknown as BuildResult;
26
+ }
27
+
28
+ describe("hashProps", () => {
29
+ test("produces the same hash for identical props", () => {
30
+ expect(hashProps({ a: 1, b: 2 })).toBe(hashProps({ a: 1, b: 2 }));
31
+ });
32
+
33
+ test("is order-independent (sorted JSON serialization)", () => {
34
+ expect(hashProps({ a: 1, b: 2 })).toBe(hashProps({ b: 2, a: 1 }));
35
+ });
36
+
37
+ test("produces different hashes for different props", () => {
38
+ expect(hashProps({ a: 1 })).not.toBe(hashProps({ a: 2 }));
39
+ });
40
+ });
41
+
42
+ describe("computeBuildDigest", () => {
43
+ test("emits one entry per entity with type, lexicon, and propsHash", () => {
44
+ const buildResult = makeBuildResult({
45
+ aws: [{ name: "bucket", type: "AWS::S3::Bucket", props: { name: "data" } }],
46
+ });
47
+ const digest = computeBuildDigest(buildResult);
48
+ expect(digest.resources["bucket"]).toMatchObject({
49
+ type: "AWS::S3::Bucket",
50
+ lexicon: "aws",
51
+ propsHash: expect.any(String),
52
+ });
53
+ });
54
+
55
+ test("missing props default to empty object", () => {
56
+ const buildResult = makeBuildResult({ aws: [{ name: "x", type: "T", props: undefined }] });
57
+ const digest = computeBuildDigest(buildResult);
58
+ expect(digest.resources["x"].propsHash).toBe(hashProps({}));
59
+ });
60
+
61
+ test("mirrors the build manifest's deployOrder and outputs", () => {
62
+ const buildResult = makeBuildResult({
63
+ aws: [{ name: "b", type: "T", props: {} }],
64
+ gcp: [{ name: "g", type: "T", props: {} }],
65
+ });
66
+ const digest = computeBuildDigest(buildResult);
67
+ expect(digest.deployOrder).toEqual(["aws", "gcp"]);
68
+ expect(digest.outputs).toEqual({});
69
+ });
70
+ });
71
+
72
+ describe("diffDigests", () => {
73
+ function makeDigest(resources: Record<string, string>): BuildDigest {
74
+ const out: BuildDigest["resources"] = {};
75
+ for (const [name, propsHash] of Object.entries(resources)) {
76
+ out[name] = { type: "T", lexicon: "aws", propsHash };
77
+ }
78
+ return { resources: out, dependencies: {}, outputs: {}, deployOrder: [] };
79
+ }
80
+
81
+ test("no previous digest → everything is added", () => {
82
+ const result = diffDigests(makeDigest({ a: "x", b: "y" }), undefined);
83
+ expect(result.added).toEqual(["a", "b"]);
84
+ expect(result.removed).toEqual([]);
85
+ expect(result.changed).toEqual([]);
86
+ expect(result.unchanged).toEqual([]);
87
+ });
88
+
89
+ test("identical digests → all unchanged", () => {
90
+ const d = makeDigest({ a: "x" });
91
+ const result = diffDigests(d, d);
92
+ expect(result.unchanged).toEqual(["a"]);
93
+ expect(result.added).toEqual([]);
94
+ expect(result.changed).toEqual([]);
95
+ expect(result.removed).toEqual([]);
96
+ });
97
+
98
+ test("different propsHash → changed", () => {
99
+ const result = diffDigests(makeDigest({ a: "x2" }), makeDigest({ a: "x1" }));
100
+ expect(result.changed).toEqual(["a"]);
101
+ });
102
+
103
+ test("resource gone from current → removed", () => {
104
+ const result = diffDigests(makeDigest({}), makeDigest({ a: "x" }));
105
+ expect(result.removed).toEqual(["a"]);
106
+ });
107
+
108
+ test("mixed: added + removed + changed + unchanged", () => {
109
+ const previous = makeDigest({ a: "x1", b: "y", c: "z" });
110
+ const current = makeDigest({ a: "x2", b: "y", d: "w" });
111
+ const result = diffDigests(current, previous);
112
+ expect(result.added.sort()).toEqual(["d"]);
113
+ expect(result.changed.sort()).toEqual(["a"]);
114
+ expect(result.unchanged.sort()).toEqual(["b"]);
115
+ expect(result.removed.sort()).toEqual(["c"]);
116
+ });
117
+ });