@intentius/chant 0.32.0 → 0.33.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.
@@ -0,0 +1,263 @@
1
+ import { resolve } from "node:path";
2
+ import { build } from "../../build";
3
+ import { buildGraphIr, buildLiveGraphIr, sourceOverlayGraphs, type GraphIR, type IRNode } from "../../graph-ir";
4
+ import { buildDeclaredPerStack } from "../../graph-declared";
5
+ import { enrichEffectiveTopology } from "../../graph-effective";
6
+ import { discover } from "../../discovery/index";
7
+
8
+ import { observeResources } from "../../lifecycle/observe";
9
+ import { loadChantConfig } from "../../config";
10
+ import { loadPlugins, resolveProjectLexicons } from "../plugins";
11
+ import { formatError, formatWarning } from "../format";
12
+ import type { CommandContext } from "../registry";
13
+
14
+ /**
15
+ * `chant search <query> [--live --env <name>]` — answer an estate question with
16
+ * a COMPACT result instead of the whole graph. The point (measured on aws-bench,
17
+ * #1139): a small model shouldn't ingest a multi-thousand-token IR dump to answer
18
+ * "which instances are in public subnets" — it should query and get a few rows.
19
+ *
20
+ * Query grammar (space-separated terms, all must match — AND):
21
+ * bare word case-insensitive substring over id, kind, and attrs
22
+ * kind:<substr> node kind contains <substr> (e.g. kind:EC2::Instance)
23
+ * tag:<key>=<val> a Tags entry with Key=key and Value containing val
24
+ * attr:<name>=<val> attribute <name> equals/contains <val>
25
+ * ->kind:X / ->attr:.. this node has an edge TO a node matching the right side
26
+ * <-kind:X / <-attr:.. this node has an edge FROM a node matching the right side
27
+ *
28
+ * The edge operators are the point of "edge-aware" search (#1139): a small
29
+ * model shouldn't hand-join instance→subnet→public across many results — one
30
+ * query does the traversal. `kind:Instance ->attr:MapPublicIpOnLaunch=true`
31
+ * = instances that reference a public subnet.
32
+ *
33
+ * Output: one line per match — `<id> <kind> <key=val ...>` — with only the
34
+ * physical id and any attributes named in `attr:`/`--show`. Tens of tokens, not
35
+ * thousands.
36
+ */
37
+ export async function runSearch(ctx: CommandContext): Promise<number> {
38
+ const { args } = ctx;
39
+ const query = (args.path ?? "").trim();
40
+ if (!query) {
41
+ console.error(formatError({ message: "chant search needs a query: chant search \"<terms>\" [--live --env <name>]" }));
42
+ return 1;
43
+ }
44
+ const terms = parseQuery(query);
45
+ const show = parseShow(args);
46
+
47
+ const projectPath = resolve(".");
48
+ const { config } = await loadChantConfig(projectPath);
49
+
50
+ let ir: GraphIR;
51
+ if (args.live) {
52
+ const environment = args.env;
53
+ if (!environment) {
54
+ console.error(formatError({ message: "chant search --live needs an environment: --live --env <name>" }));
55
+ return 1;
56
+ }
57
+ if (config.environments && !config.environments.includes(environment)) {
58
+ console.error(formatError({ message: `Unknown environment "${environment}"` }));
59
+ return 1;
60
+ }
61
+ const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
62
+ const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer));
63
+ if (buildResult.errors.length > 0) {
64
+ console.error(formatError({ message: "Build failed — fix errors before searching live state" }));
65
+ return 1;
66
+ }
67
+ const observing = plugins.filter((p) => p.describeResources);
68
+ const stacks = (config.stacks ?? []).map((s) => ({ name: s.name, region: s.region, src: s.src }));
69
+ const { observations, errors } = await observeResources(environment, observing, buildResult, {
70
+ owned: true,
71
+ stacks,
72
+ });
73
+ for (const e of errors) console.error(formatWarning({ message: e }));
74
+ let live = buildLiveGraphIr(observations);
75
+ const liveAttrs: Record<string, Record<string, unknown>> = {};
76
+ for (const p of observing) {
77
+ if (!p.enrichLiveAttrs) continue;
78
+ try {
79
+ const enriched = await p.enrichLiveAttrs({ environment, owned: true, stacks });
80
+ for (const [id, a] of Object.entries(enriched)) liveAttrs[id] = { ...liveAttrs[id], ...a };
81
+ live = { ...live, nodes: live.nodes.map((n) => (enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n)) };
82
+ } catch {
83
+ /* enrichment is best-effort; search still works on describe attrs */
84
+ }
85
+ }
86
+ // Overlay live identity onto the SOURCE graph (same as `graph --overlay`):
87
+ // the declared graph is the canvas — its edges carry the topology so ->/<-
88
+ // resolves, while the live side supplies physical ids.
89
+ //
90
+ // Multi-stack (#1162): build the declared graph PER STACK (scoped to each
91
+ // stack's src, the way it deploys) and stack-qualify node ids + edges as
92
+ // `${stack}::${id}` — matching how observation qualifies. A flat whole-
93
+ // project discovery would disambiguate colliding names by module path
94
+ // (UsEast1Src…), which never matches the observed bare LogicalResourceIds.
95
+ const declared =
96
+ stacks.length > 0
97
+ ? await buildDeclaredPerStack(stacks, projectPath)
98
+ : buildGraphIr((await discover(resolve(args.src ?? config.sourceDir ?? "."))).entities, projectPath);
99
+ ir = sourceOverlayGraphs(declared, live);
100
+ // Carry live-derived attrs onto the declared canvas — some facts only exist
101
+ // in live account state (e.g. `internetFacing` for an instance in the
102
+ // account's default VPC, whose route table chant does not model). The
103
+ // overlay copies physical identity but not attrs, so merge them here.
104
+ ir = { ...ir, nodes: ir.nodes.map((n) => (liveAttrs[n.id] ? { ...n, attrs: { ...n.attrs, ...liveAttrs[n.id] } } : n)) };
105
+ } else {
106
+ const discovered = await discover(resolve(args.src ?? config.sourceDir ?? "."));
107
+ ir = buildGraphIr(discovered.entities);
108
+ }
109
+
110
+ // Fold derived reachability facts (effectiveIngress, internetFacing) onto
111
+ // instance nodes so multi-hop/launch-template joins are one node predicate (#1139).
112
+ ir = enrichEffectiveTopology(ir);
113
+ const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
114
+ const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, nodeById)));
115
+ if (matches.length === 0) {
116
+ console.log("(no matches)");
117
+ if (args.explain) explain(terms, matches, ir, nodeById, query);
118
+ return 0;
119
+ }
120
+ for (const n of matches) {
121
+ console.log(formatRow(n, show));
122
+ }
123
+ if (args.explain) explain(terms, matches, ir, nodeById, query);
124
+ return 0;
125
+ }
126
+
127
+ /**
128
+ * `--explain` footer (#1139): a compact, model-DERIVED summary that gives a
129
+ * small model a reason to trust the result instead of re-deriving it with a
130
+ * lossy CLI sweep. It reports the universe count ("4 of 6 Instances") — chant's
131
+ * structural edge, since the typed graph knows the denominator a live sweep
132
+ * doesn't — and, for the near-miss set, WHY each was excluded (which query term
133
+ * it fails). Everything here is a property of the query over the graph, not of
134
+ * any expected answer, so it stays a fair, question-agnostic capability.
135
+ */
136
+ function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string, IRNode>, query: string): void {
137
+ const kinds = new Set(matches.map((n) => n.kind).filter((k): k is string => !!k));
138
+ const universe = kinds.size > 0 ? ir.nodes.filter((n) => n.kind && kinds.has(n.kind)) : ir.nodes;
139
+ const matched = new Set(matches.map((n) => n.id));
140
+ const excluded = universe.filter((n) => !matched.has(n.id));
141
+ const kindLabel = kinds.size > 0 ? [...kinds].join("/") : "nodes";
142
+ console.log(`— ${matches.length} of ${universe.length} ${kindLabel} matched (query: ${query})`);
143
+ // Inclusion evidence: for a derived fact a CLI can't easily re-verify
144
+ // (internetFacing, resolved across the default VPC's routing), name WHY each
145
+ // match qualifies, so the agent trusts the result instead of dropping it.
146
+ if (terms.some((t) => t.kind === "attr" && t.a === "internetFacing")) {
147
+ for (const n of matches) {
148
+ const via = (n.attrs as Record<string, unknown> | undefined)?.["internetFacingVia"];
149
+ const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
150
+ if (typeof via === "string") console.log(` ✓ ${id} internet-facing via ${via}`);
151
+ }
152
+ }
153
+ const shown = excluded.slice(0, 8);
154
+ for (const n of shown) {
155
+ const failing = terms.find((t) => !matchTerm(n, t, ir, byId));
156
+ const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
157
+ console.log(` · excluded ${id} — fails ${failing ? describeTerm(failing) : "(query)"}`);
158
+ }
159
+ if (excluded.length > shown.length) console.log(` · …and ${excluded.length - shown.length} more excluded`);
160
+ }
161
+
162
+ function describeTerm(t: Term): string {
163
+ const leaf = (x: Term): string =>
164
+ x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
165
+ : x.kind === "tag" ? `tag:${x.a}${x.b !== undefined ? "=" + x.b : ""}` : `"${x.a}"`;
166
+ if (t.kind === "edge" && t.sub) return `${t.dir === "out" ? "→" : "←"}${leaf(t.sub)} (no such edge)`;
167
+ return leaf(t);
168
+ }
169
+
170
+ interface Term {
171
+ kind: "word" | "kind" | "tag" | "attr" | "edge";
172
+ a: string;
173
+ b?: string;
174
+ /** For edge terms: the direction and the sub-predicate matched at the far end. */
175
+ dir?: "out" | "in";
176
+ sub?: Term;
177
+ }
178
+
179
+ function parseLeaf(tok: string): Term {
180
+ const m = /^(kind|tag|attr):(.*)$/i.exec(tok);
181
+ if (m) {
182
+ const key = m[1].toLowerCase() as Term["kind"];
183
+ const rest = m[2];
184
+ const eq = rest.indexOf("=");
185
+ if (eq >= 0) return { kind: key, a: rest.slice(0, eq), b: rest.slice(eq + 1) };
186
+ return { kind: key, a: rest };
187
+ }
188
+ return { kind: "word", a: tok };
189
+ }
190
+
191
+ function parseQuery(query: string): Term[] {
192
+ // Split on whitespace but keep quoted phrases together.
193
+ const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
194
+ return tokens.map((raw) => {
195
+ const tok = raw.replace(/^"|"$/g, "");
196
+ if (tok.startsWith("->")) return { kind: "edge", a: "", dir: "out", sub: parseLeaf(tok.slice(2)) };
197
+ if (tok.startsWith("<-")) return { kind: "edge", a: "", dir: "in", sub: parseLeaf(tok.slice(2)) };
198
+ return parseLeaf(tok);
199
+ });
200
+ }
201
+
202
+ function parseShow(args: { show?: string }): string[] {
203
+ return args.show ? args.show.split(",").map((s) => s.trim()).filter(Boolean) : [];
204
+ }
205
+
206
+ function attrString(v: unknown): string {
207
+ if (v == null) return "";
208
+ if (typeof v === "object") {
209
+ // AttrRef placeholder ({$ref}) or nested — stringify shallowly.
210
+ return JSON.stringify(v);
211
+ }
212
+ return String(v);
213
+ }
214
+
215
+ function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>): boolean {
216
+ const attrs = n.attrs ?? {};
217
+ if (t.kind === "edge") {
218
+ if (!ir || !byId || !t.sub) return false;
219
+ // A node matches if it has an edge (out or in) to a node satisfying `sub`.
220
+ const edges = ir.edges ?? [];
221
+ const neighbors = edges
222
+ .filter((e) => (t.dir === "out" ? e.from === n.id : e.to === n.id))
223
+ .map((e) => byId.get(t.dir === "out" ? e.to : e.from))
224
+ .filter((x): x is IRNode => !!x);
225
+ return neighbors.some((m) => matchTerm(m, t.sub!, ir, byId));
226
+ }
227
+ if (t.kind === "kind") return (n.kind ?? "").toLowerCase().includes(t.a.toLowerCase());
228
+ if (t.kind === "attr") {
229
+ const val = attrString((attrs as Record<string, unknown>)[t.a]);
230
+ return t.b === undefined ? t.a in attrs : val.toLowerCase().includes(t.b.toLowerCase());
231
+ }
232
+ if (t.kind === "tag") {
233
+ const tags = (attrs as Record<string, unknown>)["Tags"];
234
+ if (!Array.isArray(tags)) return false;
235
+ return tags.some((tag) => {
236
+ const key = attrString((tag as Record<string, unknown>)?.Key);
237
+ const val = attrString((tag as Record<string, unknown>)?.Value);
238
+ return key.toLowerCase() === t.a.toLowerCase() && (t.b === undefined || val.toLowerCase().includes(t.b.toLowerCase()));
239
+ });
240
+ }
241
+ // bare word: substring over id, kind, and all attr values
242
+ const hay = [n.id, n.kind, ...Object.values(attrs).map(attrString)].join(" ").toLowerCase();
243
+ return hay.includes(t.a.toLowerCase());
244
+ }
245
+
246
+ function formatRow(n: IRNode, show: string[]): string {
247
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
248
+ // Display the bare logical id, not the `${stack}::` qualification (#1162).
249
+ const displayId = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
250
+ const parts: string[] = [displayId, n.kind ?? ""];
251
+ // Prefer the node-level live physicalId (set by the overlay), then attrs;
252
+ // skip source-mode AttrRef placeholders (objects).
253
+ const physical = (n as { physicalId?: unknown }).physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
254
+ if (physical != null && typeof physical !== "object") parts.push(String(physical));
255
+ for (const key of show) {
256
+ const v = attrs[key];
257
+ if (v != null && typeof v !== "object") parts.push(`${key}=${attrString(v)}`);
258
+ }
259
+ return parts.filter(Boolean).join(" ");
260
+ }
261
+
262
+ /** Internals exposed for unit tests. */
263
+ export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm };
package/src/cli/main.ts CHANGED
@@ -25,6 +25,7 @@ import { runCarveApply } from "./handlers/carve-apply";
25
25
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
26
26
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
27
27
  import { runGraph } from "./handlers/graph";
