@intentius/chant 0.124.44 → 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/ci-identity.d.ts +183 -12
- package/dist/ci-identity.d.ts.map +1 -1
- package/dist/ci-identity.js +362 -53
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +1 -0
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +17 -1
- 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 +8 -2
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +127 -225
- package/dist/cli/handlers/pr.d.ts +3 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +1 -0
- 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/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +4 -2
- package/dist/components/fan-out-support.d.ts +3 -0
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/fan-out-support.js +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 +254 -0
- package/dist/components/pr-env-run.d.ts.map +1 -0
- package/dist/components/pr-env-run.js +376 -0
- package/dist/components/pr-environment.d.ts +91 -15
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +95 -9
- package/dist/components/pr-pipeline.d.ts +10 -1
- package/dist/components/pr-pipeline.d.ts.map +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/op/op-waves.d.ts +17 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +23 -1
- package/dist/pr-forge.d.ts +15 -1
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +28 -16
- package/dist/pr-requires.d.ts +5 -1
- package/dist/pr-requires.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +251 -250
- 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/ci-cloud-identity.d.ts +3 -1
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
- package/dist/workspace/ci-cloud-identity.js +16 -1
- package/dist/workspace/ci-run.d.ts +35 -8
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +95 -21
- package/dist/workspace/ci-secrets.d.ts.map +1 -1
- package/dist/workspace/ci-secrets.js +4 -3
- 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/declaration.d.ts +10 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +17 -1
- package/dist/workspace/declaration.schema.json +36 -1
- 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/ci-identity-targets.test.ts +275 -0
- package/src/ci-identity.ts +394 -61
- package/src/cli/commands/lint.ts +1 -0
- package/src/cli/handlers/build.ts +15 -1
- 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 +135 -227
- package/src/cli/handlers/pr.ts +4 -0
- 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/cli-support.ts +6 -1
- package/src/components/fan-out-support.ts +4 -1
- 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.test.ts +290 -0
- package/src/components/pr-env-run.ts +563 -0
- package/src/components/pr-environment.ts +161 -13
- package/src/components/pr-pipeline.ts +10 -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/op/op-waves.ts +38 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.ts +44 -17
- package/src/pr-requires.test.ts +43 -6
- package/src/pr-requires.ts +5 -1
- 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/ci-cloud-identity.test.ts +20 -0
- package/src/workspace/ci-cloud-identity.ts +14 -1
- package/src/workspace/ci-run.test.ts +74 -1
- package/src/workspace/ci-run.ts +122 -29
- package/src/workspace/ci-secrets.test.ts +1 -1
- package/src/workspace/ci-secrets.ts +4 -3
- 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/declaration.schema.json +36 -1
- package/src/workspace/declaration.test.ts +7 -0
- package/src/workspace/declaration.ts +24 -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
|
@@ -44,6 +44,11 @@ vi.mock("../../build", async () => {
|
|
|
44
44
|
};
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
import type { ResourceMetadata } from "../../lexicon";
|
|
48
|
+
import type { RootRead } from "../../observation";
|
|
49
|
+
import searchSchema from "../../workspace/search.schema.json";
|
|
50
|
+
import { contract } from "../../workspace/__fixtures__/contract-repo";
|
|
51
|
+
const { expectValid } = contract(searchSchema);
|
|
47
52
|
const { runSearch } = await import("./search");
|
|
48
53
|
|
|
49
54
|
function decl<T extends object>(base: T): Declarable & T {
|
|
@@ -215,3 +220,385 @@ describe("search --live run-level notes (#1265)", () => {
|
|
|
215
220
|
expect(all.indexOf("(no matches)")).toBeLessThan(all.findIndex((l) => l.includes("ownership filter unavailable")));
|
|
216
221
|
});
|
|
217
222
|
});
|
|
223
|
+
|
|
224
|
+
// #4358 — a terraform root's blocks exist only once `buildRoots()` renders the
|
|
225
|
+
// root, and `--live` built without it, so no terraform root was ever observed
|
|
226
|
+
// and a plan attribute could never be matched. The live path now builds roots
|
|
227
|
+
// and joins them to the declared canvas, as `chant graph --live` does.
|
|
228
|
+
describe("search --live over build-root entities (#4358)", () => {
|
|
229
|
+
let out: string[];
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
out = [];
|
|
233
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
|
|
234
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { out.push(`ERR ${s}`); });
|
|
235
|
+
discoverMock.mockReset();
|
|
236
|
+
discoverMock.mockImplementation(async () => ({ entities: new Map(), errors: [], sourceFiles: [] }));
|
|
237
|
+
loadChantConfigMock.mockReset();
|
|
238
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
239
|
+
resolveLexMock.mockReset();
|
|
240
|
+
resolveLexMock.mockResolvedValue(["terraform"]);
|
|
241
|
+
hasSnapshotMock.mockReset();
|
|
242
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("builds roots, observes their blocks and matches the attributes the read put on them", async () => {
|
|
246
|
+
const rooted = new Map<string, Declarable>([
|
|
247
|
+
["app/terraform_data.replaced", decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} })],
|
|
248
|
+
]);
|
|
249
|
+
buildResultMock.mockReset();
|
|
250
|
+
buildResultMock.mockImplementation((_src: unknown, _s: unknown, _x: unknown, opts?: { buildRoots?: unknown[] }) => ({
|
|
251
|
+
errors: [],
|
|
252
|
+
entities: (opts?.buildRoots?.length ?? 0) > 0 ? rooted : new Map(),
|
|
253
|
+
outputs: new Map([["terraform", ""]]),
|
|
254
|
+
warnings: [],
|
|
255
|
+
}));
|
|
256
|
+
loadPluginsMock.mockResolvedValue([
|
|
257
|
+
{
|
|
258
|
+
// createMockPlugin has no `buildRoots` option; the hook is spread on.
|
|
259
|
+
...createMockPlugin({
|
|
260
|
+
name: "terraform",
|
|
261
|
+
describeResources: async ({ entityNames }: { entityNames: string[] }) =>
|
|
262
|
+
Object.fromEntries(
|
|
263
|
+
entityNames.map((n) => [n, { type: "Terraform::Resource", physicalId: "id-1", status: "managed", ownership: "owned", owned: true, attributes: { planAction: "replace" } }]),
|
|
264
|
+
),
|
|
265
|
+
}),
|
|
266
|
+
buildRoots: async () => ({ entities: rooted, warnings: [] }),
|
|
267
|
+
},
|
|
268
|
+
]);
|
|
269
|
+
const exit = await runSearch({ args: makeArgs({ path: "attr:planAction=replace" }), plugins: [], serializers: [] });
|
|
270
|
+
expect(out.join("\n")).toContain("app/terraform_data.replaced terraform_data id-1");
|
|
271
|
+
expect(exit).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// #4372 — a planned create is absent, and the plan's attributes ride beside
|
|
276
|
+
// the verdict as annotations. The handler puts them on the declared row, so
|
|
277
|
+
// `attr:planAction=create` matches a block that does not exist yet, and the
|
|
278
|
+
// row still has no physical id.
|
|
279
|
+
describe("search --live over a planned create (#4372)", () => {
|
|
280
|
+
let out: string[];
|
|
281
|
+
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
out = [];
|
|
284
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
|
|
285
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { out.push(`ERR ${s}`); });
|
|
286
|
+
discoverMock.mockReset();
|
|
287
|
+
discoverMock.mockImplementation(async () => ({ entities: new Map(), errors: [], sourceFiles: [] }));
|
|
288
|
+
loadChantConfigMock.mockReset();
|
|
289
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
290
|
+
resolveLexMock.mockReset();
|
|
291
|
+
resolveLexMock.mockResolvedValue(["terraform"]);
|
|
292
|
+
hasSnapshotMock.mockReset();
|
|
293
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("attr:planAction=create and attr:planAfterUnknown=id match it, and it stays without a physical id", async () => {
|
|
297
|
+
const rooted = new Map<string, Declarable>([
|
|
298
|
+
["app/terraform_data.replaced", decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} })],
|
|
299
|
+
["app/terraform_data.created", decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} })],
|
|
300
|
+
]);
|
|
301
|
+
buildResultMock.mockReset();
|
|
302
|
+
buildResultMock.mockImplementation((_src: unknown, _s: unknown, _x: unknown, opts?: { buildRoots?: unknown[] }) => ({
|
|
303
|
+
errors: [],
|
|
304
|
+
entities: (opts?.buildRoots?.length ?? 0) > 0 ? rooted : new Map(),
|
|
305
|
+
outputs: new Map([["terraform", ""]]),
|
|
306
|
+
warnings: [],
|
|
307
|
+
}));
|
|
308
|
+
loadPluginsMock.mockResolvedValue([
|
|
309
|
+
{
|
|
310
|
+
...createMockPlugin({
|
|
311
|
+
name: "terraform",
|
|
312
|
+
describeResources: async () => ({
|
|
313
|
+
observation: "v1",
|
|
314
|
+
resources: {
|
|
315
|
+
"app/terraform_data.replaced": { type: "Terraform::Resource", physicalId: "id-1", status: "managed", ownership: "owned", attributes: { planAction: "replace" } },
|
|
316
|
+
},
|
|
317
|
+
annotations: { "app/terraform_data.created": { planAction: "create", planAfterUnknown: ["id", "input"] } },
|
|
318
|
+
}),
|
|
319
|
+
}),
|
|
320
|
+
buildRoots: async () => ({ entities: rooted, warnings: [] }),
|
|
321
|
+
},
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
expect(await runSearch({ args: makeArgs({ path: "attr:planAction=create", json: true }), plugins: [], serializers: [] })).toBe(0);
|
|
325
|
+
const json = JSON.parse(out.filter((l) => !l.startsWith("ERR ")).join("\n")) as { matches: Array<{ id: string; physicalId?: string | null }> };
|
|
326
|
+
expect(json.matches.map((m) => m.id)).toEqual(["app/terraform_data.created"]);
|
|
327
|
+
expect(json.matches[0]!.physicalId ?? null).toBeNull();
|
|
328
|
+
|
|
329
|
+
out = [];
|
|
330
|
+
expect(await runSearch({ args: makeArgs({ path: "attr:planAfterUnknown=id" }), plugins: [], serializers: [] })).toBe(0);
|
|
331
|
+
const text = out.join("\n");
|
|
332
|
+
expect(text).toContain("app/terraform_data.created");
|
|
333
|
+
expect(text).not.toContain("app/terraform_data.replaced");
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// #4381 — when every match is a planned create, the live read worked and found
|
|
338
|
+
// nothing bound yet. "returned no bound resources" read like a failed read.
|
|
339
|
+
describe("search --live provenance when every match is a planned create (#4381)", () => {
|
|
340
|
+
let out: string[];
|
|
341
|
+
|
|
342
|
+
function setup(annotations: Record<string, Record<string, unknown>> | undefined, names: string[]) {
|
|
343
|
+
const rooted = new Map<string, Declarable>(
|
|
344
|
+
names.map((n) => [n, decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} })]),
|
|
345
|
+
);
|
|
346
|
+
buildResultMock.mockReset();
|
|
347
|
+
buildResultMock.mockImplementation((_src: unknown, _s: unknown, _x: unknown, opts?: { buildRoots?: unknown[] }) => ({
|
|
348
|
+
errors: [],
|
|
349
|
+
entities: (opts?.buildRoots?.length ?? 0) > 0 ? rooted : new Map(),
|
|
350
|
+
outputs: new Map([["terraform", ""]]),
|
|
351
|
+
warnings: [],
|
|
352
|
+
}));
|
|
353
|
+
loadPluginsMock.mockResolvedValue([
|
|
354
|
+
{
|
|
355
|
+
...createMockPlugin({
|
|
356
|
+
name: "terraform",
|
|
357
|
+
describeResources: async () => ({ observation: "v1", resources: {}, ...(annotations ? { annotations } : {}) }),
|
|
358
|
+
}),
|
|
359
|
+
buildRoots: async () => ({ entities: rooted, warnings: [] }),
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
out = [];
|
|
366
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
|
|
367
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { out.push(`ERR ${s}`); });
|
|
368
|
+
discoverMock.mockReset();
|
|
369
|
+
discoverMock.mockImplementation(async () => ({ entities: new Map(), errors: [], sourceFiles: [] }));
|
|
370
|
+
loadChantConfigMock.mockReset();
|
|
371
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
372
|
+
resolveLexMock.mockReset();
|
|
373
|
+
resolveLexMock.mockResolvedValue(["terraform"]);
|
|
374
|
+
hasSnapshotMock.mockReset();
|
|
375
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const planned = { "app/a": { planAction: "create" }, "app/b": { planAction: "create" } };
|
|
379
|
+
|
|
380
|
+
test("text footer and --json provenance say planned creates, and plannedCreates carries the count", async () => {
|
|
381
|
+
setup(planned, ["app/a", "app/b"]);
|
|
382
|
+
expect(await runSearch({ args: makeArgs({ path: "attr:planAction=create" }), plugins: [], serializers: [] })).toBe(0);
|
|
383
|
+
const text = out.join("\n");
|
|
384
|
+
expect(text).toContain("2 planned creates, none bound yet");
|
|
385
|
+
expect(text).not.toContain("returned no bound resources");
|
|
386
|
+
|
|
387
|
+
out = [];
|
|
388
|
+
expect(await runSearch({ args: makeArgs({ path: "attr:planAction=create", json: true }), plugins: [], serializers: [] })).toBe(0);
|
|
389
|
+
const doc = JSON.parse(out.filter((l) => !l.startsWith("ERR ")).join("\n")) as { provenance: string; plannedCreates?: number };
|
|
390
|
+
expectValid(doc);
|
|
391
|
+
expect(doc.provenance).toContain("2 planned creates, none bound yet");
|
|
392
|
+
expect(doc.plannedCreates).toBe(2);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a real empty live read keeps its wording and carries no plannedCreates", async () => {
|
|
396
|
+
setup(undefined, ["app/a", "app/b"]);
|
|
397
|
+
expect(await runSearch({ args: makeArgs({ path: "kind:Terraform::Resource" }), plugins: [], serializers: [] })).toBe(0);
|
|
398
|
+
expect(out.join("\n")).toContain("live read returned no bound resources");
|
|
399
|
+
expect(out.join("\n")).not.toContain("planned create");
|
|
400
|
+
|
|
401
|
+
out = [];
|
|
402
|
+
expect(await runSearch({ args: makeArgs({ path: "kind:Terraform::Resource", json: true }), plugins: [], serializers: [] })).toBe(0);
|
|
403
|
+
const doc = JSON.parse(out.filter((l) => !l.startsWith("ERR ")).join("\n")) as { provenance: string; plannedCreates?: number };
|
|
404
|
+
expectValid(doc);
|
|
405
|
+
expect(doc.provenance).toContain("live read returned no bound resources");
|
|
406
|
+
expect(doc.plannedCreates).toBeUndefined();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// #4384 - a plan-depth answer said "observed live". Each row carries the read
|
|
411
|
+
// that answered it (`sources`, #2267), and the line and --json name it.
|
|
412
|
+
describe("search --live names the plan read (#4384)", () => {
|
|
413
|
+
let out: string[];
|
|
414
|
+
const entities = new Map<string, Declarable>([
|
|
415
|
+
["orders", decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} })],
|
|
416
|
+
]);
|
|
417
|
+
const PLAN_NOTE = "terraform.roots.app: the plan read answered (`terraform plan -input=false -lock=false -out=<tmp>/plan.tfplan`)";
|
|
418
|
+
const STATE_NOTE = "terraform.roots.app: the plan read failed (no provider credentials: x), so the state read answered; plan attributes are absent";
|
|
419
|
+
const DECLARED_NOTE = "terraform.roots.app: no read answered, declared only (the plan read could not reach the backend)";
|
|
420
|
+
|
|
421
|
+
const FELL_TO_STATE: Record<string, RootRead> = { "terraform.roots.app": { depth: "plan", fellBackTo: "state", reason: "no-credentials" } };
|
|
422
|
+
const FELL_TO_DECLARED: Record<string, RootRead> = { "terraform.roots.app": { depth: "plan", fellBackTo: "declared", reason: "backend-unreachable" } };
|
|
423
|
+
|
|
424
|
+
function setup(
|
|
425
|
+
sources: Record<string, string>,
|
|
426
|
+
notes: string[],
|
|
427
|
+
resources: Record<string, ResourceMetadata> | null = null,
|
|
428
|
+
rootReads?: Record<string, RootRead>,
|
|
429
|
+
) {
|
|
430
|
+
loadPluginsMock.mockResolvedValue([
|
|
431
|
+
createMockPlugin({
|
|
432
|
+
name: "terraform",
|
|
433
|
+
describeResources: async () => ({
|
|
434
|
+
observation: "v1" as const,
|
|
435
|
+
resources: resources ?? { orders: { type: "Terraform::Resource", physicalId: "db-1", status: "OK", ownership: "owned" as const } },
|
|
436
|
+
sources,
|
|
437
|
+
notes,
|
|
438
|
+
...(rootReads ? { rootReads } : {}),
|
|
439
|
+
}),
|
|
440
|
+
}),
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function run(json = false) {
|
|
445
|
+
out = [];
|
|
446
|
+
const exit = await runSearch({ args: makeArgs({ path: "kind:Terraform::Resource", json }), plugins: [], serializers: [] });
|
|
447
|
+
expect(exit).toBe(0);
|
|
448
|
+
return out.filter((l) => !l.startsWith("ERR ")).join("\n");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
beforeEach(() => {
|
|
452
|
+
out = [];
|
|
453
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
|
|
454
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { out.push(`ERR ${s}`); });
|
|
455
|
+
discoverMock.mockReset();
|
|
456
|
+
discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
|
|
457
|
+
buildResultMock.mockReset();
|
|
458
|
+
buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["terraform", ""]]), warnings: [] });
|
|
459
|
+
loadChantConfigMock.mockReset();
|
|
460
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
461
|
+
resolveLexMock.mockReset();
|
|
462
|
+
resolveLexMock.mockResolvedValue(["terraform"]);
|
|
463
|
+
hasSnapshotMock.mockReset();
|
|
464
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("a plan answer names the plan depth in the line and in --json", async () => {
|
|
468
|
+
setup({ orders: "plan" }, [PLAN_NOTE]);
|
|
469
|
+
expect(await run()).toContain("observed at the plan depth (terraform plan -lock=false) · bound 1/1");
|
|
470
|
+
const doc = JSON.parse(await run(true));
|
|
471
|
+
expectValid(doc);
|
|
472
|
+
expect(doc.source).toMatchObject({ kind: "live", depth: "plan", reads: ["plan"] });
|
|
473
|
+
expect(doc.provenance).toBe("observed at the plan depth (terraform plan -lock=false) · bound 1/1");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("a live+plan answer names both reads", async () => {
|
|
477
|
+
setup({ orders: "live+plan" }, [PLAN_NOTE]);
|
|
478
|
+
const text = await run();
|
|
479
|
+
expect(text).toContain("observed live and at the plan depth (live-plan -json, terraform plan -lock=false) · bound 1/1");
|
|
480
|
+
const doc = JSON.parse(await run(true));
|
|
481
|
+
expectValid(doc);
|
|
482
|
+
expect(doc.source.depth).toBe("live+plan");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("matches read at different depths name both", async () => {
|
|
486
|
+
setup(
|
|
487
|
+
{ a: "plan", b: "state" },
|
|
488
|
+
[PLAN_NOTE],
|
|
489
|
+
{
|
|
490
|
+
a: { type: "Terraform::Resource", physicalId: "x", status: "OK", ownership: "owned" as const },
|
|
491
|
+
b: { type: "Terraform::Resource", physicalId: "y", status: "OK", ownership: "owned" as const },
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
entities.set("a", decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} }));
|
|
495
|
+
entities.set("b", decl({ lexicon: "terraform", entityType: "Terraform::Resource", props: {} }));
|
|
496
|
+
try {
|
|
497
|
+
const doc = JSON.parse(await run(true));
|
|
498
|
+
expectValid(doc);
|
|
499
|
+
expect(doc.source).toMatchObject({ depth: "mixed", reads: ["plan", "state"] });
|
|
500
|
+
expect(doc.provenance).toContain("at the plan depth (terraform plan -lock=false) and from state");
|
|
501
|
+
} finally {
|
|
502
|
+
entities.delete("a");
|
|
503
|
+
entities.delete("b");
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a plan read that fell back to state says so", async () => {
|
|
508
|
+
setup({ orders: "state" }, [STATE_NOTE], null, FELL_TO_STATE);
|
|
509
|
+
const text = await run();
|
|
510
|
+
expect(text).toContain("observed live · bound 1/1 · the plan read fell back to state");
|
|
511
|
+
const doc = JSON.parse(await run(true));
|
|
512
|
+
expectValid(doc);
|
|
513
|
+
expect(doc.source.planFellBackTo).toBe("state");
|
|
514
|
+
expect(doc.source.depth).toBeUndefined();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("a live root's plan read that failed says live-plan answered alone", async () => {
|
|
518
|
+
const LIVE_NOTE = "terraform.roots.app: the plan read failed (the plan failed: x), so `live-plan -json` alone answered; plan attributes are absent";
|
|
519
|
+
setup({ orders: "live" }, [LIVE_NOTE], null, { "terraform.roots.app": { depth: "live+plan", fellBackTo: "live", reason: "plan-failed" } });
|
|
520
|
+
expect(await run()).toContain("observed live · bound 1/1 · the plan read fell back to live-plan alone");
|
|
521
|
+
const doc = JSON.parse(await run(true));
|
|
522
|
+
expectValid(doc);
|
|
523
|
+
expect(doc.source.planFellBackTo).toBe("live");
|
|
524
|
+
expect(doc.source.depth).toBeUndefined();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("a live root whose plan read answered names both reads and no fallback", async () => {
|
|
528
|
+
setup({ orders: "live+plan" }, [PLAN_NOTE], null, { "terraform.roots.app": { depth: "live+plan" } });
|
|
529
|
+
const doc = JSON.parse(await run(true));
|
|
530
|
+
expectValid(doc);
|
|
531
|
+
expect(doc.source.planFellBackTo).toBeUndefined();
|
|
532
|
+
expect(doc.provenance).toBe("observed live and at the plan depth (live-plan -json, terraform plan -lock=false) · bound 1/1");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("a live root whose live-plan failed reports no plan fallback", async () => {
|
|
536
|
+
loadPluginsMock.mockResolvedValue([
|
|
537
|
+
createMockPlugin({
|
|
538
|
+
name: "terraform",
|
|
539
|
+
describeResources: async () => ({
|
|
540
|
+
observation: "v1" as const,
|
|
541
|
+
resources: {},
|
|
542
|
+
unobserved: { orders: { reason: "read-failed" as const, type: "Terraform::Resource" } },
|
|
543
|
+
sources: { orders: "live" },
|
|
544
|
+
notes: ["terraform.roots.app: no read answered (`live-plan -json` failed, so the plan read was not tried)"],
|
|
545
|
+
}),
|
|
546
|
+
}),
|
|
547
|
+
]);
|
|
548
|
+
const doc = JSON.parse(await run(true));
|
|
549
|
+
expectValid(doc);
|
|
550
|
+
expect(doc.source.planFellBackTo).toBeUndefined();
|
|
551
|
+
expect(doc.provenance).not.toContain("fell back");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("a plan read that fell back to declared says so", async () => {
|
|
555
|
+
setup(
|
|
556
|
+
{ orders: "plan" },
|
|
557
|
+
[DECLARED_NOTE],
|
|
558
|
+
null,
|
|
559
|
+
);
|
|
560
|
+
loadPluginsMock.mockResolvedValue([
|
|
561
|
+
createMockPlugin({
|
|
562
|
+
name: "terraform",
|
|
563
|
+
describeResources: async () => ({
|
|
564
|
+
observation: "v1" as const,
|
|
565
|
+
resources: {},
|
|
566
|
+
unobserved: { orders: { reason: "no-binding" as const, type: "Terraform::Resource" } },
|
|
567
|
+
sources: { orders: "plan" },
|
|
568
|
+
notes: [DECLARED_NOTE],
|
|
569
|
+
rootReads: FELL_TO_DECLARED,
|
|
570
|
+
}),
|
|
571
|
+
}),
|
|
572
|
+
]);
|
|
573
|
+
const doc = JSON.parse(await run(true));
|
|
574
|
+
expectValid(doc);
|
|
575
|
+
expect(doc.source.planFellBackTo).toBe("declared");
|
|
576
|
+
expect(doc.source.depth).toBeUndefined();
|
|
577
|
+
expect(doc.provenance).toContain("the plan read fell back to declared");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("rewording the notes changes nothing: the fallback is read from the structured field", async () => {
|
|
581
|
+
setup({ orders: "state" }, ["the app root: plan unavailable, state used instead"], null, FELL_TO_STATE);
|
|
582
|
+
const doc = JSON.parse(await run(true));
|
|
583
|
+
expectValid(doc);
|
|
584
|
+
expect(doc.source.planFellBackTo).toBe("state");
|
|
585
|
+
expect(doc.provenance).toBe("observed live · bound 1/1 · the plan read fell back to state");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("a note that reads like a fallback does not make one", async () => {
|
|
589
|
+
setup({ orders: "state" }, [STATE_NOTE, DECLARED_NOTE]);
|
|
590
|
+
const doc = JSON.parse(await run(true));
|
|
591
|
+
expectValid(doc);
|
|
592
|
+
expect(doc.source.planFellBackTo).toBeUndefined();
|
|
593
|
+
expect(doc.provenance).toBe("observed live · bound 1/1");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("a plain live or state answer is unchanged", async () => {
|
|
597
|
+
setup({ orders: "state" }, []);
|
|
598
|
+
expect(await run()).toContain("observed live · bound 1/1");
|
|
599
|
+
expect(await run()).not.toContain("plan");
|
|
600
|
+
const doc = JSON.parse(await run(true));
|
|
601
|
+
expectValid(doc);
|
|
602
|
+
expect(doc.source).toEqual({ kind: "live", env: "dev" });
|
|
603
|
+
});
|
|
604
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { __searchInternals, resolveKindTerms } from "./search";
|
|
3
|
+
|
|
4
|
+
// #4359: transitive edges, attribute-qualified edges, and the path a match was reached by.
|
|
5
|
+
const { parseQuery, matchTerm, findPath, formatPath, searchJson, explain, describeTerm } = __searchInternals;
|
|
6
|
+
|
|
7
|
+
function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
|
|
8
|
+
return { id, kind, lexicon: "terraform", attrs } as never;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type G = { nodes: { id: string }[]; edges: unknown[] };
|
|
12
|
+
|
|
13
|
+
function run(ir: G, query: string): string[] {
|
|
14
|
+
const byId = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
15
|
+
const terms = parseQuery(query);
|
|
16
|
+
// As runSearch does: `kind:aws_vpc` means aws_vpc, not aws_vpc_ipam_pool.
|
|
17
|
+
resolveKindTerms(terms, ir.nodes as never);
|
|
18
|
+
return ir.nodes
|
|
19
|
+
.filter((n) => terms.every((t) => matchTerm(n as never, t, ir as never, byId as never)))
|
|
20
|
+
.map((n) => n.id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Four resources linked by references, the way the terraform lexicon records
|
|
24
|
+
// them: each edge points from the consumer to the producer, with the
|
|
25
|
+
// consumer's attribute (viaAttr) and the producer's (toAttr).
|
|
26
|
+
const chain: G = {
|
|
27
|
+
nodes: [
|
|
28
|
+
node("aws_instance.web", "aws_instance"),
|
|
29
|
+
node("aws_subnet.a", "aws_subnet"),
|
|
30
|
+
node("aws_vpc.main", "aws_vpc"),
|
|
31
|
+
node("aws_vpc_ipam_pool.p", "aws_vpc_ipam_pool"),
|
|
32
|
+
],
|
|
33
|
+
edges: [
|
|
34
|
+
{ from: "aws_instance.web", to: "aws_subnet.a", kind: "ref", viaAttr: "subnet_id", toAttr: "id" },
|
|
35
|
+
{ from: "aws_subnet.a", to: "aws_vpc.main", kind: "ref", viaAttr: "vpc_id", toAttr: "id" },
|
|
36
|
+
{ from: "aws_vpc.main", to: "aws_vpc_ipam_pool.p", kind: "ref", viaAttr: "ipv4_ipam_pool_id", toAttr: "id" },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("edge term grammar", () => {
|
|
41
|
+
test("depth: * is one or more hops, {n} exactly n, {m,n} a range, {m,} open", () => {
|
|
42
|
+
expect(parseQuery("->*kind:aws_vpc")).toEqual([
|
|
43
|
+
{ kind: "edge", a: "", dir: "out", depth: { min: 1, max: Infinity }, sub: { kind: "kind", a: "aws_vpc" } },
|
|
44
|
+
]);
|
|
45
|
+
expect(parseQuery("<-{1,3}kind:aws_instance")[0]).toMatchObject({ dir: "in", depth: { min: 1, max: 3 } });
|
|
46
|
+
expect(parseQuery("<-{2}kind:aws_instance")[0]).toMatchObject({ depth: { min: 2, max: 2 } });
|
|
47
|
+
expect(parseQuery("<-{2,}kind:aws_instance")[0]).toMatchObject({ depth: { min: 2, max: Infinity } });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("attribute qualifiers, with or without a target", () => {
|
|
51
|
+
expect(parseQuery("<-[via=vpc_id]kind:aws_subnet")).toEqual([
|
|
52
|
+
{ kind: "edge", a: "", dir: "in", viaAttr: "vpc_id", sub: { kind: "kind", a: "aws_subnet" } },
|
|
53
|
+
]);
|
|
54
|
+
expect(parseQuery("->*[via=subnet_id,to=id]")).toEqual([
|
|
55
|
+
{ kind: "edge", a: "", dir: "out", depth: { min: 1, max: Infinity }, viaAttr: "subnet_id", toAttr: "id" },
|
|
56
|
+
]);
|
|
57
|
+
expect(parseQuery("!->[to=arn]")[0]).toMatchObject({ negated: true, toAttr: "arn" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a plain one-hop term parses exactly as before: no depth, no qualifier", () => {
|
|
61
|
+
expect(parseQuery("->kind:Subnet")).toEqual([{ kind: "edge", a: "", dir: "out", sub: { kind: "kind", a: "Subnet" } }]);
|
|
62
|
+
expect(Object.keys(parseQuery("<-kind:Instance")[0]).sort()).toEqual(["a", "dir", "kind", "sub"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("what the grammar cannot read is refused with the correction", () => {
|
|
66
|
+
expect(() => parseQuery("->*")).toThrow(/needs a target/);
|
|
67
|
+
expect(() => parseQuery("!<-{1,2}")).toThrow(/needs a target/);
|
|
68
|
+
expect(() => parseQuery("->{0}kind:X")).toThrow(/depth/);
|
|
69
|
+
expect(() => parseQuery("->{3,1}kind:X")).toThrow(/depth/);
|
|
70
|
+
expect(() => parseQuery("->{a}kind:X")).toThrow(/depth/);
|
|
71
|
+
expect(() => parseQuery("->[from=x]kind:X")).toThrow(/qualifier/);
|
|
72
|
+
expect(() => parseQuery("->[via=x")).toThrow(/qualifier/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("describeTerm writes the operator back as the query spelled it", () => {
|
|
76
|
+
expect(describeTerm(parseQuery("->*kind:aws_vpc")[0] as never)).toBe("→*kind:aws_vpc (no such edge)");
|
|
77
|
+
expect(describeTerm(parseQuery("<-{1,3}[via=vpc_id]kind:aws_subnet")[0] as never)).toBe(
|
|
78
|
+
"←{1,3}[via=vpc_id]kind:aws_subnet (no such edge)",
|
|
79
|
+
);
|
|
80
|
+
expect(describeTerm(parseQuery("!->{2,}[to=id]")[0] as never)).toBe("!→{2,}[to=id] (no such edge)");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("transitive edges on a four-resource chain", () => {
|
|
85
|
+
test("a transitive term matches every node at any depth", () => {
|
|
86
|
+
expect(run(chain, "->*kind:aws_vpc_ipam_pool")).toEqual(["aws_instance.web", "aws_subnet.a", "aws_vpc.main"]);
|
|
87
|
+
expect(run(chain, "<-*kind:aws_instance")).toEqual(["aws_subnet.a", "aws_vpc.main", "aws_vpc_ipam_pool.p"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a bounded term matches only within its limit", () => {
|
|
91
|
+
expect(run(chain, "->{1,2}kind:aws_vpc_ipam_pool")).toEqual(["aws_subnet.a", "aws_vpc.main"]);
|
|
92
|
+
expect(run(chain, "->{3}kind:aws_vpc_ipam_pool")).toEqual(["aws_instance.web"]);
|
|
93
|
+
expect(run(chain, "->{2,}kind:aws_vpc_ipam_pool")).toEqual(["aws_instance.web", "aws_subnet.a"]);
|
|
94
|
+
expect(run(chain, "<-{1,2}kind:aws_instance")).toEqual(["aws_subnet.a", "aws_vpc.main"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("one hop still means one hop", () => {
|
|
98
|
+
expect(run(chain, "->kind:aws_vpc_ipam_pool")).toEqual(["aws_vpc.main"]);
|
|
99
|
+
expect(run(chain, "!<-kind:aws_subnet kind:aws_vpc")).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("attribute-qualified edges", () => {
|
|
104
|
+
test("[via=] matches only edges whose viaAttr is the given name", () => {
|
|
105
|
+
expect(run(chain, "<-[via=vpc_id]")).toEqual(["aws_vpc.main"]);
|
|
106
|
+
expect(run(chain, "->[via=vpc_id]kind:aws_vpc")).toEqual(["aws_subnet.a"]);
|
|
107
|
+
expect(run(chain, "->[via=subnet_id]kind:aws_vpc")).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("[to=] matches only edges whose toAttr is the given name", () => {
|
|
111
|
+
expect(run(chain, "->[to=id]")).toEqual(["aws_instance.web", "aws_subnet.a", "aws_vpc.main"]);
|
|
112
|
+
expect(run(chain, "->[to=arn]")).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a qualifier holds for every edge on a transitive path", () => {
|
|
116
|
+
// web reaches the VPC through subnet_id then vpc_id, so no single name covers the path.
|
|
117
|
+
expect(run(chain, "->*[via=subnet_id]kind:aws_vpc")).toEqual([]);
|
|
118
|
+
expect(run(chain, "->*[to=id]kind:aws_vpc")).toEqual(["aws_instance.web", "aws_subnet.a"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("an edge with no recorded attribute never satisfies a qualifier", () => {
|
|
122
|
+
const g: G = {
|
|
123
|
+
nodes: [node("x", "aws_instance"), node("y", "aws_subnet")],
|
|
124
|
+
edges: [{ from: "x", to: "y", kind: "ref" }],
|
|
125
|
+
};
|
|
126
|
+
expect(run(g, "->kind:aws_subnet")).toEqual(["x"]);
|
|
127
|
+
expect(run(g, "->[via=subnet_id]kind:aws_subnet")).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("cycles", () => {
|
|
132
|
+
// a -> b -> c -> a, plus c -> d
|
|
133
|
+
const cyc: G = {
|
|
134
|
+
nodes: [node("a", "T::A"), node("b", "T::B"), node("c", "T::C"), node("d", "T::D")],
|
|
135
|
+
edges: [
|
|
136
|
+
{ from: "a", to: "b", kind: "ref", viaAttr: "next" },
|
|
137
|
+
{ from: "b", to: "c", kind: "ref", viaAttr: "next" },
|
|
138
|
+
{ from: "c", to: "a", kind: "ref", viaAttr: "next" },
|
|
139
|
+
{ from: "c", to: "d", kind: "ref", viaAttr: "sink" },
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
test("a cycle does not hang the matcher, and a miss is a clean miss", () => {
|
|
144
|
+
expect(run(cyc, "->*kind:Nothing")).toEqual([]);
|
|
145
|
+
expect(run(cyc, "<-*kind:Nothing")).toEqual([]);
|
|
146
|
+
expect(run(cyc, "->{50,}kind:Nothing")).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a walk round the cycle still finds what it should", () => {
|
|
150
|
+
// Every node of the cycle reaches d, and reaches itself.
|
|
151
|
+
expect(run(cyc, "->*kind:T::D")).toEqual(["a", "b", "c"]);
|
|
152
|
+
expect(run(cyc, "->*kind:T::A")).toEqual(["a", "b", "c"]);
|
|
153
|
+
// A depth past the cycle's length is reached by going round again.
|
|
154
|
+
expect(run(cyc, "->{4}kind:T::B")).toEqual(["a"]);
|
|
155
|
+
expect(run(cyc, "->{7,}kind:T::B")).toEqual(["a", "b", "c"]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("the path each match was reached by", () => {
|
|
160
|
+
const byId = new Map(chain.nodes.map((n) => [n.id, n]));
|
|
161
|
+
|
|
162
|
+
test("findPath returns the hops in walk order, each in the edge's own direction", () => {
|
|
163
|
+
const [t] = parseQuery("->*kind:aws_vpc_ipam_pool");
|
|
164
|
+
const hops = findPath(chain.nodes[0] as never, t, chain as never, byId as never);
|
|
165
|
+
expect(hops).toEqual([
|
|
166
|
+
{ from: "aws_instance.web", fromAttr: "subnet_id", to: "aws_subnet.a", toAttr: "id" },
|
|
167
|
+
{ from: "aws_subnet.a", fromAttr: "vpc_id", to: "aws_vpc.main", toAttr: "id" },
|
|
168
|
+
{ from: "aws_vpc.main", fromAttr: "ipv4_ipam_pool_id", to: "aws_vpc_ipam_pool.p", toAttr: "id" },
|
|
169
|
+
]);
|
|
170
|
+
expect(formatPath(hops!, "out")).toBe(
|
|
171
|
+
"aws_instance.web.subnet_id -> aws_subnet.a.id; aws_subnet.a.vpc_id -> aws_vpc.main.id; aws_vpc.main.ipv4_ipam_pool_id -> aws_vpc_ipam_pool.p.id",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("walking <- reads producer to consumer", () => {
|
|
176
|
+
const [t] = parseQuery("<-*kind:aws_instance");
|
|
177
|
+
const hops = findPath(chain.nodes[2] as never, t, chain as never, byId as never);
|
|
178
|
+
expect(formatPath(hops!, "in")).toBe("aws_vpc.main.id <- aws_subnet.a.vpc_id; aws_subnet.a.id <- aws_instance.web.subnet_id");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("the path is the shortest one", () => {
|
|
182
|
+
const g: G = {
|
|
183
|
+
nodes: [node("x", "A"), node("y", "B"), node("z", "C")],
|
|
184
|
+
edges: [
|
|
185
|
+
{ from: "x", to: "y", kind: "ref", viaAttr: "long" },
|
|
186
|
+
{ from: "y", to: "z", kind: "ref", viaAttr: "long" },
|
|
187
|
+
{ from: "x", to: "z", kind: "ref", viaAttr: "short" },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
const [t] = parseQuery("->*kind:C");
|
|
191
|
+
const hops = findPath(g.nodes[0] as never, t, g as never, new Map(g.nodes.map((n) => [n.id, n])) as never);
|
|
192
|
+
expect(hops).toEqual([{ from: "x", fromAttr: "short", to: "z" }]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("--explain prints one path line per match and edge term", () => {
|
|
196
|
+
const query = "kind:aws_instance ->*kind:aws_vpc_ipam_pool";
|
|
197
|
+
const terms = parseQuery(query);
|
|
198
|
+
const matches = [chain.nodes[0]];
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
|
|
201
|
+
explain(terms as never, matches as never, chain as never, byId as never, query);
|
|
202
|
+
spy.mockRestore();
|
|
203
|
+
expect(lines[0]).toContain("1 of 1 aws_instance matched");
|
|
204
|
+
expect(lines).toContain(
|
|
205
|
+
" path aws_instance.web.subnet_id -> aws_subnet.a.id; aws_subnet.a.vpc_id -> aws_vpc.main.id; aws_vpc.main.ipv4_ipam_pool_id -> aws_vpc_ipam_pool.p.id",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("--json carries the path as a list of hops per edge term", () => {
|
|
210
|
+
const query = "->{1,2}[to=id]kind:aws_vpc_ipam_pool !<-kind:aws_instance";
|
|
211
|
+
const terms = parseQuery(query);
|
|
212
|
+
const matches = [chain.nodes[2]];
|
|
213
|
+
const out = searchJson(query, { kind: "declared" }, matches as never, terms, chain as never, byId as never);
|
|
214
|
+
expect(out).toEqual({
|
|
215
|
+
query,
|
|
216
|
+
source: { kind: "declared" },
|
|
217
|
+
matches: [
|
|
218
|
+
{
|
|
219
|
+
id: "aws_vpc.main",
|
|
220
|
+
displayId: "aws_vpc.main",
|
|
221
|
+
kind: "aws_vpc",
|
|
222
|
+
paths: [
|
|
223
|
+
{
|
|
224
|
+
term: "->{1,2}[to=id]kind:aws_vpc_ipam_pool",
|
|
225
|
+
dir: "out",
|
|
226
|
+
hops: [{ from: "aws_vpc.main", fromAttr: "ipv4_ipam_pool_id", to: "aws_vpc_ipam_pool.p", toAttr: "id" }],
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
// A query with no edge term carries an empty path list.
|
|
233
|
+
expect(searchJson("kind:aws_vpc", { kind: "declared" }, matches as never, parseQuery("kind:aws_vpc"), chain as never, byId as never).matches[0].paths).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|