@intentius/chant 0.33.0 → 0.34.0

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 (56) hide show
  1. package/dist/cli/commands/onboard.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/cli/handlers/search.d.ts +72 -0
  5. package/dist/cli/handlers/search.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +26 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/graph-effective.d.ts.map +1 -1
  10. package/dist/graph-ir.d.ts +21 -0
  11. package/dist/graph-ir.d.ts.map +1 -1
  12. package/dist/graph-refs.d.ts +19 -0
  13. package/dist/graph-refs.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +141 -0
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/deep-observe.d.ts +4 -0
  17. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  18. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  19. package/dist/lifecycle/observe.d.ts +55 -1
  20. package/dist/lifecycle/observe.d.ts.map +1 -1
  21. package/dist/lifecycle/replay.d.ts +47 -0
  22. package/dist/lifecycle/replay.d.ts.map +1 -0
  23. package/dist/lifecycle/snapshot.d.ts +6 -0
  24. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  25. package/dist/lifecycle/types.d.ts +46 -0
  26. package/dist/lifecycle/types.d.ts.map +1 -1
  27. package/dist/observation.d.ts +71 -0
  28. package/dist/observation.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/commands/onboard.ts +10 -25
  31. package/src/cli/handlers/graph.test.ts +74 -0
  32. package/src/cli/handlers/graph.ts +77 -36
  33. package/src/cli/handlers/lifecycle.test.ts +86 -0
  34. package/src/cli/handlers/lifecycle.ts +43 -10
  35. package/src/cli/handlers/search.test.ts +246 -4
  36. package/src/cli/handlers/search.ts +432 -27
  37. package/src/cli/main.ts +9 -0
  38. package/src/cli/registry.ts +27 -0
  39. package/src/codegen/lexicon-wiring.test.ts +53 -0
  40. package/src/codegen/release-wiring.test.ts +174 -0
  41. package/src/graph-effective.ts +7 -1
  42. package/src/graph-ir-live.test.ts +83 -0
  43. package/src/graph-ir.ts +58 -1
  44. package/src/graph-refs.test.ts +59 -0
  45. package/src/graph-refs.ts +39 -8
  46. package/src/lexicon.ts +145 -0
  47. package/src/lifecycle/deep-observe.ts +5 -0
  48. package/src/lifecycle/live-diff.test.ts +38 -0
  49. package/src/lifecycle/live-diff.ts +45 -2
  50. package/src/lifecycle/observe.ts +186 -4
  51. package/src/lifecycle/replay.ts +141 -0
  52. package/src/lifecycle/snapshot.test.ts +179 -0
  53. package/src/lifecycle/snapshot.ts +88 -3
  54. package/src/lifecycle/types.ts +47 -0
  55. package/src/observation.test.ts +135 -0
  56. package/src/observation.ts +151 -0
@@ -62,6 +62,11 @@ interface StackTarget {
62
62
  /** Build root to synthesize this stack from, scoped so its logical ids match
63
63
  * what the stack actually deploys. */
64
64
  root: string;
65
+ /** Region the stack is deployed in, from `stacks[].region` (#1261). Without
66
+ * it every stack is observed against the ambient region, so a multi-region
67
+ * estate snapshots only the stacks that happen to share it and reports the
68
+ * rest as "no valid resources or artifacts returned". */
69
+ region?: string;
65
70
  }
66
71
 
