@intentius/behold 0.26.0 → 0.28.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.js +187 -32
- package/package.json +4 -4
- package/web/app.js +95 -20
- package/web/dangling.js +29 -0
- package/web/dangling.test.js +34 -0
- package/web/edge-kind.js +124 -0
- package/web/edge-kind.test.js +100 -0
- package/web/index.html +28 -0
package/dist/cli.js
CHANGED
|
@@ -5129,13 +5129,15 @@ function dropForeignDeclarations(ir, projectDir) {
|
|
|
5129
5129
|
}
|
|
5130
5130
|
groups[axis] = kept;
|
|
5131
5131
|
}
|
|
5132
|
-
|
|
5132
|
+
const out = {
|
|
5133
5133
|
...ir,
|
|
5134
5134
|
nodes: ir.nodes.filter((n) => !gone.has(n.id)),
|
|
5135
5135
|
edges: ir.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
5136
5136
|
groups,
|
|
5137
5137
|
foreign
|
|
5138
5138
|
};
|
|
5139
|
+
if (ir.dangling) out.dangling = ir.dangling.filter((d) => !gone.has(d.from));
|
|
5140
|
+
return out;
|
|
5139
5141
|
}
|
|
5140
5142
|
function foreignNote(ir) {
|
|
5141
5143
|
const f = ir.foreign;
|
|
@@ -9131,6 +9133,11 @@ var init_workspace_kinds_schema = __esm({
|
|
|
9131
9133
|
},
|
|
9132
9134
|
fields: {
|
|
9133
9135
|
$ref: "#/$defs/fields"
|
|
9136
|
+
},
|
|
9137
|
+
reader: {
|
|
9138
|
+
type: "string",
|
|
9139
|
+
pattern: "^[a-z][a-z0-9-]{0,39}$",
|
|
9140
|
+
description: "A link reader chant ships, by name, that derives a member's links from the member's own files (#3932): dbt-project reads a dbt project's sources. The reader is chant's code and runs offline, parsing files as data; the package supplies only the name. A name this chant doesn't have keeps the kind without a reader, and the load reports it. A chant older than this field fails a kinds file that uses it."
|
|
9134
9141
|
}
|
|
9135
9142
|
},
|
|
9136
9143
|
patternProperties: {
|
|
@@ -12681,11 +12688,29 @@ function edgelessNote(zoom, ir, detail, instead) {
|
|
|
12681
12688
|
if (zoom === "components" || zoom === "logical") return void 0;
|
|
12682
12689
|
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
12683
12690
|
if (instead) return instead;
|
|
12684
|
-
|
|
12691
|
+
const dangling = ir.dangling?.length ?? 0;
|
|
12692
|
+
if (dangling > 0) {
|
|
12693
|
+
return `no edges: ${referencesCount(dangling)} ${dangling === 1 ? "names a target" : "name targets"} this project does not declare, listed on the card that holds ${dangling === 1 ? "it" : "each"}`;
|
|
12694
|
+
}
|
|
12695
|
+
if (detail !== void 0 && detail < 3 && !allInPipelines(ir)) {
|
|
12685
12696
|
return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
|
|
12686
12697
|
}
|
|
12687
12698
|
return "no edges \u2014 nothing in this estate references anything else";
|
|
12688
12699
|
}
|
|
12700
|
+
function referencesCount(n) {
|
|
12701
|
+
return `${n} reference${n === 1 ? "" : "s"}`;
|
|
12702
|
+
}
|
|
12703
|
+
function allInPipelines(ir) {
|
|
12704
|
+
const byPipeline = ir.groups?.byPipeline;
|
|
12705
|
+
if (!byPipeline) return false;
|
|
12706
|
+
const inPipeline = new Set(Object.values(byPipeline).flat());
|
|
12707
|
+
return ir.nodes.every((n) => inPipeline.has(n.id ?? ""));
|
|
12708
|
+
}
|
|
12709
|
+
function danglingNote(ir) {
|
|
12710
|
+
const n = ir.dangling?.length ?? 0;
|
|
12711
|
+
if (n === 0) return void 0;
|
|
12712
|
+
return `${referencesCount(n)} to targets not declared here, such as another repo's workflow or a local action: listed on their cards, not drawn`;
|
|
12713
|
+
}
|
|
12689
12714
|
var SAME_AS_RESOURCES = {
|
|
12690
12715
|
choudoufu: ["composites", "attributes"],
|
|
12691
12716
|
terraform: ["composites"]
|
|
@@ -12721,7 +12746,9 @@ function namespaceJoinNote(joined) {
|
|
|
12721
12746
|
}
|
|
12722
12747
|
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail, edgelessInstead) {
|
|
12723
12748
|
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
12724
|
-
const
|
|
12749
|
+
const edgeless = edgelessNote(zoom, ir, detail, edgelessInstead);
|
|
12750
|
+
const counted = edgeless !== void 0 || zoom === "components" || zoom === "logical" ? void 0 : danglingNote(ir);
|
|
12751
|
+
const notes = [primary, edgeless, counted].filter((n) => n !== void 0);
|
|
12725
12752
|
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
12726
12753
|
}
|
|
12727
12754
|
function tierMismatchNote(ir, tiers, currentTier) {
|
|
@@ -12984,7 +13011,7 @@ function memberIrFromGraphDocument(doc, member) {
|
|
|
12984
13011
|
if (isRecord7(doc.groups)) {
|
|
12985
13012
|
for (const [key, table] of Object.entries(doc.groups)) {
|
|
12986
13013
|
if (key === "byMember" || !isRecord7(table)) continue;
|
|
12987
|
-
const scoped = key === "byStack" || key === "byContainer" || key === "byWave";
|
|
13014
|
+
const scoped = key === "byStack" || key === "byContainer" || key === "byWave" || key === "byPipeline";
|
|
12988
13015
|
for (const [k, ids] of Object.entries(table)) {
|
|
12989
13016
|
if (scoped && !k.startsWith(p)) continue;
|
|
12990
13017
|
const members = (Array.isArray(ids) ? ids : []).map(un).filter((id) => own.has(id));
|
|
@@ -13000,7 +13027,12 @@ function memberIrFromGraphDocument(doc, member) {
|
|
|
13000
13027
|
const { member: _m, ...rest } = i;
|
|
13001
13028
|
return { ...rest, node: un(i.node) };
|
|
13002
13029
|
});
|
|
13030
|
+
const dangling = (Array.isArray(doc.dangling) ? doc.dangling : []).filter((d) => isRecord7(d) && (d.member === member || d.member === void 0 && typeof d.from === "string" && d.from.startsWith(p))).map((d) => {
|
|
13031
|
+
const { member: _m, ...rest } = d;
|
|
13032
|
+
return { ...rest, from: un(d.from) };
|
|
13033
|
+
}).filter((d) => own.has(d.from));
|
|
13003
13034
|
const ir = { version: 1, nodes, edges, groups, exports, imports };
|
|
13035
|
+
if (dangling.length) ir.dangling = dangling;
|
|
13004
13036
|
if (isRecord7(entry.meta)) ir.meta = entry.meta;
|
|
13005
13037
|
if (entry.pipeline !== void 0) ir.pipeline = entry.pipeline;
|
|
13006
13038
|
if (isRecord7(doc.derivedAttrs)) ir.derivedAttrs = doc.derivedAttrs;
|
|
@@ -14126,8 +14158,9 @@ function projectChoudoufuLogical(ir) {
|
|
|
14126
14158
|
const edges = [];
|
|
14127
14159
|
const seen = /* @__PURE__ */ new Set();
|
|
14128
14160
|
for (const e of ir.edges) {
|
|
14129
|
-
|
|
14130
|
-
|
|
14161
|
+
const key = `${e.from}|${e.to}|${e.kind}`;
|
|
14162
|
+
if (!kept.has(e.from) || !kept.has(e.to) || seen.has(key)) continue;
|
|
14163
|
+
seen.add(key);
|
|
14131
14164
|
edges.push(e);
|
|
14132
14165
|
}
|
|
14133
14166
|
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
|
|
@@ -14155,8 +14188,9 @@ function projectTerraformLogical(ir) {
|
|
|
14155
14188
|
const edges = [];
|
|
14156
14189
|
const seen = /* @__PURE__ */ new Set();
|
|
14157
14190
|
for (const e of ir.edges) {
|
|
14158
|
-
|
|
14159
|
-
|
|
14191
|
+
const key = `${e.from}|${e.to}|${e.kind}`;
|
|
14192
|
+
if (!kept.has(e.from) || !kept.has(e.to) || seen.has(key)) continue;
|
|
14193
|
+
seen.add(key);
|
|
14160
14194
|
edges.push(e);
|
|
14161
14195
|
}
|
|
14162
14196
|
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
|
|
@@ -15643,7 +15677,7 @@ function collapseBoxes(ir, opts = {}) {
|
|
|
15643
15677
|
const from = standsFor.get(e.from) ?? e.from;
|
|
15644
15678
|
const to = standsFor.get(e.to) ?? e.to;
|
|
15645
15679
|
if (from === to) continue;
|
|
15646
|
-
const key = `${from}|${to}`;
|
|
15680
|
+
const key = `${from}|${to}|${e.kind}`;
|
|
15647
15681
|
if (seen.has(key)) continue;
|
|
15648
15682
|
seen.add(key);
|
|
15649
15683
|
edges.push(from === e.from && to === e.to ? e : { ...e, from, to });
|
|
@@ -15747,7 +15781,9 @@ function estateFace(irIn, boxesIn, opts = {}) {
|
|
|
15747
15781
|
const face = cardFaces(irIn, boxesIn, opts.prefixes ? { prefixes: opts.prefixes } : {});
|
|
15748
15782
|
const ir = face.ir;
|
|
15749
15783
|
if (Object.keys(face.overrides).length) faceOverrides.set(ir, face.overrides);
|
|
15750
|
-
const
|
|
15784
|
+
const toDisplay = (record) => record ? Object.fromEntries(Object.entries(record).map(([key, ids]) => [key, ids.map((id) => face.display.get(id) ?? id)])) : void 0;
|
|
15785
|
+
const boxes = toDisplay(boxesIn);
|
|
15786
|
+
const subgroups = boxes ? toDisplay(opts.subgroups) : void 0;
|
|
15751
15787
|
const realBand = moduleBandOf(irIn);
|
|
15752
15788
|
const toReal = new Map(ir.nodes.map((n, i) => [n.id, irIn.nodes[i].id]));
|
|
15753
15789
|
const bandOf = realBand ? (id) => realBand(toReal.get(id) ?? id) : void 0;
|
|
@@ -15757,13 +15793,14 @@ function estateFace(irIn, boxesIn, opts = {}) {
|
|
|
15757
15793
|
...opts.nodesep !== void 0 ? { nodesep: opts.nodesep } : {},
|
|
15758
15794
|
...opts.ranksep !== void 0 ? { ranksep: opts.ranksep } : {}
|
|
15759
15795
|
}) : { ir, grids: /* @__PURE__ */ new Map() };
|
|
15760
|
-
return { ir, overrides: face.overrides, boxes, display: face.display, bandOf, chains, restore: face.restore };
|
|
15796
|
+
return { ir, overrides: face.overrides, boxes, subgroups, display: face.display, bandOf, chains, restore: face.restore };
|
|
15761
15797
|
}
|
|
15762
15798
|
function packEstateBoxes(layout, face, opts = {}) {
|
|
15763
15799
|
const boxes = face.boxes;
|
|
15764
15800
|
if (!boxes) return;
|
|
15765
|
-
const boxGrids = packBoxComponents(layout, face.ir, boxes, face.bandOf);
|
|
15766
|
-
if (opts.members) packMemberBoxes(layout, face.ir, boxes);
|
|
15801
|
+
const boxGrids = packBoxComponents(layout, face.ir, boxes, face.bandOf, face.subgroups);
|
|
15802
|
+
if (opts.members) packMemberBoxes(layout, face.ir, { ...topLevelSubgroups(layout.groups, face.subgroups), ...boxes });
|
|
15803
|
+
if (face.subgroups) fitNestedBoxes(layout, face.ir, boxes, face.subgroups);
|
|
15767
15804
|
addBandBoxes(layout, new Map([...boxGrids, ...face.chains.grids]), footprints(face.ir, face.overrides));
|
|
15768
15805
|
applyBadges(layout.groups, opts.badges, opts.marks);
|
|
15769
15806
|
}
|
|
@@ -15795,15 +15832,30 @@ function renderArchitecture(ir, byContainer, opts = {}) {
|
|
|
15795
15832
|
}
|
|
15796
15833
|
function renderGraph(irIn, opts = {}) {
|
|
15797
15834
|
const groupsIn = irIn.groups;
|
|
15798
|
-
const
|
|
15799
|
-
const
|
|
15835
|
+
const pipelines = groupsIn.byPipeline && Object.keys(groupsIn.byPipeline).length ? groupsIn.byPipeline : void 0;
|
|
15836
|
+
const boxKey = groupsIn.byWave ? "byWave" : opts.boxes ?? (pipelines ? "byPipeline" : void 0);
|
|
15837
|
+
const nested = boxKey === "byStack" ? pipelines : void 0;
|
|
15838
|
+
const face = estateFace(irIn, boxKey ? groupsIn[boxKey] : void 0, nested ? { subgroups: nested } : {});
|
|
15800
15839
|
const ir = face.ir;
|
|
15801
15840
|
const boxes = face.boxes;
|
|
15802
15841
|
const chains = face.chains;
|
|
15803
|
-
const layout = layoutIr(chains.ir, {
|
|
15842
|
+
const layout = layoutIr(chains.ir, {
|
|
15843
|
+
fit: true,
|
|
15844
|
+
overrides: face.overrides,
|
|
15845
|
+
...boxes ? { groups: boxes } : {},
|
|
15846
|
+
...face.subgroups ? { subgroups: face.subgroups } : {}
|
|
15847
|
+
});
|
|
15804
15848
|
if (opts.radial && !boxes) radializeLayout(layout, groupKeyByNode(ir), footprints(ir, face.overrides));
|
|
15805
15849
|
else if (!boxes) packComponents(layout, ir);
|
|
15806
15850
|
packEstateBoxes(layout, face, { members: boxKey === "byStack", ...opts.groupBadges ? { badges: opts.groupBadges } : {} });
|
|
15851
|
+
if ((boxKey === "byPipeline" || nested) && pipelines) {
|
|
15852
|
+
const titles = pipelineTitles(irIn, pipelines);
|
|
15853
|
+
for (const box of layout.groups ?? []) {
|
|
15854
|
+
if (box.id === void 0 || titles[box.id] === void 0) continue;
|
|
15855
|
+
const title = titles[box.id];
|
|
15856
|
+
box.title = title === box.id && box.parent !== void 0 && title.startsWith(`${box.parent}/`) ? title.slice(box.parent.length + 1) : title;
|
|
15857
|
+
}
|
|
15858
|
+
}
|
|
15807
15859
|
const svg = renderSvg(ir, layout, {
|
|
15808
15860
|
fit: true,
|
|
15809
15861
|
hideTitle: true,
|
|
@@ -15813,6 +15865,22 @@ function renderGraph(irIn, opts = {}) {
|
|
|
15813
15865
|
});
|
|
15814
15866
|
return { svg: face.restore(svg) };
|
|
15815
15867
|
}
|
|
15868
|
+
function pipelineTitles(ir, byPipeline) {
|
|
15869
|
+
const nodes = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
15870
|
+
const workflowOf = (ids) => {
|
|
15871
|
+
const workflows = ids.flatMap((id) => {
|
|
15872
|
+
const n = nodes.get(id);
|
|
15873
|
+
return n && /::Workflow$/.test(n.kind) ? [n] : [];
|
|
15874
|
+
});
|
|
15875
|
+
return workflows.length === 1 ? workflows[0] : void 0;
|
|
15876
|
+
};
|
|
15877
|
+
return Object.fromEntries(
|
|
15878
|
+
Object.entries(byPipeline).map(([key, ids]) => {
|
|
15879
|
+
const name = (nodes.get(key) ?? workflowOf(ids))?.attrs?.["name"];
|
|
15880
|
+
return [key, typeof name === "string" && name.trim() ? name : key];
|
|
15881
|
+
})
|
|
15882
|
+
);
|
|
15883
|
+
}
|
|
15816
15884
|
function applyBadges(boxes, badges, marks) {
|
|
15817
15885
|
if (!badges && !marks) return;
|
|
15818
15886
|
for (const box of boxes ?? []) {
|
|
@@ -15858,6 +15926,39 @@ function addBandBoxes(layout, grids, sizes) {
|
|
|
15858
15926
|
boxes.push(...added);
|
|
15859
15927
|
}
|
|
15860
15928
|
var BAND_PAD = 14;
|
|
15929
|
+
var NEST_PAD_X = 50;
|
|
15930
|
+
var NEST_PAD_Y = 36;
|
|
15931
|
+
var NEST_OUTER = 36;
|
|
15932
|
+
function topLevelSubgroups(boxes, subgroups) {
|
|
15933
|
+
if (!subgroups) return {};
|
|
15934
|
+
return Object.fromEntries(
|
|
15935
|
+
(boxes ?? []).flatMap((b) => b.parent === void 0 && b.id !== void 0 && subgroups[b.id] ? [[b.id, subgroups[b.id]]] : [])
|
|
15936
|
+
);
|
|
15937
|
+
}
|
|
15938
|
+
function fitNestedBoxes(layout, ir, boxes, subgroups, only2) {
|
|
15939
|
+
const at2 = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
15940
|
+
const ownerOf2 = /* @__PURE__ */ new Map();
|
|
15941
|
+
for (const [key, ids] of Object.entries(boxes)) for (const id of ids) if (at2.has(id)) ownerOf2.set(id, key);
|
|
15942
|
+
const sizes = footprints(ir);
|
|
15943
|
+
for (const box of layout.groups ?? []) {
|
|
15944
|
+
if (box.parent === void 0 || box.id === void 0 || only2 !== void 0 && box.parent !== only2) continue;
|
|
15945
|
+
const cards = (subgroups[box.id] ?? []).flatMap((id) => {
|
|
15946
|
+
const p = at2.get(id);
|
|
15947
|
+
if (!p || ownerOf2.get(id) !== box.parent) return [];
|
|
15948
|
+
const s = sizes.get(id) ?? { w: NODE_W, h: NODE_H };
|
|
15949
|
+
return [{ x: p.x, y: p.y, w: s.w, h: s.h }];
|
|
15950
|
+
});
|
|
15951
|
+
if (!cards.length) continue;
|
|
15952
|
+
const x0 = Math.min(...cards.map((c) => c.x - c.w / 2)) - NEST_PAD_X;
|
|
15953
|
+
const x1 = Math.max(...cards.map((c) => c.x + c.w / 2)) + NEST_PAD_X;
|
|
15954
|
+
const y0 = Math.min(...cards.map((c) => c.y - c.h / 2)) - NEST_PAD_Y;
|
|
15955
|
+
const y1 = Math.max(...cards.map((c) => c.y + c.h / 2)) + NEST_PAD_Y;
|
|
15956
|
+
box.x = (x0 + x1) / 2;
|
|
15957
|
+
box.y = (y0 + y1) / 2;
|
|
15958
|
+
box.w = x1 - x0;
|
|
15959
|
+
box.h = y1 - y0;
|
|
15960
|
+
}
|
|
15961
|
+
}
|
|
15861
15962
|
function clampInside(r, box) {
|
|
15862
15963
|
const inset = 6;
|
|
15863
15964
|
const left = box.x - box.w / 2 + inset;
|
|
@@ -16173,7 +16274,8 @@ function packComponents(layout, ir) {
|
|
|
16173
16274
|
layout.height = Math.max(...nodes.map((n) => n.y + halfH(n))) + pad;
|
|
16174
16275
|
}
|
|
16175
16276
|
function packMemberBoxes(layout, ir, members) {
|
|
16176
|
-
const
|
|
16277
|
+
const allBoxes = layout.groups ?? [];
|
|
16278
|
+
const boxes = allBoxes.filter((b) => b.parent === void 0);
|
|
16177
16279
|
if (boxes.length < 4) return;
|
|
16178
16280
|
const nodes = layout.nodes;
|
|
16179
16281
|
const size = footprints(ir);
|
|
@@ -16289,7 +16391,7 @@ function gridComponent(one, sizes) {
|
|
|
16289
16391
|
const rows = Math.ceil(order.length / cols);
|
|
16290
16392
|
return { local, w: Math.min(cols, order.length) * (cell.w + sep12.x) - sep12.x, h: rows * (cell.h + sep12.y) - sep12.y };
|
|
16291
16393
|
}
|
|
16292
|
-
function packBoxComponents(layout, ir, boxes, bandOf) {
|
|
16394
|
+
function packBoxComponents(layout, ir, boxes, bandOf, subgroups) {
|
|
16293
16395
|
const grids = /* @__PURE__ */ new Map();
|
|
16294
16396
|
const groupBoxes = layout.groups ?? [];
|
|
16295
16397
|
const nodes = layout.nodes;
|
|
@@ -16320,6 +16422,10 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
|
|
|
16320
16422
|
const b = idxOf.get(e.to);
|
|
16321
16423
|
if (a !== void 0 && b !== void 0) parent[find(a)] = find(b);
|
|
16322
16424
|
}
|
|
16425
|
+
for (const ids2 of Object.values(subgroups ?? {})) {
|
|
16426
|
+
const inside = ids2.flatMap((id) => idxOf.get(id) ?? []);
|
|
16427
|
+
for (const i of inside.slice(1)) parent[find(i)] = find(inside[0]);
|
|
16428
|
+
}
|
|
16323
16429
|
const comps = /* @__PURE__ */ new Map();
|
|
16324
16430
|
own.forEach((n, i) => (comps.get(find(i)) ?? comps.set(find(i), []).get(find(i))).push(n));
|
|
16325
16431
|
if (comps.size < 2) continue;
|
|
@@ -16332,12 +16438,19 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
|
|
|
16332
16438
|
edges: ir.edges.filter((e) => inside.has(e.from) && inside.has(e.to)),
|
|
16333
16439
|
groups: {}
|
|
16334
16440
|
};
|
|
16335
|
-
|
|
16441
|
+
const nest = Object.fromEntries(
|
|
16442
|
+
Object.entries(subgroups ?? {}).flatMap(([k, ids2]) => {
|
|
16443
|
+
const mine = ids2.filter((id) => inside.has(id));
|
|
16444
|
+
return mine.length ? [[k, mine]] : [];
|
|
16445
|
+
})
|
|
16446
|
+
);
|
|
16447
|
+
const nests = Object.keys(nest).length > 0;
|
|
16448
|
+
let one = layoutIr(sub, { fit: true, ...nests ? { groups: nest } : {} });
|
|
16336
16449
|
if (one.width > one.height * BOX_ASPECT_MAX) {
|
|
16337
|
-
const sideways = layoutIr(sub, { fit: true, rankdir: "LR" });
|
|
16450
|
+
const sideways = layoutIr(sub, { fit: true, rankdir: "LR", ...nests ? { groups: nest } : {} });
|
|
16338
16451
|
if (offAspect(sideways) < offAspect(one)) one = sideways;
|
|
16339
16452
|
}
|
|
16340
|
-
const placed = offAspect(one) > GRID_COMPONENT ? gridComponent(one, size) : { local: new Map(one.nodes.map((n) => [n.id, { x: n.x, y: one.height - n.y }])), w: one.width, h: one.height };
|
|
16453
|
+
const placed = !nests && offAspect(one) > GRID_COMPONENT ? gridComponent(one, size) : { local: new Map(one.nodes.map((n) => [n.id, { x: n.x, y: one.height - n.y }])), w: one.width, h: one.height };
|
|
16341
16454
|
laid.set(root, { ...placed, ns });
|
|
16342
16455
|
}
|
|
16343
16456
|
const order = [void 0];
|
|
@@ -16353,8 +16466,9 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
|
|
|
16353
16466
|
const all = [...laid.values()];
|
|
16354
16467
|
const area = all.reduce((s, c) => s + (c.w + COMPONENT_GAP) * (c.h + COMPONENT_GAP), 0);
|
|
16355
16468
|
const targetW = Math.max(Math.max(...all.map((c) => c.w)), Math.sqrt(area * ROW_ASPECT));
|
|
16356
|
-
const
|
|
16357
|
-
const
|
|
16469
|
+
const holdsNested = groupBoxes.some((b) => b.parent === key);
|
|
16470
|
+
const originX = box.x - box.w / 2 + (holdsNested ? NEST_OUTER : BAND_PAD);
|
|
16471
|
+
const originY = box.y - box.h / 2 + (holdsNested ? NEST_OUTER : TITLE);
|
|
16358
16472
|
let shelfX = 0;
|
|
16359
16473
|
let shelfY = 0;
|
|
16360
16474
|
let shelfH = 0;
|
|
@@ -16386,10 +16500,18 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
|
|
|
16386
16500
|
}
|
|
16387
16501
|
bands.push({ key: band, ids: ids2 });
|
|
16388
16502
|
}
|
|
16389
|
-
|
|
16390
|
-
const
|
|
16391
|
-
|
|
16392
|
-
|
|
16503
|
+
if (subgroups) fitNestedBoxes(layout, ir, boxes, subgroups, key);
|
|
16504
|
+
const inner = groupBoxes.filter((b) => b.parent === key);
|
|
16505
|
+
let x0 = Math.min(...own.map((n) => n.x - halfW(n))) - BAND_PAD;
|
|
16506
|
+
let x1 = Math.max(...own.map((n) => n.x + halfW(n))) + BAND_PAD;
|
|
16507
|
+
let y0 = Math.min(...own.map((n) => n.y - halfH(n))) - TITLE;
|
|
16508
|
+
let y1 = Math.max(...own.map((n) => n.y + halfH(n))) + BAND_PAD;
|
|
16509
|
+
for (const b of inner) {
|
|
16510
|
+
x0 = Math.min(x0, b.x - b.w / 2 - NEST_OUTER);
|
|
16511
|
+
x1 = Math.max(x1, b.x + b.w / 2 + NEST_OUTER);
|
|
16512
|
+
y0 = Math.min(y0, b.y - b.h / 2 - NEST_OUTER);
|
|
16513
|
+
y1 = Math.max(y1, b.y + b.h / 2 + NEST_OUTER);
|
|
16514
|
+
}
|
|
16393
16515
|
box.x = (x0 + x1) / 2;
|
|
16394
16516
|
box.y = (y0 + y1) / 2;
|
|
16395
16517
|
box.w = x1 - x0;
|
|
@@ -19443,6 +19565,17 @@ async function ledgerIn(root, dir) {
|
|
|
19443
19565
|
return { members, prefix: memberLedgerPrefix(members) };
|
|
19444
19566
|
}
|
|
19445
19567
|
|
|
19568
|
+
// node_modules/@intentius/chant/src/lifecycle/no-record.ts
|
|
19569
|
+
var NO_RECORD_ENV = "CHANT_NO_RECORD";
|
|
19570
|
+
function ledgerRecordingOff(env = process.env) {
|
|
19571
|
+
const value = env[NO_RECORD_ENV]?.trim().toLowerCase();
|
|
19572
|
+
return value !== void 0 && value !== "" && value !== "0" && value !== "false";
|
|
19573
|
+
}
|
|
19574
|
+
var overlays = /* @__PURE__ */ new Map();
|
|
19575
|
+
function overlayTip(key) {
|
|
19576
|
+
return overlays.get(key)?.tip;
|
|
19577
|
+
}
|
|
19578
|
+
|
|
19446
19579
|
// node_modules/@intentius/chant/src/lifecycle/git.ts
|
|
19447
19580
|
var STATE_BRANCH = "chant/lifecycle";
|
|
19448
19581
|
async function ledgerDir(dir, opts) {
|
|
@@ -19459,7 +19592,7 @@ async function readBlobFromPath(environment, filename, opts) {
|
|
|
19459
19592
|
const rt = getRuntime();
|
|
19460
19593
|
const dir = await ledgerDir(environment, opts);
|
|
19461
19594
|
const result = await rt.spawn(
|
|
19462
|
-
["git", "show", `${
|
|
19595
|
+
["git", "show", `${await ledgerRevision(opts?.cwd)}:${dir}/${filename}`],
|
|
19463
19596
|
{ cwd: opts?.cwd }
|
|
19464
19597
|
);
|
|
19465
19598
|
if (result.exitCode !== 0) return null;
|
|
@@ -19484,7 +19617,7 @@ async function listLedgerTrees(opts) {
|
|
|
19484
19617
|
const rt = getRuntime();
|
|
19485
19618
|
const base = await ledgerDir("", opts);
|
|
19486
19619
|
const rootResult = await rt.spawn(
|
|
19487
|
-
["git", "ls-tree", "--full-tree", base === "" ?
|
|
19620
|
+
["git", "ls-tree", "--full-tree", base === "" ? tip : `${tip}:${base}`],
|
|
19488
19621
|
{ cwd: opts?.cwd }
|
|
19489
19622
|
);
|
|
19490
19623
|
if (rootResult.exitCode !== 0) return [];
|
|
@@ -19495,7 +19628,12 @@ async function listLedgerTrees(opts) {
|
|
|
19495
19628
|
}
|
|
19496
19629
|
return trees;
|
|
19497
19630
|
}
|
|
19631
|
+
var C_LOCALE_ENV = { LC_ALL: "C", LANGUAGE: "" };
|
|
19498
19632
|
async function getStateBranchTip(cwd) {
|
|
19633
|
+
if (ledgerRecordingOff()) {
|
|
19634
|
+
const tip = overlayTip(await overlayKey(cwd));
|
|
19635
|
+
if (tip !== void 0) return tip;
|
|
19636
|
+
}
|
|
19499
19637
|
const rt = getRuntime();
|
|
19500
19638
|
const result = await rt.spawn(
|
|
19501
19639
|
["git", "rev-parse", "--verify", `refs/heads/${STATE_BRANCH}`],
|
|
@@ -19504,6 +19642,20 @@ async function getStateBranchTip(cwd) {
|
|
|
19504
19642
|
if (result.exitCode !== 0) return null;
|
|
19505
19643
|
return result.stdout.trim();
|
|
19506
19644
|
}
|
|
19645
|
+
async function ledgerRevision(cwd) {
|
|
19646
|
+
if (!ledgerRecordingOff()) return STATE_BRANCH;
|
|
19647
|
+
return overlayTip(await overlayKey(cwd)) ?? STATE_BRANCH;
|
|
19648
|
+
}
|
|
19649
|
+
var overlayKeys = /* @__PURE__ */ new Map();
|
|
19650
|
+
async function overlayKey(cwd) {
|
|
19651
|
+
const from = cwd ?? process.cwd();
|
|
19652
|
+
const cached = overlayKeys.get(from);
|
|
19653
|
+
if (cached !== void 0) return cached;
|
|
19654
|
+
const result = await getRuntime().spawn(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: from, env: C_LOCALE_ENV });
|
|
19655
|
+
const key = result.exitCode === 0 && result.stdout.trim() !== "" ? result.stdout.trim() : from;
|
|
19656
|
+
overlayKeys.set(from, key);
|
|
19657
|
+
return key;
|
|
19658
|
+
}
|
|
19507
19659
|
|
|
19508
19660
|
// node_modules/@intentius/chant/src/content-digest.ts
|
|
19509
19661
|
import { createHash as createHash6 } from "node:crypto";
|
|
@@ -20878,7 +21030,7 @@ function startDriftPoll(opts) {
|
|
|
20878
21030
|
// src/frames.ts
|
|
20879
21031
|
function frameDigest(ir) {
|
|
20880
21032
|
const nodes = ir.nodes.map((n) => `${n.id}:${n.kind}:${n.attrs?._status ?? ""}`).sort();
|
|
20881
|
-
const edges = ir.edges.map((e) => `${e.from}>${e.to}`).sort();
|
|
21033
|
+
const edges = ir.edges.map((e) => `${e.from}>${e.to}${e.kind === "ref" ? "" : `:${e.kind}`}`).sort();
|
|
20882
21034
|
return `${nodes.join("|")}#${edges.join("|")}`;
|
|
20883
21035
|
}
|
|
20884
21036
|
var FrameBuffer = class {
|
|
@@ -26808,11 +26960,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
26808
26960
|
const tf = passNote(run3);
|
|
26809
26961
|
const cdNote = lexiconNote(expanded);
|
|
26810
26962
|
const sameNote = unchangedZoomNote(srcZoom, estateMembers(estateDirs).map((m) => m.kind));
|
|
26963
|
+
const ownNote = [estateLensNote, tf.note, cdNote, sameNote].filter(Boolean).join(" \xB7 ");
|
|
26964
|
+
const danglingClause = (ownNote || multi) && srcZoom !== "components" && srcZoom !== "logical" ? danglingNote(expanded) : void 0;
|
|
26811
26965
|
const srcNote = [
|
|
26812
|
-
|
|
26966
|
+
ownNote || (multi ? void 0 : notesFor(srcZoom, expanded, srcCompositeEdgesAttached, void 0, opts.detail ?? 2, cdNote)),
|
|
26967
|
+
danglingClause,
|
|
26813
26968
|
collapsedNote
|
|
26814
26969
|
].filter(Boolean).join(" \xB7 ") || void 0;
|
|
26815
|
-
const srcNoteShort = tf.noteShort || sameNote ? [estateLensNote, tf.noteShort ?? tf.note, cdNote, sameNote, collapsedNote].filter(Boolean).join(" \xB7 ") : void 0;
|
|
26970
|
+
const srcNoteShort = tf.noteShort || sameNote ? [estateLensNote, tf.noteShort ?? tf.note, cdNote, sameNote, danglingClause, collapsedNote].filter(Boolean).join(" \xB7 ") : void 0;
|
|
26816
26971
|
return c.json({
|
|
26817
26972
|
ir,
|
|
26818
26973
|
svg,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@hono/node-server": "^2.0.5",
|
|
44
|
-
"@intentius/chant": "^0.124.
|
|
45
|
-
"@intentius/chant-k8s-client": "^0.124.
|
|
46
|
-
"@intentius/pinhole": "^0.
|
|
44
|
+
"@intentius/chant": "^0.124.24",
|
|
45
|
+
"@intentius/chant-k8s-client": "^0.124.24",
|
|
46
|
+
"@intentius/pinhole": "^0.5.0",
|
|
47
47
|
"ajv": "^8.20.0",
|
|
48
48
|
"hono": "^4.12.34"
|
|
49
49
|
},
|
package/web/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { initTerragucciEstate } from "./terragucci-estate.js";
|
|
|
21
21
|
import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
|
|
22
22
|
import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
|
|
23
23
|
import { seconds, whyQuery, whyView } from "./why.js";
|
|
24
|
+
import { edgeKey, edgeLabel, edgeLegend, edgeTitle, isCiKind, viaIndex } from "./edge-kind.js";
|
|
24
25
|
import { readCostLine } from "./read-cost.js";
|
|
25
26
|
import { mountHistoryPicker } from "./history-picker.js";
|
|
26
27
|
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
@@ -97,6 +98,7 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
|
|
|
97
98
|
// which commit, approved by whom — and, when the ledger's run id cannot be
|
|
98
99
|
// followed, the reason in words rather than a guessed link.
|
|
99
100
|
import { releaseRows } from "./release.js";
|
|
101
|
+
import { danglingFor, danglingRows, danglingHeading, DANGLING_EXPLAINED } from "./dangling.js";
|
|
100
102
|
import { comparisonRow, comparisonSummary } from "./cross-env.js";
|
|
101
103
|
initTheme();
|
|
102
104
|
// #476: framed in a host pane. The flag is a class on <html>, so the CSS that
|
|
@@ -1160,6 +1162,19 @@ function inspect(node) {
|
|
|
1160
1162
|
}
|
|
1161
1163
|
}
|
|
1162
1164
|
|
|
1165
|
+
// #554: references from this card to a target the project does not declare
|
|
1166
|
+
// (chant#3912's `ir.dangling`), with the value as written. No edge is drawn
|
|
1167
|
+
// for any of them, so this is the one place they show.
|
|
1168
|
+
const dangling = danglingFor(lastGraphIr, node.id);
|
|
1169
|
+
if (dangling.length) {
|
|
1170
|
+
const row = section(danglingHeading(dangling));
|
|
1171
|
+
for (const [k, v] of danglingRows(dangling)) row(k, v);
|
|
1172
|
+
const p = document.createElement("p");
|
|
1173
|
+
p.style.color = "var(--muted)";
|
|
1174
|
+
p.textContent = DANGLING_EXPLAINED;
|
|
1175
|
+
panel.appendChild(p);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1163
1178
|
// CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
|
|
1164
1179
|
// component DAG projected — waves = stages, components = jobs, `dependsOn` =
|
|
1165
1180
|
// `needs:`. Read one way it's the deployment, read the other it's the
|
|
@@ -2833,10 +2848,47 @@ function renderPanelModel() {
|
|
|
2833
2848
|
host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
|
|
2834
2849
|
}
|
|
2835
2850
|
}
|
|
2851
|
+
renderEdgeLegend(host, ir);
|
|
2836
2852
|
renderBehaviourRefusal(host);
|
|
2837
2853
|
renderBehaviourDiagnostics(host);
|
|
2838
2854
|
}
|
|
2839
2855
|
|
|
2856
|
+
// #553: the CI edge kinds this graph draws, each with a stroke drawn by the
|
|
2857
|
+
// same rules as the graph's own edges (index.html matches `.edge-swatch` beside
|
|
2858
|
+
// `#graph`), so the legend changes with the theme. Nothing on a graph that
|
|
2859
|
+
// holds only references.
|
|
2860
|
+
function renderEdgeLegend(host, ir) {
|
|
2861
|
+
const rows = edgeLegend(ir.edges || []);
|
|
2862
|
+
if (!rows.length) return;
|
|
2863
|
+
host.appendChild(panelHeading("edges"));
|
|
2864
|
+
for (const r of rows) {
|
|
2865
|
+
const row = document.createElement("div");
|
|
2866
|
+
row.className = "count-row";
|
|
2867
|
+
row.title = r.title;
|
|
2868
|
+
const svg = document.createElementNS(SVGNS, "svg");
|
|
2869
|
+
svg.setAttribute("class", "edge-swatch");
|
|
2870
|
+
svg.setAttribute("viewBox", "0 0 28 10");
|
|
2871
|
+
svg.setAttribute("width", "28");
|
|
2872
|
+
svg.setAttribute("height", "10");
|
|
2873
|
+
const g = document.createElementNS(SVGNS, "g");
|
|
2874
|
+
if (r.kind) g.setAttribute("data-edge-kind", r.kind);
|
|
2875
|
+
if (r.cross) g.setAttribute("data-edge-cross", "true");
|
|
2876
|
+
const line = document.createElementNS(SVGNS, "path");
|
|
2877
|
+
line.setAttribute("class", "pin-edge-line");
|
|
2878
|
+
line.setAttribute("d", "M 1 5 L 27 5");
|
|
2879
|
+
g.appendChild(line);
|
|
2880
|
+
svg.appendChild(g);
|
|
2881
|
+
const name = document.createElement("span");
|
|
2882
|
+
name.className = "grow";
|
|
2883
|
+
name.textContent = r.label;
|
|
2884
|
+
const t = document.createElement("span");
|
|
2885
|
+
t.className = "tag";
|
|
2886
|
+
t.textContent = String(r.count);
|
|
2887
|
+
row.append(svg, name, t);
|
|
2888
|
+
host.appendChild(row);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2840
2892
|
/**
|
|
2841
2893
|
* The refusal, where the legend would be (#401).
|
|
2842
2894
|
*
|
|
@@ -4372,37 +4424,53 @@ async function loadReconcile() {
|
|
|
4372
4424
|
}
|
|
4373
4425
|
}
|
|
4374
4426
|
|
|
4427
|
+
// #553: pinhole writes the IR edge's `viaAttr` on the chip ("uses",
|
|
4428
|
+
// "workflow_run"). A CI edge's chip says its kind instead, with the attribute
|
|
4429
|
+
// when it adds something ("calls (uses)"), as chant's Mermaid does. The pill
|
|
4430
|
+
// is re-sized around the new text with pinhole's own metrics (5.7 per
|
|
4431
|
+
// character plus 14), centred where pinhole centred it.
|
|
4432
|
+
function relabelEdgeChip(chip, kind, via) {
|
|
4433
|
+
if (!isCiKind(kind)) return;
|
|
4434
|
+
const text = chip.querySelector("text");
|
|
4435
|
+
const rect = chip.querySelector("rect");
|
|
4436
|
+
const label = edgeLabel({ kind, via });
|
|
4437
|
+
if (!text || !label || text.textContent === label) return;
|
|
4438
|
+
text.textContent = label;
|
|
4439
|
+
if (!rect) return;
|
|
4440
|
+
const cx = Number(text.getAttribute("x"));
|
|
4441
|
+
const w = label.length * 5.7 + 14;
|
|
4442
|
+
if (!Number.isFinite(cx)) return;
|
|
4443
|
+
rect.setAttribute("width", w.toFixed(1));
|
|
4444
|
+
rect.setAttribute("x", (cx - w / 2).toFixed(1));
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4375
4447
|
// Edge hover-highlight (helps trace one edge through an overlapping bundle — see
|
|
4376
4448
|
// index.html's `.pin-edge-line` rules). pinhole already paints a fat transparent
|
|
4377
4449
|
// hit-path per edge, so the CSS `:hover` does the visual work; this adds two
|
|
4378
4450
|
// things CSS can't: raise the hovered edge to the top of the paint order (SVG has
|
|
4379
4451
|
// no z-index) so it's not buried, and light up every edge touching a hovered node
|
|
4380
4452
|
// (the `.edge-hi` class) so hovering a card traces all its connections.
|
|
4381
|
-
// Human-readable "why does this edge exist" from the IR edge's `viaAttr`
|
|
4382
|
-
//
|
|
4383
|
-
//
|
|
4384
|
-
|
|
4385
|
-
"security-group ingress": "security-group ingress — traffic is allowed here",
|
|
4386
|
-
"data dependency": "data dependency — reads/uses this store",
|
|
4387
|
-
};
|
|
4453
|
+
// Human-readable "why does this edge exist" from the IR edge's `viaAttr` and,
|
|
4454
|
+
// for a CI edge (#553), its `kind` and `crossPipeline` (web/edge-kind.js says
|
|
4455
|
+
// them in words). Shown as a native SVG <title> tooltip on hover; the stamped
|
|
4456
|
+
// attributes also drive the dashed and per-kind styles (index.html).
|
|
4388
4457
|
function wireEdgeHighlight(svgEl, ir) {
|
|
4389
4458
|
const edges = [...svgEl.querySelectorAll("g[data-edge-from]")];
|
|
4390
4459
|
if (!edges.length) return;
|
|
4391
|
-
// Reason per edge, keyed by from
|
|
4392
|
-
//
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
if (!e.viaAttr) continue;
|
|
4396
|
-
viaOf.set(`${e.from}|${e.to}`, e.viaAttr);
|
|
4397
|
-
viaOf.set(`${e.to}|${e.from}`, e.viaAttr);
|
|
4398
|
-
}
|
|
4460
|
+
// Reason per edge, keyed by from, to and kind (pinhole's edgeKey, #564), so
|
|
4461
|
+
// two kinds on one pair keep their own reason. pinhole stamps data-edge-via
|
|
4462
|
+
// itself; this fills it in where it did not.
|
|
4463
|
+
const viaOf = viaIndex(ir.edges || []);
|
|
4399
4464
|
for (const g of edges) {
|
|
4400
4465
|
const from = g.getAttribute("data-edge-from");
|
|
4401
4466
|
const to = g.getAttribute("data-edge-to");
|
|
4402
|
-
const
|
|
4467
|
+
const kind = g.getAttribute("data-edge-kind") || "ref";
|
|
4468
|
+
const via = g.getAttribute("data-edge-via") || viaOf.get(edgeKey({ from, to, kind }));
|
|
4403
4469
|
if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
|
|
4470
|
+
const cross = g.getAttribute("data-edge-cross") === "true";
|
|
4471
|
+
if (!g.querySelector("path")) relabelEdgeChip(g, kind, via);
|
|
4404
4472
|
const title = document.createElementNS(SVGNS, "title");
|
|
4405
|
-
title.textContent =
|
|
4473
|
+
title.textContent = edgeTitle(from, to, { kind, via, cross });
|
|
4406
4474
|
g.insertBefore(title, g.firstChild);
|
|
4407
4475
|
}
|
|
4408
4476
|
const raise = (g) => g.parentNode && g.parentNode.appendChild(g);
|
|
@@ -5207,17 +5275,24 @@ function userRect(el, toUser) {
|
|
|
5207
5275
|
*/
|
|
5208
5276
|
function edgeLabelOf(g) {
|
|
5209
5277
|
if (!(g.getAttribute("data-edge-via") || "").trim()) return null;
|
|
5210
|
-
|
|
5211
|
-
|
|
5278
|
+
// The pair and the kind (pinhole#128, #564): two kinds on one pair draw two
|
|
5279
|
+
// lines with a chip each, and each line keeps its own chip.
|
|
5280
|
+
const key = edgeKeyOf(g);
|
|
5212
5281
|
for (const cand of g.parentNode ? g.parentNode.children : []) {
|
|
5213
5282
|
if (cand === g || cand.tagName.toLowerCase() !== "g") continue;
|
|
5214
|
-
if (cand.
|
|
5283
|
+
if (!cand.hasAttribute("data-edge-from") || edgeKeyOf(cand) !== key) continue;
|
|
5215
5284
|
if (cand.hasAttribute("data-node-id") || cand.querySelector("path")) continue;
|
|
5216
5285
|
return cand;
|
|
5217
5286
|
}
|
|
5218
5287
|
return null;
|
|
5219
5288
|
}
|
|
5220
5289
|
|
|
5290
|
+
/** A drawn edge group's identity, pinhole's `edgeKey` over its stamped
|
|
5291
|
+
* attributes (no `data-edge-kind` is a reference). */
|
|
5292
|
+
function edgeKeyOf(g) {
|
|
5293
|
+
return edgeKey({ from: g.getAttribute("data-edge-from"), to: g.getAttribute("data-edge-to"), kind: g.getAttribute("data-edge-kind") || "ref" });
|
|
5294
|
+
}
|
|
5295
|
+
|
|
5221
5296
|
/** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
|
|
5222
5297
|
function indexLayout(svgEl) {
|
|
5223
5298
|
const toUser = userSpaceMapper(svgEl);
|
package/web/dangling.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// #554: a CI reference whose target this project does not declare, such as
|
|
2
|
+
// another repository's reusable workflow or a local composite action. chant
|
|
3
|
+
// keeps each one on `ir.dangling` (chant#3912) with the value as written, and
|
|
4
|
+
// behold draws no edge for it, so the card holding it is where it is listed.
|
|
5
|
+
// The pure half, tested without a DOM the way web/release.js is.
|
|
6
|
+
|
|
7
|
+
/** The dangling references `nodeId` holds, in the order chant listed them. */
|
|
8
|
+
export function danglingFor(ir, nodeId) {
|
|
9
|
+
const all = ir && Array.isArray(ir.dangling) ? ir.dangling : [];
|
|
10
|
+
return all.filter((d) => d && d.from === nodeId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One `[label, value]` row per reference: the edge kind it would have been
|
|
14
|
+
* and where on the card it was written, then the value exactly as written. A
|
|
15
|
+
* reference that leaves the pipeline says so. */
|
|
16
|
+
export function danglingRows(refs) {
|
|
17
|
+
return refs.map((d) => {
|
|
18
|
+
const label = d.path ? `${d.kind} · ${d.path}` : String(d.kind);
|
|
19
|
+
return [label, d.crossPipeline ? `${d.value} (cross-pipeline)` : String(d.value)];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The section heading, with the count. */
|
|
24
|
+
export function danglingHeading(refs) {
|
|
25
|
+
return `not declared here · ${refs.length}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The line under the rows, saying why no edge is drawn for them. */
|
|
29
|
+
export const DANGLING_EXPLAINED = "these targets are not declared in this project, so no edge is drawn to them";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { danglingFor, danglingRows, danglingHeading } from "../web/dangling.js";
|
|
3
|
+
|
|
4
|
+
// #554: the shape chant 0.124.20 writes (chant#3912) for a job calling another
|
|
5
|
+
// repo's reusable workflow and a step using a local composite action.
|
|
6
|
+
const IR = {
|
|
7
|
+
nodes: [{ id: "build" }, { id: "release" }, { id: "ci" }],
|
|
8
|
+
edges: [],
|
|
9
|
+
dangling: [
|
|
10
|
+
{ from: "build", kind: "calls", path: "steps.uses", value: "./.github/actions/setup" },
|
|
11
|
+
{ from: "release", kind: "calls", path: "uses", value: "octo/shared/.github/workflows/release.yml@main", crossPipeline: true },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("dangling references on a card (#554)", () => {
|
|
16
|
+
it("lists only the references the card holds", () => {
|
|
17
|
+
expect(danglingFor(IR, "build").map((d) => d.value)).toEqual(["./.github/actions/setup"]);
|
|
18
|
+
expect(danglingFor(IR, "ci")).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is empty on an IR without the field, or with none", () => {
|
|
22
|
+
expect(danglingFor({ nodes: [], edges: [] }, "build")).toEqual([]);
|
|
23
|
+
expect(danglingFor(null, "build")).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("shows kind, path and the value as written, and marks a cross-pipeline one", () => {
|
|
27
|
+
expect(danglingRows(danglingFor(IR, "build"))).toEqual([["calls · steps.uses", "./.github/actions/setup"]]);
|
|
28
|
+
expect(danglingRows(danglingFor(IR, "release"))).toEqual([["calls · uses", "octo/shared/.github/workflows/release.yml@main (cross-pipeline)"]]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("counts them in the heading", () => {
|
|
32
|
+
expect(danglingHeading(IR.dangling)).toBe("not declared here · 2");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/web/edge-kind.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// #553: an edge's kind, in words. chant 0.124.20 (chant#3912) gives every IR
|
|
2
|
+
// edge a `kind` (ref | needs | calls | triggers | includes | extends) and marks
|
|
3
|
+
// an edge that leaves its workflow or pipeline `crossPipeline`. pinhole 0.4.0
|
|
4
|
+
// stamps both on the drawn edge and its chip as `data-edge-kind` (left off for
|
|
5
|
+
// "ref") and `data-edge-cross="true"`; index.html styles them, and this module
|
|
6
|
+
// writes the chip text, the hover tooltip and the Model tab's edge rows.
|
|
7
|
+
// Pure, so web/edge-kind.test.js can pin the words.
|
|
8
|
+
|
|
9
|
+
/** The CI kinds, in the order the legend lists them. */
|
|
10
|
+
export const CI_KINDS = ["needs", "calls", "triggers", "includes", "extends"];
|
|
11
|
+
|
|
12
|
+
/** What each CI kind means, for the tooltip and the legend row's title. */
|
|
13
|
+
export const KIND_MEANING = {
|
|
14
|
+
needs: "waits for the other job to finish",
|
|
15
|
+
calls: "runs the other workflow as a reusable workflow",
|
|
16
|
+
triggers: "starts the other workflow or pipeline",
|
|
17
|
+
includes: "pulls the other file into this pipeline",
|
|
18
|
+
extends: "takes the other job's settings",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The sentence a cross-pipeline edge adds, here and on the legend row. */
|
|
22
|
+
export const CROSS_MEANING = "cross-pipeline: leaves its own workflow or pipeline";
|
|
23
|
+
|
|
24
|
+
// The reason sentences for behold's own `ref` edges (src/logical.ts tags them).
|
|
25
|
+
const REF_REASON = {
|
|
26
|
+
"security-group ingress": "security-group ingress — traffic is allowed here",
|
|
27
|
+
"data dependency": "data dependency — reads/uses this store",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A missing or "ref" kind is a reference; anything else is a CI kind. */
|
|
31
|
+
export function isCiKind(kind) {
|
|
32
|
+
return !!kind && kind !== "ref";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An edge's identity, the same key pinhole 0.5.0's `edgeKey` gives
|
|
37
|
+
* (pinhole#128): `from>to` for a reference, `from>to>kind` otherwise. Two
|
|
38
|
+
* kinds on one pair (a GitLab job that both `needs` and `extends` another)
|
|
39
|
+
* are two edges, so anything the SPA looks up per edge goes through this
|
|
40
|
+
* (#564). Read off a drawn edge, pass `data-edge-kind`, which pinhole leaves
|
|
41
|
+
* off for "ref".
|
|
42
|
+
*/
|
|
43
|
+
export function edgeKey({ from, to, kind } = {}) {
|
|
44
|
+
return `${from}>${to}${isCiKind(kind) ? `>${kind}` : ""}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The `viaAttr` per edge key, for the drawn edges pinhole did not stamp
|
|
49
|
+
* `data-edge-via` on. The reverse direction is filled in as well, where
|
|
50
|
+
* nothing already holds it, since pinhole may paint an edge either way round
|
|
51
|
+
* relative to the IR.
|
|
52
|
+
*/
|
|
53
|
+
export function viaIndex(edges = []) {
|
|
54
|
+
const via = new Map();
|
|
55
|
+
for (const e of edges) {
|
|
56
|
+
if (!e.viaAttr) continue;
|
|
57
|
+
via.set(edgeKey(e), e.viaAttr);
|
|
58
|
+
}
|
|
59
|
+
for (const e of edges) {
|
|
60
|
+
const back = edgeKey({ from: e.to, to: e.from, kind: e.kind });
|
|
61
|
+
if (e.viaAttr && !via.has(back)) via.set(back, e.viaAttr);
|
|
62
|
+
}
|
|
63
|
+
return via;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The chip text, as chant's Mermaid and DOT writers label the edge
|
|
68
|
+
* (graph-mermaid.ts `edgeLabel`): a CI edge by its kind, plus the attribute
|
|
69
|
+
* when that says something more (`needs (stage)`, `calls (uses)`); a
|
|
70
|
+
* reference by the attribute it flows through. Empty when there is nothing
|
|
71
|
+
* to say.
|
|
72
|
+
*/
|
|
73
|
+
export function edgeLabel({ kind, via, toAttr } = {}) {
|
|
74
|
+
if (!isCiKind(kind)) return [via, toAttr].filter(Boolean).join(" → ");
|
|
75
|
+
return via && via !== kind ? `${kind} (${via})` : kind;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The hover tooltip: who points at whom on the first line, then the kind
|
|
80
|
+
* (with its meaning) for a CI edge or the reason for a reference, then the
|
|
81
|
+
* cross-pipeline sentence when the edge carries it.
|
|
82
|
+
*/
|
|
83
|
+
export function edgeTitle(from, to, { kind, via, cross } = {}) {
|
|
84
|
+
const lines = [`${from} → ${to}`];
|
|
85
|
+
if (isCiKind(kind)) {
|
|
86
|
+
const meaning = KIND_MEANING[kind];
|
|
87
|
+
lines.push(meaning ? `${edgeLabel({ kind, via })}: ${meaning}` : edgeLabel({ kind, via }));
|
|
88
|
+
} else if (via) {
|
|
89
|
+
lines.push(REF_REASON[via] || via);
|
|
90
|
+
}
|
|
91
|
+
if (cross) lines.push(CROSS_MEANING);
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The Model tab's edge rows: one per CI kind the graph holds, with its count,
|
|
97
|
+
* then one for the cross-pipeline edges. A kind whose every edge is
|
|
98
|
+
* cross-pipeline (GitHub's `calls` and `triggers`) is drawn dashed on its row
|
|
99
|
+
* too, as it is on the graph. Empty when the graph has neither, so an
|
|
100
|
+
* infrastructure graph shows no edge legend.
|
|
101
|
+
*/
|
|
102
|
+
export function edgeLegend(edges = []) {
|
|
103
|
+
const counts = new Map();
|
|
104
|
+
const crossCounts = new Map();
|
|
105
|
+
let cross = 0;
|
|
106
|
+
for (const e of edges) {
|
|
107
|
+
if (isCiKind(e.kind)) {
|
|
108
|
+
counts.set(e.kind, (counts.get(e.kind) || 0) + 1);
|
|
109
|
+
if (e.crossPipeline) crossCounts.set(e.kind, (crossCounts.get(e.kind) || 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
if (e.crossPipeline) cross++;
|
|
112
|
+
}
|
|
113
|
+
const known = CI_KINDS.filter((k) => counts.has(k));
|
|
114
|
+
const other = [...counts.keys()].filter((k) => !CI_KINDS.includes(k)).sort();
|
|
115
|
+
const rows = [...known, ...other].map((kind) => ({
|
|
116
|
+
kind,
|
|
117
|
+
cross: crossCounts.get(kind) === counts.get(kind),
|
|
118
|
+
label: kind,
|
|
119
|
+
count: counts.get(kind),
|
|
120
|
+
title: KIND_MEANING[kind] || kind,
|
|
121
|
+
}));
|
|
122
|
+
if (cross) rows.push({ kind: null, cross: true, label: "cross-pipeline (dashed)", count: cross, title: CROSS_MEANING });
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { edgeKey, edgeLabel, edgeLegend, edgeTitle, viaIndex } from "./edge-kind.js";
|
|
3
|
+
|
|
4
|
+
// The edges chant 0.124.20's GitHub lexicon writes (lexicons/github/src/
|
|
5
|
+
// graph-edges.ts) and one from its GitLab lexicon.
|
|
6
|
+
const needs = { from: "Test", to: "Build", kind: "needs", viaAttr: "needs" };
|
|
7
|
+
const calls = { from: "Ci", to: "Release", kind: "calls", viaAttr: "uses", crossPipeline: true };
|
|
8
|
+
const triggers = { from: "Deploy", to: "Ci", kind: "triggers", viaAttr: "workflow_run", crossPipeline: true };
|
|
9
|
+
const stage = { from: "test", to: "build", kind: "needs", viaAttr: "stage" };
|
|
10
|
+
const ref = { from: "api", to: "db", kind: "ref", viaAttr: "data dependency" };
|
|
11
|
+
|
|
12
|
+
const title = (e) => edgeTitle(e.from, e.to, { kind: e.kind, via: e.viaAttr, cross: e.crossPipeline });
|
|
13
|
+
|
|
14
|
+
describe("edgeLabel (#553)", () => {
|
|
15
|
+
it("names a CI edge by its kind, with the attribute only when it adds something", () => {
|
|
16
|
+
expect(edgeLabel({ kind: "needs", via: "needs" })).toBe("needs");
|
|
17
|
+
expect(edgeLabel({ kind: "needs", via: "stage" })).toBe("needs (stage)");
|
|
18
|
+
expect(edgeLabel({ kind: "calls", via: "uses" })).toBe("calls (uses)");
|
|
19
|
+
expect(edgeLabel({ kind: "triggers", via: "workflow_run" })).toBe("triggers (workflow_run)");
|
|
20
|
+
expect(edgeLabel({ kind: "includes" })).toBe("includes");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("names a reference by the attribute it flows through", () => {
|
|
24
|
+
expect(edgeLabel({ kind: "ref", via: "role" })).toBe("role");
|
|
25
|
+
expect(edgeLabel({ via: "role", toAttr: "arn" })).toBe("role → arn");
|
|
26
|
+
expect(edgeLabel({ kind: "ref" })).toBe("");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("edgeTitle, the hover tooltip (#553)", () => {
|
|
31
|
+
it("says a needs edge's kind and what it means", () => {
|
|
32
|
+
expect(title(needs)).toBe("Test → Build\nneeds: waits for the other job to finish");
|
|
33
|
+
expect(title(stage)).toBe("test → build\nneeds (stage): waits for the other job to finish");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("says a call to a reusable workflow leaves its pipeline", () => {
|
|
37
|
+
expect(title(calls)).toBe(
|
|
38
|
+
"Ci → Release\ncalls (uses): runs the other workflow as a reusable workflow\ncross-pipeline: leaves its own workflow or pipeline",
|
|
39
|
+
);
|
|
40
|
+
expect(title(triggers)).toBe(
|
|
41
|
+
"Deploy → Ci\ntriggers (workflow_run): starts the other workflow or pipeline\ncross-pipeline: leaves its own workflow or pipeline",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps a reference's reason as it was", () => {
|
|
46
|
+
expect(title(ref)).toBe("api → db\ndata dependency — reads/uses this store");
|
|
47
|
+
expect(edgeTitle("a", "b", { kind: "ref", via: "role" })).toBe("a → b\nrole");
|
|
48
|
+
expect(edgeTitle("a", "b", {})).toBe("a → b");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("names a kind it has no sentence for", () => {
|
|
52
|
+
expect(edgeTitle("a", "b", { kind: "waits", via: "on" })).toBe("a → b\nwaits (on)");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("edgeLegend (#553)", () => {
|
|
57
|
+
it("lists the CI kinds the graph holds, then the cross-pipeline edges", () => {
|
|
58
|
+
const rows = edgeLegend([needs, needs, calls, triggers, ref]);
|
|
59
|
+
expect(rows.map((r) => [r.label, r.count, r.cross])).toEqual([
|
|
60
|
+
["needs", 2, false],
|
|
61
|
+
["calls", 1, true],
|
|
62
|
+
["triggers", 1, true],
|
|
63
|
+
["cross-pipeline (dashed)", 2, true],
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is empty for a graph of references", () => {
|
|
68
|
+
expect(edgeLegend([ref, { from: "a", to: "b", kind: "ref" }])).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// #564: pinhole 0.5.0 keys an edge on from, to and kind (pinhole#128), so two
|
|
73
|
+
// kinds on one pair are two edges, each with its own chip and tooltip.
|
|
74
|
+
describe("edgeKey and viaIndex (#564)", () => {
|
|
75
|
+
it("gives the key pinhole's edgeKey gives: a reference keeps from>to", () => {
|
|
76
|
+
expect(edgeKey({ from: "api", to: "db", kind: "ref" })).toBe("api>db");
|
|
77
|
+
expect(edgeKey({ from: "api", to: "db" })).toBe("api>db");
|
|
78
|
+
expect(edgeKey({ from: "test", to: "build", kind: "needs" })).toBe("test>build>needs");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps each kind's reason on a pair joined twice", () => {
|
|
82
|
+
const via = viaIndex([
|
|
83
|
+
{ from: "test", to: "build", kind: "needs", viaAttr: "stage" },
|
|
84
|
+
{ from: "test", to: "build", kind: "extends", viaAttr: "extends" },
|
|
85
|
+
]);
|
|
86
|
+
expect(via.get(edgeKey({ from: "test", to: "build", kind: "needs" }))).toBe("stage");
|
|
87
|
+
expect(via.get(edgeKey({ from: "test", to: "build", kind: "extends" }))).toBe("extends");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("fills in the reverse direction without overwriting an edge drawn that way", () => {
|
|
91
|
+
const via = viaIndex([
|
|
92
|
+
{ from: "a", to: "b", kind: "ref", viaAttr: "role" },
|
|
93
|
+
{ from: "b", to: "a", kind: "ref", viaAttr: "policy_arn" },
|
|
94
|
+
{ from: "c", to: "d", kind: "ref", viaAttr: "zone_id" },
|
|
95
|
+
]);
|
|
96
|
+
expect(via.get("a>b")).toBe("role");
|
|
97
|
+
expect(via.get("b>a")).toBe("policy_arn");
|
|
98
|
+
expect(via.get("d>c")).toBe("zone_id");
|
|
99
|
+
});
|
|
100
|
+
});
|
package/web/index.html
CHANGED
|
@@ -132,6 +132,34 @@
|
|
|
132
132
|
either for the full reason (native SVG <title>). */
|
|
133
133
|
#graph g[data-edge-via="data dependency"] .pin-edge-line { stroke-dasharray: 6 5; }
|
|
134
134
|
#graph g[data-edge-from][data-edge-via] { cursor: help; }
|
|
135
|
+
/* #553: CI edges by kind (chant's IREdge.kind, stamped by pinhole as
|
|
136
|
+
data-edge-kind, left off for a plain reference). Each kind has a hue,
|
|
137
|
+
65% of a status token and 35% --fg so it clears the background on the
|
|
138
|
+
light palettes too (all but Unikitty, whose pastel slots sit on its
|
|
139
|
+
pink ground, read at least as well as --edge). On 128 of the 552
|
|
140
|
+
themes two of those slots come out the same colour, so the line's
|
|
141
|
+
pattern carries the kind
|
|
142
|
+
as well: needs is a heavier solid line, triggers a dash-dot, includes
|
|
143
|
+
and extends dotted. An edge that leaves its workflow or pipeline
|
|
144
|
+
(data-edge-cross) is dashed, as chant's Mermaid and DOT draw it. The
|
|
145
|
+
Model tab's edge legend (.edge-swatch) is drawn by the same rules.
|
|
146
|
+
Hover and .edge-hi above stay the more specific rules, so a traced
|
|
147
|
+
edge still turns --pending. */
|
|
148
|
+
.edge-swatch { flex: none; overflow: visible; }
|
|
149
|
+
.edge-swatch .pin-edge-line { stroke: var(--edge); stroke-width: 1.5; fill: none; }
|
|
150
|
+
:is(#graph, .edge-swatch) g[data-edge-kind] .pin-edge-line { stroke-width: 1.6; }
|
|
151
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="needs"] .pin-edge-line { stroke: color-mix(in srgb, var(--runtime) 65%, var(--fg)); stroke-width: 2.1; }
|
|
152
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="calls"] .pin-edge-line { stroke: color-mix(in srgb, var(--foreign) 65%, var(--fg)); }
|
|
153
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="triggers"] .pin-edge-line { stroke: color-mix(in srgb, var(--managed) 65%, var(--fg)); }
|
|
154
|
+
:is(#graph, .edge-swatch) g[data-edge-cross="true"] .pin-edge-line { stroke-dasharray: 7 5; }
|
|
155
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="triggers"] .pin-edge-line { stroke-dasharray: 9 4 2 4; }
|
|
156
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="includes"] .pin-edge-line,
|
|
157
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="extends"] .pin-edge-line { stroke: var(--muted); stroke-dasharray: 1.5 4; stroke-linecap: round; }
|
|
158
|
+
/* The chip wears its kind's hue on its outline; its text stays pinhole's. */
|
|
159
|
+
#graph g[data-edge-kind="needs"] rect { stroke: color-mix(in srgb, var(--runtime) 65%, var(--fg)); }
|
|
160
|
+
#graph g[data-edge-kind="calls"] rect { stroke: color-mix(in srgb, var(--foreign) 65%, var(--fg)); }
|
|
161
|
+
#graph g[data-edge-kind="triggers"] rect { stroke: color-mix(in srgb, var(--managed) 65%, var(--fg)); }
|
|
162
|
+
#graph g[data-edge-kind="includes"] rect, #graph g[data-edge-kind="extends"] rect { stroke: var(--muted); }
|
|
135
163
|
/* #477: a declared workspace's waiting gates, over the top of the graph
|
|
136
164
|
pane (same grid cell, so #graph's re-renders never take it with them). */
|
|
137
165
|
#ws-gates { grid-row: 1; grid-column: 1; align-self: start; justify-self: center; z-index: 3; margin-top: 8px;
|