@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { parseLens, applyLens } from "./graph-lens";
|
|
3
|
+
import type { GraphIR } from "./graph-ir";
|
|
4
|
+
|
|
5
|
+
// vpc <- subnet <- cluster (gcp), cluster <- pod (k8s). A linear dependency
|
|
6
|
+
// chain crossing two lexicons.
|
|
7
|
+
const ir: GraphIR = {
|
|
8
|
+
nodes: [
|
|
9
|
+
{ id: "vpc", kind: "Vpc", lexicon: "gcp", attrs: {} },
|
|
10
|
+
{ id: "subnet", kind: "Subnet", lexicon: "gcp", attrs: {} },
|
|
11
|
+
{ id: "cluster", kind: "GkeCluster", lexicon: "gcp", attrs: {} },
|
|
12
|
+
{ id: "pod", kind: "Pod", lexicon: "k8s", attrs: {} },
|
|
13
|
+
],
|
|
14
|
+
edges: [
|
|
15
|
+
{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" },
|
|
16
|
+
{ from: "cluster", to: "subnet", kind: "ref", viaAttr: "subnetwork" },
|
|
17
|
+
{ from: "pod", to: "cluster", kind: "ref", viaAttr: "cluster" },
|
|
18
|
+
],
|
|
19
|
+
groups: { byLexicon: { gcp: ["cluster", "subnet", "vpc"], k8s: ["pod"] } },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("parseLens", () => {
|
|
23
|
+
test("parses kind:target", () => {
|
|
24
|
+
expect(parseLens("lexicon:gcp")).toMatchObject({ kind: "lexicon", target: "gcp" });
|
|
25
|
+
});
|
|
26
|
+
test("blast defaults to both directions", () => {
|
|
27
|
+
expect(parseLens("blast:cluster")).toMatchObject({ up: true, down: true });
|
|
28
|
+
});
|
|
29
|
+
test("blast honours --up / --down", () => {
|
|
30
|
+
expect(parseLens("blast:cluster", { up: true })).toMatchObject({ up: true, down: false });
|
|
31
|
+
});
|
|
32
|
+
test("rejects malformed and unknown lenses", () => {
|
|
33
|
+
expect(() => parseLens("gcp")).toThrow();
|
|
34
|
+
expect(() => parseLens("bogus:x")).toThrow();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("applyLens", () => {
|
|
39
|
+
test("lexicon: keeps only that lexicon's nodes and internal edges", () => {
|
|
40
|
+
const out = applyLens(ir, parseLens("lexicon:gcp"));
|
|
41
|
+
expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "subnet", "vpc"]);
|
|
42
|
+
// cross-lexicon pod→cluster edge dropped; gcp-internal edges kept
|
|
43
|
+
expect(out.edges.map((e) => `${e.from}->${e.to}`).sort()).toEqual([
|
|
44
|
+
"cluster->subnet",
|
|
45
|
+
"subnet->vpc",
|
|
46
|
+
]);
|
|
47
|
+
expect(out.groups.byLexicon).toEqual({ gcp: ["cluster", "subnet", "vpc"] });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("lexicon: count matches the byLexicon partition", () => {
|
|
51
|
+
const out = applyLens(ir, parseLens("lexicon:gcp"));
|
|
52
|
+
expect(out.nodes.length).toBe(ir.groups.byLexicon!.gcp.length);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("blast --up returns the producer chain above a node", () => {
|
|
56
|
+
const out = applyLens(ir, parseLens("blast:cluster", { up: true }));
|
|
57
|
+
expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "subnet", "vpc"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("blast --down returns the dependents below a node", () => {
|
|
61
|
+
const out = applyLens(ir, parseLens("blast:cluster", { down: true }));
|
|
62
|
+
expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "pod"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("blast both returns the whole connected chain", () => {
|
|
66
|
+
const out = applyLens(ir, parseLens("blast:cluster"));
|
|
67
|
+
expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "pod", "subnet", "vpc"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("blast on a leaf returns just its chain", () => {
|
|
71
|
+
const out = applyLens(ir, parseLens("blast:vpc", { up: true }));
|
|
72
|
+
expect(out.nodes.map((n) => n.id)).toEqual(["vpc"]); // vpc depends on nothing
|
|
73
|
+
expect(out.edges).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws when the target matches nothing", () => {
|
|
77
|
+
expect(() => applyLens(ir, parseLens("lexicon:aws"))).toThrow();
|
|
78
|
+
expect(() => applyLens(ir, parseLens("blast:ghost"))).toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { GraphIR, IRNode, IREdge } from "./graph-ir";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lenses — focus the graph IR on a slice without touching the source. Pure
|
|
5
|
+
* IR → IR filters, composable with detail tiers (apply a lens, then view the
|
|
6
|
+
* result at any `--detail`). See issue #495 / epic #492.
|
|
7
|
+
*
|
|
8
|
+
* - `lexicon:<name>` — only nodes in a lexicon
|
|
9
|
+
* - `stack:<name>` — only one stack's nodes (stacks map to lexicon partitions today)
|
|
10
|
+
* - `blast:<nodeId>` — the transitive neighbourhood of a node: what it depends
|
|
11
|
+
* on (`--up`), what depends on it (`--down`), or both (default)
|
|
12
|
+
*
|
|
13
|
+
* Every lens drops edges that would dangle and rebuilds group metadata from the
|
|
14
|
+
* surviving nodes, so the result is always a self-consistent graph.
|
|
15
|
+
*/
|
|
16
|
+
export interface LensSpec {
|
|
17
|
+
kind: "lexicon" | "stack" | "blast";
|
|
18
|
+
target: string;
|
|
19
|
+
/** blast: include upstream producers (what the node depends on). */
|
|
20
|
+
up: boolean;
|
|
21
|
+
/** blast: include downstream dependents (what depends on the node). */
|
|
22
|
+
down: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const KINDS = new Set(["lexicon", "stack", "blast"]);
|
|
26
|
+
|
|
27
|
+
/** Parse a `--lens` spec. Throws a descriptive Error on a malformed spec. */
|
|
28
|
+
export function parseLens(spec: string, opts: { up?: boolean; down?: boolean } = {}): LensSpec {
|
|
29
|
+
const colon = spec.indexOf(":");
|
|
30
|
+
if (colon <= 0 || colon === spec.length - 1) {
|
|
31
|
+
throw new Error(`Invalid --lens "${spec}". Expected <kind>:<target>, e.g. lexicon:gcp or blast:vpc.`);
|
|
32
|
+
}
|
|
33
|
+
const kind = spec.slice(0, colon);
|
|
34
|
+
const target = spec.slice(colon + 1);
|
|
35
|
+
if (!KINDS.has(kind)) {
|
|
36
|
+
throw new Error(`Unknown lens "${kind}". Expected one of: ${[...KINDS].join(", ")}.`);
|
|
37
|
+
}
|
|
38
|
+
// For blast, default to both directions when neither flag is given.
|
|
39
|
+
const both = !opts.up && !opts.down;
|
|
40
|
+
return {
|
|
41
|
+
kind: kind as LensSpec["kind"],
|
|
42
|
+
target,
|
|
43
|
+
up: opts.up ?? both,
|
|
44
|
+
down: opts.down ?? both,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Apply a lens to the IR. Throws a descriptive Error when the target matches nothing. */
|
|
49
|
+
export function applyLens(ir: GraphIR, lens: LensSpec): GraphIR {
|
|
50
|
+
const keep =
|
|
51
|
+
lens.kind === "blast" ? blastSet(ir, lens) : partitionSet(ir, lens);
|
|
52
|
+
return subgraph(ir, keep);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Node ids for a lexicon/stack lens. */
|
|
56
|
+
function partitionSet(ir: GraphIR, lens: LensSpec): Set<string> {
|
|
57
|
+
if (lens.kind === "stack") {
|
|
58
|
+
const members = ir.groups.byStack?.[lens.target] ?? ir.groups.byLexicon?.[lens.target];
|
|
59
|
+
if (members && members.length) return new Set(members);
|
|
60
|
+
// Fall through to a direct scan if groups are absent.
|
|
61
|
+
}
|
|
62
|
+
const keep = new Set<string>();
|
|
63
|
+
for (const n of ir.nodes) if (n.lexicon === lens.target) keep.add(n.id);
|
|
64
|
+
if (keep.size === 0) {
|
|
65
|
+
throw new Error(`No nodes match lens ${lens.kind}:${lens.target}.`);
|
|
66
|
+
}
|
|
67
|
+
return keep;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Node ids in the transitive neighbourhood of a node. */
|
|
71
|
+
function blastSet(ir: GraphIR, lens: LensSpec): Set<string> {
|
|
72
|
+
if (!ir.nodes.some((n) => n.id === lens.target)) {
|
|
73
|
+
throw new Error(`No node "${lens.target}" for lens blast:${lens.target}.`);
|
|
74
|
+
}
|
|
75
|
+
const out = new Map<string, string[]>(); // from -> [to] (depends-on)
|
|
76
|
+
const inc = new Map<string, string[]>(); // to -> [from] (depended-on-by)
|
|
77
|
+
const push = (m: Map<string, string[]>, k: string, v: string): void => {
|
|
78
|
+
const arr = m.get(k);
|
|
79
|
+
if (arr) arr.push(v);
|
|
80
|
+
else m.set(k, [v]);
|
|
81
|
+
};
|
|
82
|
+
for (const e of ir.edges) {
|
|
83
|
+
push(out, e.from, e.to);
|
|
84
|
+
push(inc, e.to, e.from);
|
|
85
|
+
}
|
|
86
|
+
const keep = new Set<string>([lens.target]);
|
|
87
|
+
if (lens.up) walk(lens.target, out, keep);
|
|
88
|
+
if (lens.down) walk(lens.target, inc, keep);
|
|
89
|
+
return keep;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function walk(start: string, adj: Map<string, string[]>, keep: Set<string>): void {
|
|
93
|
+
const stack = [start];
|
|
94
|
+
while (stack.length) {
|
|
95
|
+
const cur = stack.pop()!;
|
|
96
|
+
for (const next of adj.get(cur) ?? []) {
|
|
97
|
+
if (!keep.has(next)) {
|
|
98
|
+
keep.add(next);
|
|
99
|
+
stack.push(next);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Restrict the IR to a node set: keep internal edges, rebuild groups. */
|
|
106
|
+
function subgraph(ir: GraphIR, keep: Set<string>): GraphIR {
|
|
107
|
+
const nodes: IRNode[] = ir.nodes.filter((n) => keep.has(n.id));
|
|
108
|
+
const edges: IREdge[] = ir.edges.filter((e) => keep.has(e.from) && keep.has(e.to));
|
|
109
|
+
|
|
110
|
+
const byLexicon: Record<string, string[]> = {};
|
|
111
|
+
const byComposite: Record<string, string[]> = {};
|
|
112
|
+
for (const n of nodes) {
|
|
113
|
+
(byLexicon[n.lexicon] ??= []).push(n.id);
|
|
114
|
+
if (n.compositeInstance) (byComposite[n.compositeInstance] ??= []).push(n.id);
|
|
115
|
+
}
|
|
116
|
+
const groups: GraphIR["groups"] = {};
|
|
117
|
+
if (Object.keys(byLexicon).length) groups.byLexicon = sortGroups(byLexicon);
|
|
118
|
+
if (Object.keys(byComposite).length) groups.byComposite = sortGroups(byComposite);
|
|
119
|
+
|
|
120
|
+
return { nodes, edges, groups };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sortGroups(rec: Record<string, string[]>): Record<string, string[]> {
|
|
124
|
+
const out: Record<string, string[]> = {};
|
|
125
|
+
for (const k of Object.keys(rec).sort()) out[k] = rec[k].sort();
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { toMermaid } from "./graph-mermaid";
|
|
3
|
+
import type { GraphIR } from "./graph-ir";
|
|
4
|
+
|
|
5
|
+
const ir: GraphIR = {
|
|
6
|
+
nodes: [
|
|
7
|
+
{ id: "vpc", kind: "Vpc", lexicon: "gcp", attrs: {} },
|
|
8
|
+
{ id: "subnet", kind: "Subnet", lexicon: "gcp", attrs: {} },
|
|
9
|
+
{ id: "ns", kind: "Namespace", lexicon: "k8s", attrs: {} },
|
|
10
|
+
],
|
|
11
|
+
edges: [
|
|
12
|
+
{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" },
|
|
13
|
+
{ from: "ns", to: "subnet", kind: "ref", viaAttr: "subnet", toAttr: "selfLink" },
|
|
14
|
+
],
|
|
15
|
+
groups: { byLexicon: { gcp: ["subnet", "vpc"], k8s: ["ns"] } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("toMermaid", () => {
|
|
19
|
+
test("renders a flowchart with lexicon subgraphs, nodes, and labelled edges", () => {
|
|
20
|
+
const out = toMermaid(ir);
|
|
21
|
+
expect(out).toContain("flowchart TD");
|
|
22
|
+
expect(out).toContain('subgraph lex_gcp["gcp"]');
|
|
23
|
+
expect(out).toContain('subgraph lex_k8s["k8s"]');
|
|
24
|
+
// node label = name + kind on two lines
|
|
25
|
+
expect(out).toContain('vpc["vpc<br/>Vpc"]');
|
|
26
|
+
// edge with consumer-property label
|
|
27
|
+
expect(out).toContain("subnet -->|\"network\"| vpc");
|
|
28
|
+
// T3 edge shows consumer → producer attribute
|
|
29
|
+
expect(out).toContain('ns -->|"subnet → selfLink"| subnet');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("is deterministic", () => {
|
|
33
|
+
expect(toMermaid(ir)).toEqual(toMermaid(ir));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("sanitizes ids that aren't Mermaid-safe but keeps the human label", () => {
|
|
37
|
+
const dirty: GraphIR = {
|
|
38
|
+
nodes: [
|
|
39
|
+
{ id: "east/db-0", kind: "StatefulSet", lexicon: "k8s", attrs: {} },
|
|
40
|
+
{ id: "east/db-1", kind: "StatefulSet", lexicon: "k8s", attrs: {} },
|
|
41
|
+
],
|
|
42
|
+
edges: [{ from: "east/db-1", to: "east/db-0", kind: "ref" }],
|
|
43
|
+
groups: {},
|
|
44
|
+
};
|
|
45
|
+
const out = toMermaid(dirty);
|
|
46
|
+
// ids are sanitized and unique
|
|
47
|
+
expect(out).toContain('east_db_0["east/db-0<br/>StatefulSet"]');
|
|
48
|
+
expect(out).toContain('east_db_1["east/db-1<br/>StatefulSet"]');
|
|
49
|
+
expect(out).toContain("east_db_1 --> east_db_0");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("places ungrouped nodes at the top level", () => {
|
|
53
|
+
const out = toMermaid({
|
|
54
|
+
nodes: [{ id: "loner", kind: "Thing", lexicon: "x", attrs: {} }],
|
|
55
|
+
edges: [],
|
|
56
|
+
groups: {},
|
|
57
|
+
});
|
|
58
|
+
expect(out).toContain('loner["loner<br/>Thing"]');
|
|
59
|
+
expect(out).not.toContain("subgraph");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { GraphIR, IRNode, IREdge } from "./graph-ir";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render the graph IR as a Mermaid `flowchart`. Mermaid is the zero-install
|
|
5
|
+
* default — it renders in GitHub, docs, and browsers with no native dependency,
|
|
6
|
+
* so `chant graph --format mermaid` gives a diagram out of the box without the
|
|
7
|
+
* standalone painter. Lower fidelity than a custom painter, but portable.
|
|
8
|
+
*
|
|
9
|
+
* Consumes whatever IR it is given, so it honours `--detail` and `--lens` for
|
|
10
|
+
* free (those are IR → IR transforms). See issue #496 / epic #492.
|
|
11
|
+
*
|
|
12
|
+
* Known limits: Mermaid owns layout, so there is little control over node
|
|
13
|
+
* placement, and very large graphs get hard to read — that is the trade-off for
|
|
14
|
+
* zero-install portability. Reach for the graphviz/custom-painter path (#497,
|
|
15
|
+
* pinhole) when fidelity matters.
|
|
16
|
+
*/
|
|
17
|
+
export function toMermaid(ir: GraphIR): string {
|
|
18
|
+
const ids = new Map<string, string>(); // logical name -> mermaid-safe id
|
|
19
|
+
for (const n of ir.nodes) safeId(n.id, ids);
|
|
20
|
+
|
|
21
|
+
const lines: string[] = ["flowchart TD"];
|
|
22
|
+
|
|
23
|
+
// Cluster by lexicon when grouping is available; nodes outside any group fall
|
|
24
|
+
// through to the top level. byLexicon is sorted, so output is deterministic.
|
|
25
|
+
const byLexicon = ir.groups.byLexicon;
|
|
26
|
+
const grouped = new Set<string>();
|
|
27
|
+
if (byLexicon) {
|
|
28
|
+
for (const [lexicon, members] of Object.entries(byLexicon)) {
|
|
29
|
+
lines.push(` subgraph ${safeId(`lex_${lexicon}`, ids)}[${quote(lexicon)}]`);
|
|
30
|
+
for (const id of members) {
|
|
31
|
+
const node = ir.nodes.find((n) => n.id === id);
|
|
32
|
+
if (!node) continue;
|
|
33
|
+
grouped.add(id);
|
|
34
|
+
lines.push(` ${nodeLine(node, ids)}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push(" end");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const node of ir.nodes) {
|
|
40
|
+
if (grouped.has(node.id)) continue;
|
|
41
|
+
lines.push(` ${nodeLine(node, ids)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const e of ir.edges) {
|
|
45
|
+
lines.push(` ${edgeLine(e, ids)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return lines.join("\n") + "\n";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nodeLine(node: IRNode, ids: Map<string, string>): string {
|
|
52
|
+
const id = safeId(node.id, ids);
|
|
53
|
+
const parts = [node.id];
|
|
54
|
+
if (node.kind && node.kind !== node.id) parts.push(node.kind);
|
|
55
|
+
return `${id}[${quote(parts.join("\n"))}]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function edgeLine(e: IREdge, ids: Map<string, string>): string {
|
|
59
|
+
const from = safeId(e.from, ids);
|
|
60
|
+
const to = safeId(e.to, ids);
|
|
61
|
+
const label = [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
|
|
62
|
+
return label ? `${from} -->|${quote(label)}| ${to}` : `${from} --> ${to}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Map an arbitrary logical name to a stable, unique Mermaid-safe node id. */
|
|
66
|
+
function safeId(raw: string, ids: Map<string, string>): string {
|
|
67
|
+
const existing = ids.get(raw);
|
|
68
|
+
if (existing) return existing;
|
|
69
|
+
let base = raw.replace(/[^A-Za-z0-9_]/g, "_");
|
|
70
|
+
if (base === "" || /^[0-9]/.test(base)) base = `n_${base}`;
|
|
71
|
+
const taken = new Set(ids.values());
|
|
72
|
+
let candidate = base;
|
|
73
|
+
let i = 1;
|
|
74
|
+
while (taken.has(candidate)) candidate = `${base}_${i++}`;
|
|
75
|
+
ids.set(raw, candidate);
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Quote a Mermaid label, escaping markup and turning newlines into <br/>. */
|
|
80
|
+
function quote(text: string): string {
|
|
81
|
+
const esc = (s: string): string =>
|
|
82
|
+
s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
83
|
+
const body = text.split("\n").map(esc).join("<br/>");
|
|
84
|
+
return `"${body}"`;
|
|
85
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,12 @@ export * from "./discovery/graph";
|
|
|
25
25
|
export * from "./discovery/index";
|
|
26
26
|
export * from "./discovery/cache";
|
|
27
27
|
export * from "./build";
|
|
28
|
+
export * from "./graph-ir";
|
|
29
|
+
export * from "./graph-detail";
|
|
30
|
+
export * from "./graph-mermaid";
|
|
31
|
+
export * from "./graph-dot";
|
|
32
|
+
export * from "./graph-layout";
|
|
33
|
+
export * from "./graph-lens";
|
|
28
34
|
export * from "./detectLexicon";
|
|
29
35
|
export * from "./lint/parser";
|
|
30
36
|
export * from "./lint/rule";
|
package/src/provenance.ts
CHANGED
|
@@ -15,8 +15,15 @@ const PROVENANCE = Symbol.for("chant.provenance");
|
|
|
15
15
|
export interface EntityProvenance {
|
|
16
16
|
/** Absolute path of the source file that declared (or exported) the entity. */
|
|
17
17
|
sourceFile?: string;
|
|
18
|
-
/** The composite that expanded this entity, when it came from one. */
|
|
18
|
+
/** The composite (type) that expanded this entity, when it came from one. */
|
|
19
19
|
composite?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The composite *instance* this entity belongs to — the export name of the
|
|
22
|
+
* top-level composite, shared by every member it expanded to. Distinguishes
|
|
23
|
+
* two instances of the same composite type, which `composite` cannot. Used to
|
|
24
|
+
* collapse a composite to a single node at coarse diagram detail levels (#494).
|
|
25
|
+
*/
|
|
26
|
+
compositeInstance?: string;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -30,6 +37,7 @@ export function setProvenance(entity: object, prov: EntityProvenance): void {
|
|
|
30
37
|
if (existing) {
|
|
31
38
|
existing.sourceFile ??= prov.sourceFile;
|
|
32
39
|
existing.composite ??= prov.composite;
|
|
40
|
+
existing.compositeInstance ??= prov.compositeInstance;
|
|
33
41
|
return;
|
|
34
42
|
}
|
|
35
43
|
Object.defineProperty(entity, PROVENANCE, {
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the provider-agnostic reconcile primitive.
|
|
3
|
+
*
|
|
4
|
+
* Pure unit tests over the generic primitives — no provider types, no I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
deepEqual,
|
|
10
|
+
diffFields,
|
|
11
|
+
diffCollection,
|
|
12
|
+
summarizeChangeSet,
|
|
13
|
+
renderChangeSet,
|
|
14
|
+
resolveRenames,
|
|
15
|
+
removalDeltaCap,
|
|
16
|
+
runGuardrailChecks,
|
|
17
|
+
} from "./reconcile";
|
|
18
|
+
import type { ChangeSet, ChangeSetEntry, DiffOptions, GuardrailCheck } from "./reconcile";
|
|
19
|
+
|
|
20
|
+
const noOpts: DiffOptions = {};
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// deepEqual / diffFields
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe("deepEqual", () => {
|
|
27
|
+
test("compares primitives and nested structures", () => {
|
|
28
|
+
expect(deepEqual(1, 1)).toBe(true);
|
|
29
|
+
expect(deepEqual("a", "b")).toBe(false);
|
|
30
|
+
expect(deepEqual({ a: [1, 2] }, { a: [1, 2] })).toBe(true);
|
|
31
|
+
expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false);
|
|
32
|
+
expect(deepEqual(null, undefined)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("diffFields", () => {
|
|
37
|
+
test("compares every key of desired when no key list is given", () => {
|
|
38
|
+
expect(diffFields({ a: 1, b: 2 }, { a: 1, b: 9 })).toEqual([{ field: "b", before: 9, after: 2 }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("compares only listed keys present in desired", () => {
|
|
42
|
+
expect(diffFields({ a: 1, b: 2 }, { a: 9, b: 9 }, ["a"])).toEqual([{ field: "a", before: 9, after: 1 }]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("ignores listed keys absent from desired (selective-by-omission)", () => {
|
|
46
|
+
expect(diffFields({ a: 1 }, { a: 1, b: 2 }, ["a", "b"])).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// diffCollection
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
interface D {
|
|
55
|
+
name: string;
|
|
56
|
+
v?: number;
|
|
57
|
+
}
|
|
58
|
+
interface L {
|
|
59
|
+
name: string;
|
|
60
|
+
v?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runCollection(desired: D[], live: L[], opts: DiffOptions = noOpts): ChangeSetEntry[] {
|
|
64
|
+
const out: ChangeSetEntry[] = [];
|
|
65
|
+
diffCollection<D, L>({
|
|
66
|
+
resourceType: "thing",
|
|
67
|
+
keyPrefix: "p/",
|
|
68
|
+
desired: new Map(desired.map((d) => [d.name, d])),
|
|
69
|
+
live: new Map(live.map((l) => [l.name, l])),
|
|
70
|
+
compareFields: (d, l) => (d.v !== l.v ? [{ field: "v", before: l.v, after: d.v }] : []),
|
|
71
|
+
opts,
|
|
72
|
+
out,
|
|
73
|
+
});
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("diffCollection", () => {
|
|
78
|
+
test("creates entries for desired-not-live (with key prefix)", () => {
|
|
79
|
+
const out = runCollection([{ name: "a", v: 1 }], []);
|
|
80
|
+
expect(out).toEqual([{ kind: "create", resourceType: "thing", key: "p/a", after: { name: "a", v: 1 } }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("updates when compareFields reports differences", () => {
|
|
84
|
+
const out = runCollection([{ name: "a", v: 2 }], [{ name: "a", v: 1 }]);
|
|
85
|
+
expect(out[0]!.kind).toBe("update");
|
|
86
|
+
expect(out[0]!.fields).toEqual([{ field: "v", before: 1, after: 2 }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("emits no entry when live matches desired", () => {
|
|
90
|
+
expect(runCollection([{ name: "a", v: 1 }], [{ name: "a", v: 1 }])).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("only deletes live-not-desired when ownership-gated", () => {
|
|
94
|
+
const live = [
|
|
95
|
+
{ name: "a", v: 1 },
|
|
96
|
+
{ name: "stray", v: 9 },
|
|
97
|
+
];
|
|
98
|
+
expect(runCollection([{ name: "a", v: 1 }], live)).toEqual([]); // no predicate
|
|
99
|
+
const owned = runCollection([{ name: "a", v: 1 }], live, { isOwned: (_t, k) => k === "p/stray" });
|
|
100
|
+
expect(owned).toEqual([
|
|
101
|
+
{ kind: "delete", resourceType: "thing", key: "p/stray", before: { name: "stray", v: 9 } },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("honours createAfter / updateAfter mappers", () => {
|
|
106
|
+
const out: ChangeSetEntry[] = [];
|
|
107
|
+
diffCollection<D, L>({
|
|
108
|
+
resourceType: "thing",
|
|
109
|
+
desired: new Map([["a", { name: "a", v: 5 }]]),
|
|
110
|
+
live: new Map(),
|
|
111
|
+
compareFields: () => [],
|
|
112
|
+
createAfter: (key, d) => ({ normalized: key, v: d.v }),
|
|
113
|
+
opts: noOpts,
|
|
114
|
+
out,
|
|
115
|
+
});
|
|
116
|
+
expect(out[0]!.after).toEqual({ normalized: "a", v: 5 });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// summarize / render
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("summarizeChangeSet / renderChangeSet", () => {
|
|
125
|
+
const cs: ChangeSet = {
|
|
126
|
+
org: "acme",
|
|
127
|
+
entries: [
|
|
128
|
+
{ kind: "create", resourceType: "thing", key: "a" },
|
|
129
|
+
{ kind: "update", resourceType: "thing", key: "b", fields: [{ field: "v", before: 1, after: 2 }] },
|
|
130
|
+
{ kind: "delete", resourceType: "thing", key: "c" },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
test("counts entries by kind", () => {
|
|
135
|
+
expect(summarizeChangeSet(cs)).toEqual({ create: 1, update: 1, delete: 1 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("renders a readable plan with the scope id and field changes", () => {
|
|
139
|
+
const out = renderChangeSet(cs);
|
|
140
|
+
expect(out).toContain("Plan for acme: 1 to create, 1 to update, 1 to delete");
|
|
141
|
+
expect(out).toContain("[thing] b");
|
|
142
|
+
expect(out).toContain("v: 1 → 2");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("renders 'No changes.' for an empty set", () => {
|
|
146
|
+
expect(renderChangeSet({ org: "acme", entries: [] })).toContain("No changes.");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Guardrail framework
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe("resolveRenames", () => {
|
|
155
|
+
test("collapses delete(previously)+create(key) into one update", () => {
|
|
156
|
+
const cs: ChangeSet = {
|
|
157
|
+
org: "acme",
|
|
158
|
+
entries: [
|
|
159
|
+
{ kind: "delete", resourceType: "team", key: "old", before: { slug: "old" } },
|
|
160
|
+
{ kind: "create", resourceType: "team", key: "new", after: { previously: "old" } },
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
const resolved = resolveRenames(cs);
|
|
164
|
+
expect(resolved.entries.some((e) => e.kind === "delete")).toBe(false);
|
|
165
|
+
const update = resolved.entries.find((e) => e.kind === "update")!;
|
|
166
|
+
expect(update.key).toBe("new");
|
|
167
|
+
expect(update.before).toEqual({ slug: "old" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("is a no-op without a matching previously alias", () => {
|
|
171
|
+
const cs: ChangeSet = { org: "acme", entries: [{ kind: "delete", resourceType: "team", key: "old" }] };
|
|
172
|
+
expect(resolveRenames(cs)).toBe(cs);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("removalDeltaCap", () => {
|
|
177
|
+
test("trips when deletes exceed the fraction of pre-existing entries", () => {
|
|
178
|
+
const cs: ChangeSet = {
|
|
179
|
+
org: "acme",
|
|
180
|
+
entries: Array.from({ length: 4 }, (_, i) => ({
|
|
181
|
+
kind: "delete" as const,
|
|
182
|
+
resourceType: "x",
|
|
183
|
+
key: `k${i}`,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
expect(removalDeltaCap(cs)!.guardrail).toBe("removalDeltaCap");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("excludes creates from the denominator and passes under the cap", () => {
|
|
190
|
+
const cs: ChangeSet = {
|
|
191
|
+
org: "acme",
|
|
192
|
+
entries: [
|
|
193
|
+
{ kind: "delete", resourceType: "x", key: "d" },
|
|
194
|
+
{ kind: "update", resourceType: "x", key: "u1" },
|
|
195
|
+
{ kind: "update", resourceType: "x", key: "u2" },
|
|
196
|
+
{ kind: "update", resourceType: "x", key: "u3" },
|
|
197
|
+
{ kind: "create", resourceType: "x", key: "c" },
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
expect(removalDeltaCap(cs)).toBeNull(); // 1/4 = 25%, not > 25%
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("runGuardrailChecks", () => {
|
|
205
|
+
test("resolves renames once and aggregates failing checks", () => {
|
|
206
|
+
const cs: ChangeSet = {
|
|
207
|
+
org: "acme",
|
|
208
|
+
entries: [
|
|
209
|
+
{ kind: "delete", resourceType: "x", key: "a" },
|
|
210
|
+
{ kind: "delete", resourceType: "x", key: "b" },
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
const failing: GuardrailCheck = (resolved) => removalDeltaCap(resolved);
|
|
214
|
+
const passing: GuardrailCheck = () => null;
|
|
215
|
+
const result = runGuardrailChecks(cs, [failing, passing]);
|
|
216
|
+
expect(result.ok).toBe(false);
|
|
217
|
+
if (!result.ok) expect(result.diagnostics).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("returns ok when every check passes", () => {
|
|
221
|
+
const cs: ChangeSet = { org: "acme", entries: [{ kind: "create", resourceType: "x", key: "a" }] };
|
|
222
|
+
expect(runGuardrailChecks(cs, [() => null])).toEqual({ ok: true });
|
|
223
|
+
});
|
|
224
|
+
});
|