67
72
  /**
@@ -75,7 +80,7 @@ interface StackTarget {
75
80
  function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
76
81
  if (args.src) return [{ root: resolve(args.src) }];
77
82
  if (config.stacks && config.stacks.length > 0) {
78
- return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src) }));
83
+ return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src), region: s.region }));
79
84
  }
80
85
  return [{ root: resolveBuildRoot(args, config) }];
81
86
  }
@@ -133,19 +138,39 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
133
138
  const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
134
139
  if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
135
140
 
141
+ // Build every stack first, so the ambient scan (#1278) can be bounded by the
142
+ // kinds the PROJECT manages rather than the ones this stack happens to
143
+ // declare. "Which of my security groups are unused" is a question about the
144
+ // estate; a region whose stack declares no security group still has a default
145
+ // one, and scoping the bound per stack silently drops it.
146
+ const built: Array<{ target: (typeof targets)[number]; buildResult: Awaited<ReturnType<typeof build>> }> = [];
147
+ for (const target of targets) {
148
+ const label = target.stack ? `stack "${target.stack}"` : "project";
149
+ const buildResult = await build(target.root, targetSerializers);
150
+ if (buildResult.errors.length > 0) {
151
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
152
+ anyHardError = true;
153
+ continue;
154
+ }
155
+ built.push({ target, buildResult });
156
+ }
157
+ const projectKinds = [
158
+ ...new Set(built.flatMap(({ buildResult }) => [...buildResult.entities.values()].map((e) => e.entityType))),
159
+ ];
160
+
136
161
  try {
137
- for (const target of targets) {
162
+ for (const { target, buildResult } of built) {
138
163
  const label = target.stack ? `stack "${target.stack}"` : "project";
139
- const buildResult = await build(target.root, targetSerializers);
140
- if (buildResult.errors.length > 0) {
141
- console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
142
- anyHardError = true;
143
- continue;
144
- }
145
164
 
146
165
  let result;
147
166
  try {
148
- result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
167
+ result = await takeSnapshot(environment, observingPlugins, buildResult, {
168
+ stack: target.stack,
169
+ region: target.region,
170
+ deep: args.deep,
171
+ ambient: args.ambient,
172
+ ambientKinds: projectKinds,
173
+ });
149
174
  } catch (err) {
150
175
  if (err instanceof StaleLifecycleBranchError) {
151
176
  console.error(formatError({
@@ -214,7 +239,15 @@ export async function runLifecycleShow(ctx: CommandContext): Promise<number> {
214
239
 
215
240
  for (const [lexicon, content] of snapshots) {
216
241
  const snapshot: LifecycleSnapshot = JSON.parse(content);
217
- console.log(`\n${formatBold(`${environment}/${lexicon}`)} ${Object.keys(snapshot.resources).length} resources ${snapshot.timestamp}`);
242
+ // Depth is stated, not inferred (#1267). An identity snapshot cannot
243
+ // answer a property question, and a reader that assumes otherwise reads
244
+ // "no properties recorded" as "no such properties".
245
+ const depth = snapshot.depth ?? "identity";
246
+ const depthNote =
247
+ depth === "deep"
248
+ ? ` — deep (${Object.keys(snapshot.properties ?? {}).length} property trees)`
249
+ : " — identity only";
250
+ console.log(`\n${formatBold(`${environment}/${lexicon}`)} — ${Object.keys(snapshot.resources).length} resources${depthNote} — ${snapshot.timestamp}`);
218
251
  printSnapshotTable(snapshot);
219
252
  }
220
253
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect, vi } from "vitest";
2
2
  import { __searchInternals } from "./search";
3
3
 
4
- const { parseQuery, matchTerm, formatRow, explain, describeTerm } = __searchInternals;
4
+ const { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss } = __searchInternals;
5
5
 
6
6
  function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
7
7
  return { id, kind, lexicon: "aws", attrs } as never;
@@ -57,9 +57,23 @@ describe("search formatting", () => {
57
57
  expect(formatRow(src, [])).toBe("webServer AWS::EC2::Instance");
58
58
  });
59
59
 
60
- test("--show adds named primitive attributes only", () => {
61
- const n = node("web", "AWS::EC2::Instance", { physicalId: "i-1", InstanceType: "t3.micro", Tags: [{}] });
62
- expect(formatRow(n, ["InstanceType", "Tags"])).toBe("web AWS::EC2::Instance i-1 InstanceType=t3.micro");
60
+ test("--show renders a named column whatever shape the value is", () => {
61
+ // A list used to be dropped silently, so `--show effectiveIngress` the
62
+ // derived reachability fact — printed a blank column and read as "chant
63
+ // does not have this". A column the caller named is a column they get.
64
+ const n = node("web", "AWS::EC2::Instance", {
65
+ physicalId: "i-1",
66
+ InstanceType: "t3.micro",
67
+ effectiveIngress: ["tcp:22:0.0.0.0/0"],
68
+ });
69
+ expect(formatRow(n, ["InstanceType", "effectiveIngress"])).toBe(
70
+ 'web AWS::EC2::Instance i-1 InstanceType=t3.micro effectiveIngress=["tcp:22:0.0.0.0/0"]',
71
+ );
72
+ });
73
+
74
+ test("--show omits a column the node does not carry", () => {
75
+ const n = node("web", "AWS::EC2::Instance", { physicalId: "i-1" });
76
+ expect(formatRow(n, ["VpcId"])).toBe("web AWS::EC2::Instance i-1");
63
77
  });
64
78
  });
65
79
 
@@ -111,3 +125,231 @@ describe("search edge traversal", () => {
111
125
  .toBe("→attr:MapPublicIpOnLaunch=true (no such edge)");
112
126
  });
113
127
  });
128
+
129
+ describe("search surfaces what the graph derived", () => {
130
+ const insts = [
131
+ node("webServer", "AWS::EC2::Instance", { internetFacing: true, internetFacingVia: "rtb-1 → igw-1", effectiveIngress: ["tcp:22:0.0.0.0/0"] }),
132
+ node("privServer", "AWS::EC2::Instance", { internetFacing: false, effectiveIngress: [] }),
133
+ ];
134
+ const derivedIr = { nodes: insts, edges: [], groups: {}, derivedAttrs: { Instance: ["internetFacing", "effectiveIngress"] } } as never;
135
+
136
+ function capture(fn: () => void): string {
137
+ const lines: string[] = [];
138
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
139
+ fn();
140
+ spy.mockRestore();
141
+ return lines.join("\n");
142
+ }
143
+
144
+ test("names derived facts the query did not use, and omits the ones it did", () => {
145
+ const out = capture(() => derivedSurface(parseQuery("kind:EC2::Instance attr:internetFacing=true") as never, insts as never, derivedIr));
146
+ expect(out).toContain("effectiveIngress");
147
+ expect(out).not.toContain("internetFacing");
148
+ });
149
+
150
+ test("says nothing when the query already used every derived fact", () => {
151
+ const q = "kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0";
152
+ expect(capture(() => derivedSurface(parseQuery(q) as never, insts as never, derivedIr))).toBe("");
153
+ });
154
+
155
+ test("says nothing for a graph with no derived facts recorded", () => {
156
+ const plain = { nodes: insts, edges: [], groups: {} } as never;
157
+ expect(capture(() => derivedSurface(parseQuery("kind:EC2::Instance") as never, insts as never, plain))).toBe("");
158
+ });
159
+
160
+ test("a miss lists the attributes the queried kind actually carries", () => {
161
+ const out = capture(() => availableAttrs(parseQuery("kind:EC2::Instance attr:nosuchattr=1") as never, derivedIr));
162
+ expect(out).toContain("effectiveIngress");
163
+ expect(out).toContain("internetFacing");
164
+ expect(out).not.toContain("nosuchattr");
165
+ });
166
+
167
+ test("inclusion evidence is keyed off <attr>Via provenance, not a fixed attribute name", () => {
168
+ const byId = new Map(insts.map((n: { id: string }) => [n.id, n]));
169
+ const q = "attr:internetFacing=true";
170
+ const out = capture(() => explain(parseQuery(q) as never, [insts[0]] as never, derivedIr, byId as never, q));
171
+ expect(out).toContain("webServer internetFacing via rtb-1 → igw-1");
172
+ });
173
+ });
174
+
175
+ // #1280 — absence is a real estate question ("what does nothing reference"),
176
+ // and the grammar could only express presence.
177
+ describe("negated terms (#1280)", () => {
178
+ const ir = {
179
+ nodes: [
180
+ { id: "used", kind: "AWS::EC2::SecurityGroup", lexicon: "aws", attrs: {} },
181
+ { id: "spare", kind: "AWS::EC2::SecurityGroup", lexicon: "aws", attrs: {} },
182
+ { id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: {} },
183
+ ],
184
+ edges: [{ from: "web", to: "used", kind: "ref" as const, viaAttr: "SecurityGroupIds" }],
185
+ groups: {},
186
+ };
187
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
188
+ const match = (q: string) =>
189
+ ir.nodes.filter((n) => parseQuery(q).every((t) => matchTerm(n, t, ir, byId))).map((n) => n.id);
190
+
191
+ test("selects what nothing references — the complement of an edge term", () => {
192
+ expect(match("kind:SecurityGroup !<-kind:EC2::Instance")).toEqual(["spare"]);
193
+ });
194
+
195
+ test("the un-negated query still selects what IS referenced", () => {
196
+ expect(match("kind:SecurityGroup <-kind:EC2::Instance")).toEqual(["used"]);
197
+ });
198
+
199
+ test("negates a plain attribute term too", () => {
200
+ const nodes = [
201
+ { id: "a", kind: "K", lexicon: "x", attrs: { env: "prod" } },
202
+ { id: "b", kind: "K", lexicon: "x", attrs: { env: "dev" } },
203
+ ];
204
+ const g = { nodes, edges: [], groups: {} };
205
+ const m = new Map(nodes.map((n) => [n.id, n]));
206
+ expect(
207
+ nodes.filter((n) => parseQuery("!attr:env=prod").every((t) => matchTerm(n, t, g, m))).map((n) => n.id),
208
+ ).toEqual(["b"]);
209
+ });
210
+
211
+ test("a bare edge term is refused, and the refusal names the correction", () => {
212
+ // Accepting it as "no edge in this direction" is coherent and still the
213
+ // wrong query for "what is unused": it counts every reference, including a
214
+ // stack output that merely publishes a resource's id, so it omits the very
215
+ // group the question is about. Measured — refused: 3/3 right; accepted:
216
+ // wrong in 2 runs of 3.
217
+ expect(() => parseQuery("kind:Foo !<-")).toThrow(/needs a target/);
218
+ expect(() => parseQuery("kind:Foo ->")).toThrow(/needs a target/);
219
+ });
220
+
221
+ test("a made-up prefix is refused, and names the correction", () => {
222
+ // An agent looking for SSH reachability wrote
223
+ // `effectiveIngress:tcp:22:0.0.0.0/0` — right idea, right attribute, wrong
224
+ // spelling — and this parsed as a free-text word that matched nothing. It
225
+ // read the clean empty result as "chant does not hold this fact" and
226
+ // rebuilt the answer by hand from security-group rows.
227
+ expect(() => parseQuery("kind:EC2::Instance effectiveIngress:tcp:22")).toThrow(
228
+ /there is no "effectiveIngress:" prefix/,
229
+ );
230
+ try {
231
+ parseQuery("effectiveIngress:tcp:22");
232
+ } catch (e) {
233
+ expect((e as { hint: string }).hint).toContain("attr:effectiveIngress=tcp:22");
234
+ }
235
+ });
236
+
237
+ test("still accepts a word that merely contains colons", () => {
238
+ // `AWS::EC2::Instance` and a URL are words, not malformed terms — a real
239
+ // prefix is one colon, not two.
240
+ expect(parseQuery("AWS::EC2::Instance")).toEqual([{ kind: "word", a: "AWS::EC2::Instance" }]);
241
+ expect(parseQuery("https://example.com")).toEqual([{ kind: "word", a: "https://example.com" }]);
242
+ });
243
+
244
+ test("an edge term WITH a target still parses", () => {
245
+ expect(() => parseQuery("kind:Foo !<-kind:Bar")).not.toThrow();
246
+ });
247
+
248
+ test("--explain says the term was negated, or an exclusion reads inverted", () => {
249
+ expect(describeTerm(parseQuery("!kind:Foo")[0])).toBe("!kind:Foo");
250
+ });
251
+ });
252
+
253
+ // #1278/#1279 — `--ambient` changes what a LIVE read goes and looks for. On a
254
+ // replay it changes nothing: what is ambient in a recording was fixed when the
255
+ // recording was taken.
256
+ describe("the --ambient hint", () => {
257
+ const sg = node("sg-1", "AWS::EC2::SecurityGroup");
258
+ const kinds = ["AWS::EC2::SecurityGroup"];
259
+ const capture = (fn: () => void): string => {
260
+ const lines: string[] = [];
261
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
262
+ fn();
263
+ spy.mockRestore();
264
+ return lines.join("\n");
265
+ };
266
+
267
+ test("names the flag on a live read that did not use it", () => {
268
+ expect(capture(() => ambientHint([sg] as never, kinds, false))).toContain("--ambient");
269
+ });
270
+
271
+ test("says nothing when the caller already asked for it", () => {
272
+ expect(capture(() => ambientHint([sg] as never, kinds, true))).toBe("");
273
+ });
274
+
275
+ test("says nothing on a replay whose snapshot already holds ambient resources", () => {
276
+ // The answer is complete. Saying the flag would add something is worse than
277
+ // silence: an agent read "6 of 6 matched" next to this hint, went looking
278
+ // for a seventh group, and hand-built a wrong answer from the raw graph.
279
+ expect(capture(() => ambientHint([sg] as never, kinds, false, { recordedAmbient: true }))).toBe("");
280
+ });
281
+
282
+ test("on a replay without them, points at the recording rather than the query", () => {
283
+ // `--at --ambient` cannot go and look; only a new snapshot can.
284
+ const out = capture(() => ambientHint([sg] as never, kinds, false, { recordedAmbient: false }));
285
+ expect(out).toContain("lifecycle snapshot");
286
+ expect(out).not.toContain("--ambient includes those");
287
+ });
288
+ });
289
+
290
+ // #1279 — asked to list instances "in all regions", an agent printed six
291
+ // correct ids with no region against any of them, and was judged wrong.
292
+ describe("the region spread of an answer", () => {
293
+ const inst = (id: string, region?: string) =>
294
+ node(id, "AWS::EC2::Instance", region ? { region } : {});
295
+ const capture = (fn: () => void): string => {
296
+ const lines: string[] = [];
297
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
298
+ fn();
299
+ spy.mockRestore();
300
+ return lines.join("\n");
301
+ };
302
+ const spread = (ns: unknown[], show: string[] = [], q = "kind:EC2::Instance") =>
303
+ capture(() => regionSpread(parseQuery(q) as never, ns as never, show));
304
+
305
+ test("names the regions when the answer spans several", () => {
306
+ const out = spread([inst("a", "us-east-1"), inst("b", "us-west-2")]);
307
+ expect(out).toContain("us-east-1, us-west-2");
308
+ expect(out).toContain("2 regions");
309
+ });
310
+
311
+ test("says nothing when everything is in one region", () => {
312
+ expect(spread([inst("a", "us-east-1"), inst("b", "us-east-1")])).toBe("");
313
+ });
314
+
315
+ test("says nothing when the caller already asked for region", () => {
316
+ expect(spread([inst("a", "us-east-1"), inst("b", "us-west-2")], ["region"])).toBe("");
317
+ expect(spread([inst("a", "us-east-1"), inst("b", "us-west-2")], [], "attr:region=us-east-1")).toBe("");
318
+ });
319
+
320
+ test("says nothing when the resources carry no region", () => {
321
+ expect(spread([inst("a"), inst("b")])).toBe("");
322
+ });
323
+ });
324
+
325
+ // #1279 — seven `--show` names in one benchmark run missed on case alone, and
326
+ // a missed name printed nothing at all rather than saying it had missed.
327
+ describe("--show name matching", () => {
328
+ const n = node("web", "AWS::EC2::Instance", { physicalId: "i-1", region: "us-east-1" });
329
+ const capture = (fn: () => void): string => {
330
+ const lines: string[] = [];
331
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
332
+ fn();
333
+ spy.mockRestore();
334
+ return lines.join("\n");
335
+ };
336
+
337
+ test("matches a column name whatever case the caller used", () => {
338
+ // AWS names are PascalCase and chant's derived ones are not, so a caller
339
+ // mixing them is normal. Both are the same request.
340
+ expect(formatRow(n, ["Region"])).toContain("region=us-east-1");
341
+ expect(formatRow(n, ["region"])).toContain("region=us-east-1");
342
+ });
343
+
344
+ test("prints the name the resource actually uses, not the one asked for", () => {
345
+ expect(formatRow(n, ["REGION"])).toContain("region=us-east-1");
346
+ });
347
+
348
+ test("says when nothing carries a requested column", () => {
349
+ expect(capture(() => showMiss([n] as never, ["VpcId"]))).toContain("VpcId");
350
+ });
351
+
352
+ test("says nothing when every requested column is present", () => {
353
+ expect(capture(() => showMiss([n] as never, ["Region"]))).toBe("");
354
+ });
355
+ });