@intentius/chant 0.124.45 → 0.124.46
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/capabilities/aws.paths +148 -0
- package/dist/capabilities/base-mappings.json +122 -0
- package/dist/capabilities/catalog.d.ts +89 -0
- package/dist/capabilities/catalog.d.ts.map +1 -0
- package/dist/capabilities/catalog.js +204 -0
- package/dist/capabilities/language.d.ts +103 -0
- package/dist/capabilities/language.d.ts.map +1 -0
- package/dist/capabilities/language.js +335 -0
- package/dist/capabilities/matcher.d.ts +65 -0
- package/dist/capabilities/matcher.d.ts.map +1 -0
- package/dist/capabilities/matcher.js +299 -0
- package/dist/capabilities/view.d.ts +207 -0
- package/dist/capabilities/view.d.ts.map +1 -0
- package/dist/capabilities/view.js +484 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +1 -0
- package/dist/cli/handlers/declared-graph.d.ts +44 -0
- package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
- package/dist/cli/handlers/declared-graph.js +87 -0
- package/dist/cli/handlers/graph.d.ts +25 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +93 -87
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +2 -1
- package/dist/cli/handlers/search-count.d.ts +89 -0
- package/dist/cli/handlers/search-count.d.ts.map +1 -0
- package/dist/cli/handlers/search-count.js +132 -0
- package/dist/cli/handlers/search.d.ts +358 -6
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/handlers/search.js +1065 -155
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +23 -3
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/server.js +3 -0
- package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
- package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
- package/dist/cli/mcp/tools/capabilities.js +27 -0
- package/dist/cli/mcp/workspace-tools.d.ts +3 -1
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +47 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +64 -17
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-env-ports.d.ts +29 -0
- package/dist/components/pr-env-ports.d.ts.map +1 -0
- package/dist/components/pr-env-ports.js +77 -0
- package/dist/components/pr-env-run.d.ts +5 -21
- package/dist/components/pr-env-run.d.ts.map +1 -1
- package/dist/components/pr-env-run.js +6 -70
- package/dist/components/pr-environment.d.ts +2 -1
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +1 -1
- package/dist/config-environments.d.ts +92 -0
- package/dist/config-environments.d.ts.map +1 -0
- package/dist/config-environments.js +102 -0
- package/dist/config.d.ts +24 -84
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -96
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +15 -3
- package/dist/graph-effective.d.ts +33 -2
- package/dist/graph-effective.d.ts.map +1 -1
- package/dist/graph-effective.js +215 -68
- package/dist/graph-ir.d.ts +23 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +130 -0
- package/dist/graph-sub-template.d.ts +11 -0
- package/dist/graph-sub-template.d.ts.map +1 -0
- package/dist/graph-sub-template.js +19 -0
- package/dist/lexicon.d.ts +9 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +28 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/observe.js +23 -1
- package/dist/lint/rule.d.ts +6 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +1 -1
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +1 -1
- package/dist/observation.d.ts +48 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +21 -5
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/wave-runtime.mjs +250 -249
- package/dist/workspace/capabilities-contract.d.ts +66 -0
- package/dist/workspace/capabilities-contract.d.ts.map +1 -0
- package/dist/workspace/capabilities-contract.js +107 -0
- package/dist/workspace/capabilities.schema.json +229 -0
- package/dist/workspace/compose-graph.d.ts +9 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +3 -1
- package/dist/workspace/conformance/index.d.ts +1 -1
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/index.js +39 -2
- package/dist/workspace/graph-cache.d.ts +8 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +27 -3
- package/dist/workspace/graph-cli.d.ts +39 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/graph-cli.js +66 -24
- package/dist/workspace/graph.schema.json +148 -0
- package/dist/workspace/kind-readers.d.ts +3 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +7 -1
- package/dist/workspace/member-commands.d.ts +10 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +17 -1
- package/dist/workspace/nested-graph.d.ts +2 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +32 -1
- package/dist/workspace/reason-codes.d.ts +10 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +12 -0
- package/dist/workspace/search-cli.d.ts +204 -0
- package/dist/workspace/search-cli.d.ts.map +1 -0
- package/dist/workspace/search-cli.js +466 -0
- package/dist/workspace/search-contract.d.ts +56 -0
- package/dist/workspace/search-contract.d.ts.map +1 -0
- package/dist/workspace/search-contract.js +70 -0
- package/dist/workspace/search.schema.json +822 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +10 -0
- package/src/capabilities/aws.paths +148 -0
- package/src/capabilities/base-mappings.json +122 -0
- package/src/capabilities/catalog.ts +230 -0
- package/src/capabilities/language.test.ts +191 -0
- package/src/capabilities/language.ts +373 -0
- package/src/capabilities/matcher.ts +315 -0
- package/src/capabilities/view-compose.test.ts +76 -0
- package/src/capabilities/view.ts +631 -0
- package/src/cli/commands/lint.ts +1 -0
- package/src/cli/handlers/declared-graph.ts +99 -0
- package/src/cli/handlers/graph.test.ts +94 -3
- package/src/cli/handlers/graph.ts +123 -96
- package/src/cli/handlers/pr-env.ts +1 -2
- package/src/cli/handlers/search-build-roots.test.ts +144 -0
- package/src/cli/handlers/search-count.test.ts +191 -0
- package/src/cli/handlers/search-count.ts +165 -0
- package/src/cli/handlers/search-json.test.ts +333 -0
- package/src/cli/handlers/search-live.test.ts +387 -0
- package/src/cli/handlers/search-paths.test.ts +235 -0
- package/src/cli/handlers/search.ts +1312 -166
- package/src/cli/main.test.ts +28 -3
- package/src/cli/main.ts +23 -3
- package/src/cli/mcp/server.test.ts +3 -3
- package/src/cli/mcp/server.ts +3 -0
- package/src/cli/mcp/tools/capabilities.ts +32 -0
- package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
- package/src/cli/mcp/workspace-tools.ts +47 -2
- package/src/cli/parse-args.ts +52 -13
- package/src/cli/registry.ts +22 -0
- package/src/components/pr-env-ports.ts +89 -0
- package/src/components/pr-env-run-bundle.test.ts +54 -0
- package/src/components/pr-env-run.ts +5 -76
- package/src/components/pr-environment.ts +2 -1
- package/src/config-environments.ts +133 -0
- package/src/config.ts +37 -124
- package/src/graph-detail.ts +15 -3
- package/src/graph-effective.ts +226 -75
- package/src/graph-ir.test.ts +163 -0
- package/src/graph-ir.ts +147 -0
- package/src/graph-sub-template.ts +22 -0
- package/src/lexicon.ts +10 -0
- package/src/lifecycle/observe.test.ts +27 -1
- package/src/lifecycle/observe.ts +43 -1
- package/src/lint/rule.ts +6 -0
- package/src/live-endpoint.ts +1 -1
- package/src/observation.test.ts +25 -0
- package/src/observation.ts +69 -5
- package/src/workspace/capabilities-contract.test.ts +88 -0
- package/src/workspace/capabilities-contract.ts +132 -0
- package/src/workspace/capabilities.schema.json +229 -0
- package/src/workspace/compose-graph.ts +10 -1
- package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
- package/src/workspace/conformance/index.ts +31 -2
- package/src/workspace/graph-cache.ts +27 -3
- package/src/workspace/graph-cli.ts +96 -24
- package/src/workspace/graph.schema.json +148 -0
- package/src/workspace/kind-readers.e2e.test.ts +28 -0
- package/src/workspace/kind-readers.test.ts +1 -1
- package/src/workspace/kind-readers.ts +7 -1
- package/src/workspace/member-commands.ts +22 -1
- package/src/workspace/nested-graph.ts +32 -1
- package/src/workspace/reason-codes.test.ts +7 -0
- package/src/workspace/reason-codes.ts +12 -0
- package/src/workspace/search-cli.e2e.test.ts +305 -0
- package/src/workspace/search-cli.ts +591 -0
- package/src/workspace/search-contract.ts +95 -0
- package/src/workspace/search.schema.json +822 -0
|
@@ -2,16 +2,22 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { build } from "../../build.js";
|
|
3
3
|
import { buildGraphIr, buildLiveGraphIr, collectUnobserved, sourceOverlayGraphs } from "../../graph-ir.js";
|
|
4
4
|
import { buildDeclaredPerStack } from "../../graph-declared.js";
|
|
5
|
-
import { enrichEffectiveTopology } from "../../graph-effective.js";
|
|
5
|
+
import { enrichEffectiveTopology, terraformResourceType, terraformType } from "../../graph-effective.js";
|
|
6
|
+
import { attachCapabilities, attachView, capabilityEngine, parseCapabilityCall } from "../../capabilities/catalog.js";
|
|
7
|
+
import { CapabilityError } from "../../capabilities/matcher.js";
|
|
6
8
|
import { reconstructEdges, mergeCatalogs } from "../../graph-refs.js";
|
|
7
9
|
import { discover } from "../../discovery/index.js";
|
|
8
|
-
import { observeResources } from "../../lifecycle/observe.js";
|
|
10
|
+
import { carryAnnotations, observeResources } from "../../lifecycle/observe.js";
|
|
9
11
|
import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay.js";
|
|
10
12
|
import { diffLive } from "../../lifecycle/live-diff.js";
|
|
11
13
|
import { formatUnobserved } from "../../observation.js";
|
|
14
|
+
import { readChantPackageVersion } from "../../package-version.js";
|
|
12
15
|
import { loadChantConfig, matchesDeclaredEnvironment } from "../../config.js";
|
|
13
|
-
import { loadPlugins, resolveProjectLexicons } from "../plugins.js";
|
|
16
|
+
import { collectBuildRootContributors, loadPlugins, resolveProjectLexicons } from "../plugins.js";
|
|
17
|
+
import { declaredBuildRootContributors, mergeDeclaredBuildRoots, withLexiconEdges } from "./declared-graph.js";
|
|
18
|
+
import { parseCountBy, countGroups, countsJson, formatCounts, groupMembers, hasManyTerraformRoots } from "./search-count.js";
|
|
14
19
|
import { formatError, formatWarning } from "../format.js";
|
|
20
|
+
import { SEARCH_CONTRACT_VERSION, SEARCH_OUTPUT_SCHEMA_ID } from "../../workspace/search-contract.js";
|
|
15
21
|
/**
|
|
16
22
|
* `chant search <query> [--live --env <name>]` — answer an estate question with
|
|
17
23
|
* a COMPACT result instead of the whole graph. The point (measured on aws-bench,
|
|
@@ -20,11 +26,14 @@ import { formatError, formatWarning } from "../format.js";
|
|
|
20
26
|
*
|
|
21
27
|
* Query grammar (space-separated terms, all must match — AND):
|
|
22
28
|
* bare word case-insensitive substring over id, kind, and attrs
|
|
23
|
-
* kind:<substr> node kind contains <substr> (e.g. kind:EC2::Instance)
|
|
29
|
+
* kind:<substr> node kind contains <substr> (e.g. kind:EC2::Instance);
|
|
30
|
+
* on a terraform resource, its type (kind:aws_instance, kind:aws_iam_)
|
|
24
31
|
* tag:<key>=<val> a Tags entry with Key=key and Value containing val
|
|
25
32
|
* attr:<name>=<val> attribute <name> equals/contains <val>
|
|
26
33
|
* ->kind:X / ->attr:.. this node has an edge TO a node matching the right side
|
|
27
34
|
* <-kind:X / <-attr:.. this node has an edge FROM a node matching the right side
|
|
35
|
+
* ->*X, ->{n}X, ->{m,n}X the far end is any depth / n / m..n hops away (#4359)
|
|
36
|
+
* ->[via=a]X, ->[to=a]X only edges through consumer attr a / reading producer attr a
|
|
28
37
|
*
|
|
29
38
|
* The edge operators are the point of "edge-aware" search (#1139): a small
|
|
30
39
|
* model shouldn't hand-join instance→subnet→public across many results — one
|
|
@@ -38,46 +47,36 @@ import { formatError, formatWarning } from "../format.js";
|
|
|
38
47
|
export async function runSearch(ctx) {
|
|
39
48
|
const { args } = ctx;
|
|
40
49
|
const query = (args.path ?? "").trim();
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
// A refusal (#4367): the error on stderr as always, and under --json the
|
|
51
|
+
// same message and hint as the one document on stdout, so a program reading
|
|
52
|
+
// stdout sees why there are no rows. Exit 1 either way. The code is from the
|
|
53
|
+
// read contract's closed list (#4374).
|
|
54
|
+
const refuse = (error) => {
|
|
55
|
+
console.error(formatError(error));
|
|
56
|
+
if (args.json)
|
|
57
|
+
console.log(JSON.stringify(searchErrorJson(query, error)));
|
|
43
58
|
return 1;
|
|
59
|
+
};
|
|
60
|
+
// The capability catalog (#4375): the `capabilities` document, not an answer.
|
|
61
|
+
if (args.capabilities)
|
|
62
|
+
return printCapabilities(args, query, await resolveProjectLexicons(resolve(".")).catch(() => []));
|
|
63
|
+
// The snapshot to replay (#4374): `--snapshot <ref>`, or `--at <ref>`, which
|
|
64
|
+
// plain search keeps for compatibility. Everywhere else in the read contract
|
|
65
|
+
// `--at` is a git revision, so the workspace form takes only `--snapshot`.
|
|
66
|
+
if (args.snapshot !== undefined && args.at !== undefined && String(args.snapshot) !== String(args.at)) {
|
|
67
|
+
return refuse({
|
|
68
|
+
code: "usage-invalid",
|
|
69
|
+
message: `chant search was given --snapshot ${args.snapshot} and --at ${args.at}`,
|
|
70
|
+
hint: "they name the same thing, the recorded snapshot to answer from; give one",
|
|
71
|
+
});
|
|
44
72
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.error(formatError({ message: err.message, hint: err.hint }));
|
|
52
|
-
return 1;
|
|
53
|
-
}
|
|
54
|
-
throw err;
|
|
55
|
-
}
|
|
73
|
+
const snapshotRef = args.snapshot ?? (args.at !== undefined ? String(args.at) : undefined);
|
|
74
|
+
const snapshotFlag = args.snapshot !== undefined ? "--snapshot" : "--at";
|
|
75
|
+
const checked = checkSearchArgs(args, query, { ref: snapshotRef, flag: snapshotFlag });
|
|
76
|
+
if ("error" in checked)
|
|
77
|
+
return refuse(checked.error);
|
|
78
|
+
const { terms, countBy } = checked;
|
|
56
79
|
const show = parseShow(args);
|
|
57
|
-
// Query-scoped drift (#1268): --check-live checks a snapshot answer against
|
|
58
|
-
// a live read, --check-snapshot checks a live answer against the recorded
|
|
59
|
-
// snapshot — the direction each needs is the source it does NOT already have.
|
|
60
|
-
if (args.checkLive && !args.at) {
|
|
61
|
-
console.error(formatError({
|
|
62
|
-
message: "chant search --check-live needs --at <ref>",
|
|
63
|
-
hint: "answer from a snapshot and check the matched rows against a live read: --at latest --check-live --env <name>",
|
|
64
|
-
}));
|
|
65
|
-
return 1;
|
|
66
|
-
}
|
|
67
|
-
if (args.checkSnapshot && !args.live) {
|
|
68
|
-
console.error(formatError({
|
|
69
|
-
message: "chant search --check-snapshot needs --live",
|
|
70
|
-
hint: "answer live and check the matched rows against the recorded snapshot: --live --check-snapshot --env <name>",
|
|
71
|
-
}));
|
|
72
|
-
return 1;
|
|
73
|
-
}
|
|
74
|
-
if (args.failOnDrift && !args.checkLive && !args.checkSnapshot) {
|
|
75
|
-
console.error(formatError({
|
|
76
|
-
message: "chant search --fail-on-drift needs --check-live or --check-snapshot",
|
|
77
|
-
hint: "it fails the check those flags run, so it is meaningless without one",
|
|
78
|
-
}));
|
|
79
|
-
return 1;
|
|
80
|
-
}
|
|
81
80
|
const projectPath = resolve(".");
|
|
82
81
|
const { config } = await loadChantConfig(projectPath);
|
|
83
82
|
let ir;
|
|
@@ -89,6 +88,9 @@ export async function runSearch(ctx) {
|
|
|
89
88
|
// on this path" — printed with the provenance footer, after the rows, so the
|
|
90
89
|
// answer is not preceded by what qualifies it.
|
|
91
90
|
let liveNotes = [];
|
|
91
|
+
// Which read answered each entity (#2267), from a live read.
|
|
92
|
+
let readSources = {};
|
|
93
|
+
let rootReads = {};
|
|
92
94
|
// Kinds that can exist in the account without being declared (#1278). Known
|
|
93
95
|
// without a scan, so it costs nothing to mention.
|
|
94
96
|
let ambientKinds = [];
|
|
@@ -102,58 +104,51 @@ export async function runSearch(ctx) {
|
|
|
102
104
|
let declaredForDrift;
|
|
103
105
|
let snapshotDepth;
|
|
104
106
|
let driftFailure = false;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return 1;
|
|
111
|
-
}
|
|
112
|
-
if (args.live && args.at) {
|
|
113
|
-
// Two different observations of the same estate, and no rule for which
|
|
114
|
-
// wins. Comparing them is a real question (#1268) but it is not this one.
|
|
115
|
-
console.error(formatError({
|
|
116
|
-
message: "chant search takes --live or --at, not both",
|
|
117
|
-
hint: "--live reads the estate now; --at answers from a recorded snapshot",
|
|
118
|
-
}));
|
|
119
|
-
return 1;
|
|
120
|
-
}
|
|
107
|
+
// Why --check-snapshot had nothing to check against, for --json (#4367).
|
|
108
|
+
let driftUnavailable;
|
|
109
|
+
if (args.live || snapshotRef) {
|
|
110
|
+
// checkSearchArgs refused a live or snapshot read without one.
|
|
111
|
+
const environment = String(args.env);
|
|
121
112
|
// Membership via matchesDeclaredEnvironment (#1221): handles `{ name,
|
|
122
113
|
// endpoint }` entries (#1166) — a plain `.includes` never matched those —
|
|
123
114
|
// and glob-pattern entries like `"pr-*"`.
|
|
124
115
|
if (config.environments && config.environments.length > 0 && !matchesDeclaredEnvironment(config.environments, environment)) {
|
|
125
|
-
|
|
126
|
-
return 1;
|
|
116
|
+
return refuse({ code: "environment-unknown", message: `Unknown environment "${environment}"` });
|
|
127
117
|
}
|
|
128
118
|
const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
|
|
119
|
+
// Build roots too (#4358), as `chant graph --live` does: a terraform
|
|
120
|
+
// root's blocks are entities only once its root is rendered, and an
|
|
121
|
+
// entity the build never produced is never observed.
|
|
122
|
+
const buildRoots = collectBuildRootContributors(plugins, config, projectPath);
|
|
129
123
|
const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer), undefined, {
|
|
124
|
+
buildRoots,
|
|
130
125
|
config: config,
|
|
131
126
|
});
|
|
132
127
|
if (buildResult.errors.length > 0) {
|
|
133
|
-
|
|
134
|
-
return 1;
|
|
128
|
+
return refuse({ code: "build-failed", message: "Build failed — fix errors before searching live state" });
|
|
135
129
|
}
|
|
136
130
|
const observing = plugins.filter((p) => p.describeResources);
|
|
137
131
|
ambientKinds = observing.flatMap((p) => p.ambientKinds?.() ?? []);
|
|
138
132
|
const stacks = (config.stacks ?? []).map((s) => ({ name: s.name, region: s.region, src: s.src }));
|
|
139
133
|
let observations;
|
|
134
|
+
// Facts about absent entities (#4372): a planned create's plan attributes.
|
|
135
|
+
let annotations;
|
|
140
136
|
const liveAttrs = {};
|
|
141
|
-
if (
|
|
137
|
+
if (snapshotRef) {
|
|
142
138
|
// Answer from a recorded observation (#1266). Everything downstream is
|
|
143
139
|
// the live path's — the point is that a snapshot replays into the same
|
|
144
140
|
// shape a live read produces, so one pipeline serves both and a fold
|
|
145
141
|
// improvement reaches an old snapshot for free.
|
|
146
142
|
const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
|
|
147
|
-
const replay = await replaySnapshots(environment,
|
|
143
|
+
const replay = await replaySnapshots(environment, snapshotRef, scoped);
|
|
148
144
|
if ("error" in replay) {
|
|
149
|
-
|
|
150
|
-
return 1;
|
|
145
|
+
return refuse({ code: "snapshot-unavailable", message: replay.error, ...(replay.hint ? { hint: replay.hint } : {}) });
|
|
151
146
|
}
|
|
152
147
|
observations = replay.observations;
|
|
153
148
|
replayAmbient = {
|
|
154
149
|
recordedAmbient: replay.observations.some((o) => Object.values(o.resources).some((m) => m.ambient === true)),
|
|
155
150
|
};
|
|
156
|
-
source = { kind: "snapshot", commit: replay.commit, timestamp: replay.timestamp };
|
|
151
|
+
source = { kind: "snapshot", snapshot: snapshotRef, commit: replay.commit, timestamp: replay.timestamp };
|
|
157
152
|
snapshotDepth = replay.depth;
|
|
158
153
|
if (args.checkLive) {
|
|
159
154
|
// A fresh live read, additional to the snapshot the answer itself came
|
|
@@ -184,6 +179,9 @@ export async function runSearch(ctx) {
|
|
|
184
179
|
console.error(formatError({ message: `live read failed — ${e}` }));
|
|
185
180
|
}
|
|
186
181
|
observations = observed.observations;
|
|
182
|
+
annotations = observed.annotations;
|
|
183
|
+
readSources = observed.sources ?? {};
|
|
184
|
+
rootReads = observed.rootReads ?? {};
|
|
187
185
|
liveNotes = observed.notes ?? [];
|
|
188
186
|
source = { kind: "live" };
|
|
189
187
|
if (args.checkSnapshot) {
|
|
@@ -195,6 +193,7 @@ export async function runSearch(ctx) {
|
|
|
195
193
|
const checkReplay = await replaySnapshots(environment, "latest", scoped);
|
|
196
194
|
if ("error" in checkReplay) {
|
|
197
195
|
console.error(formatWarning({ message: `--check-snapshot: ${checkReplay.error}` }));
|
|
196
|
+
driftUnavailable = checkReplay.error;
|
|
198
197
|
}
|
|
199
198
|
else {
|
|
200
199
|
checkObservations = checkReplay.observations;
|
|
@@ -208,7 +207,7 @@ export async function runSearch(ctx) {
|
|
|
208
207
|
let containmentEdges = [];
|
|
209
208
|
// Live-only: enrichment is a provider call, so it has no place in a replay.
|
|
210
209
|
// A recorded answer that quietly reached for the API would stop being one.
|
|
211
|
-
if (!
|
|
210
|
+
if (!snapshotRef) {
|
|
212
211
|
for (const p of observing) {
|
|
213
212
|
if (!p.enrichLiveAttrs)
|
|
214
213
|
continue;
|
|
@@ -265,9 +264,16 @@ export async function runSearch(ctx) {
|
|
|
265
264
|
// `${stack}::${id}` — matching how observation qualifies. A flat whole-
|
|
266
265
|
// project discovery would disambiguate colliding names by module path
|
|
267
266
|
// (UsEast1Src…), which never matches the observed bare LogicalResourceIds.
|
|
267
|
+
//
|
|
268
|
+
// The flat canvas is the declared path's graph (#4365, #4358): discovery
|
|
269
|
+
// plus the build-root contributions (a terraform root's blocks, a rendered
|
|
270
|
+
// kustomize root) plus the lexicon edges, so a live answer over a Terraform
|
|
271
|
+
// root overlays onto a canvas that has the root's resources on it.
|
|
268
272
|
const declared = stacks.length > 0
|
|
269
|
-
? await buildDeclaredPerStack(stacks, projectPath)
|
|
270
|
-
:
|
|
273
|
+
? await buildDeclaredPerStack(stacks, projectPath, (g, entities) => withLexiconEdges(g, plugins, entities))
|
|
274
|
+
: await declaredGraph(ctx, projectPath, resolve(args.src ?? config.sourceDir ?? "."), plugins);
|
|
275
|
+
if (!declared)
|
|
276
|
+
return refuse(BUILD_ROOT_FAILED);
|
|
271
277
|
declaredForDrift = declared;
|
|
272
278
|
// Carry the NOT-OBSERVED half of the tri-state (#1089) onto the rows, so a
|
|
273
279
|
// declared entity nobody could read is painted `_unobserved` and a row can
|
|
@@ -305,21 +311,102 @@ export async function runSearch(ctx) {
|
|
|
305
311
|
// account's default VPC, whose route table chant does not model). The
|
|
306
312
|
// overlay copies physical identity but not attrs, so merge them here.
|
|
307
313
|
ir = { ...ir, nodes: ir.nodes.map((n) => (liveAttrs[n.id] ? { ...n, attrs: { ...n.attrs, ...liveAttrs[n.id] } } : n)) };
|
|
314
|
+
// And what the read learned about an entity it found absent (#4372), so
|
|
315
|
+
// `attr:planAction=create` matches a block the plan will create. The node
|
|
316
|
+
// stays pending: only its attributes change.
|
|
317
|
+
ir = { ...ir, nodes: carryAnnotations(ir.nodes, annotations) };
|
|
308
318
|
}
|
|
309
319
|
else {
|
|
310
|
-
const
|
|
311
|
-
|
|
320
|
+
const declared = await declaredGraph(ctx, projectPath, resolve(args.src ?? config.sourceDir ?? "."));
|
|
321
|
+
if (!declared)
|
|
322
|
+
return refuse(BUILD_ROOT_FAILED);
|
|
323
|
+
ir = declared;
|
|
312
324
|
}
|
|
313
325
|
// Fold derived reachability facts (effectiveIngress, internetFacing) onto
|
|
314
326
|
// instance nodes so multi-hop/launch-template joins are one node predicate (#1139).
|
|
315
327
|
ir = enrichEffectiveTopology(ir);
|
|
328
|
+
// Capability terms (#4375) read the graph through the lexicons' mappings.
|
|
329
|
+
if (terms.some(usesCapability)) {
|
|
330
|
+
const refused = await bindCapabilities(ctx, projectPath, ir, terms);
|
|
331
|
+
if (refused)
|
|
332
|
+
return refuse({ code: "query-invalid", ...refused });
|
|
333
|
+
}
|
|
316
334
|
// Resolve `kind:` terms against what is actually in this graph, before any
|
|
317
335
|
// node is tested against them.
|
|
318
336
|
resolveKindTerms(terms, ir.nodes);
|
|
319
337
|
const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
320
338
|
const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, nodeById)));
|
|
339
|
+
// Query-scoped drift (#1268): the matched rows, and only the matched rows,
|
|
340
|
+
// checked against the observation the primary answer did NOT use. Scoping
|
|
341
|
+
// both sides of diffLive() to the matched ids is what keeps a query-scoped-
|
|
342
|
+
// out resource from reading as `missing` or `unobserved` — it was never
|
|
343
|
+
// asked about, which is a third thing from both.
|
|
344
|
+
let drift;
|
|
345
|
+
if (checkObservations && declaredForDrift && primaryObservations) {
|
|
346
|
+
const declaredIds = new Set(declaredForDrift.nodes.map((n) => n.id));
|
|
347
|
+
const matchedIds = new Set(matches.map((n) => n.id));
|
|
348
|
+
const nowObservations = args.checkLive ? checkObservations : primaryObservations;
|
|
349
|
+
const thenObservations = args.checkLive ? primaryObservations : checkObservations;
|
|
350
|
+
drift = diffLive({
|
|
351
|
+
declared: new Set([...matchedIds].filter((id) => declaredIds.has(id))),
|
|
352
|
+
observedNow: pickResources(mergeResources(nowObservations), matchedIds),
|
|
353
|
+
observedThen: pickResources(mergeResources(thenObservations), matchedIds),
|
|
354
|
+
unobserved: pickResources(collectUnobserved(nowObservations), matchedIds),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Name the plan read when it answered (#4384).
|
|
358
|
+
if (source.kind === "live")
|
|
359
|
+
source = withReadDepth(source, matches, readSources, rootReads);
|
|
360
|
+
const driftAgainst = args.checkLive ? "live" : "snapshot";
|
|
361
|
+
// Machine-readable answer (#4359, #4367): the rows, their paths, and every
|
|
362
|
+
// footer the text prints as prose, as one document for a caller that draws
|
|
363
|
+
// it (behold#913). Same exit codes as the text path.
|
|
364
|
+
if (args.json) {
|
|
365
|
+
const recorded = source.kind === "live" && !matches.some((n) => n.physicalId) && args.env
|
|
366
|
+
? (await hasSnapshot(String(args.env))) ? "yes" : undefined
|
|
367
|
+
: undefined;
|
|
368
|
+
const env = args.env && source.kind !== "declared" ? String(args.env) : undefined;
|
|
369
|
+
const provenanceText = provenanceLine(matches, source, recorded, liveFailures, [...derivedUsed(terms, matches, ir), ...capabilitiesUsed(terms)]).replace(/^— /, "");
|
|
370
|
+
const capNotes = capabilityNotes(terms, ir, source);
|
|
371
|
+
const plannedCreates = source.kind !== "declared" && liveFailures.length === 0 ? plannedCreateCount(matches) : 0;
|
|
372
|
+
const driftJson = drift
|
|
373
|
+
? searchDriftJson(drift, matches.length, driftAgainst, snapshotDepth)
|
|
374
|
+
: driftUnavailable !== undefined
|
|
375
|
+
? { against: driftAgainst, unavailable: driftUnavailable }
|
|
376
|
+
: undefined;
|
|
377
|
+
// With --count-by (#4360) the object carries the groups and the total in
|
|
378
|
+
// place of the matches, the same answer the grouped lines give, with the
|
|
379
|
+
// provenance line beside it (#4367).
|
|
380
|
+
const head = { ...searchHead(), ...(await workspaceIdentity(projectPath)) };
|
|
381
|
+
const payload = countBy
|
|
382
|
+
? {
|
|
383
|
+
...head,
|
|
384
|
+
...countsJson(query, env ? { ...source, env } : source, groupMembers(matches, countBy, hasManyTerraformRoots(ir)), countBy, liveFailures),
|
|
385
|
+
provenance: provenanceText,
|
|
386
|
+
...(plannedCreates > 0 ? { plannedCreates } : {}),
|
|
387
|
+
...(driftJson ? { drift: driftJson } : {}),
|
|
388
|
+
}
|
|
389
|
+
: {
|
|
390
|
+
...head,
|
|
391
|
+
...searchJson(query, source, matches, terms, ir, nodeById, liveFailures, {
|
|
392
|
+
...(env ? { env } : {}),
|
|
393
|
+
show,
|
|
394
|
+
provenance: provenanceText,
|
|
395
|
+
...(plannedCreates > 0 ? { plannedCreates } : {}),
|
|
396
|
+
...(capNotes.length > 0 ? { capabilities: capNotes } : {}),
|
|
397
|
+
...(args.explain ? { explain: explainData(terms, matches, ir, nodeById) } : {}),
|
|
398
|
+
...(driftJson ? { drift: driftJson } : {}),
|
|
399
|
+
}),
|
|
400
|
+
};
|
|
401
|
+
console.log(JSON.stringify(payload));
|
|
402
|
+
for (const n of liveNotes)
|
|
403
|
+
console.error(formatWarning({ message: n }));
|
|
404
|
+
if (liveFailures.length > 0)
|
|
405
|
+
return 1;
|
|
406
|
+
return args.failOnDrift && drift && driftCountOf(drift) > 0 ? 1 : 0;
|
|
407
|
+
}
|
|
321
408
|
if (matches.length === 0) {
|
|
322
|
-
console.log("(no matches)");
|
|
409
|
+
console.log(countBy ? "(no matches) · 0 total" : "(no matches)");
|
|
323
410
|
availableAttrs(terms, ir);
|
|
324
411
|
if (args.explain)
|
|
325
412
|
explain(terms, matches, ir, nodeById, query);
|
|
@@ -327,8 +414,14 @@ export async function runSearch(ctx) {
|
|
|
327
414
|
console.error(formatWarning({ message: n }));
|
|
328
415
|
return 0;
|
|
329
416
|
}
|
|
330
|
-
|
|
331
|
-
|
|
417
|
+
if (countBy) {
|
|
418
|
+
for (const line of formatCounts(countGroups(matches, countBy, hasManyTerraformRoots(ir)), countBy))
|
|
419
|
+
console.log(line);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
for (const n of matches) {
|
|
423
|
+
console.log(formatRow(n, show));
|
|
424
|
+
}
|
|
332
425
|
}
|
|
333
426
|
const backed = source.kind === "declared" || matches.some((n) => n.physicalId);
|
|
334
427
|
// Only worth asking when the live read came back empty — that is the one case
|
|
@@ -336,33 +429,26 @@ export async function runSearch(ctx) {
|
|
|
336
429
|
const recorded = source.kind === "live" && !matches.some((n) => n.physicalId) && args.env
|
|
337
430
|
? (await hasSnapshot(String(args.env))) ? "yes" : undefined
|
|
338
431
|
: undefined;
|
|
339
|
-
provenance(matches, source, recorded, liveFailures);
|
|
432
|
+
provenance(matches, source, recorded, liveFailures, [...derivedUsed(terms, matches, ir), ...capabilitiesUsed(terms)]);
|
|
433
|
+
for (const c of capabilityNotes(terms, ir, source)) {
|
|
434
|
+
if (c.notEvaluated.length > 0)
|
|
435
|
+
console.log(`— ${c.name} does not evaluate: ${c.notEvaluated.join("; ")}`);
|
|
436
|
+
}
|
|
340
437
|
// Qualifies the provenance line, so it sits with it: one line per distinct
|
|
341
438
|
// note for the whole run, not one per stack, and after the rows (#1265).
|
|
342
439
|
for (const n of liveNotes)
|
|
343
440
|
console.error(formatWarning({ message: n }));
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// both sides of diffLive() to the matched ids is what keeps a query-scoped-
|
|
347
|
-
// out resource from reading as `missing` or `unobserved` — it was never
|
|
348
|
-
// asked about, which is a third thing from both.
|
|
349
|
-
if (checkObservations && declaredForDrift && primaryObservations) {
|
|
350
|
-
const declaredIds = new Set(declaredForDrift.nodes.map((n) => n.id));
|
|
351
|
-
const matchedIds = new Set(matches.map((n) => n.id));
|
|
352
|
-
const nowObservations = args.checkLive ? checkObservations : primaryObservations;
|
|
353
|
-
const thenObservations = args.checkLive ? primaryObservations : checkObservations;
|
|
354
|
-
const driftCount = renderDrift(diffLive({
|
|
355
|
-
declared: new Set([...matchedIds].filter((id) => declaredIds.has(id))),
|
|
356
|
-
observedNow: pickResources(mergeResources(nowObservations), matchedIds),
|
|
357
|
-
observedThen: pickResources(mergeResources(thenObservations), matchedIds),
|
|
358
|
-
unobserved: pickResources(collectUnobserved(nowObservations), matchedIds),
|
|
359
|
-
}), matches.length, args.checkLive ? "live" : "snapshot", snapshotDepth);
|
|
441
|
+
if (drift) {
|
|
442
|
+
const driftCount = renderDrift(drift, matches.length, driftAgainst, snapshotDepth);
|
|
360
443
|
if (args.failOnDrift && driftCount > 0)
|
|
361
444
|
driftFailure = true;
|
|
362
445
|
}
|
|
363
446
|
ambientHint(matches, ambientKinds, args.ambient === true, replayAmbient);
|
|
364
|
-
|
|
365
|
-
|
|
447
|
+
// Both point at `--show`, which a grouped count does not print (#4360).
|
|
448
|
+
if (!countBy) {
|
|
449
|
+
showMiss(matches, show);
|
|
450
|
+
regionSpread(terms, matches, show);
|
|
451
|
+
}
|
|
366
452
|
derivedSurface(terms, matches, ir, backed);
|
|
367
453
|
if (args.explain)
|
|
368
454
|
explain(terms, matches, ir, nodeById, query);
|
|
@@ -383,6 +469,317 @@ export async function runSearch(ctx) {
|
|
|
383
469
|
return 1;
|
|
384
470
|
return 0;
|
|
385
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* Check a search's query and flags before anything is read (#4374): the
|
|
474
|
+
* grammar, `--count-by`, and the flags that need or exclude each other. The
|
|
475
|
+
* rules `chant search` and `chant workspace search` share, so the workspace
|
|
476
|
+
* form refuses once, with the same code and message, instead of once per
|
|
477
|
+
* member. `snapshot` is the snapshot asked for and the flag that asked:
|
|
478
|
+
* `--snapshot`, or plain search's `--at`.
|
|
479
|
+
*/
|
|
480
|
+
export function checkSearchArgs(args, query, snapshot) {
|
|
481
|
+
const flag = snapshot.flag;
|
|
482
|
+
// `--count-by` groups the matched set (#4360). It takes any query, and no
|
|
483
|
+
// query at all counts the whole graph: "which kinds does this estate hold".
|
|
484
|
+
let countBy;
|
|
485
|
+
if (args.countBy !== undefined) {
|
|
486
|
+
const parsed = parseCountBy(args.countBy);
|
|
487
|
+
if ("error" in parsed)
|
|
488
|
+
return { error: { code: "query-invalid", message: parsed.error, hint: parsed.hint } };
|
|
489
|
+
countBy = parsed;
|
|
490
|
+
}
|
|
491
|
+
if (!query && !countBy) {
|
|
492
|
+
return { error: { code: "query-invalid", message: "chant search needs a query: chant search \"<terms>\" [--live --env <name>]" } };
|
|
493
|
+
}
|
|
494
|
+
let terms;
|
|
495
|
+
try {
|
|
496
|
+
terms = parseQuery(query);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
if (err instanceof QueryError)
|
|
500
|
+
return { error: { code: "query-invalid", message: err.message, hint: err.hint } };
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
// Query-scoped drift (#1268): --check-live checks a snapshot answer against
|
|
504
|
+
// a live read, --check-snapshot checks a live answer against the recorded
|
|
505
|
+
// snapshot — the direction each needs is the source it does NOT already have.
|
|
506
|
+
if (args.checkLive && !snapshot.ref) {
|
|
507
|
+
return {
|
|
508
|
+
error: {
|
|
509
|
+
code: "usage-invalid",
|
|
510
|
+
message: `chant search --check-live needs ${flag} <ref>`,
|
|
511
|
+
hint: `answer from a snapshot and check the matched rows against a live read: ${flag} latest --check-live --env <name>`,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (args.checkSnapshot && !args.live) {
|
|
516
|
+
return {
|
|
517
|
+
error: {
|
|
518
|
+
code: "usage-invalid",
|
|
519
|
+
message: "chant search --check-snapshot needs --live",
|
|
520
|
+
hint: "answer live and check the matched rows against the recorded snapshot: --live --check-snapshot --env <name>",
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (args.failOnDrift && !args.checkLive && !args.checkSnapshot) {
|
|
525
|
+
return {
|
|
526
|
+
error: {
|
|
527
|
+
code: "usage-invalid",
|
|
528
|
+
message: "chant search --fail-on-drift needs --check-live or --check-snapshot",
|
|
529
|
+
hint: "it fails the check those flags run, so it is meaningless without one",
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if ((args.live || snapshot.ref) && !args.env) {
|
|
534
|
+
const needs = snapshot.ref ? flag : "--live";
|
|
535
|
+
return { error: { code: "usage-invalid", message: `chant search ${needs} needs an environment: ${needs} --env <name>` } };
|
|
536
|
+
}
|
|
537
|
+
if (args.live && snapshot.ref) {
|
|
538
|
+
// Two different observations of the same estate, and no rule for which
|
|
539
|
+
// wins. Comparing them is a real question (#1268) but it is not this one.
|
|
540
|
+
return {
|
|
541
|
+
error: {
|
|
542
|
+
code: "usage-invalid",
|
|
543
|
+
message: `chant search takes --live or ${flag}, not both`,
|
|
544
|
+
hint: `--live reads the estate now; ${flag} answers from a recorded snapshot`,
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return { terms, ...(countBy ? { countBy } : {}) };
|
|
549
|
+
}
|
|
550
|
+
/** Why a search over a project whose build root failed refuses (#4365), as the refusal prints it. */
|
|
551
|
+
const BUILD_ROOT_FAILED = {
|
|
552
|
+
code: "build-failed",
|
|
553
|
+
message: "chant search: a build root failed to render, so the graph it would answer over is incomplete",
|
|
554
|
+
hint: "fix the error above; `chant graph . --format ir` reports the same one",
|
|
555
|
+
};
|
|
556
|
+
/**
|
|
557
|
+
* The declared graph a search answers over (#4362, #4365, #4368): discovery,
|
|
558
|
+
* plus the build-root entities (a terraform root's blocks, a rendered
|
|
559
|
+
* kustomize root), plus the edges the lexicons' `graphEdges` hooks add. The
|
|
560
|
+
* same join and the same edges `chant graph --format ir` draws, through the
|
|
561
|
+
* same helpers (`./declared-graph.ts`), so a node's id here is its id there.
|
|
562
|
+
*
|
|
563
|
+
* Returns undefined, having printed the build root's own errors, when a build
|
|
564
|
+
* root failed to render or collided with a discovered name; the caller refuses
|
|
565
|
+
* with {@link BUILD_ROOT_FAILED}. A search over the rest would answer
|
|
566
|
+
* `(no matches)` for a root it never read, which is a wrong answer stated as
|
|
567
|
+
* a fact.
|
|
568
|
+
*/
|
|
569
|
+
async function declaredGraph(ctx, projectPath, srcDir, loaded) {
|
|
570
|
+
const discovered = await discover(srcDir);
|
|
571
|
+
if (!(await mergeDeclaredBuildRoots(discovered.entities, await declaredBuildRootContributors(ctx, projectPath))))
|
|
572
|
+
return undefined;
|
|
573
|
+
let plugins = loaded ?? ctx.plugins;
|
|
574
|
+
if (plugins.length === 0) {
|
|
575
|
+
try {
|
|
576
|
+
plugins = await loadPlugins(await resolveProjectLexicons(projectPath));
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
plugins = [];
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return withLexiconEdges(buildGraphIr(discovered.entities, projectPath), plugins, discovered.entities);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Print the capability catalog (#4375): the `capabilities` document under
|
|
586
|
+
* `--json`, a line per capability otherwise. It takes no query and no other
|
|
587
|
+
* search flag, since it answers nothing about the estate. Shared by `chant
|
|
588
|
+
* search` and `chant workspace search`, which differ only in the lexicons
|
|
589
|
+
* they report on.
|
|
590
|
+
*/
|
|
591
|
+
export async function printCapabilities(args, query, lexicons) {
|
|
592
|
+
const { capabilitiesDocument, capabilitiesFailure, formatCapabilities } = await import("../../workspace/capabilities-contract.js");
|
|
593
|
+
const extra = [
|
|
594
|
+
// `args.path` is "." when no query was given.
|
|
595
|
+
...(query && query !== "." ? [`the query "${query}"`] : []),
|
|
596
|
+
...["live", "env", "snapshot", "at", "countBy", "explain", "show"]
|
|
597
|
+
.filter((k) => args[k] !== undefined && args[k] !== false)
|
|
598
|
+
.map((k) => `--${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`),
|
|
599
|
+
];
|
|
600
|
+
if (extra.length > 0) {
|
|
601
|
+
const error = {
|
|
602
|
+
code: "usage-invalid",
|
|
603
|
+
message: `--capabilities prints the catalog, so it takes no query and no other search flag; it was given ${extra.join(", ")}`,
|
|
604
|
+
hint: "ask with a capability instead: chant search \"cap:internet-reachable(22)\"",
|
|
605
|
+
};
|
|
606
|
+
console.error(formatError(error));
|
|
607
|
+
if (args.json)
|
|
608
|
+
console.log(JSON.stringify(await capabilitiesFailure(error)));
|
|
609
|
+
return 1;
|
|
610
|
+
}
|
|
611
|
+
const doc = await capabilitiesDocument(lexicons);
|
|
612
|
+
if (args.json)
|
|
613
|
+
console.log(JSON.stringify(doc));
|
|
614
|
+
else
|
|
615
|
+
for (const line of formatCapabilities(doc))
|
|
616
|
+
console.log(line);
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
619
|
+
/** The capabilities a query names, in query order, for the provenance line (#4375). */
|
|
620
|
+
function capabilitiesUsed(terms) {
|
|
621
|
+
const out = [];
|
|
622
|
+
const walk = (t) => {
|
|
623
|
+
if (t.negated)
|
|
624
|
+
return;
|
|
625
|
+
const call = t.kind === "cap" ? t.a : t.capability;
|
|
626
|
+
const name = call !== undefined ? parseCapabilityCall(call)?.name : undefined;
|
|
627
|
+
if (name && !out.includes(name))
|
|
628
|
+
out.push(name);
|
|
629
|
+
if (t.sub)
|
|
630
|
+
walk(t.sub);
|
|
631
|
+
};
|
|
632
|
+
terms.forEach(walk);
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* What each capability the query used could not evaluate (#4375), with a
|
|
637
|
+
* count where this graph holds the thing left out: an answer computed from
|
|
638
|
+
* declared references says what it skipped, so a caller knows the rows are a
|
|
639
|
+
* floor, not the whole of it.
|
|
640
|
+
*/
|
|
641
|
+
function capabilityNotes(terms, ir, source) {
|
|
642
|
+
const names = capabilitiesUsed(terms);
|
|
643
|
+
if (names.length === 0)
|
|
644
|
+
return [];
|
|
645
|
+
const engine = capabilityEngine(ir);
|
|
646
|
+
const statements = [...engine.view.nodes.values()].filter((n) => n.label === "Statement" && n.attrs.effect === "Allow");
|
|
647
|
+
const counts = {
|
|
648
|
+
'Resource "*"': statements.filter((n) => n.attrs.anyResource).length,
|
|
649
|
+
"IAM Condition keys": statements.filter((n) => n.attrs.condition).length,
|
|
650
|
+
"resources the graph holds but can't resolve, such as an expression naming nothing declared": statements.filter((n) => n.attrs.unresolved).length,
|
|
651
|
+
};
|
|
652
|
+
return names.map((name) => ({
|
|
653
|
+
name,
|
|
654
|
+
source: source.kind,
|
|
655
|
+
notEvaluated: (engine.rule(name)?.meta["not-evaluated"] ?? "")
|
|
656
|
+
.split(/;|\n/)
|
|
657
|
+
.map((x) => x.trim())
|
|
658
|
+
.filter(Boolean)
|
|
659
|
+
.map((x) => (counts[x] ? `${x} (${counts[x]} statement${counts[x] === 1 ? "" : "s"} here)` : x)),
|
|
660
|
+
}));
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Answer capability terms over a graph that is not this project's own (#4375):
|
|
664
|
+
* `chant workspace search` composes its members' graphs and views and asks
|
|
665
|
+
* here, so the rules run once over the whole of it. Given packs, the view is
|
|
666
|
+
* built here; given a view, it is the one the members' own chants mapped
|
|
667
|
+
* (#4378). Returns the rows
|
|
668
|
+
* as `--json` carries them, or the refusal for a capability the query names
|
|
669
|
+
* wrongly.
|
|
670
|
+
*/
|
|
671
|
+
export function answerOverGraph(ir, terms, query, packsOrView) {
|
|
672
|
+
// A workspace hands over the view its members' chants mapped (#4378).
|
|
673
|
+
const engine = Array.isArray(packsOrView) ? attachCapabilities(ir, packsOrView) : attachView(ir, packsOrView);
|
|
674
|
+
const refused = checkCapabilityTerms(engine, terms);
|
|
675
|
+
if (refused)
|
|
676
|
+
return { error: refused };
|
|
677
|
+
resolveKindTerms(terms, ir.nodes);
|
|
678
|
+
const byId = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
679
|
+
const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, byId)));
|
|
680
|
+
const doc = searchJson(query, { kind: "declared" }, matches, terms, ir, byId);
|
|
681
|
+
return { matches: doc.matches, capabilities: capabilityNotes(terms, ir, { kind: "declared" }) };
|
|
682
|
+
}
|
|
683
|
+
/** Whether a term, or the leaf under it, names a capability (#4375). */
|
|
684
|
+
export function usesCapability(t) {
|
|
685
|
+
return t.kind === "cap" || t.capability !== undefined || (t.sub !== undefined && usesCapability(t.sub));
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Bind the graph to its lexicons' capability packs and check every capability
|
|
689
|
+
* the query names (#4375): an unknown name, a set used as an edge or the
|
|
690
|
+
* wrong arguments is a refusal, never an empty answer.
|
|
691
|
+
*/
|
|
692
|
+
async function bindCapabilities(ctx, projectPath, ir, terms) {
|
|
693
|
+
let plugins = ctx.plugins;
|
|
694
|
+
if (plugins.length === 0) {
|
|
695
|
+
try {
|
|
696
|
+
plugins = await loadPlugins(await resolveProjectLexicons(projectPath));
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
plugins = [];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const engine = attachCapabilities(ir, plugins.map((p) => p.capabilityPack).filter((p) => !!p));
|
|
703
|
+
return checkCapabilityTerms(engine, terms);
|
|
704
|
+
}
|
|
705
|
+
/** Refuse a capability the query names that doesn't exist, a set used as an edge, or the wrong arguments. */
|
|
706
|
+
function checkCapabilityTerms(engine, terms) {
|
|
707
|
+
const known = [...new Set(engine.publicNames())].join(", ");
|
|
708
|
+
const check = (t) => {
|
|
709
|
+
if (t.sub) {
|
|
710
|
+
const inner = check(t.sub);
|
|
711
|
+
if (inner)
|
|
712
|
+
return inner;
|
|
713
|
+
}
|
|
714
|
+
const call = t.kind === "cap" ? t.a : t.capability;
|
|
715
|
+
if (call === undefined)
|
|
716
|
+
return undefined;
|
|
717
|
+
const parsed = parseCapabilityCall(call);
|
|
718
|
+
if (!engine.has(parsed.name)) {
|
|
719
|
+
return { message: `no capability "${parsed.name}"`, hint: `the capabilities are ${known}; chant search --capabilities describes each` };
|
|
720
|
+
}
|
|
721
|
+
const shape = engine.rule(parsed.name).vars.length === 1 ? "set" : "edge";
|
|
722
|
+
if (t.capability !== undefined && shape === "set") {
|
|
723
|
+
return { message: `"${parsed.name}" is a set, not an edge`, hint: `test it as a term: cap:${call}` };
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
engine.bind(parsed.name, parsed.args);
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
if (err instanceof CapabilityError)
|
|
730
|
+
return { message: `cap:${call}: ${err.message}`, hint: "chant search --capabilities lists each capability's parameters" };
|
|
731
|
+
throw err;
|
|
732
|
+
}
|
|
733
|
+
return undefined;
|
|
734
|
+
};
|
|
735
|
+
for (const t of terms) {
|
|
736
|
+
const r = check(t);
|
|
737
|
+
if (r)
|
|
738
|
+
return r;
|
|
739
|
+
}
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* The read a plan-depth root fell back to, from the lexicon's structured
|
|
744
|
+
* per-root report. Never from note text. "state" wins over "declared" when
|
|
745
|
+
* roots disagree, because some read still answered.
|
|
746
|
+
*/
|
|
747
|
+
function planFallback(rootReads) {
|
|
748
|
+
const falls = Object.values(rootReads)
|
|
749
|
+
.filter((r) => (r.depth === "plan" || r.depth === "live+plan") && r.fellBackTo)
|
|
750
|
+
.map((r) => r.fellBackTo);
|
|
751
|
+
if (falls.includes("state"))
|
|
752
|
+
return "state";
|
|
753
|
+
if (falls.includes("live"))
|
|
754
|
+
return "live";
|
|
755
|
+
if (falls.includes("declared"))
|
|
756
|
+
return "declared";
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Add the read depth to a live source (#4384). Only bound matches count: a
|
|
761
|
+
* row nobody read has no read to name. Nothing is added when no match was
|
|
762
|
+
* answered by the plan and the plan did not fall back.
|
|
763
|
+
*/
|
|
764
|
+
function withReadDepth(source, matches, sources, rootReads) {
|
|
765
|
+
const reads = [...new Set(matches.filter((n) => n.physicalId).map((n) => sources[n.id]).filter((r) => !!r))].sort();
|
|
766
|
+
const fellBack = planFallback(rootReads);
|
|
767
|
+
const planned = reads.filter((r) => r === "plan" || r === "live+plan");
|
|
768
|
+
if (planned.length === 0 && !fellBack)
|
|
769
|
+
return source;
|
|
770
|
+
return {
|
|
771
|
+
...source,
|
|
772
|
+
...(planned.length > 0 ? { depth: reads.length === 1 ? reads[0] : "mixed" } : {}),
|
|
773
|
+
...(reads.length > 0 ? { reads } : {}),
|
|
774
|
+
...(fellBack ? { planFellBackTo: fellBack } : {}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const READ_PHRASE = {
|
|
778
|
+
plan: "at the plan depth (terraform plan -lock=false)",
|
|
779
|
+
"live+plan": "live and at the plan depth (live-plan -json, terraform plan -lock=false)",
|
|
780
|
+
state: "from state",
|
|
781
|
+
live: "live",
|
|
782
|
+
};
|
|
386
783
|
/**
|
|
387
784
|
* Name the `--show` columns nothing carries (#1279).
|
|
388
785
|
*
|
|
@@ -479,10 +876,25 @@ function ambientHint(matches, ambientKinds, asked, replay) {
|
|
|
479
876
|
* already done. That is a fact about the query, printed for every query, and it
|
|
480
877
|
* encodes no expected answer.
|
|
481
878
|
*/
|
|
482
|
-
function
|
|
879
|
+
function provenanceLine(matches, source, recorded, liveFailures = [], derived = []) {
|
|
880
|
+
const line = provenanceBase(matches, source, recorded, liveFailures, derived);
|
|
881
|
+
// The plan read fell back (#4384): say to what, on whichever line this is.
|
|
882
|
+
return source.kind === "live" ? line + fellBackText(source) : line;
|
|
883
|
+
}
|
|
884
|
+
/** ` · the plan read fell back to state`, or nothing. */
|
|
885
|
+
function fellBackText(source) {
|
|
886
|
+
if (!source.planFellBackTo)
|
|
887
|
+
return "";
|
|
888
|
+
// A live root has no state read to fall to: live-plan answered alone.
|
|
889
|
+
const to = source.planFellBackTo === "live" ? "live-plan alone" : source.planFellBackTo;
|
|
890
|
+
return ` · the plan read fell back to ${to}`;
|
|
891
|
+
}
|
|
892
|
+
function provenanceBase(matches, source, recorded, liveFailures = [], derived = []) {
|
|
483
893
|
if (source.kind === "declared") {
|
|
484
|
-
|
|
485
|
-
|
|
894
|
+
// A derived fact the query relied on (#4362) was folded from declared
|
|
895
|
+
// references alone, so it describes the source, not the account.
|
|
896
|
+
const from = derived.length > 0 ? ` · ${derived.join(", ")} derived from declared references` : "";
|
|
897
|
+
return `— declared only · no observation · physical ids unavailable${from}`;
|
|
486
898
|
}
|
|
487
899
|
const bound = matches.filter((n) => n.physicalId).length;
|
|
488
900
|
const what = source.kind === "live" ? "live read" : "snapshot";
|
|
@@ -494,10 +906,15 @@ function provenance(matches, source, recorded, liveFailures = []) {
|
|
|
494
906
|
const rest = recorded
|
|
495
907
|
? "a snapshot of this environment is recorded — answer from it with --at latest"
|
|
496
908
|
: "answered from the declared graph · physical ids unavailable";
|
|
497
|
-
|
|
498
|
-
return;
|
|
909
|
+
return `— live read failed (${lexicons}) · ${unobserved}/${matches.length} rows unobserved · ${rest}`;
|
|
499
910
|
}
|
|
500
911
|
if (bound === 0) {
|
|
912
|
+
// Every match a planned create (#4372): absent on purpose, so "returned no
|
|
913
|
+
// bound resources" reads as a failed read when the read worked.
|
|
914
|
+
const planned = plannedCreateCount(matches);
|
|
915
|
+
if (planned > 0) {
|
|
916
|
+
return `— ${what}: ${planned} planned ${planned === 1 ? "create" : "creates"}, none bound yet · the plan has not been applied`;
|
|
917
|
+
}
|
|
501
918
|
// The estate was asked for and nothing came back bound. Naming it is the
|
|
502
919
|
// difference between "these do not exist" and "nobody could see them".
|
|
503
920
|
// A snapshot sitting unused is the actionable half of this. Denied network,
|
|
@@ -505,21 +922,37 @@ function provenance(matches, source, recorded, liveFailures = []) {
|
|
|
505
922
|
// retrying `--live` — the tool knew the estate was unreachable AND that a
|
|
506
923
|
// recording of it was on disk, and said only the first half.
|
|
507
924
|
if (recorded) {
|
|
508
|
-
|
|
509
|
-
return;
|
|
925
|
+
return (`— ${what} returned no bound resources · a snapshot of this environment is recorded — answer from it with --at latest`);
|
|
510
926
|
}
|
|
511
|
-
|
|
512
|
-
return;
|
|
927
|
+
return (`— ${what} returned no bound resources · answered from the declared graph · physical ids unavailable`);
|
|
513
928
|
}
|
|
514
929
|
if (source.kind === "live") {
|
|
515
|
-
|
|
516
|
-
|
|
930
|
+
// The plan answered some or all of it (#4384): say so, and name each read.
|
|
931
|
+
if (source.depth && source.reads) {
|
|
932
|
+
return `— observed ${source.reads.map((r) => READ_PHRASE[r] ?? r).join(" and ")} · bound ${bound}/${matches.length}`;
|
|
933
|
+
}
|
|
934
|
+
return `— observed live · bound ${bound}/${matches.length}`;
|
|
517
935
|
}
|
|
518
936
|
// Time is the whole risk of a recorded answer, so it leads. A caller can see
|
|
519
937
|
// how old this is and decide, rather than discovering staleness later.
|
|
520
938
|
const taken = source.timestamp ? ` taken ${source.timestamp}` : "";
|
|
521
939
|
const at = source.commit ? ` ${source.commit.slice(0, 7)}` : "";
|
|
522
|
-
|
|
940
|
+
return `— observed from snapshot${at}${taken} · bound ${bound}/${matches.length}`;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* How many matches there are when every one is a planned create (#4372): an
|
|
944
|
+
* absent entity (no physical id) carrying `planAction=create`. Zero for no
|
|
945
|
+
* matches or any other mix, so a real empty read keeps its wording.
|
|
946
|
+
*/
|
|
947
|
+
function plannedCreateCount(matches) {
|
|
948
|
+
if (matches.length === 0)
|
|
949
|
+
return 0;
|
|
950
|
+
const all = matches.every((n) => !n.physicalId && n.attrs?.planAction === "create");
|
|
951
|
+
return all ? matches.length : 0;
|
|
952
|
+
}
|
|
953
|
+
/** Print {@link provenanceLine}: the text footer. */
|
|
954
|
+
function provenance(matches, source, recorded, liveFailures = [], derived = []) {
|
|
955
|
+
console.log(provenanceLine(matches, source, recorded, liveFailures, derived));
|
|
523
956
|
}
|
|
524
957
|
/** Union `LiveObservation.resources` across lexicons into one name-keyed map. */
|
|
525
958
|
function mergeResources(observations) {
|
|
@@ -564,12 +997,36 @@ function renderDrift(diff, checked, direction, depth) {
|
|
|
564
997
|
console.log(` · ${r.name} (${r.type}) — runtime, owned by ${r.owner}`);
|
|
565
998
|
for (const u of diff.unobserved)
|
|
566
999
|
console.log(` ? ${formatUnobserved(u.name, u)}`);
|
|
567
|
-
const driftCount = diff
|
|
1000
|
+
const driftCount = driftCountOf(diff);
|
|
568
1001
|
const label = direction === "live" ? "a live read" : "the recorded snapshot";
|
|
569
1002
|
const depthNote = depth === "deep" ? " · snapshot recorded at deep depth, compared here at identity" : "";
|
|
570
1003
|
console.log(`— checked against ${label} · ${driftCount > 0 ? `${driftCount} of ${checked} matched drifted` : `no drift across ${checked} matched`}${depthNote}`);
|
|
571
1004
|
return driftCount;
|
|
572
1005
|
}
|
|
1006
|
+
/** What counts as drift: the set `lifecycle diff` totals (#1268). */
|
|
1007
|
+
function driftCountOf(diff) {
|
|
1008
|
+
return diff.missing.length + diff.orphan.length + diff.disappeared.length + diff.driftedSinceSnapshot.length;
|
|
1009
|
+
}
|
|
1010
|
+
function searchDriftJson(diff, checked, against, depth) {
|
|
1011
|
+
return {
|
|
1012
|
+
against,
|
|
1013
|
+
checked,
|
|
1014
|
+
count: driftCountOf(diff),
|
|
1015
|
+
missing: diff.missing,
|
|
1016
|
+
orphan: diff.orphan,
|
|
1017
|
+
disappeared: diff.disappeared,
|
|
1018
|
+
drifted: diff.driftedSinceSnapshot.flatMap((d) => d.changes.map((c) => ({ id: d.name, path: c.path, old: c.oldValue, new: c.newValue }))),
|
|
1019
|
+
newlyObserved: diff.newlyObserved,
|
|
1020
|
+
runtimeChildren: diff.runtimeChildren.map((r) => ({ id: r.name, type: r.type, owner: r.owner })),
|
|
1021
|
+
unobserved: diff.unobserved.map((u) => ({
|
|
1022
|
+
id: u.name,
|
|
1023
|
+
...(u.type ? { type: u.type } : {}),
|
|
1024
|
+
reason: u.reason,
|
|
1025
|
+
...(u.detail ? { detail: u.detail } : {}),
|
|
1026
|
+
})),
|
|
1027
|
+
...(depth ? { depth } : {}),
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
573
1030
|
function formatValue(v) {
|
|
574
1031
|
if (v === undefined)
|
|
575
1032
|
return "<unset>";
|
|
@@ -599,19 +1056,31 @@ function derivedSurface(terms, matches, ir, backed = true) {
|
|
|
599
1056
|
return;
|
|
600
1057
|
const used = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
601
1058
|
const unused = new Set();
|
|
602
|
-
for (const n of matches)
|
|
603
|
-
for (const
|
|
604
|
-
if (!
|
|
605
|
-
|
|
606
|
-
for (const name of names)
|
|
607
|
-
if (!used.has(name))
|
|
608
|
-
unused.add(name);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
1059
|
+
for (const n of matches)
|
|
1060
|
+
for (const name of derivedNames(n, ir))
|
|
1061
|
+
if (!used.has(name))
|
|
1062
|
+
unused.add(name);
|
|
611
1063
|
if (unused.size === 0)
|
|
612
1064
|
return;
|
|
613
1065
|
console.log(`— also derived for these resources: ${[...unused].sort().join(", ")}`);
|
|
614
1066
|
}
|
|
1067
|
+
/** The derived attributes recorded for a node's kind (or its terraform resource type, #4362). */
|
|
1068
|
+
function derivedNames(n, ir) {
|
|
1069
|
+
const tfType = terraformResourceType(n);
|
|
1070
|
+
return Object.entries(ir.derivedAttrs ?? {})
|
|
1071
|
+
.filter(([kind]) => n.kind?.includes(kind) || kind === tfType)
|
|
1072
|
+
.flatMap(([, names]) => names);
|
|
1073
|
+
}
|
|
1074
|
+
/** The derived attributes the query's `attr:` terms relied on, for the provenance line. */
|
|
1075
|
+
function derivedUsed(terms, matches, ir) {
|
|
1076
|
+
const queried = new Set(terms.filter((t) => t.kind === "attr" && !t.negated).map((t) => t.a));
|
|
1077
|
+
const out = new Set();
|
|
1078
|
+
for (const n of matches)
|
|
1079
|
+
for (const name of derivedNames(n, ir))
|
|
1080
|
+
if (queried.has(name))
|
|
1081
|
+
out.add(name);
|
|
1082
|
+
return [...out].sort();
|
|
1083
|
+
}
|
|
615
1084
|
/**
|
|
616
1085
|
* `--explain` footer (#1139): a compact, model-DERIVED summary that gives a
|
|
617
1086
|
* small model a reason to trust the result instead of re-deriving it with a
|
|
@@ -622,33 +1091,70 @@ function derivedSurface(terms, matches, ir, backed = true) {
|
|
|
622
1091
|
* any expected answer, so it stays a fair, question-agnostic capability.
|
|
623
1092
|
*/
|
|
624
1093
|
function explain(terms, matches, ir, byId, query) {
|
|
625
|
-
const
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
const excluded = universe.filter((n) => !matched.has(n.id));
|
|
629
|
-
const kindLabel = kinds.size > 0 ? [...kinds].join("/") : "nodes";
|
|
630
|
-
console.log(`— ${matches.length} of ${universe.length} ${kindLabel} matched (query: ${query})`);
|
|
1094
|
+
const data = explainData(terms, matches, ir, byId);
|
|
1095
|
+
const kindLabel = data.universe.kinds.length > 0 ? data.universe.kinds.join("/") : "nodes";
|
|
1096
|
+
console.log(`— ${data.universe.matched} of ${data.universe.of} ${kindLabel} matched (query: ${query})`);
|
|
631
1097
|
// Inclusion evidence: for a derived fact a CLI can't easily re-verify
|
|
632
1098
|
// (internetFacing, resolved across the default VPC's routing), name WHY each
|
|
633
1099
|
// match qualifies, so the agent trusts the result instead of dropping it.
|
|
1100
|
+
for (const e of data.evidence)
|
|
1101
|
+
console.log(` ✓ ${bareId(e.id)} ${e.attr} via ${e.via}`);
|
|
1102
|
+
// The chain each match was reached by (#4359), so a caller can quote the
|
|
1103
|
+
// path instead of re-deriving it with one search per hop.
|
|
1104
|
+
for (const n of matches) {
|
|
1105
|
+
for (const p of matchPaths(n, terms, ir, byId)) {
|
|
1106
|
+
// A capability's path (#4375) turns as the rule does, so each hop says which way it went.
|
|
1107
|
+
if (p.term.kind === "cap" || p.term.capability !== undefined) {
|
|
1108
|
+
// A set a node is in by its own attributes (stores-data) has no path to print.
|
|
1109
|
+
if (p.hops.length === 0 && !p.because)
|
|
1110
|
+
continue;
|
|
1111
|
+
console.log(` path ${bareId(n.id)} ${termText({ ...p.term, negated: false })}: ${formatHops(p.hops, n.id) || "(the node itself)"}`);
|
|
1112
|
+
for (const b of p.because ?? []) {
|
|
1113
|
+
console.log(` ${b.capability} ${bareId(b.id)}: ${formatHops(b.hops, b.id) || "(an attribute of the node)"}`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
else
|
|
1117
|
+
console.log(` path ${formatPath(p.hops, p.term.dir)}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
for (const m of data.nearMisses) {
|
|
1121
|
+
console.log(` · excluded ${bareId(m.id)} — fails ${m.term ? describeTerm(m.term) : "(query)"}`);
|
|
1122
|
+
}
|
|
1123
|
+
if (data.moreExcluded > 0)
|
|
1124
|
+
console.log(` · …and ${data.moreExcluded} more excluded`);
|
|
1125
|
+
}
|
|
1126
|
+
/** How many near-misses `--explain` names before summarising the rest. */
|
|
1127
|
+
const NEAR_MISS_LIMIT = 8;
|
|
1128
|
+
/**
|
|
1129
|
+
* The facts behind the `--explain` footer, shared by the text and `--json`
|
|
1130
|
+
* (#4367). The universe is every node of the matched kinds (every node when
|
|
1131
|
+
* nothing matched); a near-miss is a node of that universe the query
|
|
1132
|
+
* excluded, with the first term it fails.
|
|
1133
|
+
*/
|
|
1134
|
+
function explainData(terms, matches, ir, byId) {
|
|
1135
|
+
// Counted against a terraform block's type, not `Terraform::Resource` (#4366):
|
|
1136
|
+
// "2 of 2 aws_instance", not "2 of 185 Terraform::Resource".
|
|
1137
|
+
const kinds = new Set(matches.map(displayKind).filter((k) => !!k));
|
|
1138
|
+
const universe = kinds.size > 0 ? ir.nodes.filter((n) => kinds.has(displayKind(n) ?? "")) : ir.nodes;
|
|
1139
|
+
const matched = new Set(matches.map((n) => n.id));
|
|
1140
|
+
const excluded = universe.filter((n) => !matched.has(n.id));
|
|
1141
|
+
const evidence = [];
|
|
634
1142
|
for (const t of terms) {
|
|
635
1143
|
if (t.kind !== "attr")
|
|
636
1144
|
continue;
|
|
637
1145
|
for (const n of matches) {
|
|
638
1146
|
const via = n.attrs?.[`${t.a}Via`];
|
|
639
|
-
const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
|
|
640
1147
|
if (typeof via === "string")
|
|
641
|
-
|
|
1148
|
+
evidence.push({ id: n.id, attr: t.a, via });
|
|
642
1149
|
}
|
|
643
1150
|
}
|
|
644
|
-
const shown = excluded.slice(0,
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
console.log(` · …and ${excluded.length - shown.length} more excluded`);
|
|
1151
|
+
const shown = excluded.slice(0, NEAR_MISS_LIMIT);
|
|
1152
|
+
return {
|
|
1153
|
+
universe: { matched: matches.length, of: universe.length, kinds: [...kinds] },
|
|
1154
|
+
evidence,
|
|
1155
|
+
nearMisses: shown.map((n) => ({ id: n.id, term: terms.find((t) => !matchTerm(n, t, ir, byId)) })),
|
|
1156
|
+
moreExcluded: excluded.length - shown.length,
|
|
1157
|
+
};
|
|
652
1158
|
}
|
|
653
1159
|
/**
|
|
654
1160
|
* On a miss, name the attributes the queried kind actually carries. A graph knows
|
|
@@ -662,7 +1168,7 @@ function availableAttrs(terms, ir) {
|
|
|
662
1168
|
const kindTerm = terms.find((t) => t.kind === "kind");
|
|
663
1169
|
if (!kindTerm)
|
|
664
1170
|
return;
|
|
665
|
-
const of = ir.nodes.filter((n) => n
|
|
1171
|
+
const of = ir.nodes.filter((n) => matchTerm(n, { ...kindTerm, negated: false }));
|
|
666
1172
|
if (of.length === 0)
|
|
667
1173
|
return;
|
|
668
1174
|
const names = new Set();
|
|
@@ -703,11 +1209,86 @@ function describeTerm(t) {
|
|
|
703
1209
|
// the opposite of what it is.
|
|
704
1210
|
if (t.negated)
|
|
705
1211
|
return `!${describeTerm({ ...t, negated: false })}`;
|
|
706
|
-
|
|
1212
|
+
if (t.kind === "edge")
|
|
1213
|
+
return `${edgeOperator(t, { out: "→", in: "←" })}${t.sub ? leafText(t.sub) : ""} (no such edge)`;
|
|
1214
|
+
return leafText(t);
|
|
1215
|
+
}
|
|
1216
|
+
function leafText(x) {
|
|
1217
|
+
if (x.kind === "cap")
|
|
1218
|
+
return `cap:${x.a}`;
|
|
1219
|
+
return x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
|
|
707
1220
|
: x.kind === "tag" ? `tag:${x.a}${x.b !== undefined ? "=" + x.b : ""}` : `"${x.a}"`;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
return
|
|
1221
|
+
}
|
|
1222
|
+
function searchErrorJson(query, error) {
|
|
1223
|
+
return { ...searchHead(), query, error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) } };
|
|
1224
|
+
}
|
|
1225
|
+
/** The head of every `search` document (#4374): the contract, its schema, and the chant that wrote it. */
|
|
1226
|
+
function searchHead() {
|
|
1227
|
+
return { $schema: SEARCH_OUTPUT_SCHEMA_ID, contract: SEARCH_CONTRACT_VERSION, chant: readChantPackageVersion() };
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* The workspace a project belongs to, when it belongs to one (#4374, ws-016):
|
|
1231
|
+
* its declared name and its root relative to the git root, with `at: null`,
|
|
1232
|
+
* since plain search reads the working tree. Outside a workspace, or when its
|
|
1233
|
+
* declaration can't be read, only `at`.
|
|
1234
|
+
*/
|
|
1235
|
+
async function workspaceIdentity(cwd) {
|
|
1236
|
+
try {
|
|
1237
|
+
const [{ locateWorkspace }, { readDeclaration }] = await Promise.all([import("../../workspace/which-chant.js"), import("../../workspace/declaration.js")]);
|
|
1238
|
+
const located = locateWorkspace(cwd);
|
|
1239
|
+
return { workspace: { name: readDeclaration(located.tree, "").name, root: located.root }, at: null };
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
return { at: null };
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function searchJson(query, source, matches, terms, ir, byId, liveFailures = [], extra = {}) {
|
|
1246
|
+
const show = extra.show ?? [];
|
|
1247
|
+
return {
|
|
1248
|
+
query,
|
|
1249
|
+
source: extra.env ? { ...source, env: extra.env } : source,
|
|
1250
|
+
matches: matches.map((n) => {
|
|
1251
|
+
const attrs = (n.attrs ?? {});
|
|
1252
|
+
const physical = n.physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
|
|
1253
|
+
return {
|
|
1254
|
+
id: n.id,
|
|
1255
|
+
displayId: bareId(n.id),
|
|
1256
|
+
...(n.kind ? { kind: n.kind } : {}),
|
|
1257
|
+
...(physical != null && typeof physical !== "object" ? { physicalId: String(physical) } : {}),
|
|
1258
|
+
...(typeof attrs["_unobserved"] === "string" ? { unobserved: attrs["_unobserved"] } : {}),
|
|
1259
|
+
...(show.length > 0 ? { show: Object.fromEntries(showValues(attrs, show)) } : {}),
|
|
1260
|
+
paths: matchPaths(n, terms, ir, byId).map((p) => ({
|
|
1261
|
+
term: termText({ ...p.term, negated: false }),
|
|
1262
|
+
dir: p.term.dir ?? "out",
|
|
1263
|
+
hops: p.hops,
|
|
1264
|
+
...(p.because ? { because: p.because } : {}),
|
|
1265
|
+
})),
|
|
1266
|
+
};
|
|
1267
|
+
}),
|
|
1268
|
+
...(extra.provenance !== undefined ? { provenance: extra.provenance } : {}),
|
|
1269
|
+
...(extra.plannedCreates ? { plannedCreates: extra.plannedCreates } : {}),
|
|
1270
|
+
...(extra.capabilities ? { capabilities: extra.capabilities } : {}),
|
|
1271
|
+
...(liveFailures.length > 0 ? { liveFailures } : {}),
|
|
1272
|
+
...(extra.explain
|
|
1273
|
+
? {
|
|
1274
|
+
explain: {
|
|
1275
|
+
universe: extra.explain.universe,
|
|
1276
|
+
evidence: extra.explain.evidence,
|
|
1277
|
+
nearMisses: extra.explain.nearMisses.map((m) => ({ id: m.id, ...(m.term ? { fails: termText(m.term) } : {}) })),
|
|
1278
|
+
moreExcluded: extra.explain.moreExcluded,
|
|
1279
|
+
},
|
|
1280
|
+
}
|
|
1281
|
+
: {}),
|
|
1282
|
+
...(extra.drift ? { drift: extra.drift } : {}),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
/** A term as the query grammar spells it, `!` and ASCII arrows included (#4367). */
|
|
1286
|
+
function termText(t) {
|
|
1287
|
+
if (t.negated)
|
|
1288
|
+
return `!${termText({ ...t, negated: false })}`;
|
|
1289
|
+
if (t.kind === "edge")
|
|
1290
|
+
return `${edgeOperator(t, { out: "->", in: "<-" })}${t.sub ? leafText(t.sub) : ""}`;
|
|
1291
|
+
return leafText(t);
|
|
711
1292
|
}
|
|
712
1293
|
/**
|
|
713
1294
|
* Decide which kinds a `kind:` term means, given what is in the graph.
|
|
@@ -730,14 +1311,28 @@ function describeTerm(t) {
|
|
|
730
1311
|
* Substring is kept as the fallback, so `kind:Gateway` still finds both
|
|
731
1312
|
* `InternetGateway` and `VPCGatewayAttachment` — a genuine substring search
|
|
732
1313
|
* that no segment rule would serve. Most specific wins; nothing else changes.
|
|
1314
|
+
*
|
|
1315
|
+
* A terraform resource is `Terraform::Resource` whatever it is, so the term is
|
|
1316
|
+
* also read against each resource's type (#4366), with the same boundary rule
|
|
1317
|
+
* on `_`: `kind:aws_iam_role` means that type exactly and never
|
|
1318
|
+
* `aws_iam_role_policy`, and a term ending in `_` (`kind:aws_iam_`) or a bare
|
|
1319
|
+
* provider name (`kind:aws`) means every type it begins. A data source's type
|
|
1320
|
+
* keeps its `data.` prefix (`kind:data.aws_ami`), the same type `--count-by kind`
|
|
1321
|
+
* groups it under, so `kind:aws_ami` is not a data source. A term that names a
|
|
1322
|
+
* type turns off the substring fallback for the IR kind, so `kind:aws_instance`
|
|
1323
|
+
* is the instances and nothing found by accident.
|
|
733
1324
|
*/
|
|
734
1325
|
export function resolveKindTerms(terms, nodes) {
|
|
735
1326
|
const present = [...new Set(nodes.map((n) => n.kind).filter((k) => !!k))];
|
|
1327
|
+
const tfPresent = [...new Set(nodes.map(terraformType).filter((k) => !!k))];
|
|
736
1328
|
const walk = (t) => {
|
|
737
1329
|
if (t.sub)
|
|
738
1330
|
walk(t.sub);
|
|
739
1331
|
if (t.kind !== "kind")
|
|
740
1332
|
return;
|
|
1333
|
+
const types = resolveTerraformTypes(t.a, tfPresent);
|
|
1334
|
+
if (types.length > 0)
|
|
1335
|
+
t.tfTypes = new Set(types);
|
|
741
1336
|
const want = t.a.toLowerCase().split("::").filter(Boolean);
|
|
742
1337
|
if (want.length === 0)
|
|
743
1338
|
return;
|
|
@@ -754,7 +1349,29 @@ export function resolveKindTerms(terms, nodes) {
|
|
|
754
1349
|
};
|
|
755
1350
|
terms.forEach(walk);
|
|
756
1351
|
}
|
|
1352
|
+
/** The terraform resource types a `kind:` term names, by the rule in {@link resolveKindTerms}. */
|
|
1353
|
+
function resolveTerraformTypes(term, present) {
|
|
1354
|
+
const want = term.toLowerCase();
|
|
1355
|
+
if (want.length === 0 || want.includes("::"))
|
|
1356
|
+
return [];
|
|
1357
|
+
const exact = present.filter((p) => p === want);
|
|
1358
|
+
if (exact.length > 0)
|
|
1359
|
+
return exact;
|
|
1360
|
+
const prefix = want.endsWith("_") || want.endsWith(".") ? want : /[_.]/.test(want) ? undefined : `${want}_`;
|
|
1361
|
+
return prefix ? present.filter((p) => p.startsWith(prefix)) : [];
|
|
1362
|
+
}
|
|
1363
|
+
/** What a row and `--explain` call a node's kind: a terraform block's type (#4366), else its IR kind. Same as `--count-by kind`. */
|
|
1364
|
+
function displayKind(n) {
|
|
1365
|
+
return terraformType(n) ?? n.kind;
|
|
1366
|
+
}
|
|
757
1367
|
function parseLeaf(tok) {
|
|
1368
|
+
const cap = /^cap:(.*)$/i.exec(tok);
|
|
1369
|
+
if (cap) {
|
|
1370
|
+
if (!parseCapabilityCall(cap[1])) {
|
|
1371
|
+
throw new QueryError(`"${tok}" is not a capability term`, "name a capability, with arguments in parentheses when it takes any: cap:internet-reachable, cap:open-ingress(22); chant search --capabilities lists them");
|
|
1372
|
+
}
|
|
1373
|
+
return { kind: "cap", a: cap[1] };
|
|
1374
|
+
}
|
|
758
1375
|
const m = /^(kind|tag|attr):(.*)$/i.exec(tok);
|
|
759
1376
|
if (m) {
|
|
760
1377
|
const key = m[1].toLowerCase();
|
|
@@ -789,6 +1406,69 @@ class QueryError extends Error {
|
|
|
789
1406
|
this.hint = hint;
|
|
790
1407
|
}
|
|
791
1408
|
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Parse an edge term (#4359): `->` or `<-`, then an optional depth, then an
|
|
1411
|
+
* optional attribute qualifier, then the target leaf.
|
|
1412
|
+
*
|
|
1413
|
+
* ->kind:X one hop, as it always was
|
|
1414
|
+
* ->*kind:X any number of hops, one or more
|
|
1415
|
+
* ->{3}kind:X exactly three hops
|
|
1416
|
+
* ->{1,3}kind:X one to three hops ({2,} is two or more)
|
|
1417
|
+
* <-[via=vpc_id]kind:X only edges through the consumer attribute vpc_id
|
|
1418
|
+
* ->[to=id]kind:X only edges that read the producer attribute id
|
|
1419
|
+
* <-*[via=subnet_id] a qualifier may stand without a target: any node
|
|
1420
|
+
*
|
|
1421
|
+
* A qualifier constrains every edge on the path, not only the first one.
|
|
1422
|
+
*/
|
|
1423
|
+
function parseEdge(tok, negated) {
|
|
1424
|
+
const dir = tok.startsWith("->") ? "out" : "in";
|
|
1425
|
+
const arrow = tok.slice(0, 2);
|
|
1426
|
+
let rest = tok.slice(2);
|
|
1427
|
+
const term = { kind: "edge", a: "", dir };
|
|
1428
|
+
if (rest.startsWith("*")) {
|
|
1429
|
+
term.depth = { min: 1, max: Infinity };
|
|
1430
|
+
rest = rest.slice(1);
|
|
1431
|
+
}
|
|
1432
|
+
else if (rest.startsWith("{")) {
|
|
1433
|
+
const m = /^\{(\d+)(,(\d*))?\}/.exec(rest);
|
|
1434
|
+
const min = m ? Number(m[1]) : NaN;
|
|
1435
|
+
const max = !m ? NaN : m[2] === undefined ? min : m[3] === "" ? Infinity : Number(m[3]);
|
|
1436
|
+
if (!m || min < 1 || max < min) {
|
|
1437
|
+
throw new QueryError(`"${tok}" has a depth this grammar cannot read`, `a depth is {n}, {min,max} or {min,} with 1 <= min <= max, e.g. ${arrow}{1,3}kind:aws_vpc; ${arrow}* is any depth`);
|
|
1438
|
+
}
|
|
1439
|
+
term.depth = { min, max };
|
|
1440
|
+
rest = rest.slice(m[0].length);
|
|
1441
|
+
}
|
|
1442
|
+
if (rest.startsWith("[")) {
|
|
1443
|
+
const close = rest.indexOf("]");
|
|
1444
|
+
// Split on commas outside parentheses, so `[cap=open-ingress(22,tcp)]` is one part.
|
|
1445
|
+
const parts = close < 0 ? [""] : (rest.slice(1, close).match(/(?:[^,(]|\([^)]*\))+/g) ?? [""]).map((p) => p.trim());
|
|
1446
|
+
for (const part of parts) {
|
|
1447
|
+
const c = /^cap=(.+)$/.exec(part);
|
|
1448
|
+
if (c && parseCapabilityCall(c[1])) {
|
|
1449
|
+
term.capability = c[1];
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
const q = /^(via|to)=(.+)$/.exec(part);
|
|
1453
|
+
if (!q) {
|
|
1454
|
+
throw new QueryError(`"${tok}" has an edge qualifier this grammar cannot read`, `qualify an edge by attribute with [via=<consumer attr>] or [to=<producer attr>], e.g. <-[via=vpc_id]kind:aws_subnet or ->[via=subnet_id,to=id], or walk a capability with [cap=<name>], e.g. ->[cap=reaches]cap:stores-data`);
|
|
1455
|
+
}
|
|
1456
|
+
if (q[1] === "via")
|
|
1457
|
+
term.viaAttr = q[2];
|
|
1458
|
+
else
|
|
1459
|
+
term.toAttr = q[2];
|
|
1460
|
+
}
|
|
1461
|
+
rest = rest.slice(close + 1);
|
|
1462
|
+
}
|
|
1463
|
+
if (rest.trim() === "") {
|
|
1464
|
+
if (term.viaAttr === undefined && term.toAttr === undefined && term.capability === undefined) {
|
|
1465
|
+
const bang = negated ? "!" : "";
|
|
1466
|
+
throw new QueryError(`"${bang}${tok}" needs a target`, `say what the edge reaches: ${bang}${tok}kind:EC2::Instance, or qualify it by attribute: ${bang}${tok}[via=vpc_id]`);
|
|
1467
|
+
}
|
|
1468
|
+
return term;
|
|
1469
|
+
}
|
|
1470
|
+
return { ...term, sub: parseLeaf(rest) };
|
|
1471
|
+
}
|
|
792
1472
|
function parseQuery(query) {
|
|
793
1473
|
// Split on whitespace but keep quoted phrases together.
|
|
794
1474
|
const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
|
|
@@ -827,11 +1507,7 @@ function parseQuery(query) {
|
|
|
827
1507
|
if (/^(->|<-)\s*$/.test(tok)) {
|
|
828
1508
|
throw new QueryError(`"${negated ? "!" : ""}${tok}" needs a target`, `say what the edge reaches: ${negated ? "!" : ""}${tok}kind:EC2::Instance, or ${negated ? "!" : ""}${tok}attr:Name=web`);
|
|
829
1509
|
}
|
|
830
|
-
const term = tok.startsWith("->")
|
|
831
|
-
? { kind: "edge", a: "", dir: "out", sub: parseLeaf(tok.slice(2)) }
|
|
832
|
-
: tok.startsWith("<-")
|
|
833
|
-
? { kind: "edge", a: "", dir: "in", sub: parseLeaf(tok.slice(2)) }
|
|
834
|
-
: parseLeaf(tok);
|
|
1510
|
+
const term = tok.startsWith("->") || tok.startsWith("<-") ? parseEdge(tok, negated) : parseLeaf(tok);
|
|
835
1511
|
return negated ? { ...term, negated: true } : term;
|
|
836
1512
|
});
|
|
837
1513
|
}
|
|
@@ -847,12 +1523,30 @@ function attrString(v) {
|
|
|
847
1523
|
}
|
|
848
1524
|
return String(v);
|
|
849
1525
|
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Attributes a bare word does not search, by lexicon (#4368). A terraform
|
|
1528
|
+
* node's `source` is the whole `.tf` file its block came from (the lint
|
|
1529
|
+
* checks need the raw text), so a bare `aws_security_group` matched every
|
|
1530
|
+
* node declared in a file that mentions one: 21 nodes on behold's example
|
|
1531
|
+
* estate, where three are security groups. The block itself is still
|
|
1532
|
+
* searched, through `address` and `body`, and `attr:source=<text>` still
|
|
1533
|
+
* reads the file when that is what the caller means.
|
|
1534
|
+
*/
|
|
1535
|
+
const BARE_WORD_SKIPS = {
|
|
1536
|
+
terraform: new Set(["source"]),
|
|
1537
|
+
};
|
|
850
1538
|
function matchTerm(n, t, ir, byId) {
|
|
851
1539
|
if (t.negated)
|
|
852
1540
|
return !matchTerm(n, { ...t, negated: false }, ir, byId);
|
|
853
1541
|
const attrs = n.attrs ?? {};
|
|
854
1542
|
if (t.kind === "edge") {
|
|
855
|
-
if (!ir || !byId
|
|
1543
|
+
if (!ir || !byId)
|
|
1544
|
+
return false;
|
|
1545
|
+
// Depth and attribute qualifiers (#4359) go through the path search. A
|
|
1546
|
+
// plain one-hop term keeps the original code below, unchanged.
|
|
1547
|
+
if (t.depth || t.viaAttr !== undefined || t.toAttr !== undefined || t.capability !== undefined)
|
|
1548
|
+
return findPath(n, t, ir, byId) !== null;
|
|
1549
|
+
if (!t.sub)
|
|
856
1550
|
return false;
|
|
857
1551
|
// A node matches if it has an edge (out or in) to a node satisfying `sub`.
|
|
858
1552
|
const edges = ir.edges ?? [];
|
|
@@ -862,11 +1556,18 @@ function matchTerm(n, t, ir, byId) {
|
|
|
862
1556
|
.filter((x) => !!x);
|
|
863
1557
|
return neighbors.some((m) => matchTerm(m, t.sub, ir, byId));
|
|
864
1558
|
}
|
|
1559
|
+
if (t.kind === "cap")
|
|
1560
|
+
return !!ir && capabilityEvidence(n, t, ir) !== undefined;
|
|
865
1561
|
if (t.kind === "kind") {
|
|
866
1562
|
// Resolved kinds when the term lined up with `::` boundaries; the
|
|
867
|
-
// documented substring otherwise.
|
|
1563
|
+
// documented substring otherwise. A term that named a terraform type
|
|
1564
|
+
// (#4366) matches resources of that type as well, and drops the substring.
|
|
1565
|
+
if (t.tfTypes?.has(terraformType(n) ?? ""))
|
|
1566
|
+
return true;
|
|
868
1567
|
if (t.kinds)
|
|
869
1568
|
return t.kinds.has(n.kind ?? "");
|
|
1569
|
+
if (t.tfTypes)
|
|
1570
|
+
return false;
|
|
870
1571
|
return (n.kind ?? "").toLowerCase().includes(t.a.toLowerCase());
|
|
871
1572
|
}
|
|
872
1573
|
if (t.kind === "attr") {
|
|
@@ -883,15 +1584,213 @@ function matchTerm(n, t, ir, byId) {
|
|
|
883
1584
|
return key.toLowerCase() === t.a.toLowerCase() && (t.b === undefined || val.toLowerCase().includes(t.b.toLowerCase()));
|
|
884
1585
|
});
|
|
885
1586
|
}
|
|
886
|
-
// bare word: substring over id, kind, and all attr values
|
|
887
|
-
|
|
1587
|
+
// bare word: substring over id, kind, and all attr values, less the ones
|
|
1588
|
+
// that hold text about other nodes (see BARE_WORD_SKIPS).
|
|
1589
|
+
const skip = BARE_WORD_SKIPS[n.lexicon];
|
|
1590
|
+
const values = Object.entries(attrs)
|
|
1591
|
+
.filter(([k]) => !skip?.has(k))
|
|
1592
|
+
.map(([, v]) => attrString(v));
|
|
1593
|
+
const hay = [n.id, n.kind, ...values].join(" ").toLowerCase();
|
|
888
1594
|
return hay.includes(t.a.toLowerCase());
|
|
889
1595
|
}
|
|
1596
|
+
const adjacency = new WeakMap();
|
|
1597
|
+
function edgeIndex(ir) {
|
|
1598
|
+
let idx = adjacency.get(ir);
|
|
1599
|
+
if (!idx) {
|
|
1600
|
+
idx = { out: new Map(), in: new Map() };
|
|
1601
|
+
for (const e of ir.edges ?? []) {
|
|
1602
|
+
if (!idx.out.has(e.from))
|
|
1603
|
+
idx.out.set(e.from, []);
|
|
1604
|
+
idx.out.get(e.from).push(e);
|
|
1605
|
+
if (!idx.in.has(e.to))
|
|
1606
|
+
idx.in.set(e.to, []);
|
|
1607
|
+
idx.in.get(e.to).push(e);
|
|
1608
|
+
}
|
|
1609
|
+
adjacency.set(ir, idx);
|
|
1610
|
+
}
|
|
1611
|
+
return idx;
|
|
1612
|
+
}
|
|
1613
|
+
function sameName(a, b) {
|
|
1614
|
+
return a !== undefined && a.toLowerCase() === b.toLowerCase();
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* The shortest path by which an edge term reaches `n` (#4359), or null.
|
|
1618
|
+
*
|
|
1619
|
+
* Breadth-first over the edges in the term's direction, keeping only edges
|
|
1620
|
+
* whose attributes satisfy its qualifier, and accepting a node that satisfies
|
|
1621
|
+
* the target leaf (any node when the term has none) at a depth inside the
|
|
1622
|
+
* term's range. A plain `->`/`<-` is the range 1..1.
|
|
1623
|
+
*
|
|
1624
|
+
* Termination on a cycle: a state is (node, depth capped at the range's
|
|
1625
|
+
* minimum). Past the minimum, arriving at a node again by a longer walk can
|
|
1626
|
+
* reach nothing the first arrival could not, so it is not queued twice. The
|
|
1627
|
+
* state space is finite and the walk always ends.
|
|
1628
|
+
*/
|
|
1629
|
+
function findPath(n, t, ir, byId) {
|
|
1630
|
+
if (t.kind !== "edge")
|
|
1631
|
+
return null;
|
|
1632
|
+
const { min, max } = t.depth ?? { min: 1, max: 1 };
|
|
1633
|
+
const idx = edgeIndex(ir);
|
|
1634
|
+
const out = t.dir === "out";
|
|
1635
|
+
// A capability edge (#4375) stands for the hops that proved it.
|
|
1636
|
+
const steps = (id) => {
|
|
1637
|
+
if (t.capability !== undefined)
|
|
1638
|
+
return capabilitySteps(id, t.capability, out, ir);
|
|
1639
|
+
return (((out ? idx.out.get(id) : idx.in.get(id)) ?? [])
|
|
1640
|
+
.filter((e) => (t.viaAttr === undefined || sameName(e.viaAttr, t.viaAttr)) && (t.toAttr === undefined || sameName(e.toAttr, t.toAttr)))
|
|
1641
|
+
.map((e) => ({
|
|
1642
|
+
far: out ? e.to : e.from,
|
|
1643
|
+
hops: [{ from: e.from, ...(e.viaAttr ? { fromAttr: e.viaAttr } : {}), to: e.to, ...(e.toAttr ? { toAttr: e.toAttr } : {}) }],
|
|
1644
|
+
})));
|
|
1645
|
+
};
|
|
1646
|
+
const seen = new Set([`${n.id}|0`]);
|
|
1647
|
+
let frontier = [{ id: n.id, depth: 0, hops: [] }];
|
|
1648
|
+
while (frontier.length > 0) {
|
|
1649
|
+
const next = [];
|
|
1650
|
+
for (const s of frontier) {
|
|
1651
|
+
if (s.depth >= max)
|
|
1652
|
+
continue;
|
|
1653
|
+
for (const step of steps(s.id)) {
|
|
1654
|
+
const farId = step.far;
|
|
1655
|
+
const far = byId.get(farId);
|
|
1656
|
+
if (!far)
|
|
1657
|
+
continue;
|
|
1658
|
+
const depth = s.depth + 1;
|
|
1659
|
+
const hops = [...s.hops, ...step.hops];
|
|
1660
|
+
if (depth >= min && (!t.sub || matchTerm(far, t.sub, ir, byId)))
|
|
1661
|
+
return hops;
|
|
1662
|
+
const key = `${farId}|${Math.min(depth, min)}`;
|
|
1663
|
+
if (seen.has(key))
|
|
1664
|
+
continue;
|
|
1665
|
+
seen.add(key);
|
|
1666
|
+
next.push({ id: farId, depth, hops });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
frontier = next;
|
|
1670
|
+
}
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
function bareId(id) {
|
|
1674
|
+
return id.includes("::") ? id.slice(id.lastIndexOf("::") + 2) : id;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* A path as one line of `id.attr` steps, read from the match outward (#4359).
|
|
1678
|
+
* Walking `->`, each hop reads `consumer.via -> producer.attr`; walking `<-`,
|
|
1679
|
+
* `producer.attr <- consumer.via`. Hops are joined with `; `.
|
|
1680
|
+
*/
|
|
1681
|
+
function formatPath(hops, dir) {
|
|
1682
|
+
const end = (id, attr) => (attr ? `${bareId(id)}.${attr}` : bareId(id));
|
|
1683
|
+
return hops
|
|
1684
|
+
.map((h) => dir === "out"
|
|
1685
|
+
? `${end(h.from, h.fromAttr)} -> ${end(h.to, h.toAttr)}`
|
|
1686
|
+
: `${end(h.to, h.toAttr)} <- ${end(h.from, h.fromAttr)}`)
|
|
1687
|
+
.join("; ");
|
|
1688
|
+
}
|
|
1689
|
+
/** The edge operator as written: arrow, depth and qualifier, no target. */
|
|
1690
|
+
function edgeOperator(t, arrows) {
|
|
1691
|
+
const arrow = t.dir === "out" ? arrows.out : arrows.in;
|
|
1692
|
+
const depth = !t.depth
|
|
1693
|
+
? ""
|
|
1694
|
+
: t.depth.min === 1 && t.depth.max === Infinity
|
|
1695
|
+
? "*"
|
|
1696
|
+
: t.depth.min === t.depth.max
|
|
1697
|
+
? `{${t.depth.min}}`
|
|
1698
|
+
: `{${t.depth.min},${t.depth.max === Infinity ? "" : t.depth.max}}`;
|
|
1699
|
+
const quals = [
|
|
1700
|
+
...(t.viaAttr !== undefined ? [`via=${t.viaAttr}`] : []),
|
|
1701
|
+
...(t.toAttr !== undefined ? [`to=${t.toAttr}`] : []),
|
|
1702
|
+
...(t.capability !== undefined ? [`cap=${t.capability}`] : []),
|
|
1703
|
+
];
|
|
1704
|
+
return `${arrow}${depth}${quals.length > 0 ? `[${quals.join(",")}]` : ""}`;
|
|
1705
|
+
}
|
|
1706
|
+
/** Each positive edge term's path to `n`, in query order (#4359). */
|
|
1707
|
+
function matchPaths(n, terms, ir, byId) {
|
|
1708
|
+
const out = [];
|
|
1709
|
+
for (const t of terms) {
|
|
1710
|
+
if (t.negated)
|
|
1711
|
+
continue;
|
|
1712
|
+
if (t.kind === "cap") {
|
|
1713
|
+
const ev = capabilityEvidence(n, t, ir);
|
|
1714
|
+
if (ev)
|
|
1715
|
+
out.push({ term: t, hops: ev.hops.map(pathHop), because: becauseOf(ev) });
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
if (t.kind !== "edge")
|
|
1719
|
+
continue;
|
|
1720
|
+
const hops = findPath(n, t, ir, byId);
|
|
1721
|
+
if (hops)
|
|
1722
|
+
out.push({ term: t, hops });
|
|
1723
|
+
}
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* The evidence for a `cap:` term on a node (#4375): its path when the node
|
|
1728
|
+
* is in the set, or the source of an edge capability, else undefined. A
|
|
1729
|
+
* capability or argument the catalog can't take throws a CapabilityError,
|
|
1730
|
+
* which `runSearch` turns into a refusal before any node is tested.
|
|
1731
|
+
*/
|
|
1732
|
+
function capabilityEvidence(n, t, ir) {
|
|
1733
|
+
const call = parseCapabilityCall(t.a);
|
|
1734
|
+
if (!call)
|
|
1735
|
+
return undefined;
|
|
1736
|
+
const engine = capabilityEngine(ir);
|
|
1737
|
+
const args = engine.bind(call.name, call.args);
|
|
1738
|
+
if (engine.rule(call.name).vars.length === 1)
|
|
1739
|
+
return engine.set(call.name, args).get(n.id);
|
|
1740
|
+
const targets = engine.relation(call.name, args).out.get(n.id);
|
|
1741
|
+
return targets ? targets.values().next().value : undefined;
|
|
1742
|
+
}
|
|
1743
|
+
/** One step along an edge capability from `id` (#4375), each with the hops that proved it, in walk order. */
|
|
1744
|
+
function capabilitySteps(id, call, out, ir) {
|
|
1745
|
+
const parsed = parseCapabilityCall(call);
|
|
1746
|
+
if (!parsed)
|
|
1747
|
+
return [];
|
|
1748
|
+
const engine = capabilityEngine(ir);
|
|
1749
|
+
const rel = engine.relation(parsed.name, engine.bind(parsed.name, parsed.args));
|
|
1750
|
+
const m = (out ? rel.out : rel.in).get(id);
|
|
1751
|
+
if (!m)
|
|
1752
|
+
return [];
|
|
1753
|
+
return [...m].map(([far, ev]) => ({ far, hops: (out ? ev.hops : [...ev.hops].reverse()).map(pathHop) }));
|
|
1754
|
+
}
|
|
1755
|
+
function pathHop(h) {
|
|
1756
|
+
return { from: h.from, ...(h.fromAttr ? { fromAttr: h.fromAttr } : {}), to: h.to, ...(h.toAttr ? { toAttr: h.toAttr } : {}), edge: h.edge };
|
|
1757
|
+
}
|
|
1758
|
+
function becauseOf(ev) {
|
|
1759
|
+
const out = [];
|
|
1760
|
+
const walk = (e) => {
|
|
1761
|
+
for (const b of e.because) {
|
|
1762
|
+
out.push({ capability: b.capability, id: b.id, hops: b.evidence.hops.map(pathHop) });
|
|
1763
|
+
walk(b.evidence);
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
walk(ev);
|
|
1767
|
+
return out.length > 0 ? out : undefined;
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* A capability's path as one line (#4375), in #4359's hop format, read from
|
|
1771
|
+
* `start`: each hop is `consumer.via -> producer.attr` when the walk follows
|
|
1772
|
+
* the reference, `producer.attr <- consumer.via` when it goes against it. A
|
|
1773
|
+
* hop with no IR attribute names the capability edge instead (`-statement->`).
|
|
1774
|
+
*/
|
|
1775
|
+
function formatHops(hops, start) {
|
|
1776
|
+
const end = (id, attr) => (attr ? `${bareId(id)}.${attr}` : bareId(id));
|
|
1777
|
+
let at = start;
|
|
1778
|
+
return hops
|
|
1779
|
+
.map((h) => {
|
|
1780
|
+
const forward = h.from === at || h.to !== at;
|
|
1781
|
+
at = forward ? h.to : h.from;
|
|
1782
|
+
return forward
|
|
1783
|
+
? `${end(h.from, h.fromAttr)} -> ${end(h.to, h.toAttr)}`
|
|
1784
|
+
: `${end(h.to, h.toAttr)} <- ${end(h.from, h.fromAttr)}`;
|
|
1785
|
+
})
|
|
1786
|
+
.join("; ");
|
|
1787
|
+
}
|
|
890
1788
|
function formatRow(n, show) {
|
|
891
1789
|
const attrs = (n.attrs ?? {});
|
|
892
1790
|
// Display the bare logical id, not the `${stack}::` qualification (#1162).
|
|
893
1791
|
const displayId = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
|
|
894
|
-
|
|
1792
|
+
// A terraform resource shows its type (#4366); its IR kind is `Terraform::Resource` for all of them.
|
|
1793
|
+
const parts = [displayId, displayKind(n) ?? ""];
|
|
895
1794
|
// Prefer the node-level live physicalId (set by the overlay), then attrs;
|
|
896
1795
|
// skip source-mode AttrRef placeholders (objects).
|
|
897
1796
|
const physical = n.physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
|
|
@@ -902,6 +1801,23 @@ function formatRow(n, show) {
|
|
|
902
1801
|
// established; the reason is the tri-state's (#1089).
|
|
903
1802
|
else if (typeof attrs["_unobserved"] === "string")
|
|
904
1803
|
parts.push(`(unobserved: ${attrs["_unobserved"]})`);
|
|
1804
|
+
for (const [actual, v] of showValues(attrs, show)) {
|
|
1805
|
+
// A column the caller explicitly asked for is shown whatever shape it is.
|
|
1806
|
+
// Skipping non-scalars silently meant `--show effectiveIngress` — the
|
|
1807
|
+
// derived reachability fact, and the reason to reach for chant at all —
|
|
1808
|
+
// printed a blank column, because it is a list. The agent read that as
|
|
1809
|
+
// "chant does not have this" and hand-rolled the answer from raw
|
|
1810
|
+
// security-group rows, which is exactly the work the fold exists to avoid.
|
|
1811
|
+
parts.push(`${actual}=${typeof v === "object" ? JSON.stringify(v) : attrString(v)}`);
|
|
1812
|
+
}
|
|
1813
|
+
return parts.filter(Boolean).join(" ");
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* The `--show` columns a row carries, as `[name as the graph spells it, value]`,
|
|
1817
|
+
* in the order asked. Shared by the text row and `--json` (#4367).
|
|
1818
|
+
*/
|
|
1819
|
+
function showValues(attrs, show) {
|
|
1820
|
+
const out = [];
|
|
905
1821
|
for (const key of show) {
|
|
906
1822
|
// Match the name case-insensitively, and report what was actually found.
|
|
907
1823
|
// AWS attribute names are PascalCase and chant's derived ones are not, so a
|
|
@@ -912,15 +1828,9 @@ function formatRow(n, show) {
|
|
|
912
1828
|
const v = actual == null ? undefined : attrs[actual];
|
|
913
1829
|
if (v == null)
|
|
914
1830
|
continue;
|
|
915
|
-
|
|
916
|
-
// Skipping non-scalars silently meant `--show effectiveIngress` — the
|
|
917
|
-
// derived reachability fact, and the reason to reach for chant at all —
|
|
918
|
-
// printed a blank column, because it is a list. The agent read that as
|
|
919
|
-
// "chant does not have this" and hand-rolled the answer from raw
|
|
920
|
-
// security-group rows, which is exactly the work the fold exists to avoid.
|
|
921
|
-
parts.push(`${actual}=${typeof v === "object" ? JSON.stringify(v) : attrString(v)}`);
|
|
1831
|
+
out.push([actual, v]);
|
|
922
1832
|
}
|
|
923
|
-
return
|
|
1833
|
+
return out;
|
|
924
1834
|
}
|
|
925
1835
|
/** Internals exposed for unit tests. */
|
|
926
|
-
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance, renderDrift, mergeResources, pickResources };
|
|
1836
|
+
export const __searchInternals = { parseQuery, matchTerm, findPath, formatPath, searchJson, searchErrorJson, explainData, termText, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance, provenanceLine, renderDrift, searchDriftJson, mergeResources, pickResources };
|