28
+ import { runSearch } from "./handlers/search";
28
29
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
29
30
  import { runEmulator } from "./handlers/emulator";
30
31
  import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
@@ -49,6 +50,7 @@ const BOOLEAN_FLAGS = new Set([
49
50
  "--verbose",
50
51
  "--live",
51
52
  "--overlay",
53
+ "--explain",
52
54
  "--owned",
53
55
  "--verbatim",
54
56
  "--apply-rewrites",
@@ -241,6 +243,10 @@ export function parseArgs(args: string[]): ParsedArgs {
241
243
  result.detail = Number(args[++i]);
242
244
  } else if (arg === "--lens") {
243
245
  result.lens = args[++i];
246
+ } else if (arg === "--explain") {
247
+ result.explain = true;
248
+ } else if (arg === "--show") {
249
+ result.show = args[++i];
244
250
  } else if (arg === "--up") {
245
251
  result.up = true;
246
252
  } else if (arg === "--down") {
@@ -662,6 +668,7 @@ const registry: CommandDef[] = [
662
668
  { name: "lint", handler: runLint },
663
669
  { name: "list", handler: runList },
664
670
  { name: "describe", handler: runDescribe },
671
+ { name: "search", handler: runSearch },
665
672
  { name: "import", handler: runImport },
666
673
  { name: "audit", handler: runAudit },
667
674
  { name: "migrate", handler: runMigrate },
@@ -95,6 +95,10 @@ export interface ParsedArgs {
95
95
  detail?: number;
96
96
  /** `chant graph --lens <kind>:<target>` — focus the graph IR on a slice */
97
97
  lens?: string;
98
+ /** `chant search --show a,b` — extra attributes to include per matched row (#1139). */
99
+ show?: string;
100
+ /** `chant search --explain` — append a footer: universe count + why non-matches were excluded (#1139). */
101
+ explain?: boolean;
98
102
  /** `chant graph --lens blast:<node> --up` — include upstream producers */
99
103
  up?: boolean;
100
104
  /** `chant graph --lens blast:<node> --down` — include downstream dependents */
package/src/config.ts CHANGED
@@ -172,6 +172,9 @@ export interface ChantConfig {
172
172
  name: string;
173
173
  /** Source directory to build for this stack, relative to the project root. */
174
174
  src: string;
175
+ /** AWS region this stack is deployed in (multi-region estates). When set,
176
+ * observation/enrichment target this region instead of the ambient one. */
177
+ region?: string;
175
178
  }>;
176
179
 
177
180
  /** Lint configuration (rules, extends, overrides, plugins) */
@@ -0,0 +1,33 @@
1
+ import { resolve } from "node:path";
2
+ import { discover } from "./discovery/index";
3
+ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
4
+
5
+ /**
6
+ * Build the DECLARED graph for a multi-stack project, scoped per stack (#1162).
7
+ *
8
+ * A whole-project discovery disambiguates colliding logical names by module path
9
+ * (two `server`s become `UsEast1Srcserver` / `UsWest1Srcserver`) — names that
10
+ * never appear in any deployed template, because each stack deploys its OWN
11
+ * scoped source with BARE LogicalResourceIds. Observation therefore keys live
12
+ * nodes by `${stack}::${logicalId}` (bare id). To make the declared side join
13
+ * that live side, build each stack's `src` in isolation and qualify its node ids
14
+ * and edge endpoints the same way. Stacks are merged into one graph.
15
+ *
16
+ * A stack without `src` contributes nothing here — it has no declared source to
17
+ * scope to (its live nodes still show as foreign in the overlay).
18
+ */
19
+ export async function buildDeclaredPerStack(
20
+ stacks: Array<{ name: string; src?: string }>,
21
+ projectPath: string,
22
+ ): Promise<GraphIR> {
23
+ const nodes: IRNode[] = [];
24
+ const edges: GraphIR["edges"] = [];
25
+ for (const st of stacks) {
26
+ if (!st.src) continue;
27
+ const g = buildGraphIr((await discover(resolve(projectPath, st.src))).entities, projectPath);
28
+ const q = (id: string) => `${st.name}::${id}`;
29
+ for (const n of g.nodes) nodes.push({ ...n, id: q(n.id) });
30
+ for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
31
+ }
32
+ return { nodes, edges, groups: {} };
33
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { enrichEffectiveTopology } from "./graph-effective";
3
+ import type { GraphIR } from "./graph-ir";
4
+
5
+ function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
6
+ return { id, kind: `AWS::EC2::${kind}`, attrs };
7
+ }
8
+ const sshRule = { IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0" };
9
+
10
+ /**
11
+ * webServer: direct SG with SSH-open, subnet routes to an IGW → SSH-reachable
12
+ * ltServer: SG via LAUNCH TEMPLATE, same public subnet → SSH-reachable (the CLI-missed hop)
13
+ * westServer: public subnet (IGW) but its SG has NO ingress → internet-facing, not SSH-open
14
+ * privServer: private subnet (no IGW route) → not internet-facing
15
+ */
16
+ const ir: GraphIR = {
17
+ nodes: [
18
+ node("webServer", "Instance"),
19
+ node("ltServer", "Instance"),
20
+ node("westServer", "Instance"),
21
+ node("privServer", "Instance"),
22
+ node("webSg", "SecurityGroup", { SecurityGroupIngress: [sshRule, { IpProtocol: "tcp", FromPort: 80, ToPort: 80, CidrIp: "0.0.0.0/0" }] }),
23
+ node("westSg", "SecurityGroup", { SecurityGroupIngress: [] }),
24
+ node("lt", "LaunchTemplate"),
25
+ node("pubSubnet", "Subnet"),
26
+ node("westSubnet", "Subnet"),
27
+ node("privSubnet", "Subnet"),
28
+ node("pubRt", "RouteTable"),
29
+ node("westRt", "RouteTable"),
30
+ node("pubAssoc", "SubnetRouteTableAssociation"),
31
+ node("westAssoc", "SubnetRouteTableAssociation"),
32
+ node("pubRoute", "Route", { DestinationCidrBlock: "0.0.0.0/0" }),
33
+ node("westRoute", "Route", { DestinationCidrBlock: "0.0.0.0/0" }),
34
+ node("igw", "InternetGateway"),
35
+ node("westIgw", "InternetGateway"),
36
+ ] as never,
37
+ edges: [
38
+ { from: "webServer", to: "webSg", viaAttr: "SecurityGroupIds" },
39
+ { from: "webServer", to: "pubSubnet", viaAttr: "SubnetId" },
40
+ { from: "ltServer", to: "lt", viaAttr: "LaunchTemplate" },
41
+ { from: "lt", to: "webSg", viaAttr: "LaunchTemplateData" },
42
+ { from: "ltServer", to: "pubSubnet", viaAttr: "SubnetId" },
43
+ { from: "westServer", to: "westSg", viaAttr: "SecurityGroupIds" },
44
+ { from: "westServer", to: "westSubnet", viaAttr: "SubnetId" },
45
+ { from: "privServer", to: "privSubnet", viaAttr: "SubnetId" },
46
+ { from: "pubAssoc", to: "pubSubnet", viaAttr: "SubnetId" },
47
+ { from: "pubAssoc", to: "pubRt", viaAttr: "RouteTableId" },
48
+ { from: "pubRoute", to: "pubRt", viaAttr: "RouteTableId" },
49
+ { from: "pubRoute", to: "igw", viaAttr: "GatewayId" },
50
+ { from: "westAssoc", to: "westSubnet", viaAttr: "SubnetId" },
51
+ { from: "westAssoc", to: "westRt", viaAttr: "RouteTableId" },
52
+ { from: "westRoute", to: "westRt", viaAttr: "RouteTableId" },
53
+ { from: "westRoute", to: "westIgw", viaAttr: "GatewayId" },
54
+ ] as never,
55
+ groups: {},
56
+ };
57
+
58
+ describe("enrichEffectiveTopology", () => {
59
+ const enriched = enrichEffectiveTopology(ir);
60
+ const attrs = (id: string) => enriched.nodes.find((n) => n.id === id)!.attrs as Record<string, unknown>;
61
+
62
+ it("resolves the security group reached VIA a launch template (the CLI-missed hop)", () => {
63
+ expect(attrs("ltServer").effectiveIngress).toContain("tcp:22:0.0.0.0/0");
64
+ });
65
+
66
+ it("resolves a direct security group", () => {
67
+ expect(attrs("webServer").effectiveIngress).toContain("tcp:22:0.0.0.0/0");
68
+ });
69
+
70
+ it("marks instances whose subnet routes to an IGW as internetFacing", () => {
71
+ expect(attrs("webServer").internetFacing).toBe(true);
72
+ expect(attrs("ltServer").internetFacing).toBe(true);
73
+ expect(attrs("westServer").internetFacing).toBe(true);
74
+ expect(attrs("privServer").internetFacing).toBe(false);
75
+ });
76
+
77
+ it("keeps a live-supplied internetFacing (e.g. default VPC) even with no declared route", () => {
78
+ // A live enrichment marks an instance internetFacing; its subnet's routing
79
+ // is not in the declared graph (the account's default VPC). Enrichment
80
+ // must NOT overwrite that truth back to false.
81
+ const withLive: GraphIR = {
82
+ nodes: [node("defaultVpcServer", "Instance", { internetFacing: true })] as never,
83
+ edges: [] as never,
84
+ groups: {},
85
+ };
86
+ const out = enrichEffectiveTopology(withLive);
87
+ expect((out.nodes[0].attrs as Record<string, unknown>).internetFacing).toBe(true);
88
+ });
89
+
90
+ it("SSH-reachable = internetFacing AND effectiveIngress tcp:22:0.0.0.0/0 → only web + lt", () => {
91
+ const reachable = enriched.nodes.filter(
92
+ (n) => (n.attrs as Record<string, unknown>).internetFacing === true &&
93
+ ((n.attrs as Record<string, unknown>).effectiveIngress as string[] | undefined)?.includes("tcp:22:0.0.0.0/0"),
94
+ );
95
+ expect(reachable.map((n) => n.id).sort()).toEqual(["ltServer", "webServer"]);
96
+ });
97
+ });
@@ -0,0 +1,110 @@
1
+ import type { GraphIR, IRNode } from "./graph-ir";
2
+
3
+ /**
4
+ * Fold DERIVED reachability facts onto EC2 instance nodes so a single-node query
5
+ * can answer questions that are otherwise a multi-hop join with a union (#1139).
6
+ *
7
+ * Two facts, both things a live AWS-CLI sweep gets wrong because it can't cheaply
8
+ * resolve the topology:
9
+ *
10
+ * - `effectiveIngress` — the union of security-group ingress rules reachable
11
+ * from the instance, BOTH directly (`SecurityGroupIds`) AND through its launch
12
+ * template (`LaunchTemplate → LaunchTemplateData → SecurityGroupIds`). The
13
+ * launch-template hop is exactly what a CLI agent misses (it under-counts
14
+ * SSH-reachable instances). Each rule is normalized to `proto:port:cidr`
15
+ * (e.g. `tcp:22:0.0.0.0/0`) so it is precisely queryable.
16
+ * - `internetFacing` — whether the instance's subnet routes to an Internet
17
+ * Gateway (`subnet ← SubnetRouteTableAssociation → RouteTable ← Route →
18
+ * InternetGateway`). "Public subnet" means an IGW route, not
19
+ * `MapPublicIpOnLaunch`.
20
+ *
21
+ * With these, "instances SSH-reachable from the internet" is one predicate:
22
+ * `kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0`
23
+ * — no hand-joined CLI sweep, no over/under-counting.
24
+ */
25
+ export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
26
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
27
+ const edges = ir.edges ?? [];
28
+ const kind = (n?: IRNode): string => n?.kind ?? "";
29
+ const isKind = (n: IRNode | undefined, suffix: string): boolean =>
30
+ !!n && (kind(n) === suffix || kind(n).endsWith("::" + suffix));
31
+ const via = (...names: string[]) => (v: string): boolean => names.includes(v);
32
+
33
+ /** Out-neighbours of `id` (edges from → to), optionally filtered by viaAttr. */
34
+ const out = (id: string, pred?: (v: string) => boolean): IRNode[] =>
35
+ edges
36
+ .filter((e) => e.from === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
37
+ .map((e) => byId.get(e.to))
38
+ .filter((x): x is IRNode => !!x);
39
+ /** In-neighbours of `id` (edges to ← from), optionally filtered by viaAttr. */
40
+ const incoming = (id: string, pred?: (v: string) => boolean): IRNode[] =>
41
+ edges
42
+ .filter((e) => e.to === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
43
+ .map((e) => byId.get(e.from))
44
+ .filter((x): x is IRNode => !!x);
45
+
46
+ const normalizeIngress = (sg: IRNode): string[] => {
47
+ const rules = (sg.attrs as Record<string, unknown> | undefined)?.["SecurityGroupIngress"];
48
+ if (!Array.isArray(rules)) return [];
49
+ return rules.map((r) => {
50
+ const rule = r as Record<string, unknown>;
51
+ const proto = String(rule.IpProtocol ?? "-1");
52
+ const from = rule.FromPort as number | undefined;
53
+ const to = rule.ToPort as number | undefined;
54
+ const port = from == null ? "all" : from === to ? `${from}` : `${from}-${to}`;
55
+ const cidr =
56
+ (rule.CidrIp as string | undefined) ??
57
+ (rule.CidrIpv6 as string | undefined) ??
58
+ (rule.SourceSecurityGroupId ? `sg:${String(rule.SourceSecurityGroupId)}` : "?");
59
+ return `${proto}:${port}:${cidr}`;
60
+ });
61
+ };
62
+
63
+ /** Security groups reachable from an instance — direct and via launch template. */
64
+ const effectiveSgs = (inst: IRNode): IRNode[] => {
65
+ const direct = out(inst.id, via("SecurityGroupIds", "SecurityGroupId"));
66
+ const templates = out(inst.id, via("LaunchTemplate", "LaunchTemplateId"));
67
+ const viaTemplate = templates
68
+ .flatMap((lt) => out(lt.id, via("LaunchTemplateData", "SecurityGroupIds", "SecurityGroupId")))
69
+ .filter((n) => isKind(n, "SecurityGroup"));
70
+ const all = [...direct.filter((n) => isKind(n, "SecurityGroup")), ...viaTemplate];
71
+ return [...new Map(all.map((s) => [s.id, s])).values()];
72
+ };
73
+
74
+ /** The IGW an instance's subnet routes to (evidence), or undefined. */
75
+ const internetFacingVia = (inst: IRNode): string | undefined => {
76
+ for (const subnet of out(inst.id, via("SubnetId")).filter((n) => isKind(n, "Subnet"))) {
77
+ const assocs = incoming(subnet.id, via("SubnetId")).filter((a) => isKind(a, "SubnetRouteTableAssociation"));
78
+ const routeTables = assocs.flatMap((a) => out(a.id, via("RouteTableId")).filter((n) => isKind(n, "RouteTable")));
79
+ for (const rt of routeTables) {
80
+ const routes = incoming(rt.id, via("RouteTableId")).filter((n) => isKind(n, "Route"));
81
+ for (const route of routes) {
82
+ const dest = (route.attrs as Record<string, unknown> | undefined)?.["DestinationCidrBlock"];
83
+ const igw = out(route.id, via("GatewayId")).find((g) => isKind(g, "InternetGateway"));
84
+ if (igw && (dest == null || dest === "0.0.0.0/0")) {
85
+ const id = igw.id.includes("::") ? igw.id.slice(igw.id.lastIndexOf("::") + 2) : igw.id;
86
+ return `${rt.id.includes("::") ? rt.id.slice(rt.id.lastIndexOf("::") + 2) : rt.id} → ${id}`;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return undefined;
92
+ };
93
+
94
+ const nodes = ir.nodes.map((n) => {
95
+ if (!isKind(n, "Instance")) return n;
96
+ const effectiveIngress = effectiveSgs(n).flatMap(normalizeIngress);
97
+ // A live enrichment may already have set internetFacing (+ its evidence) for
98
+ // a subnet chant doesn't model declaratively (e.g. the account's default
99
+ // VPC). Keep that truth; otherwise derive it from the declared route topology.
100
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
101
+ const liveFacing = attrs["internetFacing"] === true;
102
+ const declaredVia = internetFacingVia(n);
103
+ const via = (attrs["internetFacingVia"] as string | undefined) ?? declaredVia;
104
+ return {
105
+ ...n,
106
+ attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
107
+ };
108
+ });
109
+ return { ...ir, nodes };
110
+ }
package/src/lexicon.ts CHANGED
@@ -608,6 +608,9 @@ export interface LexiconPlugin {
608
608
  * convention (AWS: the stack named after `environment`).
609
609
  */
610
610
  stack?: string;
611
+ /** AWS region the stack is in (multi-region). When set, the observation
612
+ * targets this region instead of the ambient one (#1161 follow-up). */
613
+ region?: string;
611
614
  /**
612
615
  * Restrict the result to chant-owned resources (those carrying the
613
616
  * ownership marker, #119). Where a lexicon has no durable marker channel,
@@ -695,7 +698,7 @@ export interface LexiconPlugin {
695
698
  * thin to carry references (e.g. AWS CloudFormation, where it's sourced from
696
699
  * the fuller `exportResources` config).
697
700
  */
698
- enrichLiveAttrs?(options: { environment: string; stack?: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
701
+ enrichLiveAttrs?(options: { environment: string; stack?: string; stacks?: Array<string | { name: string; region?: string }>; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
699
702
 
700
703
  /**
701
704
  * List runtime artifacts in the given environment. Opt-in.
@@ -748,6 +751,8 @@ export interface LexiconPlugin {
748
751
  * keeps its single-stack convention (AWS: the stack named after
749
752
  * `environment`). */
750
753
  stack?: string;
754
+ /** AWS region the stack is in (multi-region estates). */
755
+ region?: string;
751
756
  selector?: ResourceSelector;
752
757
  owned?: boolean;
753
758
  verbatim?: boolean;