@intentius/chant 0.31.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.
- package/dist/cli/command-group.d.ts +134 -0
- package/dist/cli/command-group.d.ts.map +1 -0
- package/dist/cli/conflict-check.d.ts +1 -1
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +58 -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 +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +49 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +15 -7
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +14 -6
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +118 -0
- package/dist/managed-fields.d.ts.map +1 -0
- package/dist/owner-chain.d.ts +99 -0
- package/dist/owner-chain.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/command-group.test.ts +208 -0
- package/src/cli/command-group.ts +199 -0
- package/src/cli/conflict-check.test.ts +36 -1
- package/src/cli/conflict-check.ts +22 -1
- package/src/cli/handlers/graph.test.ts +1 -1
- package/src/cli/handlers/graph.ts +33 -11
- package/src/cli/handlers/lifecycle.ts +5 -0
- package/src/cli/handlers/search.test.ts +113 -0
- package/src/cli/handlers/search.ts +263 -0
- package/src/cli/main.ts +114 -27
- package/src/cli/registry.ts +4 -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 +110 -0
- package/src/graph-ir-live.test.ts +40 -0
- package/src/graph-ir.ts +32 -7
- package/src/index.ts +1 -0
- package/src/lexicon.ts +50 -1
- package/src/lifecycle/change-set.test.ts +100 -0
- package/src/lifecycle/change-set.ts +39 -10
- package/src/lifecycle/live-diff.test.ts +88 -0
- package/src/lifecycle/live-diff.ts +55 -8
- package/src/lifecycle/observe.test.ts +66 -2
- package/src/lifecycle/observe.ts +79 -18
- package/src/managed-fields.test.ts +179 -0
- package/src/managed-fields.ts +328 -0
- package/src/owner-chain.test.ts +97 -0
- package/src/owner-chain.ts +128 -0
|
@@ -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,8 +25,11 @@ 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";
|
|
31
|
+
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
32
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
* Long-form flags that are pure booleans in {@link parseArgs} — their branch
|
|
@@ -47,6 +50,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
47
50
|
"--verbose",
|
|
48
51
|
"--live",
|
|
49
52
|
"--overlay",
|
|
53
|
+
"--explain",
|
|
50
54
|
"--owned",
|
|
51
55
|
"--verbatim",
|
|
52
56
|
"--apply-rewrites",
|
|
@@ -118,31 +122,24 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
118
122
|
env: undefined,
|
|
119
123
|
};
|
|
120
124
|
|
|
125
|
+
// chant #1127 — generic joined `--flag=value` support, factored out to
|
|
126
|
+
// ./command-group.ts (chant #1078) so a lexicon's own mounted command can
|
|
127
|
+
// apply the identical splitting discipline to its own flag vocabulary.
|
|
128
|
+
// Every value-taking flag below is matched by an exact `arg === "--flag"`
|
|
129
|
+
// check and then consumes the *next* array element (`args[++i]`) as its
|
|
130
|
+
// value; a joined token like `--env=prod` never matches any of those,
|
|
131
|
+
// doesn't match the trailing positional branch either (it starts with
|
|
132
|
+
// `-`), and used to vanish with no error. Splitting the token at its FIRST
|
|
133
|
+
// `=` and re-dispatching as two array elements makes every flag below see
|
|
134
|
+
// the exact shape it already handles — including a flag like `--param`
|
|
135
|
+
// whose own value legitimately contains `=` (`--param=tier=production`
|
|
136
|
+
// splits to flag `--param`, value `tier=production`, not further split on
|
|
137
|
+
// the second `=`).
|
|
138
|
+
args = splitJoinedFlags(args, BOOLEAN_FLAGS);
|
|
139
|
+
|
|
121
140
|
let i = 0;
|
|
122
141
|
while (i < args.length) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// chant #1127 — generic joined `--flag=value` support. Every value-taking
|
|
126
|
-
// flag below is matched by an exact `arg === "--flag"` check and then
|
|
127
|
-
// consumes the *next* array element (`args[++i]`) as its value; a joined
|
|
128
|
-
// token like `--env=prod` never matches any of those, doesn't match the
|
|
129
|
-
// trailing positional branch either (it starts with `-`), and used to
|
|
130
|
-
// vanish with no error. Splitting the token at its FIRST `=` and
|
|
131
|
-
// re-dispatching as two array elements makes every flag below see the
|
|
132
|
-
// exact shape it already handles — including a flag like `--param`
|
|
133
|
-
// whose own value legitimately contains `=` (`--param=tier=production`
|
|
134
|
-
// splits to flag `--param`, value `tier=production`, not further split
|
|
135
|
-
// on the second `=`).
|
|
136
|
-
if (arg.startsWith("--") && arg.includes("=")) {
|
|
137
|
-
const eq = arg.indexOf("=");
|
|
138
|
-
const flag = arg.slice(0, eq);
|
|
139
|
-
const value = arg.slice(eq + 1);
|
|
140
|
-
if (BOOLEAN_FLAGS.has(flag)) {
|
|
141
|
-
throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
|
|
142
|
-
}
|
|
143
|
-
args.splice(i, 1, flag, value);
|
|
144
|
-
arg = args[i];
|
|
145
|
-
}
|
|
142
|
+
const arg = args[i];
|
|
146
143
|
|
|
147
144
|
if (arg === "--help" || arg === "-h") {
|
|
148
145
|
result.help = true;
|
|
@@ -246,6 +243,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
246
243
|
result.detail = Number(args[++i]);
|
|
247
244
|
} else if (arg === "--lens") {
|
|
248
245
|
result.lens = args[++i];
|
|
246
|
+
} else if (arg === "--explain") {
|
|
247
|
+
result.explain = true;
|
|
248
|
+
} else if (arg === "--show") {
|
|
249
|
+
result.show = args[++i];
|
|
249
250
|
} else if (arg === "--up") {
|
|
250
251
|
result.up = true;
|
|
251
252
|
} else if (arg === "--down") {
|
|
@@ -348,9 +349,12 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
348
349
|
}
|
|
349
350
|
|
|
350
351
|
/**
|
|
351
|
-
* Print help message
|
|
352
|
+
* Print help message. `groups` — lexicon-contributed command groups
|
|
353
|
+
* (chant #1078), best-effort loaded from the current project; composed in
|
|
354
|
+
* below the static command list so `--help` lists every mounted verb group
|
|
355
|
+
* alongside core's own commands.
|
|
352
356
|
*/
|
|
353
|
-
function printHelp(): void {
|
|
357
|
+
function printHelp(groups: CommandGroup[] = []): void {
|
|
354
358
|
console.log(`
|
|
355
359
|
chant - Declarative infrastructure specification toolkit
|
|
356
360
|
|
|
@@ -575,6 +579,60 @@ Examples:
|
|
|
575
579
|
chant describe myComponent src/
|
|
576
580
|
chant describe myComponent src/ --format json
|
|
577
581
|
`);
|
|
582
|
+
const groupsHelp = formatCommandGroupsHelp(groups);
|
|
583
|
+
if (groupsHelp) console.log(groupsHelp);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Best-effort load the current project's lexicon plugins for a purely
|
|
588
|
+
* read-only lookup (help composition, plugin-command dispatch) — never
|
|
589
|
+
* throws, empty on any failure (no config, no lexicons, not a chant
|
|
590
|
+
* project at all). Mirrors the existing best-effort loading already used
|
|
591
|
+
* for `emulator`/`components status` in {@link main} below.
|
|
592
|
+
*/
|
|
593
|
+
async function loadPluginsBestEffort(): Promise<LexiconPlugin[]> {
|
|
594
|
+
try {
|
|
595
|
+
const lexiconNames = await resolveProjectLexicons(resolve("."));
|
|
596
|
+
return await loadPlugins(lexiconNames);
|
|
597
|
+
} catch {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* chant #1078 — the lexicon command-group seam's dispatch-time half. Core's
|
|
604
|
+
* own `parseArgs`/`resolveCommand` know nothing about a lexicon's mounted
|
|
605
|
+
* verbs, so this is only ever consulted after BOTH of those have already
|
|
606
|
+
* failed to make sense of the invocation: either `parseArgs` threw on a flag
|
|
607
|
+
* it doesn't recognize (which is *expected* for a mounted command's own
|
|
608
|
+
* vocabulary — core has none), or it parsed fine but `resolveCommand` found
|
|
609
|
+
* no match in the static registry (a mounted command with no extra flags,
|
|
610
|
+
* e.g. `chant kube version`). Either way, a lexicon-mounted command's group
|
|
611
|
+
* name and verb are always the first two CLI tokens (mirrors the `emulator
|
|
612
|
+
* <up|down|status>` compound shape from #920), so `rawArgv` — the untouched
|
|
613
|
+
* `process.argv.slice(2)` — is all this needs; nothing from the partially or
|
|
614
|
+
* fully parsed `ParsedArgs` is used, on purpose, since core's flag-parsing
|
|
615
|
+
* failure or success is irrelevant to a namespace it doesn't own.
|
|
616
|
+
*
|
|
617
|
+
* Returns `undefined` when nothing claims the leading token as a command
|
|
618
|
+
* group at all, so the caller falls back to its own error handling
|
|
619
|
+
* unchanged — a project with no lexicon exposing `commands()` (or none of
|
|
620
|
+
* its plugins matching) is completely unaffected.
|
|
621
|
+
*/
|
|
622
|
+
async function tryPluginCommand(rawArgv: string[]): Promise<number | undefined> {
|
|
623
|
+
const [groupName, verbName] = rawArgv;
|
|
624
|
+
if (!groupName || groupName.startsWith("-")) return undefined;
|
|
625
|
+
|
|
626
|
+
const plugins = await loadPluginsBestEffort();
|
|
627
|
+
const rawArgs = rawArgv.slice(2);
|
|
628
|
+
const result = await dispatchCommandGroup(plugins, groupName, verbName, rawArgs);
|
|
629
|
+
|
|
630
|
+
if (result.kind === "no-group") return undefined;
|
|
631
|
+
if (result.kind === "usage-error") {
|
|
632
|
+
console.error(formatError({ message: result.message, hint: result.hint }));
|
|
633
|
+
return 1;
|
|
634
|
+
}
|
|
635
|
+
return result.exitCode;
|
|
578
636
|
}
|
|
579
637
|
|
|
580
638
|
/**
|
|
@@ -610,6 +668,7 @@ const registry: CommandDef[] = [
|
|
|
610
668
|
{ name: "lint", handler: runLint },
|
|
611
669
|
{ name: "list", handler: runList },
|
|
612
670
|
{ name: "describe", handler: runDescribe },
|
|
671
|
+
{ name: "search", handler: runSearch },
|
|
613
672
|
{ name: "import", handler: runImport },
|
|
614
673
|
{ name: "audit", handler: runAudit },
|
|
615
674
|
{ name: "migrate", handler: runMigrate },
|
|
@@ -688,10 +747,29 @@ const registry: CommandDef[] = [
|
|
|
688
747
|
* Main entry point
|
|
689
748
|
*/
|
|
690
749
|
async function main(): Promise<void> {
|
|
691
|
-
const
|
|
750
|
+
const rawArgv = process.argv.slice(2);
|
|
751
|
+
|
|
752
|
+
let args: ParsedArgs;
|
|
753
|
+
try {
|
|
754
|
+
args = parseArgs(rawArgv);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
// chant #1078 — core's parser has no idea what flags a lexicon's own
|
|
757
|
+
// mounted verb accepts, so an "unknown flag" here is expected, not a
|
|
758
|
+
// real error, until we've checked whether this invocation actually
|
|
759
|
+
// targets a command group. `tryPluginCommand` returns `undefined` when
|
|
760
|
+
// nothing claims the leading token, in which case this was a genuine
|
|
761
|
+
// core-flag error and the original is rethrown unchanged.
|
|
762
|
+
const code = await tryPluginCommand(rawArgv);
|
|
763
|
+
if (code !== undefined) {
|
|
764
|
+
await flushAndExit(code);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
692
769
|
|
|
693
770
|
if (args.help || !args.command) {
|
|
694
|
-
|
|
771
|
+
const groups = await loadPluginsBestEffort().then(collectCommandGroups).catch(() => []);
|
|
772
|
+
printHelp(groups);
|
|
695
773
|
process.exit(args.help ? 0 : 1);
|
|
696
774
|
}
|
|
697
775
|
|
|
@@ -742,6 +820,15 @@ async function main(): Promise<void> {
|
|
|
742
820
|
|
|
743
821
|
const match = resolveCommand(args, registry);
|
|
744
822
|
if (!match) {
|
|
823
|
+
// chant #1078 — not one of core's own commands; check whether a lexicon
|
|
824
|
+
// mounted a command group under this name before giving up. This is the
|
|
825
|
+
// "parsed fine, matched nothing" trigger for the seam — the flag-error
|
|
826
|
+
// trigger is above, in the `parseArgs` catch block.
|
|
827
|
+
const code = await tryPluginCommand(rawArgv);
|
|
828
|
+
if (code !== undefined) {
|
|
829
|
+
await flushAndExit(code);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
745
832
|
console.error(formatError({
|
|
746
833
|
message: `Unknown command: ${args.command}`,
|
|
747
834
|
hint: 'Run "chant --help" to see available commands',
|
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 */
|
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
|
+
});
|