@intentius/chant 0.33.1 → 0.34.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/commands/onboard.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 +49 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +26 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +14 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +19 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/lexicon.d.ts +141 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +4 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +55 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +47 -0
- package/dist/lifecycle/replay.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts +6 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +46 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/onboard.ts +10 -25
- package/src/cli/handlers/graph.test.ts +74 -0
- package/src/cli/handlers/graph.ts +77 -36
- package/src/cli/handlers/lifecycle.test.ts +86 -0
- package/src/cli/handlers/lifecycle.ts +43 -10
- package/src/cli/handlers/search.test.ts +200 -4
- package/src/cli/handlers/search.ts +383 -29
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +27 -0
- package/src/codegen/lexicon-wiring.test.ts +53 -0
- package/src/codegen/publish-order.test.ts +133 -0
- package/src/codegen/release-wiring.test.ts +92 -0
- package/src/graph-ir-live.test.ts +83 -0
- package/src/graph-ir.ts +51 -1
- package/src/graph-refs.test.ts +59 -0
- package/src/graph-refs.ts +39 -8
- package/src/lexicon.ts +145 -0
- package/src/lifecycle/deep-observe.ts +5 -0
- package/src/lifecycle/live-diff.test.ts +38 -0
- package/src/lifecycle/live-diff.ts +45 -2
- package/src/lifecycle/observe.ts +186 -4
- package/src/lifecycle/replay.ts +141 -0
- package/src/lifecycle/snapshot.test.ts +179 -0
- package/src/lifecycle/snapshot.ts +88 -3
- package/src/lifecycle/types.ts +47 -0
|
@@ -3,9 +3,12 @@ import { build } from "../../build";
|
|
|
3
3
|
import { buildGraphIr, buildLiveGraphIr, sourceOverlayGraphs, type GraphIR, type IRNode } from "../../graph-ir";
|
|
4
4
|
import { buildDeclaredPerStack } from "../../graph-declared";
|
|
5
5
|
import { enrichEffectiveTopology } from "../../graph-effective";
|
|
6
|
+
import { reconstructEdges, mergeCatalogs, type ReferenceCatalog } from "../../graph-refs";
|
|
6
7
|
import { discover } from "../../discovery/index";
|
|
7
8
|
|
|
8
9
|
import { observeResources } from "../../lifecycle/observe";
|
|
10
|
+
import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
|
|
11
|
+
import type { LiveObservation } from "../../graph-ir";
|
|
9
12
|
import { loadChantConfig } from "../../config";
|
|
10
13
|
import { loadPlugins, resolveProjectLexicons } from "../plugins";
|
|
11
14
|
import { formatError, formatWarning } from "../format";
|
|
@@ -41,17 +44,42 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
41
44
|
console.error(formatError({ message: "chant search needs a query: chant search \"<terms>\" [--live --env <name>]" }));
|
|
42
45
|
return 1;
|
|
43
46
|
}
|
|
44
|
-
|
|
47
|
+
let terms: Term[];
|
|
48
|
+
try {
|
|
49
|
+
terms = parseQuery(query);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof QueryError) {
|
|
52
|
+
console.error(formatError({ message: err.message, hint: err.hint }));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
45
57
|
const show = parseShow(args);
|
|
46
58
|
|
|
47
59
|
const projectPath = resolve(".");
|
|
48
60
|
const { config } = await loadChantConfig(projectPath);
|
|
49
61
|
|
|
50
62
|
let ir: GraphIR;
|
|
51
|
-
|
|
63
|
+
let source: AnswerSource = { kind: "declared" };
|
|
64
|
+
// Kinds that can exist in the account without being declared (#1278). Known
|
|
65
|
+
// without a scan, so it costs nothing to mention.
|
|
66
|
+
let ambientKinds: string[] = [];
|
|
67
|
+
// Set only on a replay: whether the recording itself holds ambient resources.
|
|
68
|
+
let replayAmbient: { recordedAmbient: boolean } | undefined;
|
|
69
|
+
if (args.live || args.at) {
|
|
52
70
|
const environment = args.env;
|
|
53
71
|
if (!environment) {
|
|
54
|
-
|
|
72
|
+
const flag = args.at ? "--at" : "--live";
|
|
73
|
+
console.error(formatError({ message: `chant search ${flag} needs an environment: ${flag} --env <name>` }));
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
if (args.live && args.at) {
|
|
77
|
+
// Two different observations of the same estate, and no rule for which
|
|
78
|
+
// wins. Comparing them is a real question (#1268) but it is not this one.
|
|
79
|
+
console.error(formatError({
|
|
80
|
+
message: "chant search takes --live or --at, not both",
|
|
81
|
+
hint: "--live reads the estate now; --at answers from a recorded snapshot",
|
|
82
|
+
}));
|
|
55
83
|
return 1;
|
|
56
84
|
}
|
|
57
85
|
if (config.environments && !config.environments.includes(environment)) {
|
|
@@ -65,23 +93,75 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
65
93
|
return 1;
|
|
66
94
|
}
|
|
67
95
|
const observing = plugins.filter((p) => p.describeResources);
|
|
96
|
+
ambientKinds = observing.flatMap((p) => p.ambientKinds?.() ?? []);
|
|
68
97
|
const stacks = (config.stacks ?? []).map((s) => ({ name: s.name, region: s.region, src: s.src }));
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
stacks,
|
|
72
|
-
});
|
|
73
|
-
for (const e of errors) console.error(formatWarning({ message: e }));
|
|
74
|
-
let live = buildLiveGraphIr(observations);
|
|
98
|
+
|
|
99
|
+
let observations: LiveObservation[];
|
|
75
100
|
const liveAttrs: Record<string, Record<string, unknown>> = {};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
if (args.at) {
|
|
102
|
+
// Answer from a recorded observation (#1266). Everything downstream is
|
|
103
|
+
// the live path's — the point is that a snapshot replays into the same
|
|
104
|
+
// shape a live read produces, so one pipeline serves both and a fold
|
|
105
|
+
// improvement reaches an old snapshot for free.
|
|
106
|
+
const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
|
|
107
|
+
const replay = await replaySnapshots(environment, String(args.at), scoped);
|
|
108
|
+
if ("error" in replay) {
|
|
109
|
+
console.error(formatError({ message: replay.error, ...(replay.hint ? { hint: replay.hint } : {}) }));
|
|
110
|
+
return 1;
|
|
84
111
|
}
|
|
112
|
+
observations = replay.observations;
|
|
113
|
+
replayAmbient = {
|
|
114
|
+
recordedAmbient: replay.observations.some((o) =>
|
|
115
|
+
Object.values(o.resources).some((m) => m.ambient === true),
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
source = { kind: "snapshot", commit: replay.commit, timestamp: replay.timestamp };
|
|
119
|
+
} else {
|
|
120
|
+
const observed = await observeResources(environment, observing, buildResult, {
|
|
121
|
+
owned: true,
|
|
122
|
+
stacks,
|
|
123
|
+
ambient: args.ambient === true,
|
|
124
|
+
});
|
|
125
|
+
for (const e of observed.errors) console.error(formatWarning({ message: e }));
|
|
126
|
+
observations = observed.observations;
|
|
127
|
+
source = { kind: "live" };
|
|
128
|
+
}
|
|
129
|
+
let live = buildLiveGraphIr(observations);
|
|
130
|
+
// Live-only: enrichment is a provider call, so it has no place in a replay.
|
|
131
|
+
// A recorded answer that quietly reached for the API would stop being one.
|
|
132
|
+
if (!args.at) {
|
|
133
|
+
for (const p of observing) {
|
|
134
|
+
if (!p.enrichLiveAttrs) continue;
|
|
135
|
+
try {
|
|
136
|
+
const enriched = await p.enrichLiveAttrs({ environment, owned: true, stacks });
|
|
137
|
+
for (const [id, a] of Object.entries(enriched)) liveAttrs[id] = { ...liveAttrs[id], ...a };
|
|
138
|
+
live = { ...live, nodes: live.nodes.map((n) => (enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n)) };
|
|
139
|
+
} catch {
|
|
140
|
+
/* enrichment is best-effort; search still works on describe attrs */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Reconstruct edges from live references (#778), the same way `graph --live`
|
|
145
|
+
// does (#1271). `buildLiveGraphIr` projects nodes only, so without this the
|
|
146
|
+
// live side of the graph has no relationships at all — and a fold over
|
|
147
|
+
// topology has nothing to traverse on anything the declared graph does not
|
|
148
|
+
// already model.
|
|
149
|
+
const catalogs = observing.map((p) => p.referenceCatalog).filter((c): c is ReferenceCatalog => !!c);
|
|
150
|
+
if (catalogs.length > 0) {
|
|
151
|
+
// Merge, never replace. A lexicon reports relationships a catalog cannot
|
|
152
|
+
// reconstruct (#1273) — an instance placed in a subnet it did not declare
|
|
153
|
+
// carries a template `Ref` in its attributes, not the physical subnet id,
|
|
154
|
+
// so no identity index resolves it. Overwriting here dropped exactly those
|
|
155
|
+
// edges and left the fold with a chain missing its first hop.
|
|
156
|
+
const reconstructed = reconstructEdges(live.nodes, mergeCatalogs(catalogs)).edges;
|
|
157
|
+
const seen = new Set((live.edges ?? []).map((e) => `${e.from}|${e.to}|${e.viaAttr ?? ""}`));
|
|
158
|
+
live = {
|
|
159
|
+
...live,
|
|
160
|
+
edges: [
|
|
161
|
+
...(live.edges ?? []),
|
|
162
|
+
...reconstructed.filter((e) => !seen.has(`${e.from}|${e.to}|${e.viaAttr ?? ""}`)),
|
|
163
|
+
],
|
|
164
|
+
};
|
|
85
165
|
}
|
|
86
166
|
// Overlay live identity onto the SOURCE graph (same as `graph --overlay`):
|
|
87
167
|
// the declared graph is the canvas — its edges carry the topology so ->/<-
|
|
@@ -121,11 +201,174 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
121
201
|
for (const n of matches) {
|
|
122
202
|
console.log(formatRow(n, show));
|
|
123
203
|
}
|
|
124
|
-
|
|
204
|
+
const backed = source.kind === "declared" || matches.some((n) => n.physicalId);
|
|
205
|
+
// Only worth asking when the live read came back empty — that is the one case
|
|
206
|
+
// where a recording changes what the caller should do next.
|
|
207
|
+
const recorded =
|
|
208
|
+
source.kind === "live" && !matches.some((n) => n.physicalId) && args.env
|
|
209
|
+
? (await hasSnapshot(String(args.env))) ? "yes" : undefined
|
|
210
|
+
: undefined;
|
|
211
|
+
provenance(matches, source, recorded);
|
|
212
|
+
ambientHint(matches, ambientKinds, args.ambient === true, replayAmbient);
|
|
213
|
+
showMiss(matches, show);
|
|
214
|
+
regionSpread(terms, matches, show);
|
|
215
|
+
derivedSurface(terms, matches, ir, backed);
|
|
125
216
|
if (args.explain) explain(terms, matches, ir, nodeById, query);
|
|
126
217
|
return 0;
|
|
127
218
|
}
|
|
128
219
|
|
|
220
|
+
|
|
221
|
+
/** Where an answer's facts came from, for the provenance line (#1266). */
|
|
222
|
+
type AnswerSource =
|
|
223
|
+
| { kind: "declared" }
|
|
224
|
+
| { kind: "live" }
|
|
225
|
+
| { kind: "snapshot"; commit: string; timestamp: string };
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Name the `--show` columns nothing carries (#1279).
|
|
232
|
+
*
|
|
233
|
+
* A requested column that no matched resource has simply did not appear, so the
|
|
234
|
+
* result looked like a resource with no such value rather than a name that was
|
|
235
|
+
* never going to match. Combined with case sensitivity that made `--show
|
|
236
|
+
* Region` an invisible no-op on an estate where every resource carries
|
|
237
|
+
* `region`.
|
|
238
|
+
*/
|
|
239
|
+
function showMiss(matches: IRNode[], show: string[]): void {
|
|
240
|
+
if (show.length === 0 || matches.length === 0) return;
|
|
241
|
+
const present = new Set(
|
|
242
|
+
matches.flatMap((n) => Object.keys((n.attrs ?? {}) as object).map((k) => k.toLowerCase())),
|
|
243
|
+
);
|
|
244
|
+
const missing = show.filter((k) => !present.has(k.toLowerCase()));
|
|
245
|
+
if (missing.length === 0) return;
|
|
246
|
+
console.log(`— no matched resource carries ${missing.join(", ")}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Say when the answer spans more than one region (#1279).
|
|
251
|
+
*
|
|
252
|
+
* A result is a list of resources with no shape to it, and region is the one
|
|
253
|
+
* dimension of this estate that is invisible in a row unless asked for. Asked
|
|
254
|
+
* to list instances "in all regions", an agent printed six correct ids with no
|
|
255
|
+
* region against any of them — a complete answer to a question about regions
|
|
256
|
+
* that never mentions one, and it was judged wrong.
|
|
257
|
+
*
|
|
258
|
+
* Stated only when the matched set actually spans several and the caller has
|
|
259
|
+
* not already asked: a fact about the result, in the same family as the
|
|
260
|
+
* provenance line. It names the regions and no resource, so it cannot stand in
|
|
261
|
+
* for the answer — it says the answer has a dimension, not what to say about it.
|
|
262
|
+
*/
|
|
263
|
+
function regionSpread(terms: Term[], matches: IRNode[], show: string[]): void {
|
|
264
|
+
if (show.includes("region") || terms.some((t) => t.a === "region")) return;
|
|
265
|
+
const regions = [
|
|
266
|
+
...new Set(
|
|
267
|
+
matches
|
|
268
|
+
.map((n) => (n.attrs as Record<string, unknown>)?.region)
|
|
269
|
+
.filter((r): r is string => typeof r === "string" && r.length > 0),
|
|
270
|
+
),
|
|
271
|
+
].sort();
|
|
272
|
+
if (regions.length < 2) return;
|
|
273
|
+
console.log(`— these span ${regions.length} regions: ${regions.join(", ")} · add --show region to see which`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Point out that `--ambient` is relevant to the kind just queried (#1278).
|
|
278
|
+
*
|
|
279
|
+
* A resource nothing declares and nothing references is invisible to every
|
|
280
|
+
* other observation path, so an answer about "my security groups" can be
|
|
281
|
+
* complete for the declared estate and still not be the answer the question
|
|
282
|
+
* wanted. The caller cannot know that from the result — it looks like the whole
|
|
283
|
+
* set. An agent asked which groups were unused queried the three declared ones,
|
|
284
|
+
* never learned three more existed, and spent twenty-five turns trying to
|
|
285
|
+
* reconcile the shortfall from the graph.
|
|
286
|
+
*
|
|
287
|
+
* Says only that the flag applies to this kind, which is knowable without a
|
|
288
|
+
* scan. It reports no count and names no resource, so it cannot stand in for
|
|
289
|
+
* the answer.
|
|
290
|
+
*/
|
|
291
|
+
function ambientHint(
|
|
292
|
+
matches: IRNode[],
|
|
293
|
+
ambientKinds: string[],
|
|
294
|
+
asked: boolean,
|
|
295
|
+
replay?: { recordedAmbient: boolean },
|
|
296
|
+
): void {
|
|
297
|
+
if (asked || ambientKinds.length === 0 || matches.length === 0) return;
|
|
298
|
+
// On a replay the flag cannot change the answer: what is ambient in a
|
|
299
|
+
// recording was fixed when it was recorded. Telling a caller to add
|
|
300
|
+
// `--ambient` to `--at` is advice that does nothing — and when the snapshot
|
|
301
|
+
// already holds ambient resources it is worse than nothing, because the
|
|
302
|
+
// answer is complete and the hint says it is not. An agent read "6 of 6
|
|
303
|
+
// matched" alongside it, went looking for a seventh, and hand-built a wrong
|
|
304
|
+
// answer from the raw graph over twelve turns.
|
|
305
|
+
if (replay) {
|
|
306
|
+
if (!replay.recordedAmbient) {
|
|
307
|
+
console.log(
|
|
308
|
+
`— this snapshot recorded no ambient resources · re-record with \`chant lifecycle snapshot <env> --ambient\` to include them`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const relevant = [...new Set(ambientKinds.filter((k) => matches.some((n) => n.kind === k)))];
|
|
314
|
+
if (relevant.length === 0) return;
|
|
315
|
+
const label = relevant.map((k) => k.split("::").slice(-1)[0]).join(", ");
|
|
316
|
+
console.log(
|
|
317
|
+
`— ${label} can also exist in the account without being declared or referenced; --ambient includes those`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Say what backed this answer (#1266).
|
|
323
|
+
*
|
|
324
|
+
* Two things went wrong without it. A `--live` read that failed entirely
|
|
325
|
+
* returned the declared graph, exit 0, with no physical ids and nothing to say
|
|
326
|
+
* so — indistinguishable from a working live answer (#1263). And the derived
|
|
327
|
+
* surface below named folds like `internetFacing` whether or not the
|
|
328
|
+
* observation could support them, which is worse than saying nothing.
|
|
329
|
+
*
|
|
330
|
+
* It is also the most direct thing the tool can say to a caller deciding
|
|
331
|
+
* whether to re-check with a raw provider sweep: the API has already been read,
|
|
332
|
+
* and this many resources were bound to what it returned. A sweep repeats work
|
|
333
|
+
* already done. That is a fact about the query, printed for every query, and it
|
|
334
|
+
* encodes no expected answer.
|
|
335
|
+
*/
|
|
336
|
+
function provenance(matches: IRNode[], source: AnswerSource, recorded?: string): void {
|
|
337
|
+
if (source.kind === "declared") {
|
|
338
|
+
console.log("— declared only · no observation · physical ids unavailable");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const bound = matches.filter((n) => n.physicalId).length;
|
|
342
|
+
const what = source.kind === "live" ? "live read" : "snapshot";
|
|
343
|
+
if (bound === 0) {
|
|
344
|
+
// The estate was asked for and nothing came back bound. Naming it is the
|
|
345
|
+
// difference between "these do not exist" and "nobody could see them".
|
|
346
|
+
// A snapshot sitting unused is the actionable half of this. Denied network,
|
|
347
|
+
// agents read six declared rows as a live answer and spent their turns
|
|
348
|
+
// retrying `--live` — the tool knew the estate was unreachable AND that a
|
|
349
|
+
// recording of it was on disk, and said only the first half.
|
|
350
|
+
if (recorded) {
|
|
351
|
+
console.log(
|
|
352
|
+
`— ${what} returned no bound resources · a snapshot of this environment is recorded — answer from it with --at latest`,
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
console.log(
|
|
357
|
+
`— ${what} returned no bound resources · answered from the declared graph · physical ids unavailable`,
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (source.kind === "live") {
|
|
362
|
+
console.log(`— observed live · bound ${bound}/${matches.length}`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// Time is the whole risk of a recorded answer, so it leads. A caller can see
|
|
366
|
+
// how old this is and decide, rather than discovering staleness later.
|
|
367
|
+
const taken = source.timestamp ? ` taken ${source.timestamp}` : "";
|
|
368
|
+
const at = source.commit ? ` ${source.commit.slice(0, 7)}` : "";
|
|
369
|
+
console.log(`— observed from snapshot${at}${taken} · bound ${bound}/${matches.length}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
129
372
|
/**
|
|
130
373
|
* Name the facts chant computed for the kinds in this result that the query did not use.
|
|
131
374
|
*
|
|
@@ -138,9 +381,12 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
138
381
|
* produced them. Nothing here knows what any attribute means or which question it answers;
|
|
139
382
|
* add a pass and its facts appear, remove one and they stop.
|
|
140
383
|
*/
|
|
141
|
-
function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR): void {
|
|
384
|
+
function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR, backed = true): void {
|
|
142
385
|
const derived = ir.derivedAttrs;
|
|
143
|
-
|
|
386
|
+
// A fold over live topology has nothing to report when the observation came
|
|
387
|
+
// back empty (#1263). Naming the surface anyway advertises facts this answer
|
|
388
|
+
// could not have computed, which is worse than saying nothing at all.
|
|
389
|
+
if (!derived || matches.length === 0 || !backed) return;
|
|
144
390
|
const used = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
145
391
|
const unused = new Set<string>();
|
|
146
392
|
for (const n of matches) {
|
|
@@ -206,11 +452,36 @@ function availableAttrs(terms: Term[], ir: GraphIR): void {
|
|
|
206
452
|
for (const n of of) for (const k of Object.keys((n.attrs as Record<string, unknown>) ?? {})) names.add(k);
|
|
207
453
|
const queried = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
208
454
|
const unused = [...names].filter((k) => !queried.has(k)).sort();
|
|
209
|
-
if (unused.length
|
|
210
|
-
|
|
455
|
+
if (unused.length > 0) {
|
|
456
|
+
console.log(` · ${of.length} ${kindTerm.a} node(s) carry: ${unused.join(", ")}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A queried attribute that EXISTS but matched nothing is the more useful
|
|
460
|
+
// miss to explain, and it was the one left silent: the list above omits
|
|
461
|
+
// anything the caller asked about, so querying a real attribute with an
|
|
462
|
+
// unmatchable value taught nothing at all. A caller reaching for a wildcard —
|
|
463
|
+
// `attr:effectiveIngress=*tcp:22:0.0.0.0/0`, which this grammar has no
|
|
464
|
+
// operator for — got "(no matches)" and concluded the tool had nothing.
|
|
465
|
+
for (const term of terms) {
|
|
466
|
+
if (term.kind !== "attr" || term.b === undefined || !names.has(term.a)) continue;
|
|
467
|
+
const values = new Set<string>();
|
|
468
|
+
for (const n of of) {
|
|
469
|
+
const v = (n.attrs as Record<string, unknown> | undefined)?.[term.a];
|
|
470
|
+
for (const one of Array.isArray(v) ? v : [v]) {
|
|
471
|
+
if (one !== undefined && one !== null) values.add(String(one));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (values.size === 0) continue;
|
|
475
|
+
const sample = [...values].sort().slice(0, 8);
|
|
476
|
+
const more = values.size > sample.length ? `, … ${values.size - sample.length} more` : "";
|
|
477
|
+
console.log(` · ${term.a} is present but no value matched "${term.b}" — values seen: ${sample.join(", ")}${more}`);
|
|
478
|
+
}
|
|
211
479
|
}
|
|
212
480
|
|
|
213
481
|
function describeTerm(t: Term): string {
|
|
482
|
+
// `--explain` has to say a negated term was negated, or an exclusion reads as
|
|
483
|
+
// the opposite of what it is.
|
|
484
|
+
if (t.negated) return `!${describeTerm({ ...t, negated: false })}`;
|
|
214
485
|
const leaf = (x: Term): string =>
|
|
215
486
|
x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
|
|
216
487
|
: x.kind === "tag" ? `tag:${x.a}${x.b !== undefined ? "=" + x.b : ""}` : `"${x.a}"`;
|
|
@@ -220,6 +491,8 @@ function describeTerm(t: Term): string {
|
|
|
220
491
|
|
|
221
492
|
interface Term {
|
|
222
493
|
kind: "word" | "kind" | "tag" | "attr" | "edge";
|
|
494
|
+
/** `!term` — the node must NOT satisfy this (#1280). */
|
|
495
|
+
negated?: boolean;
|
|
223
496
|
a: string;
|
|
224
497
|
b?: string;
|
|
225
498
|
/** For edge terms: the direction and the sub-predicate matched at the far end. */
|
|
@@ -236,17 +509,84 @@ function parseLeaf(tok: string): Term {
|
|
|
236
509
|
if (eq >= 0) return { kind: key, a: rest.slice(0, eq), b: rest.slice(eq + 1) };
|
|
237
510
|
return { kind: key, a: rest };
|
|
238
511
|
}
|
|
512
|
+
// `name:value` with a prefix the grammar does not have. This parsed as a
|
|
513
|
+
// free-text word and matched nothing, which is the worst available outcome:
|
|
514
|
+
// an agent looking for SSH reachability wrote
|
|
515
|
+
// `effectiveIngress:tcp:22:0.0.0.0/0` — the right idea, the right attribute,
|
|
516
|
+
// the wrong spelling — got a clean empty result, concluded chant did not hold
|
|
517
|
+
// the fact, and rebuilt the answer by hand from security-group rows. An empty
|
|
518
|
+
// result must never be the reply to a question the grammar could not read.
|
|
519
|
+
//
|
|
520
|
+
// `::` and `://` are excluded so a genuine word search for `AWS::EC2::Instance`
|
|
521
|
+
// or a URL still works — a real prefix is one colon, not two.
|
|
522
|
+
const bad = /^([A-Za-z][A-Za-z0-9_]*):(?![:/])/.exec(tok);
|
|
523
|
+
if (bad) {
|
|
524
|
+
const name = bad[1];
|
|
525
|
+
const value = tok.slice(name.length + 1);
|
|
526
|
+
throw new QueryError(
|
|
527
|
+
`"${tok}" is not a term — there is no "${name}:" prefix`,
|
|
528
|
+
`for an attribute, say attr:${name}=${value || "<value>"}; the prefixes are kind:, attr:, tag:, and ->/<- for edges`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
239
531
|
return { kind: "word", a: tok };
|
|
240
532
|
}
|
|
241
533
|
|
|
534
|
+
/** A query the grammar cannot accept, carrying the correction to print. */
|
|
535
|
+
class QueryError extends Error {
|
|
536
|
+
constructor(
|
|
537
|
+
message: string,
|
|
538
|
+
readonly hint: string,
|
|
539
|
+
) {
|
|
540
|
+
super(message);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
242
544
|
function parseQuery(query: string): Term[] {
|
|
243
545
|
// Split on whitespace but keep quoted phrases together.
|
|
244
546
|
const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
|
|
245
547
|
return tokens.map((raw) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
548
|
+
let tok = raw.replace(/^"|"$/g, "");
|
|
549
|
+
// A leading `!` negates the term (#1280). Absence is a real estate
|
|
550
|
+
// question — "which security groups does nothing reference", "which
|
|
551
|
+
// subnets hold no instances" — and the grammar could only express
|
|
552
|
+
// presence, so the one question a graph is uniquely good at needed a
|
|
553
|
+
// provider sweep and a hand-built set difference.
|
|
554
|
+
const negated = tok.startsWith("!");
|
|
555
|
+
if (negated) tok = tok.slice(1);
|
|
556
|
+
// An edge term needs a target. A bare `<-` used to parse to an empty leaf
|
|
557
|
+
// and quietly match something arbitrary — an agent wrote
|
|
558
|
+
// `kind:EC2::SecurityGroup !<-` meaning "referenced by nothing" and got a
|
|
559
|
+
// silently wrong set. Refusing it is right beyond the parse bug too:
|
|
560
|
+
// "referenced by nothing at all" and "referenced by no Instance" are
|
|
561
|
+
// different questions, and on any estate whose declared graph carries
|
|
562
|
+
// references they give different answers.
|
|
563
|
+
// A bare edge term is refused, and the refusal names the correction.
|
|
564
|
+
//
|
|
565
|
+
// It first parsed to an empty leaf and matched arbitrarily. The fix was to
|
|
566
|
+
// refuse it; then, because agents kept writing `!<-` for "what is unused",
|
|
567
|
+
// it was made to mean "no edge in this direction" — which is a coherent
|
|
568
|
+
// query and still the wrong one to answer that question with. It counts
|
|
569
|
+
// every reference in the project, and a stack output that publishes a
|
|
570
|
+
// resource's id is one, so `kind:EC2::SecurityGroup !<-` omits precisely
|
|
571
|
+
// the unattached group the question was about.
|
|
572
|
+
//
|
|
573
|
+
// Measured both ways: refusing it, agents wrote `!<-kind:EC2::Instance` and
|
|
574
|
+
// got the right answer 3/3; accepting it, they wrote `!<-` and got a wrong
|
|
575
|
+
// one 2 runs out of 3. A query whose plain reading is reliably not what the
|
|
576
|
+
// caller means is worth refusing, and the correction below is what makes
|
|
577
|
+
// the refusal useful rather than merely strict.
|
|
578
|
+
if (/^(->|<-)\s*$/.test(tok)) {
|
|
579
|
+
throw new QueryError(
|
|
580
|
+
`"${negated ? "!" : ""}${tok}" needs a target`,
|
|
581
|
+
`say what the edge reaches: ${negated ? "!" : ""}${tok}kind:EC2::Instance, or ${negated ? "!" : ""}${tok}attr:Name=web`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const term = tok.startsWith("->")
|
|
585
|
+
? { kind: "edge" as const, a: "", dir: "out" as const, sub: parseLeaf(tok.slice(2)) }
|
|
586
|
+
: tok.startsWith("<-")
|
|
587
|
+
? { kind: "edge" as const, a: "", dir: "in" as const, sub: parseLeaf(tok.slice(2)) }
|
|
588
|
+
: parseLeaf(tok);
|
|
589
|
+
return negated ? { ...term, negated: true } : term;
|
|
250
590
|
});
|
|
251
591
|
}
|
|
252
592
|
|
|
@@ -264,6 +604,7 @@ function attrString(v: unknown): string {
|
|
|
264
604
|
}
|
|
265
605
|
|
|
266
606
|
function matchTerm(n: IRNode, t: Term, ir?: GraphIR, byId?: Map<string, IRNode>): boolean {
|
|
607
|
+
if (t.negated) return !matchTerm(n, { ...t, negated: false }, ir, byId);
|
|
267
608
|
const attrs = n.attrs ?? {};
|
|
268
609
|
if (t.kind === "edge") {
|
|
269
610
|
if (!ir || !byId || !t.sub) return false;
|
|
@@ -304,11 +645,24 @@ function formatRow(n: IRNode, show: string[]): string {
|
|
|
304
645
|
const physical = (n as { physicalId?: unknown }).physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
|
|
305
646
|
if (physical != null && typeof physical !== "object") parts.push(String(physical));
|
|
306
647
|
for (const key of show) {
|
|
307
|
-
|
|
308
|
-
|
|
648
|
+
// Match the name case-insensitively, and report what was actually found.
|
|
649
|
+
// AWS attribute names are PascalCase and chant's derived ones are not, so a
|
|
650
|
+
// caller mixing them is normal: `--show region` and `--show Region` are the
|
|
651
|
+
// same request, and one of them silently printed nothing. Seven of the
|
|
652
|
+
// `--show` names in one benchmark run missed on case alone.
|
|
653
|
+
const actual = key in attrs ? key : Object.keys(attrs).find((k) => k.toLowerCase() === key.toLowerCase());
|
|
654
|
+
const v = actual == null ? undefined : attrs[actual];
|
|
655
|
+
if (v == null) continue;
|
|
656
|
+
// A column the caller explicitly asked for is shown whatever shape it is.
|
|
657
|
+
// Skipping non-scalars silently meant `--show effectiveIngress` — the
|
|
658
|
+
// derived reachability fact, and the reason to reach for chant at all —
|
|
659
|
+
// printed a blank column, because it is a list. The agent read that as
|
|
660
|
+
// "chant does not have this" and hand-rolled the answer from raw
|
|
661
|
+
// security-group rows, which is exactly the work the fold exists to avoid.
|
|
662
|
+
parts.push(`${actual}=${typeof v === "object" ? JSON.stringify(v) : attrString(v)}`);
|
|
309
663
|
}
|
|
310
664
|
return parts.filter(Boolean).join(" ");
|
|
311
665
|
}
|
|
312
666
|
|
|
313
667
|
/** Internals exposed for unit tests. */
|
|
314
|
-
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs };
|
|
668
|
+
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss };
|
package/src/cli/main.ts
CHANGED
|
@@ -273,6 +273,12 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
273
273
|
result.updateSnapshot = true;
|
|
274
274
|
} else if (arg === "--update-baseline") {
|
|
275
275
|
result.updateBaseline = true;
|
|
276
|
+
} else if (arg === "--deep") {
|
|
277
|
+
result.deep = true;
|
|
278
|
+
} else if (arg === "--at") {
|
|
279
|
+
result.at = args[++i];
|
|
280
|
+
} else if (arg === "--ambient") {
|
|
281
|
+
result.ambient = true;
|
|
276
282
|
} else if (arg === "--run-examples") {
|
|
277
283
|
result.runExamples = true;
|
|
278
284
|
} else if (arg === "--pinned-digest") {
|
|
@@ -437,6 +443,9 @@ Ops:
|
|
|
437
443
|
|
|
438
444
|
Lifecycle (alias: lc):
|
|
439
445
|
lifecycle snapshot <env> Query API, save metadata to orphan branch
|
|
446
|
+
--deep: also record each resource's property tree,
|
|
447
|
+
not just its identity — what a fold over topology
|
|
448
|
+
needs (costs more provider calls; #1267)
|
|
440
449
|
lifecycle show <env> Show latest lifecycle snapshot
|
|
441
450
|
lifecycle diff <env> Compare current build against last snapshot
|
|
442
451
|
--live: query cloud now and detect drift
|
package/src/cli/registry.ts
CHANGED
|
@@ -130,6 +130,33 @@ export interface ParsedArgs {
|
|
|
130
130
|
* orphan branch; never touches the cloud.
|
|
131
131
|
*/
|
|
132
132
|
updateBaseline?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* `chant lifecycle snapshot <env> --deep` (#1267) — also record each
|
|
135
|
+
* resource's normalized property tree, not just its identity. What a fold
|
|
136
|
+
* over topology needs, and what a snapshot-backed query needs to answer a
|
|
137
|
+
* property question at all. Costs more provider calls and a larger record,
|
|
138
|
+
* so it is opt-in.
|
|
139
|
+
*/
|
|
140
|
+
deep?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* `chant search "<q>" --at <ref> --env <name>` (#1266) — answer from a
|
|
143
|
+
* recorded observation instead of reading the estate now. `latest` uses the
|
|
144
|
+
* most recent snapshot. Mutually exclusive with `--live`: they are two
|
|
145
|
+
* different observations of the same estate, and there is no rule for which
|
|
146
|
+
* should win.
|
|
147
|
+
*/
|
|
148
|
+
at?: string;
|
|
149
|
+
/**
|
|
150
|
+
* `chant search "<q>" --ambient --live --env <name>` (#1278) — also report
|
|
151
|
+
* resources of a kind this estate manages that exist in the account without
|
|
152
|
+
* being declared or referenced. What "which of my security groups are
|
|
153
|
+
* unused" is asking about, and unreachable from a state file.
|
|
154
|
+
*
|
|
155
|
+
* Opt-in: it asks the provider what exists rather than resolving out from
|
|
156
|
+
* what is declared, which is a broader read and a different claim.
|
|
157
|
+
*/
|
|
158
|
+
ambient?: boolean;
|
|
159
|
+
|
|
133
160
|
/** `chant dev surface-diff --run-examples` — also run the example build harness */
|
|
134
161
|
runExamples?: boolean;
|
|
135
162
|
/** `chant dev surface-diff --pinned-digest <file>` — path to SHA-256 digest file for supply-chain verification */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant — the root package.json lexicon list is hand-maintained, and it drifted.
|
|
3
|
+
*
|
|
4
|
+
* `chant dev onboard` adds `@intentius/chant-lexicon-<name>` to the root
|
|
5
|
+
* `dependencies`, but three lexicons (fly, forgejo, fountain) were never added
|
|
6
|
+
* and nobody noticed, because nothing checks. That is the same shape as the
|
|
7
|
+
* lexicon-upgrade miswiring (#1218/#1226) and the missing publish wiring that
|
|
8
|
+
* stranded two packages: a list a human has to remember, with no gate.
|
|
9
|
+
*
|
|
10
|
+
* Resolution itself does not depend on this list — `workspaces: ["lexicons/*"]`
|
|
11
|
+
* symlinks every lexicon into node_modules regardless, which is why the three
|
|
12
|
+
* omissions never broke anything. The entry is an explicit declaration, and the
|
|
13
|
+
* point of this test is that it either applies to every lexicon or to none,
|
|
14
|
+
* rather than silently landing somewhere in between.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readFileSync, readdirSync, existsSync } 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 publishableLexicons(): string[] {
|
|
25
|
+
return readdirSync(join(repoRoot, "lexicons"))
|
|
26
|
+
.filter((name) => {
|
|
27
|
+
const manifest = join(repoRoot, "lexicons", name, "package.json");
|
|
28
|
+
if (!existsSync(manifest)) return false;
|
|
29
|
+
return JSON.parse(readFileSync(manifest, "utf-8")).private !== true;
|
|
30
|
+
})
|
|
31
|
+
.sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rootLexiconDeps(): string[] {
|
|
35
|
+
const root = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8"));
|
|
36
|
+
return Object.keys(root.dependencies ?? {})
|
|
37
|
+
.filter((dep) => dep.startsWith("@intentius/chant-lexicon-"))
|
|
38
|
+
.map((dep) => dep.replace("@intentius/chant-lexicon-", ""))
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("root package.json lexicon wiring", () => {
|
|
43
|
+
it("lists every publishable lexicon", () => {
|
|
44
|
+
expect(rootLexiconDeps()).toEqual(publishableLexicons());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lists no lexicon that does not exist", () => {
|
|
48
|
+
const onDisk = new Set(readdirSync(join(repoRoot, "lexicons")));
|
|
49
|
+
for (const name of rootLexiconDeps()) {
|
|
50
|
+
expect(onDisk.has(name), `root package.json depends on a missing lexicon "${name}"`).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|