@intentius/chant 0.8.2 → 0.10.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 +2 -1
- 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 +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-detail.d.ts +21 -0
- package/dist/graph-detail.d.ts.map +1 -0
- package/dist/graph-dot.d.ts +12 -0
- package/dist/graph-dot.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +78 -0
- package/dist/graph-ir.d.ts.map +1 -0
- package/dist/graph-layout.d.ts +39 -0
- package/dist/graph-layout.d.ts.map +1 -0
- package/dist/graph-lens.d.ts +30 -0
- package/dist/graph-lens.d.ts.map +1 -0
- package/dist/graph-mermaid.d.ts +17 -0
- package/dist/graph-mermaid.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/provenance.d.ts +8 -1
- package/dist/provenance.d.ts.map +1 -1
- package/dist/reconcile.d.ts +147 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +166 -48
- package/src/cli/handlers/graph.ts +86 -1
- package/src/cli/main.test.ts +16 -0
- package/src/cli/main.ts +14 -1
- package/src/cli/registry.ts +8 -0
- package/src/discovery/collect.ts +2 -2
- package/src/graph-detail.test.ts +79 -0
- package/src/graph-detail.ts +149 -0
- package/src/graph-dot.test.ts +68 -0
- package/src/graph-dot.ts +58 -0
- package/src/graph-ir.test.ts +122 -0
- package/src/graph-ir.ts +285 -0
- package/src/graph-layout.ts +104 -0
- package/src/graph-lens.test.ts +80 -0
- package/src/graph-lens.ts +127 -0
- package/src/graph-mermaid.test.ts +61 -0
- package/src/graph-mermaid.ts +85 -0
- package/src/index.ts +6 -0
- package/src/provenance.ts +9 -1
- package/src/reconcile.test.ts +224 -0
- package/src/reconcile.ts +346 -0
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import type { ParsedArgs } from "../registry";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
|
|
4
|
+
import { AttrRef } from "../../attrref";
|
|
3
5
|
|
|
4
6
|
const discoverOpsMock = vi.fn();
|
|
5
7
|
vi.mock("../../op/discover", () => ({
|
|
6
8
|
discoverOps: () => discoverOpsMock(),
|
|
7
9
|
}));
|
|
8
10
|
|
|
11
|
+
const discoverMock = vi.fn();
|
|
12
|
+
vi.mock("../../discovery/index", () => ({
|
|
13
|
+
discover: () => discoverMock(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const lintMock = vi.fn();
|
|
17
|
+
vi.mock("../commands/lint", () => ({
|
|
18
|
+
lintCommand: () => lintMock(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Avoid shelling out to graphviz in tests; the format dispatch is what matters.
|
|
22
|
+
const layoutMock = vi.fn();
|
|
23
|
+
vi.mock("../../graph-layout", () => ({
|
|
24
|
+
GraphvizLayout: class {
|
|
25
|
+
layout() {
|
|
26
|
+
return layoutMock();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
9
31
|
const { runGraph } = await import("./graph");
|
|
10
32
|
|
|
11
33
|
function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
|
|
@@ -20,6 +42,18 @@ function makeOp(name: string, depends: string[] = []): [string, { config: { name
|
|
|
20
42
|
return [name, { config: { name, depends } }];
|
|
21
43
|
}
|
|
22
44
|
|
|
45
|
+
function decl<T extends object>(base: T): Declarable & T {
|
|
46
|
+
return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A small two-lexicon graph: vpc <- subnet (gcp), subnet <- pod (k8s). */
|
|
50
|
+
function sampleEntities(): Map<string, Declarable> {
|
|
51
|
+
const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
|
|
52
|
+
const subnet = decl({ lexicon: "gcp", entityType: "Subnet", props: { network: new AttrRef(vpc, "id") } });
|
|
53
|
+
const pod = decl({ lexicon: "k8s", entityType: "Pod", props: { net: new AttrRef(subnet, "id") } });
|
|
54
|
+
return new Map<string, Declarable>([["vpc", vpc], ["subnet", subnet], ["pod", pod]]);
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
describe("runGraph", () => {
|
|
24
58
|
let stdoutBuf: string[];
|
|
25
59
|
let stderrBuf: string[];
|
|
@@ -30,62 +64,146 @@ describe("runGraph", () => {
|
|
|
30
64
|
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
31
65
|
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
32
66
|
discoverOpsMock.mockReset();
|
|
67
|
+
discoverMock.mockReset();
|
|
68
|
+
lintMock.mockReset();
|
|
69
|
+
layoutMock.mockReset();
|
|
33
70
|
});
|
|
34
71
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
72
|
+
describe("Op graph (default)", () => {
|
|
73
|
+
test("prints 'No Ops found' when discovery is empty", async () => {
|
|
74
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
75
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
76
|
+
expect(exit).toBe(0);
|
|
77
|
+
expect(stdoutBuf.join("\n")).toContain("No Ops found");
|
|
78
|
+
});
|
|
41
79
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
80
|
+
test("prints 'No Op dependencies' when ops have no depends", async () => {
|
|
81
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("solo")]), errors: [] });
|
|
82
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
83
|
+
expect(exit).toBe(0);
|
|
84
|
+
expect(stdoutBuf.join("\n")).toContain("No Op dependencies");
|
|
46
85
|
});
|
|
47
|
-
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
48
|
-
expect(exit).toBe(0);
|
|
49
|
-
expect(stdoutBuf.join("\n")).toContain("No Op dependencies");
|
|
50
|
-
});
|
|
51
86
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
])
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(exit).toBe(0);
|
|
62
|
-
const out = stdoutBuf.join("\n");
|
|
63
|
-
expect(out).toContain("infra → app");
|
|
64
|
-
});
|
|
87
|
+
test("prints `dep -> name` edge per dependency", async () => {
|
|
88
|
+
discoverOpsMock.mockResolvedValue({
|
|
89
|
+
ops: new Map([makeOp("infra"), makeOp("app", ["infra"])]),
|
|
90
|
+
errors: [],
|
|
91
|
+
});
|
|
92
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
93
|
+
expect(exit).toBe(0);
|
|
94
|
+
expect(stdoutBuf.join("\n")).toContain("infra → app");
|
|
95
|
+
});
|
|
65
96
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
test("handles multi-edge graphs", async () => {
|
|
98
|
+
discoverOpsMock.mockResolvedValue({
|
|
99
|
+
ops: new Map([makeOp("a"), makeOp("b", ["a"]), makeOp("c", ["a", "b"])]),
|
|
100
|
+
errors: [],
|
|
101
|
+
});
|
|
102
|
+
await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
103
|
+
const out = stdoutBuf.join("\n");
|
|
104
|
+
expect(out).toContain("a → b");
|
|
105
|
+
expect(out).toContain("a → c");
|
|
106
|
+
expect(out).toContain("b → c");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("forwards discovery errors to stderr", async () => {
|
|
110
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: ["failed to parse ops/bad.op.ts"] });
|
|
111
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
112
|
+
expect(exit).toBe(0);
|
|
113
|
+
expect(stderrBuf.join("\n")).toContain("failed to parse ops/bad.op.ts");
|
|
114
|
+
});
|
|
80
115
|
});
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
errors: [
|
|
117
|
+
describe("graph IR views (--format ir|mermaid|dot|layout)", () => {
|
|
118
|
+
const lintClean = (): void => { lintMock.mockResolvedValue({ success: true }); };
|
|
119
|
+
const discovered = (): void => {
|
|
120
|
+
discoverMock.mockResolvedValue({ entities: sampleEntities(), errors: [], dependencies: new Map(), sourceFiles: [] });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
test("--format ir emits the graph IR as JSON", async () => {
|
|
124
|
+
lintClean(); discovered();
|
|
125
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
126
|
+
expect(exit).toBe(0);
|
|
127
|
+
const ir = JSON.parse(stdoutBuf.join("\n"));
|
|
128
|
+
expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual(["pod", "subnet", "vpc"]);
|
|
129
|
+
expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("lint gate: refuses to emit when source has lint errors", async () => {
|
|
133
|
+
lintMock.mockResolvedValue({ success: false });
|
|
134
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
135
|
+
expect(exit).toBe(1);
|
|
136
|
+
expect(stdoutBuf.join("\n")).toBe("");
|
|
137
|
+
expect(stderrBuf.join("\n")).toMatch(/lint errors/i);
|
|
138
|
+
expect(discoverMock).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("--format mermaid emits a flowchart", async () => {
|
|
142
|
+
lintClean(); discovered();
|
|
143
|
+
const exit = await runGraph({ args: makeArgs({ format: "mermaid" }), plugins: [], serializers: [] });
|
|
144
|
+
expect(exit).toBe(0);
|
|
145
|
+
expect(stdoutBuf.join("\n")).toContain("flowchart TD");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("--format dot emits a digraph", async () => {
|
|
149
|
+
lintClean(); discovered();
|
|
150
|
+
const exit = await runGraph({ args: makeArgs({ format: "dot" }), plugins: [], serializers: [] });
|
|
151
|
+
expect(exit).toBe(0);
|
|
152
|
+
expect(stdoutBuf.join("\n")).toContain("digraph chant {");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("--format layout emits positions from the layout engine", async () => {
|
|
156
|
+
lintClean(); discovered();
|
|
157
|
+
layoutMock.mockResolvedValue({ width: 100, height: 50, nodes: [{ id: "vpc", x: 1, y: 2 }] });
|
|
158
|
+
const exit = await runGraph({ args: makeArgs({ format: "layout" }), plugins: [], serializers: [] });
|
|
159
|
+
expect(exit).toBe(0);
|
|
160
|
+
expect(JSON.parse(stdoutBuf.join("\n"))).toMatchObject({ width: 100, nodes: [{ id: "vpc", x: 1, y: 2 }] });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("--format layout reports a clear error when the engine fails (e.g. dot missing)", async () => {
|
|
164
|
+
lintClean(); discovered();
|
|
165
|
+
layoutMock.mockRejectedValue(new Error("could not run 'dot'"));
|
|
166
|
+
const exit = await runGraph({ args: makeArgs({ format: "layout" }), plugins: [], serializers: [] });
|
|
167
|
+
expect(exit).toBe(1);
|
|
168
|
+
expect(stderrBuf.join("\n")).toContain("could not run 'dot'");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("--detail 0 collapses to one node per lexicon", async () => {
|
|
172
|
+
lintClean(); discovered();
|
|
173
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", detail: 0 }), plugins: [], serializers: [] });
|
|
174
|
+
expect(exit).toBe(0);
|
|
175
|
+
const ir = JSON.parse(stdoutBuf.join("\n"));
|
|
176
|
+
expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual(["gcp", "k8s"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("rejects an out-of-range --detail", async () => {
|
|
180
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", detail: 9 }), plugins: [], serializers: [] });
|
|
181
|
+
expect(exit).toBe(1);
|
|
182
|
+
expect(stderrBuf.join("\n")).toMatch(/detail/i);
|
|
183
|
+
expect(lintMock).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("--lens lexicon:gcp filters to that lexicon", async () => {
|
|
187
|
+
lintClean(); discovered();
|
|
188
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", lens: "lexicon:gcp" }), plugins: [], serializers: [] });
|
|
189
|
+
expect(exit).toBe(0);
|
|
190
|
+
const ir = JSON.parse(stdoutBuf.join("\n"));
|
|
191
|
+
expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual(["subnet", "vpc"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("--lens with a bad spec errors out", async () => {
|
|
195
|
+
lintClean(); discovered();
|
|
196
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", lens: "nope" }), plugins: [], serializers: [] });
|
|
197
|
+
expect(exit).toBe(1);
|
|
198
|
+
expect(stderrBuf.join("\n")).toMatch(/lens/i);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("--format ir forwards discovery errors and exits non-zero", async () => {
|
|
202
|
+
lintClean();
|
|
203
|
+
discoverMock.mockResolvedValue({ entities: new Map(), errors: [{ message: "boom" }], dependencies: new Map(), sourceFiles: [] });
|
|
204
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
205
|
+
expect(exit).toBe(1);
|
|
206
|
+
expect(stderrBuf.join("\n")).toContain("boom");
|
|
86
207
|
});
|
|
87
|
-
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
88
|
-
expect(exit).toBe(0);
|
|
89
|
-
expect(stderrBuf.join("\n")).toContain("failed to parse ops/bad.op.ts");
|
|
90
208
|
});
|
|
91
209
|
});
|
|
@@ -2,19 +2,104 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { discover } from "../../discovery/index";
|
|
4
4
|
import { partitionByLexicon, computeStackGraph } from "../../build";
|
|
5
|
+
import { buildGraphIr, type GraphIR } from "../../graph-ir";
|
|
6
|
+
import { applyDetail, type DetailLevel } from "../../graph-detail";
|
|
7
|
+
import { applyLens, parseLens } from "../../graph-lens";
|
|
8
|
+
import { toMermaid } from "../../graph-mermaid";
|
|
9
|
+
import { toDot } from "../../graph-dot";
|
|
10
|
+
import { GraphvizLayout } from "../../graph-layout";
|
|
11
|
+
import { lintCommand } from "../commands/lint";
|
|
5
12
|
import { formatError, formatWarning, formatBold } from "../format";
|
|
6
13
|
import type { CommandContext } from "../registry";
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* `chant graph` — the Op dependency graph by default; `--stacks` renders the
|
|
10
17
|
* cross-stack apply-ordering graph (edges, order, waves) chant computes from
|
|
11
|
-
* cross-lexicon references
|
|
18
|
+
* cross-lexicon references; `--format ir|mermaid` emits the lint-gated
|
|
19
|
+
* entity-graph IR (or a Mermaid flowchart of it) for diagrams (#493/#496).
|
|
12
20
|
*/
|
|
13
21
|
export async function runGraph(ctx: CommandContext): Promise<number> {
|
|
22
|
+
const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
|
|
23
|
+
if ((viewFormats as readonly string[]).includes(ctx.args.format)) {
|
|
24
|
+
return runGraphView(ctx, ctx.args.format as (typeof viewFormats)[number]);
|
|
25
|
+
}
|
|
14
26
|
if (ctx.args.stacks) return runStackGraph(ctx);
|
|
15
27
|
return runOpGraph();
|
|
16
28
|
}
|
|
17
29
|
|
|
30
|
+
/**
|
|
31
|
+
* `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
|
|
32
|
+
* `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node
|
|
33
|
+
* positions from a layout engine. Lint-gated: the IR represents valid infra, so
|
|
34
|
+
* we refuse to emit for source that does not pass lint. Non-zero on discovery
|
|
35
|
+
* errors, or on a missing `dot` for `--format layout`.
|
|
36
|
+
*/
|
|
37
|
+
async function runGraphView(
|
|
38
|
+
ctx: CommandContext,
|
|
39
|
+
format: "ir" | "mermaid" | "dot" | "layout",
|
|
40
|
+
): Promise<number> {
|
|
41
|
+
const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
|
|
42
|
+
|
|
43
|
+
const level = ctx.args.detail ?? 2;
|
|
44
|
+
if (![0, 1, 2, 3].includes(level)) {
|
|
45
|
+
console.error(formatError({ message: `Invalid --detail ${level}. Expected 0, 1, 2, or 3.` }));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Gate: only emit for lint-clean source.
|
|
50
|
+
const lint = await lintCommand({ path: ctx.args.path, format: "stylish" });
|
|
51
|
+
if (!lint.success) {
|
|
52
|
+
console.error(
|
|
53
|
+
formatError({
|
|
54
|
+
message:
|
|
55
|
+
"Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first.",
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = await discover(projectPath);
|
|
62
|
+
if (result.errors.length > 0) {
|
|
63
|
+
for (const e of result.errors) console.error(formatError({ message: e.message }));
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build the base IR, focus with a lens (declarable-level, most precise), then
|
|
68
|
+
// apply the detail tier — so e.g. blast:<resource> works before any collapse.
|
|
69
|
+
let ir: GraphIR = buildGraphIr(result.entities, projectPath);
|
|
70
|
+
if (ctx.args.lens) {
|
|
71
|
+
try {
|
|
72
|
+
ir = applyLens(ir, parseLens(ctx.args.lens, { up: ctx.args.up, down: ctx.args.down }));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
ir = applyDetail(ir, level as DetailLevel);
|
|
79
|
+
|
|
80
|
+
switch (format) {
|
|
81
|
+
case "mermaid":
|
|
82
|
+
console.log(toMermaid(ir));
|
|
83
|
+
return 0;
|
|
84
|
+
case "dot":
|
|
85
|
+
console.log(toDot(ir));
|
|
86
|
+
return 0;
|
|
87
|
+
case "layout":
|
|
88
|
+
try {
|
|
89
|
+
const layout = await new GraphvizLayout().layout(toDot(ir));
|
|
90
|
+
console.log(JSON.stringify(layout, null, 2));
|
|
91
|
+
return 0;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
case "ir":
|
|
97
|
+
default:
|
|
98
|
+
console.log(JSON.stringify(ir, null, 2));
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
18
103
|
async function runOpGraph(): Promise<number> {
|
|
19
104
|
const { ops, errors } = await discoverOps();
|
|
20
105
|
for (const err of errors) console.error(formatError({ message: err }));
|
package/src/cli/main.test.ts
CHANGED
|
@@ -72,6 +72,22 @@ describe("parseArgs", () => {
|
|
|
72
72
|
expect(result.format).toBe("invalid"); // format is passed as-is to main
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
test("parses graph --detail as a number", () => {
|
|
76
|
+
const result = parseArgs(["graph", "--format", "ir", "--detail", "1"]);
|
|
77
|
+
expect(result.detail).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("parses graph --lens with a kind:target value", () => {
|
|
81
|
+
const result = parseArgs(["graph", "--format", "ir", "--lens", "blast:vpc"]);
|
|
82
|
+
expect(result.lens).toBe("blast:vpc");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("parses graph --up and --down flags", () => {
|
|
86
|
+
const result = parseArgs(["graph", "--lens", "blast:vpc", "--up", "--down"]);
|
|
87
|
+
expect(result.up).toBe(true);
|
|
88
|
+
expect(result.down).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
75
91
|
test("combines multiple options", () => {
|
|
76
92
|
const result = parseArgs([
|
|
77
93
|
"build",
|
package/src/cli/main.ts
CHANGED
|
@@ -126,6 +126,14 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
126
126
|
result.theme = args[++i];
|
|
127
127
|
} else if (arg === "--stacks") {
|
|
128
128
|
result.stacks = true;
|
|
129
|
+
} else if (arg === "--detail") {
|
|
130
|
+
result.detail = Number(args[++i]);
|
|
131
|
+
} else if (arg === "--lens") {
|
|
132
|
+
result.lens = args[++i];
|
|
133
|
+
} else if (arg === "--up") {
|
|
134
|
+
result.up = true;
|
|
135
|
+
} else if (arg === "--down") {
|
|
136
|
+
result.down = true;
|
|
129
137
|
} else if (arg === "--base") {
|
|
130
138
|
result.base = args[++i];
|
|
131
139
|
} else if (arg === "--head") {
|
|
@@ -190,7 +198,12 @@ Ops:
|
|
|
190
198
|
run cancel <name> Cancel the active workflow run (requires --force)
|
|
191
199
|
run log <name> Show run history for an Op
|
|
192
200
|
|
|
193
|
-
graph Show Op dependency graph (--stacks for cross-stack order
|
|
201
|
+
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
202
|
+
--format ir|mermaid|dot|layout for the lint-gated graph IR,
|
|
203
|
+
a Mermaid flowchart, Graphviz DOT, or node positions
|
|
204
|
+
(layout needs graphviz);
|
|
205
|
+
--detail 0..3: stacks|composites|declarables|attributes;
|
|
206
|
+
--lens lexicon:<n>|stack:<n>|blast:<node> (--up/--down))
|
|
194
207
|
|
|
195
208
|
Lifecycle (alias: lc):
|
|
196
209
|
lifecycle snapshot <env> Query API, save metadata to orphan branch
|
package/src/cli/registry.ts
CHANGED
|
@@ -57,6 +57,14 @@ export interface ParsedArgs {
|
|
|
57
57
|
env?: string;
|
|
58
58
|
/** `chant graph --stacks` — render the cross-stack apply-ordering graph */
|
|
59
59
|
stacks?: boolean;
|
|
60
|
+
/** `chant graph --format ir --detail <0..3>` — graph IR detail tier */
|
|
61
|
+
detail?: number;
|
|
62
|
+
/** `chant graph --lens <kind>:<target>` — focus the graph IR on a slice */
|
|
63
|
+
lens?: string;
|
|
64
|
+
/** `chant graph --lens blast:<node> --up` — include upstream producers */
|
|
65
|
+
up?: boolean;
|
|
66
|
+
/** `chant graph --lens blast:<node> --down` — include downstream dependents */
|
|
67
|
+
down?: boolean;
|
|
60
68
|
/** `chant lifecycle affected --base <ref>` — base git ref to diff against */
|
|
61
69
|
base?: string;
|
|
62
70
|
/** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
|
package/src/discovery/collect.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function collectEntities(
|
|
|
58
58
|
"resolution",
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
|
-
setProvenance(entity, { sourceFile: file });
|
|
61
|
+
setProvenance(entity, { sourceFile: file, compositeInstance: indexedName });
|
|
62
62
|
entities.set(expandedName, entity);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -73,7 +73,7 @@ export function collectEntities(
|
|
|
73
73
|
"resolution",
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
|
-
setProvenance(entity, { sourceFile: file });
|
|
76
|
+
setProvenance(entity, { sourceFile: file, compositeInstance: name });
|
|
77
77
|
entities.set(expandedName, entity);
|
|
78
78
|
}
|
|
79
79
|
} else if (isLexiconOutput(value)) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { applyDetail, DETAIL } from "./graph-detail";
|
|
3
|
+
import type { GraphIR } from "./graph-ir";
|
|
4
|
+
|
|
5
|
+
// A small graph: a gcp vpc/subnet pair plus a k8s namespace and deployment that
|
|
6
|
+
// came from one composite instance ("db"), with the deployment referencing the
|
|
7
|
+
// subnet across lexicons.
|
|
8
|
+
const base: GraphIR = {
|
|
9
|
+
nodes: [
|
|
10
|
+
{ id: "vpc", kind: "Vpc", lexicon: "gcp", attrs: {} },
|
|
11
|
+
{ id: "subnet", kind: "Subnet", lexicon: "gcp", attrs: { network: { $ref: "vpc.id" } } },
|
|
12
|
+
{
|
|
13
|
+
id: "dbNamespace",
|
|
14
|
+
kind: "Namespace",
|
|
15
|
+
lexicon: "k8s",
|
|
16
|
+
compositeParent: "DbStack",
|
|
17
|
+
compositeInstance: "db",
|
|
18
|
+
attrs: {},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "dbDeployment",
|
|
22
|
+
kind: "Deployment",
|
|
23
|
+
lexicon: "k8s",
|
|
24
|
+
compositeParent: "DbStack",
|
|
25
|
+
compositeInstance: "db",
|
|
26
|
+
attrs: { subnet: { $ref: "subnet.selfLink" } },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
edges: [
|
|
30
|
+
{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" },
|
|
31
|
+
{ from: "dbDeployment", to: "subnet", kind: "ref", viaAttr: "subnet" },
|
|
32
|
+
],
|
|
33
|
+
groups: { byLexicon: { gcp: ["subnet", "vpc"], k8s: ["dbDeployment", "dbNamespace"] } },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("applyDetail", () => {
|
|
37
|
+
test("T2 (declarables) is the identity", () => {
|
|
38
|
+
expect(applyDetail(base, DETAIL.DECLARABLES)).toBe(base);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("T0 (stacks) collapses to one node per lexicon with cross-lexicon edges", () => {
|
|
42
|
+
const ir = applyDetail(base, DETAIL.STACKS);
|
|
43
|
+
expect(ir.nodes.map((n) => n.id)).toEqual(["gcp", "k8s"]);
|
|
44
|
+
expect(ir.nodes.every((n) => n.kind === "stack")).toBe(true);
|
|
45
|
+
// subnet→vpc is intra-gcp (dropped); dbDeployment→subnet is k8s→gcp (kept).
|
|
46
|
+
expect(ir.edges).toEqual([{ from: "k8s", to: "gcp", kind: "ref" }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("T1 (composites) collapses a composite instance to a single node", () => {
|
|
50
|
+
const ir = applyDetail(base, DETAIL.COMPOSITES);
|
|
51
|
+
expect(ir.nodes.map((n) => n.id).sort()).toEqual(["db", "subnet", "vpc"]);
|
|
52
|
+
const db = ir.nodes.find((n) => n.id === "db")!;
|
|
53
|
+
expect(db).toMatchObject({ kind: "DbStack", lexicon: "k8s", attrs: { members: 2 } });
|
|
54
|
+
// The composite's external ref is preserved, remapped to the composite node.
|
|
55
|
+
expect(ir.edges).toContainEqual({ from: "db", to: "subnet", kind: "ref" });
|
|
56
|
+
// The intra-gcp edge survives with its label.
|
|
57
|
+
expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("T1 node count is between T0 and T2", () => {
|
|
61
|
+
const t0 = applyDetail(base, DETAIL.STACKS).nodes.length;
|
|
62
|
+
const t1 = applyDetail(base, DETAIL.COMPOSITES).nodes.length;
|
|
63
|
+
const t2 = applyDetail(base, DETAIL.DECLARABLES).nodes.length;
|
|
64
|
+
expect(t0).toBeLessThan(t1);
|
|
65
|
+
expect(t1).toBeLessThan(t2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("T3 (attributes) annotates edges with the producer attribute", () => {
|
|
69
|
+
const ir = applyDetail(base, DETAIL.ATTRIBUTES);
|
|
70
|
+
expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network", toAttr: "id" });
|
|
71
|
+
expect(ir.edges).toContainEqual({
|
|
72
|
+
from: "dbDeployment",
|
|
73
|
+
to: "subnet",
|
|
74
|
+
kind: "ref",
|
|
75
|
+
viaAttr: "subnet",
|
|
76
|
+
toAttr: "selfLink",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { GraphIR, IRNode, IREdge } from "./graph-ir";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detail tiers — the diagram "detail dial". Each level is a pure IR → IR
|
|
5
|
+
* transform over the base graph IR (no re-discovery), so every emitter and the
|
|
6
|
+
* painter get them for free. See issue #494 / epic #492.
|
|
7
|
+
*
|
|
8
|
+
* - 0 STACKS — one node per lexicon; edges are cross-lexicon dependencies
|
|
9
|
+
* - 1 COMPOSITES — composite instances collapsed to a single node each
|
|
10
|
+
* - 2 DECLARABLES — every resource (the base produced by buildGraphIr)
|
|
11
|
+
* - 3 ATTRIBUTES — declarables plus the producer attribute on each edge
|
|
12
|
+
*/
|
|
13
|
+
export type DetailLevel = 0 | 1 | 2 | 3;
|
|
14
|
+
|
|
15
|
+
export const DETAIL = {
|
|
16
|
+
STACKS: 0,
|
|
17
|
+
COMPOSITES: 1,
|
|
18
|
+
DECLARABLES: 2,
|
|
19
|
+
ATTRIBUTES: 3,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
/** Apply a detail tier to the base (declarable-level) IR. */
|
|
23
|
+
export function applyDetail(ir: GraphIR, level: DetailLevel): GraphIR {
|
|
24
|
+
switch (level) {
|
|
25
|
+
case 0:
|
|
26
|
+
return toStacks(ir);
|
|
27
|
+
case 1:
|
|
28
|
+
return toComposites(ir);
|
|
29
|
+
case 3:
|
|
30
|
+
return toAttributes(ir);
|
|
31
|
+
case 2:
|
|
32
|
+
default:
|
|
33
|
+
return ir;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function edgeSortKey(e: IREdge): string {
|
|
38
|
+
return `${e.from}\0${e.to}\0${e.viaAttr ?? ""}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortEdges(edges: IREdge[]): IREdge[] {
|
|
42
|
+
return edges.sort((a, b) => edgeSortKey(a).localeCompare(edgeSortKey(b)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function byLexiconOf(nodes: IRNode[]): Record<string, string[]> {
|
|
46
|
+
const out: Record<string, string[]> = {};
|
|
47
|
+
for (const n of nodes) (out[n.lexicon] ??= []).push(n.id);
|
|
48
|
+
const sorted: Record<string, string[]> = {};
|
|
49
|
+
for (const k of Object.keys(out).sort()) sorted[k] = out[k].sort();
|
|
50
|
+
return sorted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** T0 — collapse every resource to its lexicon; edges become cross-lexicon deps. */
|
|
54
|
+
function toStacks(ir: GraphIR): GraphIR {
|
|
55
|
+
const lexOf = new Map(ir.nodes.map((n) => [n.id, n.lexicon]));
|
|
56
|
+
const lexicons = [...new Set(ir.nodes.map((n) => n.lexicon))].sort();
|
|
57
|
+
const nodes: IRNode[] = lexicons.map((l) => ({ id: l, kind: "stack", lexicon: l, attrs: {} }));
|
|
58
|
+
|
|
59
|
+
const seen = new Map<string, IREdge>();
|
|
60
|
+
for (const e of ir.edges) {
|
|
61
|
+
const from = lexOf.get(e.from);
|
|
62
|
+
const to = lexOf.get(e.to);
|
|
63
|
+
if (!from || !to || from === to) continue;
|
|
64
|
+
const key = `${from}\0${to}`;
|
|
65
|
+
if (!seen.has(key)) seen.set(key, { from, to, kind: "ref" });
|
|
66
|
+
}
|
|
67
|
+
return { nodes, edges: sortEdges([...seen.values()]), groups: {} };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** T1 — collapse each composite instance to one node; internal edges disappear. */
|
|
71
|
+
function toComposites(ir: GraphIR): GraphIR {
|
|
72
|
+
const idMap = new Map<string, string>();
|
|
73
|
+
for (const n of ir.nodes) idMap.set(n.id, n.compositeInstance ?? n.id);
|
|
74
|
+
|
|
75
|
+
const instances = new Map<string, IRNode[]>();
|
|
76
|
+
const plain: IRNode[] = [];
|
|
77
|
+
for (const n of ir.nodes) {
|
|
78
|
+
if (n.compositeInstance) {
|
|
79
|
+
const arr = instances.get(n.compositeInstance) ?? [];
|
|
80
|
+
arr.push(n);
|
|
81
|
+
instances.set(n.compositeInstance, arr);
|
|
82
|
+
} else {
|
|
83
|
+
plain.push({ ...n });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nodes: IRNode[] = [...plain];
|
|
88
|
+
for (const [inst, members] of instances) {
|
|
89
|
+
const types = new Set(members.map((m) => m.compositeParent).filter(Boolean) as string[]);
|
|
90
|
+
const lexicons = new Set(members.map((m) => m.lexicon));
|
|
91
|
+
nodes.push({
|
|
92
|
+
id: inst,
|
|
93
|
+
kind: types.size === 1 ? [...types][0] : "Composite",
|
|
94
|
+
lexicon: lexicons.size === 1 ? [...lexicons][0] : "multi",
|
|
95
|
+
attrs: { members: members.length },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const seen = new Map<string, IREdge>();
|
|
100
|
+
for (const e of ir.edges) {
|
|
101
|
+
const from = idMap.get(e.from) ?? e.from;
|
|
102
|
+
const to = idMap.get(e.to) ?? e.to;
|
|
103
|
+
if (from === to) continue; // edge internal to a composite
|
|
104
|
+
// A property label only makes sense when neither endpoint was collapsed.
|
|
105
|
+
const collapsed = from !== e.from || to !== e.to;
|
|
106
|
+
const edge: IREdge = collapsed
|
|
107
|
+
? { from, to, kind: "ref" }
|
|
108
|
+
: { from, to, kind: "ref", viaAttr: e.viaAttr };
|
|
109
|
+
const key = `${from}\0${to}\0${edge.viaAttr ?? ""}`;
|
|
110
|
+
if (!seen.has(key)) seen.set(key, edge);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
114
|
+
return { nodes, edges: sortEdges([...seen.values()]), groups: { byLexicon: byLexiconOf(nodes) } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** T3 — annotate each edge with the producer attribute it references. */
|
|
118
|
+
function toAttributes(ir: GraphIR): GraphIR {
|
|
119
|
+
const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
120
|
+
const edges = ir.edges.map((e) => {
|
|
121
|
+
const node = nodeById.get(e.from);
|
|
122
|
+
const toAttr = node ? findRefAttr(node.attrs, e.to) : undefined;
|
|
123
|
+
return toAttr ? { ...e, toAttr } : { ...e };
|
|
124
|
+
});
|
|
125
|
+
return { ...ir, edges };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Find the attribute in a `{ $ref: "producer.attribute" }` envelope under attrs. */
|
|
129
|
+
function findRefAttr(attrs: Record<string, unknown>, producer: string): string | undefined {
|
|
130
|
+
let found: string | undefined;
|
|
131
|
+
const visit = (v: unknown): void => {
|
|
132
|
+
if (found !== undefined || v === null || typeof v !== "object") return;
|
|
133
|
+
if (Array.isArray(v)) {
|
|
134
|
+
for (const item of v) visit(item);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const ref = (v as { $ref?: unknown }).$ref;
|
|
138
|
+
if (typeof ref === "string") {
|
|
139
|
+
const dot = ref.indexOf(".");
|
|
140
|
+
if (dot > 0 && ref.slice(0, dot) === producer) {
|
|
141
|
+
found = ref.slice(dot + 1);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const val of Object.values(v as Record<string, unknown>)) visit(val);
|
|
146
|
+
};
|
|
147
|
+
visit(attrs);
|
|
148
|
+
return found;
|
|
149
|
+
}
|