@intentius/chant 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +72 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +26 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-effective.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +21 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +19 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/lexicon.d.ts +141 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +4 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +55 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +47 -0
- package/dist/lifecycle/replay.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts +6 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +46 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/observation.d.ts +71 -0
- package/dist/observation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/onboard.ts +10 -25
- package/src/cli/handlers/graph.test.ts +74 -0
- package/src/cli/handlers/graph.ts +77 -36
- package/src/cli/handlers/lifecycle.test.ts +86 -0
- package/src/cli/handlers/lifecycle.ts +43 -10
- package/src/cli/handlers/search.test.ts +246 -4
- package/src/cli/handlers/search.ts +432 -27
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +27 -0
- package/src/codegen/lexicon-wiring.test.ts +53 -0
- package/src/codegen/release-wiring.test.ts +174 -0
- package/src/graph-effective.ts +7 -1
- package/src/graph-ir-live.test.ts +83 -0
- package/src/graph-ir.ts +58 -1
- package/src/graph-refs.test.ts +59 -0
- package/src/graph-refs.ts +39 -8
- package/src/lexicon.ts +145 -0
- package/src/lifecycle/deep-observe.ts +5 -0
- package/src/lifecycle/live-diff.test.ts +38 -0
- package/src/lifecycle/live-diff.ts +45 -2
- package/src/lifecycle/observe.ts +186 -4
- package/src/lifecycle/replay.ts +141 -0
- package/src/lifecycle/snapshot.test.ts +179 -0
- package/src/lifecycle/snapshot.ts +88 -3
- package/src/lifecycle/types.ts +47 -0
- package/src/observation.test.ts +135 -0
- package/src/observation.ts +151 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant — the root package.json lexicon list is hand-maintained, and it drifted.
|
|
3
|
+
*
|
|
4
|
+
* `chant dev onboard` adds `@intentius/chant-lexicon-<name>` to the root
|
|
5
|
+
* `dependencies`, but three lexicons (fly, forgejo, fountain) were never added
|
|
6
|
+
* and nobody noticed, because nothing checks. That is the same shape as the
|
|
7
|
+
* lexicon-upgrade miswiring (#1218/#1226) and the missing publish wiring that
|
|
8
|
+
* stranded two packages: a list a human has to remember, with no gate.
|
|
9
|
+
*
|
|
10
|
+
* Resolution itself does not depend on this list — `workspaces: ["lexicons/*"]`
|
|
11
|
+
* symlinks every lexicon into node_modules regardless, which is why the three
|
|
12
|
+
* omissions never broke anything. The entry is an explicit declaration, and the
|
|
13
|
+
* point of this test is that it either applies to every lexicon or to none,
|
|
14
|
+
* rather than silently landing somewhere in between.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
function publishableLexicons(): string[] {
|
|
25
|
+
return readdirSync(join(repoRoot, "lexicons"))
|
|
26
|
+
.filter((name) => {
|
|
27
|
+
const manifest = join(repoRoot, "lexicons", name, "package.json");
|
|
28
|
+
if (!existsSync(manifest)) return false;
|
|
29
|
+
return JSON.parse(readFileSync(manifest, "utf-8")).private !== true;
|
|
30
|
+
})
|
|
31
|
+
.sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rootLexiconDeps(): string[] {
|
|
35
|
+
const root = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8"));
|
|
36
|
+
return Object.keys(root.dependencies ?? {})
|
|
37
|
+
.filter((dep) => dep.startsWith("@intentius/chant-lexicon-"))
|
|
38
|
+
.map((dep) => dep.replace("@intentius/chant-lexicon-", ""))
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("root package.json lexicon wiring", () => {
|
|
43
|
+
it("lists every publishable lexicon", () => {
|
|
44
|
+
expect(rootLexiconDeps()).toEqual(publishableLexicons());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lists no lexicon that does not exist", () => {
|
|
48
|
+
const onDisk = new Set(readdirSync(join(repoRoot, "lexicons")));
|
|
49
|
+
for (const name of rootLexiconDeps()) {
|
|
50
|
+
expect(onDisk.has(name), `root package.json depends on a missing lexicon "${name}"`).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release path is two hand-maintained halves that must agree: the
|
|
3
|
+
* justfile recipes that create and push a tag, and publish.yml's tag
|
|
4
|
+
* trigger that decides whether pushing it does anything.
|
|
5
|
+
*
|
|
6
|
+
* They disagreed. `just release-lexicon <name>` has always tagged
|
|
7
|
+
* `lexicon-<name>-v<version>`, pushed it, and echoed "publish workflow
|
|
8
|
+
* triggered" — while publish.yml matched only `chant-v*`. The tag landed,
|
|
9
|
+
* no workflow ran, and the recipe reported success. fly's 0.33.0 shipped
|
|
10
|
+
* with zero rules and zero skills and could not be patched by the one
|
|
11
|
+
* recipe built for patching a single lexicon.
|
|
12
|
+
*
|
|
13
|
+
* A release that silently no-ops is worse than one that fails, so this
|
|
14
|
+
* turns the next divergence into a PR-time failure.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
function publishTagPatterns(): string[] {
|
|
25
|
+
const workflow = readFileSync(join(repoRoot, ".github", "workflows", "publish.yml"), "utf-8");
|
|
26
|
+
const match = workflow.match(/^\s*tags:\s*\[([^\]]+)\]/m);
|
|
27
|
+
if (!match) throw new Error("publish.yml: `tags: [...]` trigger not found");
|
|
28
|
+
return match[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tags the justfile actually creates, as literal-prefix + wildcard shapes. */
|
|
32
|
+
function releaseTagShapes(): Array<{ recipe: string; example: string }> {
|
|
33
|
+
const justfile = readFileSync(join(repoRoot, "justfile"), "utf-8");
|
|
34
|
+
const shapes: Array<{ recipe: string; example: string }> = [];
|
|
35
|
+
|
|
36
|
+
// `git tag "chant-v$next"` / `git tag "lexicon-{{name}}-v$next"`
|
|
37
|
+
for (const m of justfile.matchAll(/git tag "([^"]+)"/g)) {
|
|
38
|
+
const raw = m[1];
|
|
39
|
+
const example = raw
|
|
40
|
+
.replace(/\{\{name\}\}/g, "docker")
|
|
41
|
+
.replace(/\$\{?next\}?/g, "9.9.9");
|
|
42
|
+
shapes.push({ recipe: raw, example });
|
|
43
|
+
}
|
|
44
|
+
return shapes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal glob match for the `prefix*` shapes these patterns use. */
|
|
48
|
+
function matchesGlob(pattern: string, value: string): boolean {
|
|
49
|
+
const rx = new RegExp(
|
|
50
|
+
"^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$",
|
|
51
|
+
);
|
|
52
|
+
return rx.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("release wiring: justfile tags vs publish.yml trigger", () => {
|
|
56
|
+
it("finds both halves", () => {
|
|
57
|
+
expect(publishTagPatterns().length).toBeGreaterThan(0);
|
|
58
|
+
expect(releaseTagShapes().length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("every tag a release recipe pushes triggers the publish workflow", () => {
|
|
62
|
+
const patterns = publishTagPatterns();
|
|
63
|
+
|
|
64
|
+
for (const { recipe, example } of releaseTagShapes()) {
|
|
65
|
+
const hit = patterns.some((p) => matchesGlob(p, example));
|
|
66
|
+
expect(
|
|
67
|
+
hit,
|
|
68
|
+
`justfile creates tag "${recipe}" (e.g. ${example}) but publish.yml triggers on ` +
|
|
69
|
+
`[${patterns.join(", ")}] — pushing it would publish nothing while the recipe ` +
|
|
70
|
+
`reports success`,
|
|
71
|
+
).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("covers the two shapes the repo releases by", () => {
|
|
76
|
+
const examples = releaseTagShapes().map((s) => s.example);
|
|
77
|
+
// Whole-repo release and single-lexicon patch. If a recipe stops
|
|
78
|
+
// producing one of these, the assertion above would pass vacuously.
|
|
79
|
+
expect(examples).toContain("chant-v9.9.9");
|
|
80
|
+
expect(examples).toContain("lexicon-docker-v9.9.9");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The body of one justfile recipe, up to the next top-level recipe header.
|
|
86
|
+
* Line-based on purpose: a recipe header sits at column 0 and every body line
|
|
87
|
+
* is indented, which a regex over the whole file gets wrong (`^` under /m
|
|
88
|
+
* matches the start of the slice too).
|
|
89
|
+
*/
|
|
90
|
+
const RECIPE_HEADER = /^[a-z_][a-z0-9_-]*(\s+[^:]*)?:/;
|
|
91
|
+
|
|
92
|
+
function recipeBody(name: string): string {
|
|
93
|
+
const lines = readFileSync(join(repoRoot, "justfile"), "utf-8").split("\n");
|
|
94
|
+
const start = lines.findIndex((l) => new RegExp(`^${name}(\\s|:)`).test(l));
|
|
95
|
+
if (start === -1) throw new Error(`justfile: recipe "${name}" not found`);
|
|
96
|
+
|
|
97
|
+
let end = lines.length;
|
|
98
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
99
|
+
if (RECIPE_HEADER.test(lines[i])) {
|
|
100
|
+
end = i;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lines.slice(start, end).join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* #1255.2 — `just release` rewrites the `@intentius/*` peer ranges alongside
|
|
109
|
+
* `.version` (added in #411, because ranges frozen at `^0.1.0` break clean
|
|
110
|
+
* installs). `release-lexicon` only set `.version`, so a single-lexicon patch
|
|
111
|
+
* needing a newer core silently shipped a stale range.
|
|
112
|
+
*
|
|
113
|
+
* The two recipes pin to different things on purpose: a whole-repo release
|
|
114
|
+
* moves everything together so `^$next` is right, while a single-lexicon
|
|
115
|
+
* patch leaves core where it is, so the range must track the CURRENT core
|
|
116
|
+
* version. Pinning it to `$next` would demand a core that does not exist —
|
|
117
|
+
* which is why this asserts the mechanism, not a shared literal.
|
|
118
|
+
*/
|
|
119
|
+
describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
|
|
120
|
+
it("the whole-repo release rewrites both @intentius peer ranges", () => {
|
|
121
|
+
const body = recipeBody("release");
|
|
122
|
+
expect(body).toMatch(/peerDependencies\["@intentius\/chant"\]/);
|
|
123
|
+
expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("the single-lexicon release rewrites them too", () => {
|
|
127
|
+
const body = recipeBody("release-lexicon");
|
|
128
|
+
expect(
|
|
129
|
+
body.includes('peerDependencies["@intentius/chant"]'),
|
|
130
|
+
"release-lexicon sets .version without touching the @intentius/chant peer range — " +
|
|
131
|
+
"a lexicon patch that needs a newer core would ship a stale range (#411, #1255)",
|
|
132
|
+
).toBe(true);
|
|
133
|
+
expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("the single-lexicon release pins peers to core, not to its own new version", () => {
|
|
137
|
+
const body = recipeBody("release-lexicon");
|
|
138
|
+
// It reads the current core version rather than reusing $next.
|
|
139
|
+
expect(body).toMatch(/jq -r \.version packages\/core\/package\.json/);
|
|
140
|
+
expect(
|
|
141
|
+
/peerDependencies\["@intentius\/chant"\]\s*=\s*"\^"\s*\+\s*\$next/.test(body),
|
|
142
|
+
"release-lexicon must not pin the core peer range to the lexicon's own new version",
|
|
143
|
+
).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("both recipes keep the committed lockfile in step", () => {
|
|
147
|
+
for (const recipe of ["release", "release-lexicon"]) {
|
|
148
|
+
expect(recipeBody(recipe)).toMatch(/npm install --package-lock-only/);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* #1255.3 — both recipes pushed the bump commit straight to main, bypassing
|
|
155
|
+
* branch protection, so a release tag could point at a commit CI never ran.
|
|
156
|
+
*/
|
|
157
|
+
describe("release wiring: preflight gates both recipes (#1255)", () => {
|
|
158
|
+
it("every release recipe runs the preflight before tagging", () => {
|
|
159
|
+
for (const recipe of ["release", "release-lexicon"]) {
|
|
160
|
+
const body = recipeBody(recipe);
|
|
161
|
+
expect(
|
|
162
|
+
body.includes("scripts/release-preflight.sh"),
|
|
163
|
+
`justfile recipe "${recipe}" tags and pushes without running the preflight — ` +
|
|
164
|
+
"it could release a commit CI never ran (#1255)",
|
|
165
|
+
).toBe(true);
|
|
166
|
+
// Ordering matters: a check that runs after `git tag` proves nothing.
|
|
167
|
+
expect(body.indexOf("scripts/release-preflight.sh")).toBeLessThan(body.indexOf("git tag"));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("the whole-repo release requires main, since it pushes main", () => {
|
|
172
|
+
expect(recipeBody("release")).toMatch(/release-preflight\.sh main/);
|
|
173
|
+
});
|
|
174
|
+
});
|
package/src/graph-effective.ts
CHANGED
|
@@ -106,5 +106,11 @@ export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
|
|
|
106
106
|
attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
|
|
107
107
|
};
|
|
108
108
|
});
|
|
109
|
-
|
|
109
|
+
// Record what this pass computed, so a caller can report the graph's derived surface
|
|
110
|
+
// without hardcoding attribute names. Only kinds actually enriched are listed.
|
|
111
|
+
const enriched = nodes.some((n) => isKind(n, "Instance"));
|
|
112
|
+
const derivedAttrs = enriched
|
|
113
|
+
? { ...(ir.derivedAttrs ?? {}), Instance: ["internetFacing", "effectiveIngress"] }
|
|
114
|
+
: ir.derivedAttrs;
|
|
115
|
+
return { ...ir, nodes, ...(derivedAttrs ? { derivedAttrs } : {}) };
|
|
110
116
|
}
|
|
@@ -157,6 +157,34 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
|
|
|
157
157
|
expect(vpc.ownership).toBe("owned");
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
it("carries observed attributes onto a managed declared node (#1279)", () => {
|
|
161
|
+
// Before this, the overlay copied identity but dropped everything observed
|
|
162
|
+
// ABOUT the resource, so a fact that only exists in the account — an
|
|
163
|
+
// instance's VpcId — was unreachable from the overlaid graph and
|
|
164
|
+
// `search --show VpcId` printed a blank column.
|
|
165
|
+
const observed: GraphIR = {
|
|
166
|
+
...live,
|
|
167
|
+
nodes: live.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { CidrBlock: "10.0.0.0/16" } } : x)),
|
|
168
|
+
};
|
|
169
|
+
const vpc = sourceOverlayGraphs(declared, observed).nodes.find((x) => x.id === "web-vpc")!;
|
|
170
|
+
expect(vpc.attrs.CidrBlock).toBe("10.0.0.0/16");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("lets an observed value beat the declared one of the same name (#1279)", () => {
|
|
174
|
+
// The declared side holds an unresolved reference to another entity; the
|
|
175
|
+
// account holds the id it actually resolved to.
|
|
176
|
+
const withRef: GraphIR = {
|
|
177
|
+
...declared,
|
|
178
|
+
nodes: declared.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { VpcId: "${vpc.id}" } } : x)),
|
|
179
|
+
};
|
|
180
|
+
const observed: GraphIR = {
|
|
181
|
+
...live,
|
|
182
|
+
nodes: live.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { VpcId: "vpc-0a1b" } } : x)),
|
|
183
|
+
};
|
|
184
|
+
const vpc = sourceOverlayGraphs(withRef, observed).nodes.find((x) => x.id === "web-vpc")!;
|
|
185
|
+
expect(vpc.attrs.VpcId).toBe("vpc-0a1b");
|
|
186
|
+
});
|
|
187
|
+
|
|
160
188
|
it("appends foreign nodes with their live edges, and keeps declared groups", () => {
|
|
161
189
|
const ir = sourceOverlayGraphs(declared, live);
|
|
162
190
|
expect(ir.nodes.map((x) => x.id)).toContain("rogue-sg");
|
|
@@ -208,3 +236,58 @@ describe("collectUnobserved (#1089)", () => {
|
|
|
208
236
|
).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
|
|
209
237
|
});
|
|
210
238
|
});
|
|
239
|
+
|
|
240
|
+
// #1271 — an observation can report relationships, not just existence. Without
|
|
241
|
+
// these the live side of the graph has no edges, so a topology fold has nothing
|
|
242
|
+
// to traverse and a lexicon has to compute derived answers itself.
|
|
243
|
+
describe("observed edges (#1271)", () => {
|
|
244
|
+
const twoNodes = {
|
|
245
|
+
lexicon: "aws",
|
|
246
|
+
resources: {
|
|
247
|
+
"app-subnet": { type: "AWS::EC2::Subnet", status: "CREATE_COMPLETE", physicalId: "subnet-0c2d" },
|
|
248
|
+
"web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE", physicalId: "vpc-0a1b" },
|
|
249
|
+
},
|
|
250
|
+
} satisfies LiveObservation;
|
|
251
|
+
|
|
252
|
+
it("projects an observed edge between two observed nodes", () => {
|
|
253
|
+
const ir = buildLiveGraphIr([
|
|
254
|
+
{ ...twoNodes, edges: [{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "VpcId" }] },
|
|
255
|
+
]);
|
|
256
|
+
expect(ir.edges).toEqual([{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "VpcId" }]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("no edges reported → no edges, unchanged from before", () => {
|
|
260
|
+
expect(buildLiveGraphIr([twoNodes]).edges).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("drops an edge whose endpoint was never observed", () => {
|
|
264
|
+
// A dangling reference would traverse to nothing during a fold, which reads
|
|
265
|
+
// as "no such relationship" rather than "the other end was not read".
|
|
266
|
+
const ir = buildLiveGraphIr([
|
|
267
|
+
{ ...twoNodes, edges: [{ from: "app-subnet", to: "never-read", kind: "ref", viaAttr: "VpcId" }] },
|
|
268
|
+
]);
|
|
269
|
+
expect(ir.edges).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("dedupes identical edges reported by more than one observation", () => {
|
|
273
|
+
const edge = { from: "app-subnet", to: "web-vpc", kind: "ref" as const, viaAttr: "VpcId" };
|
|
274
|
+
const ir = buildLiveGraphIr([
|
|
275
|
+
{ ...twoNodes, edges: [edge] },
|
|
276
|
+
{ ...twoNodes, edges: [edge] },
|
|
277
|
+
]);
|
|
278
|
+
expect(ir.edges).toHaveLength(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("orders edges deterministically — the IR is compared and committed", () => {
|
|
282
|
+
const ir = buildLiveGraphIr([
|
|
283
|
+
{
|
|
284
|
+
...twoNodes,
|
|
285
|
+
edges: [
|
|
286
|
+
{ from: "web-vpc", to: "app-subnet", kind: "ref", viaAttr: "Z" },
|
|
287
|
+
{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "A" },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
]);
|
|
291
|
+
expect(ir.edges?.map((e) => e.from)).toEqual(["app-subnet", "web-vpc"]);
|
|
292
|
+
});
|
|
293
|
+
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -191,6 +191,13 @@ export interface GraphIR {
|
|
|
191
191
|
imports?: IRImport[];
|
|
192
192
|
/** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
|
|
193
193
|
pipeline?: IRPipeline;
|
|
194
|
+
/**
|
|
195
|
+
* Attributes chant computed rather than read back from the provider, keyed by the kind
|
|
196
|
+
* they were folded onto. An enrichment pass records what it derived here so callers can
|
|
197
|
+
* report the graph's own surface without knowing any attribute name — the set is whatever
|
|
198
|
+
* the passes produced, not a list maintained by hand.
|
|
199
|
+
*/
|
|
200
|
+
derivedAttrs?: Record<string, string[]>;
|
|
194
201
|
}
|
|
195
202
|
|
|
196
203
|
/** A node is anything that serializes to a resource — not a property or output. */
|
|
@@ -482,6 +489,20 @@ export interface LiveObservation {
|
|
|
482
489
|
* overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
|
|
483
490
|
*/
|
|
484
491
|
unobserved?: Record<string, UnobservedEntity>;
|
|
492
|
+
/**
|
|
493
|
+
* Relationships the lexicon observed between the resources it read (#1271).
|
|
494
|
+
*
|
|
495
|
+
* Without these an observation can only say what exists, never how it
|
|
496
|
+
* connects — so a fold over topology has nothing to traverse on the live side
|
|
497
|
+
* and a lexicon has to compute derived answers itself and inject them as
|
|
498
|
+
* attributes. Reporting the edges instead lets the one graph fold in
|
|
499
|
+
* {@link import("./graph-effective").enrichEffectiveTopology} do the work,
|
|
500
|
+
* for every lexicon, over live and recorded observations alike.
|
|
501
|
+
*
|
|
502
|
+
* `from`/`to` are node ids in the same space as {@link LiveObservation.resources}
|
|
503
|
+
* keys, so an edge can only reference something the observation also reported.
|
|
504
|
+
*/
|
|
505
|
+
edges?: IREdge[];
|
|
485
506
|
}
|
|
486
507
|
|
|
487
508
|
/**
|
|
@@ -533,7 +554,31 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
533
554
|
if (Object.keys(byLexicon).length) groups.byLexicon = sortKeys(byLexicon);
|
|
534
555
|
if (Object.keys(byStack).length) groups.byStack = sortKeys(byStack);
|
|
535
556
|
|
|
536
|
-
|
|
557
|
+
// Observed relationships (#1271). An edge whose endpoints were not both
|
|
558
|
+
// observed is dropped rather than kept as a dangling reference: the fold
|
|
559
|
+
// resolves ids to nodes, and a half-edge would silently traverse to nothing.
|
|
560
|
+
// Deduped and sorted for the same reason nodes are — the IR is compared and
|
|
561
|
+
// committed, so it has to be stable across reads.
|
|
562
|
+
const observedIds = new Set(nodes.map((n) => n.id));
|
|
563
|
+
const seen = new Set<string>();
|
|
564
|
+
const edges: IREdge[] = [];
|
|
565
|
+
for (const observation of observations) {
|
|
566
|
+
for (const edge of observation.edges ?? []) {
|
|
567
|
+
if (!observedIds.has(edge.from) || !observedIds.has(edge.to)) continue;
|
|
568
|
+
const key = `${edge.from}${edge.to}${edge.viaAttr ?? ""}${edge.toAttr ?? ""}`;
|
|
569
|
+
if (seen.has(key)) continue;
|
|
570
|
+
seen.add(key);
|
|
571
|
+
edges.push(edge);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
edges.sort(
|
|
575
|
+
(a, b) =>
|
|
576
|
+
a.from.localeCompare(b.from) ||
|
|
577
|
+
a.to.localeCompare(b.to) ||
|
|
578
|
+
(a.viaAttr ?? "").localeCompare(b.viaAttr ?? ""),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
return { nodes, edges, groups };
|
|
537
582
|
}
|
|
538
583
|
|
|
539
584
|
/** How an overlay learns which declared nodes were never looked at (#1089). */
|
|
@@ -640,6 +685,18 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
|
|
|
640
685
|
const merged: IRNode = { ...n }; // managed — carry the observed identity
|
|
641
686
|
if (obs.physicalId) merged.physicalId = obs.physicalId;
|
|
642
687
|
if (obs.ownership) merged.ownership = obs.ownership;
|
|
688
|
+
// ...and what was observed ABOUT it (#1279). The declared canvas holds what
|
|
689
|
+
// the source says; the observation holds what the account says, and a fact
|
|
690
|
+
// like `VpcId` exists only on the second. Dropping it here made every
|
|
691
|
+
// observed property invisible to anything reading the overlaid graph —
|
|
692
|
+
// `search --show VpcId` printed six blank columns, which reads as an estate
|
|
693
|
+
// with no VPCs rather than a read that never happened.
|
|
694
|
+
//
|
|
695
|
+
// Observed wins a collision. The declared value for something like `VpcId`
|
|
696
|
+
// is an unresolved reference to another entity, not an id — the account has
|
|
697
|
+
// the id. Same precedence `enrichLiveAttrs` already uses when it folds live
|
|
698
|
+
// facts onto this graph, so both paths agree on what a name means.
|
|
699
|
+
if (obs.attrs && Object.keys(obs.attrs).length > 0) merged.attrs = { ...n.attrs, ...obs.attrs };
|
|
643
700
|
return tagStatus(merged, "good");
|
|
644
701
|
});
|
|
645
702
|
for (const n of live.nodes) {
|
package/src/graph-refs.test.ts
CHANGED
|
@@ -129,3 +129,62 @@ describe("mergeCatalogs", () => {
|
|
|
129
129
|
expect(m.refs).toHaveLength(1);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
// #1275 — `viaAttr` defaulted to `label ?? path`, which serves a renderer and
|
|
134
|
+
// starves a traversal: the catalog's labels are human-facing ("sg", "via")
|
|
135
|
+
// while a fold matches provider attribute names ("SecurityGroupIds").
|
|
136
|
+
describe("traversal name vs rendering label (#1275)", () => {
|
|
137
|
+
const nodes: IRNode[] = [
|
|
138
|
+
{ id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: { SubnetId: "subnet-1", SecurityGroups: [{ GroupId: "sg-1" }] } },
|
|
139
|
+
{ id: "sub", kind: "AWS::EC2::Subnet", lexicon: "aws", attrs: { SubnetId: "subnet-1" } },
|
|
140
|
+
{ id: "sg", kind: "AWS::EC2::SecurityGroup", lexicon: "aws", attrs: { GroupId: "sg-1" } },
|
|
141
|
+
];
|
|
142
|
+
const identities = [
|
|
143
|
+
{ kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
|
|
144
|
+
{ kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
it("viaAttr wins over label, so the fold can match the attribute name", () => {
|
|
148
|
+
const { edges } = reconstructEdges(nodes, {
|
|
149
|
+
identities,
|
|
150
|
+
refs: [
|
|
151
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
expect(edges).toEqual([{ from: "web", to: "sg", kind: "ref", viaAttr: "SecurityGroupIds" }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("without viaAttr the label still wins, unchanged", () => {
|
|
158
|
+
const { edges } = reconstructEdges(nodes, {
|
|
159
|
+
identities,
|
|
160
|
+
refs: [
|
|
161
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
expect(edges[0].viaAttr).toBe("sg");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("a containment rule with viaAttr yields both the boundary pair and a traversable edge", () => {
|
|
168
|
+
// An instance is *in* a subnet — a boundary for the picture, and the first
|
|
169
|
+
// hop of internetFacing. It has to be both without being drawn twice.
|
|
170
|
+
const { edges, containment } = reconstructEdges(nodes, {
|
|
171
|
+
identities,
|
|
172
|
+
refs: [
|
|
173
|
+
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
expect(containment).toEqual([{ child: "web", parent: "sub", label: "in subnet" }]);
|
|
177
|
+
expect(edges).toEqual([{ from: "web", to: "sub", kind: "ref", viaAttr: "SubnetId" }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("a containment rule without viaAttr stays a boundary hint only", () => {
|
|
181
|
+
const { edges, containment } = reconstructEdges(nodes, {
|
|
182
|
+
identities,
|
|
183
|
+
refs: [
|
|
184
|
+
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
expect(containment).toHaveLength(1);
|
|
188
|
+
expect(edges).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/graph-refs.ts
CHANGED
|
@@ -40,6 +40,25 @@ export interface RefRule {
|
|
|
40
40
|
relation: "reference" | "containment";
|
|
41
41
|
/** Edge / containment label (e.g. "in VPC", "sg", "targets"). */
|
|
42
42
|
label?: string;
|
|
43
|
+
/**
|
|
44
|
+
* What the reconstructed edge's `viaAttr` should be, when traversal needs a
|
|
45
|
+
* different string than rendering does (#1275).
|
|
46
|
+
*
|
|
47
|
+
* `viaAttr` defaulted to `label ?? path`, which serves a renderer well and a
|
|
48
|
+
* traversal badly. The labels here are human-facing — "sg", "via", "in VPC" —
|
|
49
|
+
* while a fold like `enrichEffectiveTopology` matches provider attribute
|
|
50
|
+
* names: `SecurityGroupIds`, `SubnetId`, `LaunchTemplateId`. One field could
|
|
51
|
+
* not be both, so a rule that is traversed declares the name explicitly and
|
|
52
|
+
* keeps its label for the picture.
|
|
53
|
+
*
|
|
54
|
+
* On a `containment` rule this additionally opts the relation into producing
|
|
55
|
+
* an edge, on top of the boundary pair it already produces. Containment is
|
|
56
|
+
* not an edge by default and should not become one — but a fold's first hop
|
|
57
|
+
* is sometimes exactly a containment relation (an instance is *in* a subnet),
|
|
58
|
+
* and that hop has to be traversable without duplicating the rule as a
|
|
59
|
+
* reference and drawing the line twice.
|
|
60
|
+
*/
|
|
61
|
+
viaAttr?: string;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
/** A lexicon's reference knowledge — its identity map and reference rules. */
|
|
@@ -154,17 +173,29 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
154
173
|
continue;
|
|
155
174
|
}
|
|
156
175
|
if (match.id === node.id) continue; // self-reference (e.g. an SG rule to its own group)
|
|
157
|
-
|
|
158
|
-
const k = `${node.id}|${match.id}`;
|
|
159
|
-
if (seenCont.has(k)) continue;
|
|
160
|
-
seenCont.add(k);
|
|
161
|
-
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
162
|
-
} else {
|
|
163
|
-
const via = rule.label ?? rule.path;
|
|
176
|
+
const pushEdge = (via: string): void => {
|
|
164
177
|
const k = `${node.id}|${match.id}|${via}`;
|
|
165
|
-
if (seenEdge.has(k))
|
|
178
|
+
if (seenEdge.has(k)) return;
|
|
166
179
|
seenEdge.add(k);
|
|
167
180
|
edges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
|
|
181
|
+
};
|
|
182
|
+
if (rule.relation === "containment") {
|
|
183
|
+
const k = `${node.id}|${match.id}`;
|
|
184
|
+
if (!seenCont.has(k)) {
|
|
185
|
+
seenCont.add(k);
|
|
186
|
+
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
187
|
+
}
|
|
188
|
+
// A containment relation is a boundary hint, not an edge — unless the
|
|
189
|
+
// rule declares a traversal name (#1275). A fold's first hop is
|
|
190
|
+
// sometimes exactly a containment ("an instance is in a subnet"), and
|
|
191
|
+
// it must be traversable without duplicating the rule as a reference
|
|
192
|
+
// and drawing the line twice.
|
|
193
|
+
if (rule.viaAttr) pushEdge(rule.viaAttr);
|
|
194
|
+
} else {
|
|
195
|
+
// Traversal name wins over the rendering label: the labels are
|
|
196
|
+
// human-facing ("sg", "via"), and a fold matches provider attribute
|
|
197
|
+
// names (#1275).
|
|
198
|
+
pushEdge(rule.viaAttr ?? rule.label ?? rule.path);
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
}
|