@intentius/chant 0.33.1 → 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 (50) 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 +49 -1
  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-ir.d.ts +14 -0
  10. package/dist/graph-ir.d.ts.map +1 -1
  11. package/dist/graph-refs.d.ts +19 -0
  12. package/dist/graph-refs.d.ts.map +1 -1
  13. package/dist/lexicon.d.ts +141 -0
  14. package/dist/lexicon.d.ts.map +1 -1
  15. package/dist/lifecycle/deep-observe.d.ts +4 -0
  16. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  17. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  18. package/dist/lifecycle/observe.d.ts +55 -1
  19. package/dist/lifecycle/observe.d.ts.map +1 -1
  20. package/dist/lifecycle/replay.d.ts +47 -0
  21. package/dist/lifecycle/replay.d.ts.map +1 -0
  22. package/dist/lifecycle/snapshot.d.ts +6 -0
  23. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  24. package/dist/lifecycle/types.d.ts +46 -0
  25. package/dist/lifecycle/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/onboard.ts +10 -25
  28. package/src/cli/handlers/graph.test.ts +74 -0
  29. package/src/cli/handlers/graph.ts +77 -36
  30. package/src/cli/handlers/lifecycle.test.ts +86 -0
  31. package/src/cli/handlers/lifecycle.ts +43 -10
  32. package/src/cli/handlers/search.test.ts +200 -4
  33. package/src/cli/handlers/search.ts +383 -29
  34. package/src/cli/main.ts +9 -0
  35. package/src/cli/registry.ts +27 -0
  36. package/src/codegen/lexicon-wiring.test.ts +53 -0
  37. package/src/codegen/release-wiring.test.ts +92 -0
  38. package/src/graph-ir-live.test.ts +83 -0
  39. package/src/graph-ir.ts +51 -1
  40. package/src/graph-refs.test.ts +59 -0
  41. package/src/graph-refs.ts +39 -8
  42. package/src/lexicon.ts +145 -0
  43. package/src/lifecycle/deep-observe.ts +5 -0
  44. package/src/lifecycle/live-diff.test.ts +38 -0
  45. package/src/lifecycle/live-diff.ts +45 -2
  46. package/src/lifecycle/observe.ts +186 -4
  47. package/src/lifecycle/replay.ts +141 -0
  48. package/src/lifecycle/snapshot.test.ts +179 -0
  49. package/src/lifecycle/snapshot.ts +88 -3
  50. package/src/lifecycle/types.ts +47 -0
@@ -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, derivedSurface, availableAttrs } = __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
 
@@ -157,3 +171,185 @@ describe("search surfaces what the graph derived", () => {
157
171
  expect(out).toContain("webServer internetFacing via rtb-1 → igw-1");
158
172
  });
159
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
+ });