@intentius/chant 0.15.3 → 0.16.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/handlers/graph.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +3 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +46 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +89 -0
- package/dist/graph-refs.d.ts.map +1 -0
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +31 -0
- package/dist/lifecycle/observe.d.ts.map +1 -0
- package/dist/op/builders.d.ts +71 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.d.ts +52 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -0
- package/dist/op/index.d.ts +3 -1
- package/dist/op/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +131 -3
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +3 -0
- package/src/graph-ir-live.test.ts +88 -0
- package/src/graph-ir.ts +90 -0
- package/src/graph-refs.test.ts +131 -0
- package/src/graph-refs.ts +196 -0
- package/src/lexicon.ts +25 -0
- package/src/lifecycle/observe.test.ts +65 -0
- package/src/lifecycle/observe.ts +83 -0
- package/src/op/builders.ts +114 -0
- package/src/op/emulator-lifecycle.test.ts +41 -0
- package/src/op/emulator-lifecycle.ts +129 -0
- package/src/op/index.ts +5 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { discover } from "../../discovery/index";
|
|
4
|
-
import { partitionByLexicon, computeStackGraph } from "../../build";
|
|
5
|
-
import { buildGraphIr, type GraphIR } from "../../graph-ir";
|
|
4
|
+
import { partitionByLexicon, computeStackGraph, build } from "../../build";
|
|
5
|
+
import { buildGraphIr, buildLiveGraphIr, overlayGraphs, type GraphIR } from "../../graph-ir";
|
|
6
|
+
import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
|
|
7
|
+
import { observeResources } from "../../lifecycle/observe";
|
|
8
|
+
import { loadChantConfig } from "../../config";
|
|
6
9
|
import { applyDetail, type DetailLevel } from "../../graph-detail";
|
|
7
10
|
import { applyLens, parseLens } from "../../graph-lens";
|
|
8
11
|
import { toMermaid } from "../../graph-mermaid";
|
|
@@ -24,7 +27,13 @@ import { computeComponentGraph } from "../../components/cli-support";
|
|
|
24
27
|
*/
|
|
25
28
|
export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
26
29
|
const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
|
|
27
|
-
|
|
30
|
+
const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
|
|
31
|
+
// `--live` graphs the provisioned (observed) infrastructure, not the declared
|
|
32
|
+
// source (epic #776). It only makes sense as a view format; default to `ir`.
|
|
33
|
+
if (ctx.args.live) {
|
|
34
|
+
return runGraphLive(ctx, isViewFormat ? (ctx.args.format as (typeof viewFormats)[number]) : "ir");
|
|
35
|
+
}
|
|
36
|
+
if (isViewFormat) {
|
|
28
37
|
return runGraphView(ctx, ctx.args.format as (typeof viewFormats)[number]);
|
|
29
38
|
}
|
|
30
39
|
if (ctx.args.components) return runComponentGraph(ctx);
|
|
@@ -32,6 +41,116 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
|
32
41
|
return runOpGraph();
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/**
|
|
45
|
+
* `chant graph --live --env <name> [--format ir|mermaid|dot|layout]` — the
|
|
46
|
+
* **provisioned** graph (C1 of epic #776). Queries each lexicon's
|
|
47
|
+
* `describeResources()` for the environment (managed-only: `owned`) and projects
|
|
48
|
+
* the observed resources into IR nodes. Nodes only — edges are reconstructed by
|
|
49
|
+
* the reference resolver (#778), containment by #779. No lint gate: this reads
|
|
50
|
+
* the cloud, not source.
|
|
51
|
+
*/
|
|
52
|
+
async function runGraphLive(
|
|
53
|
+
ctx: CommandContext,
|
|
54
|
+
format: "ir" | "mermaid" | "dot" | "layout",
|
|
55
|
+
): Promise<number> {
|
|
56
|
+
const { args, plugins } = ctx;
|
|
57
|
+
const environment = args.env;
|
|
58
|
+
if (!environment) {
|
|
59
|
+
console.error(formatError({ message: "chant graph --live needs an environment: --live --env <name>" }));
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const projectPath = resolve(".");
|
|
64
|
+
const { config } = await loadChantConfig(projectPath);
|
|
65
|
+
if (config.environments && !config.environments.includes(environment)) {
|
|
66
|
+
console.error(formatError({
|
|
67
|
+
message: `Unknown environment "${environment}"`,
|
|
68
|
+
hint: `Defined environments: ${config.environments.join(", ")}`,
|
|
69
|
+
}));
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Build to get each lexicon's entity names + output (the scope
|
|
74
|
+
// describeResources needs), mirroring `chant lifecycle snapshot`.
|
|
75
|
+
const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer));
|
|
76
|
+
if (buildResult.errors.length > 0) {
|
|
77
|
+
console.error(formatError({ message: "Build failed — fix errors before graphing live state" }));
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const observing = plugins.filter((p) => p.describeResources);
|
|
82
|
+
if (observing.length === 0) {
|
|
83
|
+
console.error(formatError({ message: "No lexicons implement describeResources — nothing to observe live." }));
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { observations, errors } = await observeResources(environment, observing, buildResult, { owned: true });
|
|
88
|
+
for (const e of errors) console.error(formatWarning({ message: e }));
|
|
89
|
+
|
|
90
|
+
let ir: GraphIR = buildLiveGraphIr(observations);
|
|
91
|
+
|
|
92
|
+
// Enrich node attrs from the fuller live config (#784) so references are
|
|
93
|
+
// present for edge reconstruction — describeResources metadata alone is often
|
|
94
|
+
// too thin (e.g. AWS returns stack outputs, not per-resource references).
|
|
95
|
+
for (const p of observing) {
|
|
96
|
+
if (!p.enrichLiveAttrs) continue;
|
|
97
|
+
try {
|
|
98
|
+
const enriched = await p.enrichLiveAttrs({ environment, owned: true });
|
|
99
|
+
ir = {
|
|
100
|
+
...ir,
|
|
101
|
+
nodes: ir.nodes.map((n) =>
|
|
102
|
+
n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reconstruct edges + containment from live references (#778): merge the
|
|
111
|
+
// observing lexicons' reference catalogs and resolve them over the live nodes.
|
|
112
|
+
const catalogs = observing.map((p) => p.referenceCatalog).filter((c): c is ReferenceCatalog => !!c);
|
|
113
|
+
let containment: ContainmentPair[] = [];
|
|
114
|
+
if (catalogs.length > 0) {
|
|
115
|
+
const reconstructed = reconstructEdges(ir.nodes, mergeCatalogs(catalogs));
|
|
116
|
+
ir = { ...ir, edges: reconstructed.edges };
|
|
117
|
+
containment = reconstructed.containment;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Drift overlay (#780): classify the provisioned graph against declared source
|
|
121
|
+
// (managed / foreign / pending) so a renderer colours the drift.
|
|
122
|
+
if (args.overlay) {
|
|
123
|
+
const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
|
|
124
|
+
if (declared.errors.length === 0) {
|
|
125
|
+
ir = overlayGraphs(ir, buildGraphIr(declared.entities, projectPath));
|
|
126
|
+
} else {
|
|
127
|
+
console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (args.lens) {
|
|
132
|
+
try {
|
|
133
|
+
ir = applyLens(ir, parseLens(args.lens, { up: args.up, down: args.down }));
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
ir = applyDetail(ir, (args.detail ?? 2) as DetailLevel);
|
|
140
|
+
|
|
141
|
+
// Containment grouping (#779) → boundary boxes. Built after lens/detail and
|
|
142
|
+
// filtered to surviving nodes, so a lens can't leave dangling group refs.
|
|
143
|
+
if (containment.length > 0) {
|
|
144
|
+
const present = new Set(ir.nodes.map((n) => n.id));
|
|
145
|
+
const byContainer = containmentGroups(containment.filter((c) => present.has(c.child) && present.has(c.parent)));
|
|
146
|
+
if (Object.keys(byContainer).length > 0) {
|
|
147
|
+
ir = { ...ir, groups: { ...ir.groups, byContainer } };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return emitIr(ir, ctx, format);
|
|
152
|
+
}
|
|
153
|
+
|
|
35
154
|
/**
|
|
36
155
|
* `chant graph --components` (#560) — dependency order/waves for discovered
|
|
37
156
|
* `Component` declarations, mirroring `runStackGraph`'s presentation
|
|
@@ -124,7 +243,16 @@ async function runGraphView(
|
|
|
124
243
|
}
|
|
125
244
|
}
|
|
126
245
|
ir = applyDetail(ir, level as DetailLevel);
|
|
246
|
+
return emitIr(ir, ctx, format);
|
|
247
|
+
}
|
|
127
248
|
|
|
249
|
+
/** Emit a built IR in the requested view format — shared by the source
|
|
250
|
+
* (`runGraphView`) and live (`runGraphLive`) paths. */
|
|
251
|
+
async function emitIr(
|
|
252
|
+
ir: GraphIR,
|
|
253
|
+
ctx: CommandContext,
|
|
254
|
+
format: "ir" | "mermaid" | "dot" | "layout",
|
|
255
|
+
): Promise<number> {
|
|
128
256
|
switch (format) {
|
|
129
257
|
case "mermaid":
|
|
130
258
|
console.log(toMermaid(ir));
|
package/src/cli/main.ts
CHANGED
|
@@ -92,6 +92,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
92
92
|
}
|
|
93
93
|
} else if (arg === "--live") {
|
|
94
94
|
result.live = true;
|
|
95
|
+
} else if (arg === "--overlay") {
|
|
96
|
+
result.overlay = true;
|
|
95
97
|
} else if (arg === "--from") {
|
|
96
98
|
// Shared by `migrate --from <lexicon>` and `import --from <env>`; the
|
|
97
99
|
// two commands never run together, so one field carries both.
|
package/src/cli/registry.ts
CHANGED
|
@@ -58,6 +58,9 @@ export interface ParsedArgs {
|
|
|
58
58
|
env?: string;
|
|
59
59
|
/** `chant graph --stacks` — render the cross-stack apply-ordering graph */
|
|
60
60
|
stacks?: boolean;
|
|
61
|
+
/** `chant graph --live --overlay` — classify the provisioned graph against
|
|
62
|
+
* declared source: managed / foreign / pending (#780). */
|
|
63
|
+
overlay?: boolean;
|
|
61
64
|
/** `chant list --components` / `chant graph --components` — surface discovered
|
|
62
65
|
* `Component` declarations (#560) instead of/alongside lexicon resources. */
|
|
63
66
|
components?: boolean;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildLiveGraphIr, overlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
|
|
3
|
+
|
|
4
|
+
// A fixture "snapshot" — what a lexicon's describeResources() returns for a live
|
|
5
|
+
// environment (managed-only). Two AWS resources; the subnet references the VPC by
|
|
6
|
+
// physical id (that reference is #778's job, not this one — here we only project
|
|
7
|
+
// nodes).
|
|
8
|
+
const observations: LiveObservation[] = [
|
|
9
|
+
{
|
|
10
|
+
lexicon: "aws",
|
|
11
|
+
resources: {
|
|
12
|
+
"web-vpc": {
|
|
13
|
+
type: "AWS::EC2::VPC",
|
|
14
|
+
status: "CREATE_COMPLETE",
|
|
15
|
+
physicalId: "vpc-0a1b",
|
|
16
|
+
attributes: { CidrBlock: "10.0.0.0/16" },
|
|
17
|
+
ownership: "owned",
|
|
18
|
+
},
|
|
19
|
+
"app-subnet": {
|
|
20
|
+
type: "AWS::EC2::Subnet",
|
|
21
|
+
status: "CREATE_COMPLETE",
|
|
22
|
+
physicalId: "subnet-0c2d",
|
|
23
|
+
attributes: { VpcId: "vpc-0a1b", CidrBlock: "10.0.1.0/24" },
|
|
24
|
+
ownership: "owned",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe("buildLiveGraphIr", () => {
|
|
31
|
+
it("projects observed resources into IR nodes — nodes only, no edges", () => {
|
|
32
|
+
const ir = buildLiveGraphIr(observations);
|
|
33
|
+
expect(ir.nodes.map((n) => n.id)).toEqual(["app-subnet", "web-vpc"]); // sorted
|
|
34
|
+
expect(ir.edges).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("carries kind, lexicon, physicalId, ownership, and live attrs on each node", () => {
|
|
38
|
+
const ir = buildLiveGraphIr(observations);
|
|
39
|
+
const vpc = ir.nodes.find((n) => n.id === "web-vpc")!;
|
|
40
|
+
expect(vpc.kind).toBe("AWS::EC2::VPC");
|
|
41
|
+
expect(vpc.lexicon).toBe("aws");
|
|
42
|
+
expect(vpc.physicalId).toBe("vpc-0a1b"); // the reference index key for #778
|
|
43
|
+
expect(vpc.ownership).toBe("owned");
|
|
44
|
+
expect(vpc.attrs).toEqual({ CidrBlock: "10.0.0.0/16" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("groups by lexicon and stack", () => {
|
|
48
|
+
const ir = buildLiveGraphIr(observations);
|
|
49
|
+
expect(ir.groups.byLexicon).toEqual({ aws: ["app-subnet", "web-vpc"] });
|
|
50
|
+
expect(ir.groups.byStack).toEqual({ aws: ["app-subnet", "web-vpc"] });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("omits physicalId/ownership when the observation lacks them", () => {
|
|
54
|
+
const ir = buildLiveGraphIr([
|
|
55
|
+
{ lexicon: "k8s", resources: { pod: { type: "Pod", status: "Running" } } },
|
|
56
|
+
]);
|
|
57
|
+
const node = ir.nodes[0];
|
|
58
|
+
expect(node.physicalId).toBeUndefined();
|
|
59
|
+
expect(node.ownership).toBeUndefined();
|
|
60
|
+
expect(node.attrs).toEqual({});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is deterministic for a fixed observation set", () => {
|
|
64
|
+
expect(JSON.stringify(buildLiveGraphIr(observations))).toBe(
|
|
65
|
+
JSON.stringify(buildLiveGraphIr(observations)),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("overlayGraphs (#780 drift overlay)", () => {
|
|
71
|
+
const node = (id: string) => ({ id, kind: "AWS::EC2::VPC", lexicon: "aws", attrs: {} });
|
|
72
|
+
const live: GraphIR = { nodes: [node("web-vpc"), node("rogue-sg")], edges: [], groups: {} };
|
|
73
|
+
const declared: GraphIR = { nodes: [node("web-vpc"), node("planned-db")], edges: [], groups: {} };
|
|
74
|
+
|
|
75
|
+
it("classifies managed / foreign / pending via _status", () => {
|
|
76
|
+
const ir = overlayGraphs(live, declared);
|
|
77
|
+
const statusOf = (id: string) => (ir.nodes.find((n) => n.id === id)!.attrs as { _status?: string })._status;
|
|
78
|
+
expect(statusOf("web-vpc")).toBe("good"); // declared + provisioned
|
|
79
|
+
expect(statusOf("rogue-sg")).toBe("warn"); // provisioned, not declared → foreign
|
|
80
|
+
expect(statusOf("planned-db")).toBe("accent"); // declared, not provisioned → pending
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("appends pending nodes and keeps live edges/groups", () => {
|
|
84
|
+
const ir = overlayGraphs({ ...live, edges: [{ from: "rogue-sg", to: "web-vpc", kind: "ref" }] }, declared);
|
|
85
|
+
expect(ir.nodes.map((n) => n.id).sort()).toEqual(["planned-db", "rogue-sg", "web-vpc"]);
|
|
86
|
+
expect(ir.edges).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type Declarable, isDeclarable } from "./declarable";
|
|
|
5
5
|
import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
|
|
6
6
|
import { getProvenance } from "./provenance";
|
|
7
7
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
8
|
+
import type { ResourceMetadata } from "./lexicon";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Graph IR — the engine-neutral, lint-gated representation of a project's
|
|
@@ -54,6 +55,17 @@ export interface IRNode {
|
|
|
54
55
|
attrs: Record<string, unknown>;
|
|
55
56
|
/** Where the node was declared. */
|
|
56
57
|
sourceLoc?: SourceLoc;
|
|
58
|
+
/**
|
|
59
|
+
* Live-only (`chant graph --live`): the observed physical identifier
|
|
60
|
+
* (id / ARN). Absent for source-derived IR. The reference resolver (#778)
|
|
61
|
+
* indexes on this to reconstruct edges from live resource references.
|
|
62
|
+
*/
|
|
63
|
+
physicalId?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Live-only: ownership verdict read from the resource's marker (#119/#120).
|
|
66
|
+
* `owned` = chant-managed. Absent for source-derived IR.
|
|
67
|
+
*/
|
|
68
|
+
ownership?: "owned" | "foreign";
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/** A directed dependency: `from` references an attribute of `to`. */
|
|
@@ -76,6 +88,12 @@ export interface IRGroups {
|
|
|
76
88
|
* partition today; #513 phase 2 regroups by nested child-project. Consumers
|
|
77
89
|
* (e.g. pinhole's boundary boxes) read this rather than inferring stacks. */
|
|
78
90
|
byStack?: Record<string, string[]>;
|
|
91
|
+
/** Live containment (#779): a container node id → the node ids directly inside
|
|
92
|
+
* it (VPC → subnets/SGs, subnet → instances/service). Nested *flatly* — a
|
|
93
|
+
* subnet is both a member of its VPC's entry and a key with its own members —
|
|
94
|
+
* so a boundary-box renderer recurses it. Populated by `chant graph --live`
|
|
95
|
+
* from the reference resolver's containment output; absent for source IR. */
|
|
96
|
+
byContainer?: Record<string, string[]>;
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
/** A cross-stack export this stack publishes (a `stackOutput`/`output`): its
|
|
@@ -331,3 +349,75 @@ function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
|
|
|
331
349
|
for (const k of Object.keys(rec).sort()) out[k] = rec[k];
|
|
332
350
|
return out;
|
|
333
351
|
}
|
|
352
|
+
|
|
353
|
+
/** One lexicon's observed resources, keyed by logical name — the output of a
|
|
354
|
+
* plugin's `describeResources()`. The input to {@link buildLiveGraphIr}. */
|
|
355
|
+
export interface LiveObservation {
|
|
356
|
+
lexicon: string;
|
|
357
|
+
resources: Record<string, ResourceMetadata>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Build the graph IR from **live-observed** resources (`chant graph --live`) —
|
|
362
|
+
* the provisioned graph, not the declared one. This is the C1 half of epic #776:
|
|
363
|
+
* nodes only. Edges are reconstructed separately by the per-lexicon reference
|
|
364
|
+
* resolver (#778); containment grouping by #779. Pure and deterministic given a
|
|
365
|
+
* fixed observation set (nodes and group ids sorted), so the same snapshot yields
|
|
366
|
+
* identical IR.
|
|
367
|
+
*
|
|
368
|
+
* Each node carries the observed `physicalId` (the reference resolver's index
|
|
369
|
+
* key) and `ownership` marker. `attrs` is the resource's observed attributes.
|
|
370
|
+
*/
|
|
371
|
+
export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
372
|
+
const nodes: IRNode[] = [];
|
|
373
|
+
const byLexicon: Record<string, string[]> = {};
|
|
374
|
+
const byStack: Record<string, string[]> = {};
|
|
375
|
+
|
|
376
|
+
for (const { lexicon, resources } of observations) {
|
|
377
|
+
for (const [name, meta] of Object.entries(resources)) {
|
|
378
|
+
const node: IRNode = {
|
|
379
|
+
id: name,
|
|
380
|
+
kind: meta.type,
|
|
381
|
+
lexicon,
|
|
382
|
+
attrs: meta.attributes ?? {},
|
|
383
|
+
};
|
|
384
|
+
if (meta.physicalId) node.physicalId = meta.physicalId;
|
|
385
|
+
if (meta.ownership) node.ownership = meta.ownership;
|
|
386
|
+
nodes.push(node);
|
|
387
|
+
(byLexicon[lexicon] ??= []).push(name);
|
|
388
|
+
// A live lexicon maps to one deployable stack, same as the source IR.
|
|
389
|
+
(byStack[lexicon] ??= []).push(name);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
394
|
+
for (const ids of Object.values(byLexicon)) ids.sort();
|
|
395
|
+
for (const ids of Object.values(byStack)) ids.sort();
|
|
396
|
+
|
|
397
|
+
const groups: IRGroups = {};
|
|
398
|
+
if (Object.keys(byLexicon).length) groups.byLexicon = sortKeys(byLexicon);
|
|
399
|
+
if (Object.keys(byStack).length) groups.byStack = sortKeys(byStack);
|
|
400
|
+
|
|
401
|
+
return { nodes, edges: [], groups };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Overlay the declared graph on the provisioned one (#780, `chant graph --live
|
|
406
|
+
* --overlay`) and classify each resource, tagging a `_status` a renderer colours:
|
|
407
|
+
* - **managed** (declared + provisioned) → `good`
|
|
408
|
+
* - **foreign** (provisioned, not declared) → `warn`
|
|
409
|
+
* - **pending** (declared, not yet provisioned) → `accent`
|
|
410
|
+
* Live nodes keep their edges/containment; pending nodes are appended (they have
|
|
411
|
+
* no live edges). Sorted; the live groups pass through unchanged.
|
|
412
|
+
*/
|
|
413
|
+
export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
|
|
414
|
+
const declaredIds = new Set(declared.nodes.map((n) => n.id));
|
|
415
|
+
const liveIds = new Set(live.nodes.map((n) => n.id));
|
|
416
|
+
const tagged = (n: IRNode, status: "good" | "warn" | "accent"): IRNode => ({ ...n, attrs: { ...n.attrs, _status: status } });
|
|
417
|
+
|
|
418
|
+
const nodes: IRNode[] = live.nodes.map((n) => tagged(n, declaredIds.has(n.id) ? "good" : "warn"));
|
|
419
|
+
for (const n of declared.nodes) if (!liveIds.has(n.id)) nodes.push(tagged(n, "accent"));
|
|
420
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
421
|
+
|
|
422
|
+
return { ...live, nodes };
|
|
423
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { reconstructEdges, readPath, mergeCatalogs, containmentGroups, type ReferenceCatalog } from "./graph-refs";
|
|
3
|
+
import type { IRNode } from "./graph-ir";
|
|
4
|
+
|
|
5
|
+
const node = (id: string, kind: string, attrs: Record<string, unknown>, physicalId?: string): IRNode => ({
|
|
6
|
+
id,
|
|
7
|
+
kind,
|
|
8
|
+
lexicon: "test",
|
|
9
|
+
attrs,
|
|
10
|
+
...(physicalId ? { physicalId } : {}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("readPath", () => {
|
|
14
|
+
it("reads nested keys", () => {
|
|
15
|
+
expect(readPath({ a: { b: "x" } }, "a.b")).toEqual(["x"]);
|
|
16
|
+
});
|
|
17
|
+
it("fans out over arrays", () => {
|
|
18
|
+
expect(readPath({ sgs: [{ id: "sg-1" }, { id: "sg-2" }] }, "sgs[].id")).toEqual(["sg-1", "sg-2"]);
|
|
19
|
+
});
|
|
20
|
+
it("reads an array of scalars", () => {
|
|
21
|
+
expect(readPath({ subnets: ["s-1", "s-2"] }, "subnets[]")).toEqual(["s-1", "s-2"]);
|
|
22
|
+
});
|
|
23
|
+
it("returns nothing for a missing path", () => {
|
|
24
|
+
expect(readPath({ a: 1 }, "b.c")).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("reconstructEdges", () => {
|
|
29
|
+
// A tiny synthetic model: a "box" contains "things"; things reference a "peer".
|
|
30
|
+
const catalog: ReferenceCatalog = {
|
|
31
|
+
identities: [
|
|
32
|
+
{ kind: "Box", ids: ["BoxId"] },
|
|
33
|
+
{ kind: "Thing", ids: ["ThingId"] },
|
|
34
|
+
{ kind: "Peer", ids: ["PeerId"] },
|
|
35
|
+
],
|
|
36
|
+
refs: [
|
|
37
|
+
{ from: "Thing", path: "boxId", targetKind: "Box", relation: "containment", label: "in box" },
|
|
38
|
+
{ from: "Thing", path: "peerIds[]", targetKind: "Peer", relation: "reference", label: "uses" },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const nodes: IRNode[] = [
|
|
43
|
+
node("box", "Box", { BoxId: "box-1" }),
|
|
44
|
+
node("thing", "Thing", { ThingId: "thing-1", boxId: "box-1", peerIds: ["peer-1", "peer-2"] }),
|
|
45
|
+
node("peerA", "Peer", { PeerId: "peer-1" }),
|
|
46
|
+
node("peerB", "Peer", { PeerId: "peer-2" }),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
it("emits reference edges (holder → referenced)", () => {
|
|
50
|
+
const { edges } = reconstructEdges(nodes, catalog);
|
|
51
|
+
expect(edges).toEqual([
|
|
52
|
+
{ from: "thing", to: "peerA", kind: "ref", viaAttr: "uses" },
|
|
53
|
+
{ from: "thing", to: "peerB", kind: "ref", viaAttr: "uses" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("emits containment separately, not as edges", () => {
|
|
58
|
+
const { edges, containment } = reconstructEdges(nodes, catalog);
|
|
59
|
+
expect(containment).toEqual([{ child: "thing", parent: "box", label: "in box" }]);
|
|
60
|
+
// containment is NOT in edges
|
|
61
|
+
expect(edges.some((e) => e.to === "box")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("surfaces references to absent targets as dangling, never a wrong edge", () => {
|
|
65
|
+
const withDangling = [...nodes, node("orphan", "Thing", { ThingId: "t-2", boxId: "box-1", peerIds: ["peer-GONE"] })];
|
|
66
|
+
const { edges, dangling } = reconstructEdges(withDangling, catalog);
|
|
67
|
+
expect(dangling).toContainEqual({ from: "orphan", path: "peerIds[]", value: "peer-GONE", targetKind: "Peer" });
|
|
68
|
+
expect(edges.some((e) => e.to === undefined)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("resolves against physicalId too", () => {
|
|
72
|
+
const cat: ReferenceCatalog = { identities: [], refs: [{ from: "Thing", path: "peer", relation: "reference" }] };
|
|
73
|
+
const ns = [node("t", "Thing", { peer: "phys-9" }), node("p", "Peer", {}, "phys-9")];
|
|
74
|
+
expect(reconstructEdges(ns, cat).edges).toEqual([{ from: "t", to: "p", kind: "ref", viaAttr: "peer" }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("disambiguates identifier collisions by targetKind", () => {
|
|
78
|
+
const cat: ReferenceCatalog = {
|
|
79
|
+
identities: [{ kind: "A", ids: ["id"] }, { kind: "B", ids: ["id"] }],
|
|
80
|
+
refs: [{ from: "H", path: "ref", targetKind: "B", relation: "reference" }],
|
|
81
|
+
};
|
|
82
|
+
const ns = [node("a", "A", { id: "dup" }), node("b", "B", { id: "dup" }), node("h", "H", { ref: "dup" })];
|
|
83
|
+
expect(reconstructEdges(ns, cat).edges).toEqual([{ from: "h", to: "b", kind: "ref", viaAttr: "ref" }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("drops self-references", () => {
|
|
87
|
+
const cat: ReferenceCatalog = { identities: [{ kind: "SG", ids: ["id"] }], refs: [{ from: "SG", path: "peers[]", relation: "reference" }] };
|
|
88
|
+
const ns = [node("sg", "SG", { id: "sg-1", peers: ["sg-1"] })];
|
|
89
|
+
expect(reconstructEdges(ns, cat).edges).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is deterministic", () => {
|
|
93
|
+
expect(JSON.stringify(reconstructEdges(nodes, catalog))).toBe(JSON.stringify(reconstructEdges(nodes, catalog)));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("containmentGroups", () => {
|
|
98
|
+
it("inverts pairs into container → members, representing nesting flatly", () => {
|
|
99
|
+
const groups = containmentGroups([
|
|
100
|
+
{ child: "subnetA", parent: "vpc" },
|
|
101
|
+
{ child: "subnetB", parent: "vpc" },
|
|
102
|
+
{ child: "instance1", parent: "subnetA" },
|
|
103
|
+
]);
|
|
104
|
+
// vpc contains its subnets; subnetA is a key with its own members (nesting).
|
|
105
|
+
expect(groups).toEqual({ vpc: ["subnetA", "subnetB"], subnetA: ["instance1"] });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("dedupes members and sorts keys + members", () => {
|
|
109
|
+
expect(
|
|
110
|
+
containmentGroups([
|
|
111
|
+
{ child: "b", parent: "a" },
|
|
112
|
+
{ child: "b", parent: "a" },
|
|
113
|
+
{ child: "a", parent: "z" },
|
|
114
|
+
]),
|
|
115
|
+
).toEqual({ a: ["b"], z: ["a"] });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is empty for no containment", () => {
|
|
119
|
+
expect(containmentGroups([])).toEqual({});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("mergeCatalogs", () => {
|
|
124
|
+
it("concatenates identities and refs", () => {
|
|
125
|
+
const a: ReferenceCatalog = { identities: [{ kind: "A", ids: ["x"] }], refs: [{ from: "A", path: "p", relation: "reference" }] };
|
|
126
|
+
const b: ReferenceCatalog = { identities: [{ kind: "B", ids: ["y"] }], refs: [] };
|
|
127
|
+
const m = mergeCatalogs([a, b]);
|
|
128
|
+
expect(m.identities).toHaveLength(2);
|
|
129
|
+
expect(m.refs).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
});
|