@intentius/chant 0.32.0 → 0.33.1
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.
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +82 -0
- package/dist/cli/handlers/search.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +20 -0
- package/dist/graph-declared.d.ts.map +1 -0
- package/dist/graph-effective.d.ts +25 -0
- package/dist/graph-effective.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +7 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +9 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +14 -6
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/observation.d.ts +71 -0
- package/dist/observation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +1 -1
- package/src/cli/handlers/graph.ts +33 -11
- package/src/cli/handlers/search.test.ts +159 -0
- package/src/cli/handlers/search.ts +314 -0
- package/src/cli/main.ts +7 -0
- package/src/cli/registry.ts +4 -0
- package/src/codegen/release-wiring.test.ts +82 -0
- package/src/config.ts +3 -0
- package/src/graph-declared.ts +33 -0
- package/src/graph-effective.test.ts +97 -0
- package/src/graph-effective.ts +116 -0
- package/src/graph-ir.ts +7 -0
- package/src/lexicon.ts +6 -1
- package/src/lifecycle/observe.test.ts +66 -2
- package/src/lifecycle/observe.ts +79 -18
- package/src/observation.test.ts +135 -0
- package/src/observation.ts +151 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { __searchInternals } from "./search";
|
|
3
|
+
|
|
4
|
+
const { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs } = __searchInternals;
|
|
5
|
+
|
|
6
|
+
function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
|
|
7
|
+
return { id, kind, lexicon: "aws", attrs } as never;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("search query parsing", () => {
|
|
11
|
+
test("splits bare words, keyed terms, and quoted phrases", () => {
|
|
12
|
+
const terms = parseQuery('kind:EC2::Instance tag:Name=Public "public subnet"');
|
|
13
|
+
expect(terms).toEqual([
|
|
14
|
+
{ kind: "kind", a: "EC2::Instance" },
|
|
15
|
+
{ kind: "tag", a: "Name", b: "Public" },
|
|
16
|
+
{ kind: "word", a: "public subnet" },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("search matching", () => {
|
|
22
|
+
const inst = node("webServer", "AWS::EC2::Instance", {
|
|
23
|
+
physicalId: "i-abc",
|
|
24
|
+
Tags: [{ Key: "Name", Value: "Public" }],
|
|
25
|
+
MapPublicIpOnLaunch: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("kind: is substring on the resource kind", () => {
|
|
29
|
+
expect(matchTerm(inst, { kind: "kind", a: "EC2::Instance" })).toBe(true);
|
|
30
|
+
expect(matchTerm(inst, { kind: "kind", a: "SecurityGroup" })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("tag: matches Key with optional Value substring", () => {
|
|
34
|
+
expect(matchTerm(inst, { kind: "tag", a: "Name", b: "Pub" })).toBe(true);
|
|
35
|
+
expect(matchTerm(inst, { kind: "tag", a: "Name", b: "Private" })).toBe(false);
|
|
36
|
+
expect(matchTerm(inst, { kind: "tag", a: "Owner" })).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("attr: matches presence or value substring", () => {
|
|
40
|
+
expect(matchTerm(inst, { kind: "attr", a: "MapPublicIpOnLaunch", b: "true" })).toBe(true);
|
|
41
|
+
expect(matchTerm(inst, { kind: "attr", a: "MapPublicIpOnLaunch" })).toBe(true);
|
|
42
|
+
expect(matchTerm(inst, { kind: "attr", a: "Nonexistent" })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("bare word searches id, kind, and attr values", () => {
|
|
46
|
+
expect(matchTerm(inst, { kind: "word", a: "webserver" })).toBe(true);
|
|
47
|
+
expect(matchTerm(inst, { kind: "word", a: "i-abc" })).toBe(true);
|
|
48
|
+
expect(matchTerm(inst, { kind: "word", a: "nope" })).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("search formatting", () => {
|
|
53
|
+
test("compact row with live physical id, skipping object placeholders", () => {
|
|
54
|
+
const live = node("webServer", "AWS::EC2::Instance", { physicalId: "i-abc" });
|
|
55
|
+
expect(formatRow(live, [])).toBe("webServer AWS::EC2::Instance i-abc");
|
|
56
|
+
const src = node("webServer", "AWS::EC2::Instance", { InstanceId: { $ref: "webServer.InstanceId" } });
|
|
57
|
+
expect(formatRow(src, [])).toBe("webServer AWS::EC2::Instance");
|
|
58
|
+
});
|
|
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");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("search edge traversal", () => {
|
|
67
|
+
const ir = {
|
|
68
|
+
nodes: [
|
|
69
|
+
node("webServer", "AWS::EC2::Instance", { physicalId: "i-1" }),
|
|
70
|
+
node("privSubnet", "AWS::EC2::Subnet", { MapPublicIpOnLaunch: false }),
|
|
71
|
+
node("pubSubnet", "AWS::EC2::Subnet", { MapPublicIpOnLaunch: true }),
|
|
72
|
+
node("privServer", "AWS::EC2::Instance", { physicalId: "i-2" }),
|
|
73
|
+
],
|
|
74
|
+
edges: [
|
|
75
|
+
{ from: "webServer", to: "pubSubnet", kind: "ref", viaAttr: "SubnetId" },
|
|
76
|
+
{ from: "privServer", to: "privSubnet", kind: "ref", viaAttr: "SubnetId" },
|
|
77
|
+
],
|
|
78
|
+
} as never;
|
|
79
|
+
|
|
80
|
+
test("->attr resolves the instance→subnet→public join", () => {
|
|
81
|
+
const byId = new Map((ir as { nodes: { id: string }[] }).nodes.map((n) => [n.id, n]));
|
|
82
|
+
const terms = parseQuery("kind:EC2::Instance ->attr:MapPublicIpOnLaunch=true");
|
|
83
|
+
const matches = (ir as { nodes: never[] }).nodes.filter((n) =>
|
|
84
|
+
terms.every((t) => matchTerm(n as never, t, ir, byId as never)),
|
|
85
|
+
);
|
|
86
|
+
expect(matches.map((n: { id: string }) => n.id)).toEqual(["webServer"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("parses -> and <- into directional edge terms", () => {
|
|
90
|
+
expect(parseQuery("->kind:Subnet")).toEqual([{ kind: "edge", a: "", dir: "out", sub: { kind: "kind", a: "Subnet" } }]);
|
|
91
|
+
expect(parseQuery("<-kind:Instance")).toEqual([{ kind: "edge", a: "", dir: "in", sub: { kind: "kind", a: "Instance" } }]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("--explain footer: universe count + why the non-match was excluded", () => {
|
|
95
|
+
const byId = new Map((ir as { nodes: { id: string }[] }).nodes.map((n) => [n.id, n]));
|
|
96
|
+
const query = "kind:EC2::Instance ->attr:MapPublicIpOnLaunch=true";
|
|
97
|
+
const terms = parseQuery(query);
|
|
98
|
+
const matches = (ir as { nodes: never[] }).nodes.filter((n) => terms.every((t) => matchTerm(n as never, t, ir, byId as never)));
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
|
|
101
|
+
explain(terms as never, matches as never, ir, byId as never, query);
|
|
102
|
+
spy.mockRestore();
|
|
103
|
+
// 1 of 2 Instances matched (webServer public, privServer excluded).
|
|
104
|
+
expect(lines[0]).toContain("1 of 2 AWS::EC2::Instance matched");
|
|
105
|
+
expect(lines.join("\n")).toContain("excluded privServer");
|
|
106
|
+
expect(lines.join("\n")).toContain("MapPublicIpOnLaunch=true");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("describeTerm renders an edge term with direction and no-such-edge reason", () => {
|
|
110
|
+
expect(describeTerm({ kind: "edge", a: "", dir: "out", sub: { kind: "attr", a: "MapPublicIpOnLaunch", b: "true" } } as never))
|
|
111
|
+
.toBe("→attr:MapPublicIpOnLaunch=true (no such edge)");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("search surfaces what the graph derived", () => {
|
|
116
|
+
const insts = [
|
|
117
|
+
node("webServer", "AWS::EC2::Instance", { internetFacing: true, internetFacingVia: "rtb-1 → igw-1", effectiveIngress: ["tcp:22:0.0.0.0/0"] }),
|
|
118
|
+
node("privServer", "AWS::EC2::Instance", { internetFacing: false, effectiveIngress: [] }),
|
|
119
|
+
];
|
|
120
|
+
const derivedIr = { nodes: insts, edges: [], groups: {}, derivedAttrs: { Instance: ["internetFacing", "effectiveIngress"] } } as never;
|
|
121
|
+
|
|
122
|
+
function capture(fn: () => void): string {
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
|
|
125
|
+
fn();
|
|
126
|
+
spy.mockRestore();
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test("names derived facts the query did not use, and omits the ones it did", () => {
|
|
131
|
+
const out = capture(() => derivedSurface(parseQuery("kind:EC2::Instance attr:internetFacing=true") as never, insts as never, derivedIr));
|
|
132
|
+
expect(out).toContain("effectiveIngress");
|
|
133
|
+
expect(out).not.toContain("internetFacing");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("says nothing when the query already used every derived fact", () => {
|
|
137
|
+
const q = "kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0";
|
|
138
|
+
expect(capture(() => derivedSurface(parseQuery(q) as never, insts as never, derivedIr))).toBe("");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("says nothing for a graph with no derived facts recorded", () => {
|
|
142
|
+
const plain = { nodes: insts, edges: [], groups: {} } as never;
|
|
143
|
+
expect(capture(() => derivedSurface(parseQuery("kind:EC2::Instance") as never, insts as never, plain))).toBe("");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a miss lists the attributes the queried kind actually carries", () => {
|
|
147
|
+
const out = capture(() => availableAttrs(parseQuery("kind:EC2::Instance attr:nosuchattr=1") as never, derivedIr));
|
|
148
|
+
expect(out).toContain("effectiveIngress");
|
|
149
|
+
expect(out).toContain("internetFacing");
|
|
150
|
+
expect(out).not.toContain("nosuchattr");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("inclusion evidence is keyed off <attr>Via provenance, not a fixed attribute name", () => {
|
|
154
|
+
const byId = new Map(insts.map((n: { id: string }) => [n.id, n]));
|
|
155
|
+
const q = "attr:internetFacing=true";
|
|
156
|
+
const out = capture(() => explain(parseQuery(q) as never, [insts[0]] as never, derivedIr, byId as never, q));
|
|
157
|
+
expect(out).toContain("webServer internetFacing via rtb-1 → igw-1");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
availableAttrs(terms, ir);
|
|
118
|
+
if (args.explain) explain(terms, matches, ir, nodeById, query);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
for (const n of matches) {
|
|
122
|
+
console.log(formatRow(n, show));
|
|
123
|
+
}
|
|
124
|
+
derivedSurface(terms, matches, ir);
|
|
125
|
+
if (args.explain) explain(terms, matches, ir, nodeById, query);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Name the facts chant computed for the kinds in this result that the query did not use.
|
|
131
|
+
*
|
|
132
|
+
* A provider API can only return what it stores; chant additionally folds multi-hop topology
|
|
133
|
+
* onto a node, and a caller has no way to know that surface exists. Reporting it turns a
|
|
134
|
+
* one-shot query into a conversation with the graph — ask something, learn what else is
|
|
135
|
+
* knowable about the same resources, refine.
|
|
136
|
+
*
|
|
137
|
+
* The names come from {@link GraphIR.derivedAttrs}, recorded by whichever enrichment pass
|
|
138
|
+
* produced them. Nothing here knows what any attribute means or which question it answers;
|
|
139
|
+
* add a pass and its facts appear, remove one and they stop.
|
|
140
|
+
*/
|
|
141
|
+
function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR): void {
|
|
142
|
+
const derived = ir.derivedAttrs;
|
|
143
|
+
if (!derived || matches.length === 0) return;
|
|
144
|
+
const used = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
145
|
+
const unused = new Set<string>();
|
|
146
|
+
for (const n of matches) {
|
|
147
|
+
for (const [kind, names] of Object.entries(derived)) {
|
|
148
|
+
if (!n.kind?.includes(kind)) continue;
|
|
149
|
+
for (const name of names) if (!used.has(name)) unused.add(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (unused.size === 0) return;
|
|
153
|
+
console.log(`— also derived for these resources: ${[...unused].sort().join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `--explain` footer (#1139): a compact, model-DERIVED summary that gives a
|
|
158
|
+
* small model a reason to trust the result instead of re-deriving it with a
|
|
159
|
+
* lossy CLI sweep. It reports the universe count ("4 of 6 Instances") — chant's
|
|
160
|
+
* structural edge, since the typed graph knows the denominator a live sweep
|
|
161
|
+
* doesn't — and, for the near-miss set, WHY each was excluded (which query term
|
|
162
|
+
* it fails). Everything here is a property of the query over the graph, not of
|
|
163
|
+
* any expected answer, so it stays a fair, question-agnostic capability.
|
|
164
|
+
*/
|
|
165
|
+
function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string, IRNode>, query: string): void {
|
|
166
|
+
const kinds = new Set(matches.map((n) => n.kind).filter((k): k is string => !!k));
|
|
167
|
+
const universe = kinds.size > 0 ? ir.nodes.filter((n) => n.kind && kinds.has(n.kind)) : ir.nodes;
|
|
168
|
+
const matched = new Set(matches.map((n) => n.id));
|
|
169
|
+
const excluded = universe.filter((n) => !matched.has(n.id));
|
|
170
|
+
const kindLabel = kinds.size > 0 ? [...kinds].join("/") : "nodes";
|
|
171
|
+
console.log(`— ${matches.length} of ${universe.length} ${kindLabel} matched (query: ${query})`);
|
|
172
|
+
// Inclusion evidence: for a derived fact a CLI can't easily re-verify
|
|
173
|
+
// (internetFacing, resolved across the default VPC's routing), name WHY each
|
|
174
|
+
// match qualifies, so the agent trusts the result instead of dropping it.
|
|
175
|
+
for (const t of terms) {
|
|
176
|
+
if (t.kind !== "attr") continue;
|
|
177
|
+
for (const n of matches) {
|
|
178
|
+
const via = (n.attrs as Record<string, unknown> | undefined)?.[`${t.a}Via`];
|
|
179
|
+
const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
|
|
180
|
+
if (typeof via === "string") console.log(` ✓ ${id} ${t.a} via ${via}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const shown = excluded.slice(0, 8);
|
|
184
|
+
for (const n of shown) {
|
|
185
|
+
const failing = terms.find((t) => !matchTerm(n, t, ir, byId));
|
|
186
|
+
const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
|
|
187
|
+
console.log(` · excluded ${id} — fails ${failing ? describeTerm(failing) : "(query)"}`);
|
|
188
|
+
}
|
|
189
|
+
if (excluded.length > shown.length) console.log(` · …and ${excluded.length - shown.length} more excluded`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* On a miss, name the attributes the queried kind actually carries. A graph knows
|
|
194
|
+
* its own schema, so a caller who guessed an attribute name — or did not know a
|
|
195
|
+
* derived one existed — can see what is queryable instead of falling back to a
|
|
196
|
+
* lossy CLI sweep. Read off the nodes present, so it stays a property of the
|
|
197
|
+
* graph rather than of any expected answer: whatever the estate holds is what
|
|
198
|
+
* this lists, and it says nothing about which attribute answers a question.
|
|
199
|
+
*/
|
|
200
|
+
function availableAttrs(terms: Term[], ir: GraphIR): void {
|
|
201
|
+
const kindTerm = terms.find((t) => t.kind === "kind");
|
|
202
|
+
if (!kindTerm) return;
|
|
203
|
+
const of = ir.nodes.filter((n) => n.kind?.includes(kindTerm.a));
|
|
204
|
+
if (of.length === 0) return;
|
|
205
|
+
const names = new Set<string>();
|
|
206
|
+
for (const n of of) for (const k of Object.keys((n.attrs as Record<string, unknown>) ?? {})) names.add(k);
|
|
207
|
+
const queried = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
208
|
+
const unused = [...names].filter((k) => !queried.has(k)).sort();
|
|
209
|
+
if (unused.length === 0) return;
|
|
210
|
+
console.log(` · ${of.length} ${kindTerm.a} node(s) carry: ${unused.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function describeTerm(t: Term): string {
|
|
214
|
+
const leaf = (x: Term): string =>
|
|
215
|
+
x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
|
|
216
|
+
: x.kind === "tag" ? `tag:${x.a}${x.b !== undefined ? "=" + x.b : ""}` : `"${x.a}"`;
|
|
217
|
+
if (t.kind === "edge" && t.sub) return `${t.dir === "out" ? "→" : "←"}${leaf(t.sub)} (no such edge)`;
|
|
218
|
+
return leaf(t);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface Term {
|
|
222
|
+
kind: "word" | "kind" | "tag" | "attr" | "edge";
|
|
223
|
+
a: string;
|
|
224
|
+
b?: string;
|
|
225
|
+
/** For edge terms: the direction and the sub-predicate matched at the far end. */
|
|
226
|
+
dir?: "out" | "in";
|
|
227
|
+
sub?: Term;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseLeaf(tok: string): Term {
|
|
231
|
+
const m = /^(kind|tag|attr):(.*)$/i.exec(tok);
|
|
232
|
+
if (m) {
|
|
233
|
+
const key = m[1].toLowerCase() as Term["kind"];
|
|
234
|
+
const rest = m[2];
|
|
235
|
+
const eq = rest.indexOf("=");
|
|
236
|
+
if (eq >= 0) return { kind: key, a: rest.slice(0, eq), b: rest.slice(eq + 1) };
|
|
237
|
+
return { kind: key, a: rest };
|
|
238
|
+
}
|
|
239
|
+
return { kind: "word", a: tok };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseQuery(query: string): Term[] {
|
|
243
|
+
// Split on whitespace but keep quoted phrases together.
|
|
244
|
+
const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
|
|
245
|
+
return tokens.map((raw) => {
|
|
246
|
+
const tok = raw.replace(/^"|"$/g, "");
|
|
247
|
+
if (tok.startsWith("->")) return { kind: "edge", a: "", dir: "out", sub: parseLeaf(tok.slice(2)) };
|
|
248
|
+
if (tok.startsWith("<-")) return { kind: "edge", a: "", dir: "in", sub: parseLeaf(tok.slice(2)) };
|
|
249
|
+
return parseLeaf(tok);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseShow(args: { show?: string }): string[] {
|
|
254
|
+
return args.show ? args.show.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function attrString(v: unknown): string {
|
|
258
|
+
if (v == null) return "";
|
|
259
|
+
if (typeof v === "object") {
|
|
260
|
+
// AttrRef placeholder ({$ref}) or nested — stringify shallowly.
|
|
261
|
+
return JSON.stringify(v);
|
|
262
|
+
}
|
|
263
|
+
return String(v);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>): boolean {
|
|
267
|
+
const attrs = n.attrs ?? {};
|
|
268
|
+
if (t.kind === "edge") {
|
|
269
|
+
if (!ir || !byId || !t.sub) return false;
|
|
270
|
+
// A node matches if it has an edge (out or in) to a node satisfying `sub`.
|
|
271
|
+
const edges = ir.edges ?? [];
|
|
272
|
+
const neighbors = edges
|
|
273
|
+
.filter((e) => (t.dir === "out" ? e.from === n.id : e.to === n.id))
|
|
274
|
+
.map((e) => byId.get(t.dir === "out" ? e.to : e.from))
|
|
275
|
+
.filter((x): x is IRNode => !!x);
|
|
276
|
+
return neighbors.some((m) => matchTerm(m, t.sub!, ir, byId));
|
|
277
|
+
}
|
|
278
|
+
if (t.kind === "kind") return (n.kind ?? "").toLowerCase().includes(t.a.toLowerCase());
|
|
279
|
+
if (t.kind === "attr") {
|
|
280
|
+
const val = attrString((attrs as Record<string, unknown>)[t.a]);
|
|
281
|
+
return t.b === undefined ? t.a in attrs : val.toLowerCase().includes(t.b.toLowerCase());
|
|
282
|
+
}
|
|
283
|
+
if (t.kind === "tag") {
|
|
284
|
+
const tags = (attrs as Record<string, unknown>)["Tags"];
|
|
285
|
+
if (!Array.isArray(tags)) return false;
|
|
286
|
+
return tags.some((tag) => {
|
|
287
|
+
const key = attrString((tag as Record<string, unknown>)?.Key);
|
|
288
|
+
const val = attrString((tag as Record<string, unknown>)?.Value);
|
|
289
|
+
return key.toLowerCase() === t.a.toLowerCase() && (t.b === undefined || val.toLowerCase().includes(t.b.toLowerCase()));
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// bare word: substring over id, kind, and all attr values
|
|
293
|
+
const hay = [n.id, n.kind, ...Object.values(attrs).map(attrString)].join(" ").toLowerCase();
|
|
294
|
+
return hay.includes(t.a.toLowerCase());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function formatRow(n: IRNode, show: string[]): string {
|
|
298
|
+
const attrs = (n.attrs ?? {}) as Record<string, unknown>;
|
|
299
|
+
// Display the bare logical id, not the `${stack}::` qualification (#1162).
|
|
300
|
+
const displayId = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
|
|
301
|
+
const parts: string[] = [displayId, n.kind ?? ""];
|
|
302
|
+
// Prefer the node-level live physicalId (set by the overlay), then attrs;
|
|
303
|
+
// skip source-mode AttrRef placeholders (objects).
|
|
304
|
+
const physical = (n as { physicalId?: unknown }).physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
|
|
305
|
+
if (physical != null && typeof physical !== "object") parts.push(String(physical));
|
|
306
|
+
for (const key of show) {
|
|
307
|
+
const v = attrs[key];
|
|
308
|
+
if (v != null && typeof v !== "object") parts.push(`${key}=${attrString(v)}`);
|
|
309
|
+
}
|
|
310
|
+
return parts.filter(Boolean).join(" ");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Internals exposed for unit tests. */
|
|
314
|
+
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs };
|
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 },
|
package/src/cli/registry.ts
CHANGED
|
@@ -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 */
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release path is two hand-maintained halves that must agree: the
|
|
3
|
+
* justfile recipes that create and push a tag, and publish.yml's tag
|
|
4
|
+
* trigger that decides whether pushing it does anything.
|
|
5
|
+
*
|
|
6
|
+
* They disagreed. `just release-lexicon <name>` has always tagged
|
|
7
|
+
* `lexicon-<name>-v<version>`, pushed it, and echoed "publish workflow
|
|
8
|
+
* triggered" — while publish.yml matched only `chant-v*`. The tag landed,
|
|
9
|
+
* no workflow ran, and the recipe reported success. fly's 0.33.0 shipped
|
|
10
|
+
* with zero rules and zero skills and could not be patched by the one
|
|
11
|
+
* recipe built for patching a single lexicon.
|
|
12
|
+
*
|
|
13
|
+
* A release that silently no-ops is worse than one that fails, so this
|
|
14
|
+
* turns the next divergence into a PR-time failure.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
function publishTagPatterns(): string[] {
|
|
25
|
+
const workflow = readFileSync(join(repoRoot, ".github", "workflows", "publish.yml"), "utf-8");
|
|
26
|
+
const match = workflow.match(/^\s*tags:\s*\[([^\]]+)\]/m);
|
|
27
|
+
if (!match) throw new Error("publish.yml: `tags: [...]` trigger not found");
|
|
28
|
+
return match[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tags the justfile actually creates, as literal-prefix + wildcard shapes. */
|
|
32
|
+
function releaseTagShapes(): Array<{ recipe: string; example: string }> {
|
|
33
|
+
const justfile = readFileSync(join(repoRoot, "justfile"), "utf-8");
|
|
34
|
+
const shapes: Array<{ recipe: string; example: string }> = [];
|
|
35
|
+
|
|
36
|
+
// `git tag "chant-v$next"` / `git tag "lexicon-{{name}}-v$next"`
|
|
37
|
+
for (const m of justfile.matchAll(/git tag "([^"]+)"/g)) {
|
|
38
|
+
const raw = m[1];
|
|
39
|
+
const example = raw
|
|
40
|
+
.replace(/\{\{name\}\}/g, "docker")
|
|
41
|
+
.replace(/\$\{?next\}?/g, "9.9.9");
|
|
42
|
+
shapes.push({ recipe: raw, example });
|
|
43
|
+
}
|
|
44
|
+
return shapes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal glob match for the `prefix*` shapes these patterns use. */
|
|
48
|
+
function matchesGlob(pattern: string, value: string): boolean {
|
|
49
|
+
const rx = new RegExp(
|
|
50
|
+
"^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$",
|
|
51
|
+
);
|
|
52
|
+
return rx.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("release wiring: justfile tags vs publish.yml trigger", () => {
|
|
56
|
+
it("finds both halves", () => {
|
|
57
|
+
expect(publishTagPatterns().length).toBeGreaterThan(0);
|
|
58
|
+
expect(releaseTagShapes().length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("every tag a release recipe pushes triggers the publish workflow", () => {
|
|
62
|
+
const patterns = publishTagPatterns();
|
|
63
|
+
|
|
64
|
+
for (const { recipe, example } of releaseTagShapes()) {
|
|
65
|
+
const hit = patterns.some((p) => matchesGlob(p, example));
|
|
66
|
+
expect(
|
|
67
|
+
hit,
|
|
68
|
+
`justfile creates tag "${recipe}" (e.g. ${example}) but publish.yml triggers on ` +
|
|
69
|
+
`[${patterns.join(", ")}] — pushing it would publish nothing while the recipe ` +
|
|
70
|
+
`reports success`,
|
|
71
|
+
).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("covers the two shapes the repo releases by", () => {
|
|
76
|
+
const examples = releaseTagShapes().map((s) => s.example);
|
|
77
|
+
// Whole-repo release and single-lexicon patch. If a recipe stops
|
|
78
|
+
// producing one of these, the assertion above would pass vacuously.
|
|
79
|
+
expect(examples).toContain("chant-v9.9.9");
|
|
80
|
+
expect(examples).toContain("lexicon-docker-v9.9.9");
|
|
81
|
+
});
|
|
82
|
+
});
|
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
|
+
}
|