@intentius/behold 0.26.0 → 0.27.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 +59 -13
- package/package.json +4 -4
- package/web/app.js +83 -10
- package/web/dangling.js +29 -0
- package/web/dangling.test.js +34 -0
- package/web/edge-kind.js +93 -0
- package/web/edge-kind.test.js +70 -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;
|
|
@@ -12681,11 +12683,29 @@ function edgelessNote(zoom, ir, detail, instead) {
|
|
|
12681
12683
|
if (zoom === "components" || zoom === "logical") return void 0;
|
|
12682
12684
|
if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
|
|
12683
12685
|
if (instead) return instead;
|
|
12684
|
-
|
|
12686
|
+
const dangling = ir.dangling?.length ?? 0;
|
|
12687
|
+
if (dangling > 0) {
|
|
12688
|
+
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"}`;
|
|
12689
|
+
}
|
|
12690
|
+
if (detail !== void 0 && detail < 3 && !allInPipelines(ir)) {
|
|
12685
12691
|
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
12692
|
}
|
|
12687
12693
|
return "no edges \u2014 nothing in this estate references anything else";
|
|
12688
12694
|
}
|
|
12695
|
+
function referencesCount(n) {
|
|
12696
|
+
return `${n} reference${n === 1 ? "" : "s"}`;
|
|
12697
|
+
}
|
|
12698
|
+
function allInPipelines(ir) {
|
|
12699
|
+
const byPipeline = ir.groups?.byPipeline;
|
|
12700
|
+
if (!byPipeline) return false;
|
|
12701
|
+
const inPipeline = new Set(Object.values(byPipeline).flat());
|
|
12702
|
+
return ir.nodes.every((n) => inPipeline.has(n.id ?? ""));
|
|
12703
|
+
}
|
|
12704
|
+
function danglingNote(ir) {
|
|
12705
|
+
const n = ir.dangling?.length ?? 0;
|
|
12706
|
+
if (n === 0) return void 0;
|
|
12707
|
+
return `${referencesCount(n)} to targets not declared here, such as another repo's workflow or a local action: listed on their cards, not drawn`;
|
|
12708
|
+
}
|
|
12689
12709
|
var SAME_AS_RESOURCES = {
|
|
12690
12710
|
choudoufu: ["composites", "attributes"],
|
|
12691
12711
|
terraform: ["composites"]
|
|
@@ -12721,7 +12741,9 @@ function namespaceJoinNote(joined) {
|
|
|
12721
12741
|
}
|
|
12722
12742
|
function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail, edgelessInstead) {
|
|
12723
12743
|
const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
|
|
12724
|
-
const
|
|
12744
|
+
const edgeless = edgelessNote(zoom, ir, detail, edgelessInstead);
|
|
12745
|
+
const counted = edgeless !== void 0 || zoom === "components" || zoom === "logical" ? void 0 : danglingNote(ir);
|
|
12746
|
+
const notes = [primary, edgeless, counted].filter((n) => n !== void 0);
|
|
12725
12747
|
return notes.length ? notes.join(" \xB7 ") : void 0;
|
|
12726
12748
|
}
|
|
12727
12749
|
function tierMismatchNote(ir, tiers, currentTier) {
|
|
@@ -12984,7 +13006,7 @@ function memberIrFromGraphDocument(doc, member) {
|
|
|
12984
13006
|
if (isRecord7(doc.groups)) {
|
|
12985
13007
|
for (const [key, table] of Object.entries(doc.groups)) {
|
|
12986
13008
|
if (key === "byMember" || !isRecord7(table)) continue;
|
|
12987
|
-
const scoped = key === "byStack" || key === "byContainer" || key === "byWave";
|
|
13009
|
+
const scoped = key === "byStack" || key === "byContainer" || key === "byWave" || key === "byPipeline";
|
|
12988
13010
|
for (const [k, ids] of Object.entries(table)) {
|
|
12989
13011
|
if (scoped && !k.startsWith(p)) continue;
|
|
12990
13012
|
const members = (Array.isArray(ids) ? ids : []).map(un).filter((id) => own.has(id));
|
|
@@ -13000,7 +13022,12 @@ function memberIrFromGraphDocument(doc, member) {
|
|
|
13000
13022
|
const { member: _m, ...rest } = i;
|
|
13001
13023
|
return { ...rest, node: un(i.node) };
|
|
13002
13024
|
});
|
|
13025
|
+
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) => {
|
|
13026
|
+
const { member: _m, ...rest } = d;
|
|
13027
|
+
return { ...rest, from: un(d.from) };
|
|
13028
|
+
}).filter((d) => own.has(d.from));
|
|
13003
13029
|
const ir = { version: 1, nodes, edges, groups, exports, imports };
|
|
13030
|
+
if (dangling.length) ir.dangling = dangling;
|
|
13004
13031
|
if (isRecord7(entry.meta)) ir.meta = entry.meta;
|
|
13005
13032
|
if (entry.pipeline !== void 0) ir.pipeline = entry.pipeline;
|
|
13006
13033
|
if (isRecord7(doc.derivedAttrs)) ir.derivedAttrs = doc.derivedAttrs;
|
|
@@ -14126,8 +14153,9 @@ function projectChoudoufuLogical(ir) {
|
|
|
14126
14153
|
const edges = [];
|
|
14127
14154
|
const seen = /* @__PURE__ */ new Set();
|
|
14128
14155
|
for (const e of ir.edges) {
|
|
14129
|
-
|
|
14130
|
-
|
|
14156
|
+
const key = `${e.from}|${e.to}|${e.kind}`;
|
|
14157
|
+
if (!kept.has(e.from) || !kept.has(e.to) || seen.has(key)) continue;
|
|
14158
|
+
seen.add(key);
|
|
14131
14159
|
edges.push(e);
|
|
14132
14160
|
}
|
|
14133
14161
|
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
|
|
@@ -14155,8 +14183,9 @@ function projectTerraformLogical(ir) {
|
|
|
14155
14183
|
const edges = [];
|
|
14156
14184
|
const seen = /* @__PURE__ */ new Set();
|
|
14157
14185
|
for (const e of ir.edges) {
|
|
14158
|
-
|
|
14159
|
-
|
|
14186
|
+
const key = `${e.from}|${e.to}|${e.kind}`;
|
|
14187
|
+
if (!kept.has(e.from) || !kept.has(e.to) || seen.has(key)) continue;
|
|
14188
|
+
seen.add(key);
|
|
14160
14189
|
edges.push(e);
|
|
14161
14190
|
}
|
|
14162
14191
|
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
|
|
@@ -15643,7 +15672,7 @@ function collapseBoxes(ir, opts = {}) {
|
|
|
15643
15672
|
const from = standsFor.get(e.from) ?? e.from;
|
|
15644
15673
|
const to = standsFor.get(e.to) ?? e.to;
|
|
15645
15674
|
if (from === to) continue;
|
|
15646
|
-
const key = `${from}|${to}`;
|
|
15675
|
+
const key = `${from}|${to}|${e.kind}`;
|
|
15647
15676
|
if (seen.has(key)) continue;
|
|
15648
15677
|
seen.add(key);
|
|
15649
15678
|
edges.push(from === e.from && to === e.to ? e : { ...e, from, to });
|
|
@@ -15795,7 +15824,8 @@ function renderArchitecture(ir, byContainer, opts = {}) {
|
|
|
15795
15824
|
}
|
|
15796
15825
|
function renderGraph(irIn, opts = {}) {
|
|
15797
15826
|
const groupsIn = irIn.groups;
|
|
15798
|
-
const
|
|
15827
|
+
const pipelines = groupsIn.byPipeline && Object.keys(groupsIn.byPipeline).length ? groupsIn.byPipeline : void 0;
|
|
15828
|
+
const boxKey = groupsIn.byWave ? "byWave" : opts.boxes ?? (pipelines ? "byPipeline" : void 0);
|
|
15799
15829
|
const face = estateFace(irIn, boxKey ? groupsIn[boxKey] : void 0);
|
|
15800
15830
|
const ir = face.ir;
|
|
15801
15831
|
const boxes = face.boxes;
|
|
@@ -15804,6 +15834,10 @@ function renderGraph(irIn, opts = {}) {
|
|
|
15804
15834
|
if (opts.radial && !boxes) radializeLayout(layout, groupKeyByNode(ir), footprints(ir, face.overrides));
|
|
15805
15835
|
else if (!boxes) packComponents(layout, ir);
|
|
15806
15836
|
packEstateBoxes(layout, face, { members: boxKey === "byStack", ...opts.groupBadges ? { badges: opts.groupBadges } : {} });
|
|
15837
|
+
if (boxKey === "byPipeline" && pipelines) {
|
|
15838
|
+
const titles = pipelineTitles(irIn, pipelines);
|
|
15839
|
+
for (const box of layout.groups ?? []) if (box.id !== void 0 && titles[box.id] !== void 0) box.title = titles[box.id];
|
|
15840
|
+
}
|
|
15807
15841
|
const svg = renderSvg(ir, layout, {
|
|
15808
15842
|
fit: true,
|
|
15809
15843
|
hideTitle: true,
|
|
@@ -15813,6 +15847,15 @@ function renderGraph(irIn, opts = {}) {
|
|
|
15813
15847
|
});
|
|
15814
15848
|
return { svg: face.restore(svg) };
|
|
15815
15849
|
}
|
|
15850
|
+
function pipelineTitles(ir, byPipeline) {
|
|
15851
|
+
const nodes = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
15852
|
+
return Object.fromEntries(
|
|
15853
|
+
Object.keys(byPipeline).map((key) => {
|
|
15854
|
+
const name = nodes.get(key)?.attrs?.["name"];
|
|
15855
|
+
return [key, typeof name === "string" && name.trim() ? name : key];
|
|
15856
|
+
})
|
|
15857
|
+
);
|
|
15858
|
+
}
|
|
15816
15859
|
function applyBadges(boxes, badges, marks) {
|
|
15817
15860
|
if (!badges && !marks) return;
|
|
15818
15861
|
for (const box of boxes ?? []) {
|
|
@@ -20878,7 +20921,7 @@ function startDriftPoll(opts) {
|
|
|
20878
20921
|
// src/frames.ts
|
|
20879
20922
|
function frameDigest(ir) {
|
|
20880
20923
|
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();
|
|
20924
|
+
const edges = ir.edges.map((e) => `${e.from}>${e.to}${e.kind === "ref" ? "" : `:${e.kind}`}`).sort();
|
|
20882
20925
|
return `${nodes.join("|")}#${edges.join("|")}`;
|
|
20883
20926
|
}
|
|
20884
20927
|
var FrameBuffer = class {
|
|
@@ -26808,11 +26851,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
26808
26851
|
const tf = passNote(run3);
|
|
26809
26852
|
const cdNote = lexiconNote(expanded);
|
|
26810
26853
|
const sameNote = unchangedZoomNote(srcZoom, estateMembers(estateDirs).map((m) => m.kind));
|
|
26854
|
+
const ownNote = [estateLensNote, tf.note, cdNote, sameNote].filter(Boolean).join(" \xB7 ");
|
|
26855
|
+
const danglingClause = (ownNote || multi) && srcZoom !== "components" && srcZoom !== "logical" ? danglingNote(expanded) : void 0;
|
|
26811
26856
|
const srcNote = [
|
|
26812
|
-
|
|
26857
|
+
ownNote || (multi ? void 0 : notesFor(srcZoom, expanded, srcCompositeEdgesAttached, void 0, opts.detail ?? 2, cdNote)),
|
|
26858
|
+
danglingClause,
|
|
26813
26859
|
collapsedNote
|
|
26814
26860
|
].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;
|
|
26861
|
+
const srcNoteShort = tf.noteShort || sameNote ? [estateLensNote, tf.noteShort ?? tf.note, cdNote, sameNote, danglingClause, collapsedNote].filter(Boolean).join(" \xB7 ") : void 0;
|
|
26816
26862
|
return c.json({
|
|
26817
26863
|
ir,
|
|
26818
26864
|
svg,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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.20",
|
|
45
|
+
"@intentius/chant-k8s-client": "^0.124.20",
|
|
46
|
+
"@intentius/pinhole": "^0.4.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 { edgeLabel, edgeLegend, edgeTitle, isCiKind } 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,24 +4424,42 @@ 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
4460
|
// Reason per edge, keyed by from|to (and its reverse, since pinhole may paint
|
|
4392
|
-
// an edge in either direction relative to the IR).
|
|
4461
|
+
// an edge in either direction relative to the IR). pinhole stamps
|
|
4462
|
+
// data-edge-via itself; this fills it in where it did not.
|
|
4393
4463
|
const viaOf = new Map();
|
|
4394
4464
|
for (const e of ir.edges || []) {
|
|
4395
4465
|
if (!e.viaAttr) continue;
|
|
@@ -4399,10 +4469,13 @@ function wireEdgeHighlight(svgEl, ir) {
|
|
|
4399
4469
|
for (const g of edges) {
|
|
4400
4470
|
const from = g.getAttribute("data-edge-from");
|
|
4401
4471
|
const to = g.getAttribute("data-edge-to");
|
|
4402
|
-
const via = viaOf.get(`${from}|${to}`);
|
|
4472
|
+
const via = g.getAttribute("data-edge-via") || viaOf.get(`${from}|${to}`);
|
|
4403
4473
|
if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
|
|
4474
|
+
const kind = g.getAttribute("data-edge-kind") || "ref";
|
|
4475
|
+
const cross = g.getAttribute("data-edge-cross") === "true";
|
|
4476
|
+
if (!g.querySelector("path")) relabelEdgeChip(g, kind, via);
|
|
4404
4477
|
const title = document.createElementNS(SVGNS, "title");
|
|
4405
|
-
title.textContent =
|
|
4478
|
+
title.textContent = edgeTitle(from, to, { kind, via, cross });
|
|
4406
4479
|
g.insertBefore(title, g.firstChild);
|
|
4407
4480
|
}
|
|
4408
4481
|
const raise = (g) => g.parentNode && g.parentNode.appendChild(g);
|
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,93 @@
|
|
|
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
|
+
* The chip text, as chant's Mermaid and DOT writers label the edge
|
|
37
|
+
* (graph-mermaid.ts `edgeLabel`): a CI edge by its kind, plus the attribute
|
|
38
|
+
* when that says something more (`needs (stage)`, `calls (uses)`); a
|
|
39
|
+
* reference by the attribute it flows through. Empty when there is nothing
|
|
40
|
+
* to say.
|
|
41
|
+
*/
|
|
42
|
+
export function edgeLabel({ kind, via, toAttr } = {}) {
|
|
43
|
+
if (!isCiKind(kind)) return [via, toAttr].filter(Boolean).join(" → ");
|
|
44
|
+
return via && via !== kind ? `${kind} (${via})` : kind;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The hover tooltip: who points at whom on the first line, then the kind
|
|
49
|
+
* (with its meaning) for a CI edge or the reason for a reference, then the
|
|
50
|
+
* cross-pipeline sentence when the edge carries it.
|
|
51
|
+
*/
|
|
52
|
+
export function edgeTitle(from, to, { kind, via, cross } = {}) {
|
|
53
|
+
const lines = [`${from} → ${to}`];
|
|
54
|
+
if (isCiKind(kind)) {
|
|
55
|
+
const meaning = KIND_MEANING[kind];
|
|
56
|
+
lines.push(meaning ? `${edgeLabel({ kind, via })}: ${meaning}` : edgeLabel({ kind, via }));
|
|
57
|
+
} else if (via) {
|
|
58
|
+
lines.push(REF_REASON[via] || via);
|
|
59
|
+
}
|
|
60
|
+
if (cross) lines.push(CROSS_MEANING);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Model tab's edge rows: one per CI kind the graph holds, with its count,
|
|
66
|
+
* then one for the cross-pipeline edges. A kind whose every edge is
|
|
67
|
+
* cross-pipeline (GitHub's `calls` and `triggers`) is drawn dashed on its row
|
|
68
|
+
* too, as it is on the graph. Empty when the graph has neither, so an
|
|
69
|
+
* infrastructure graph shows no edge legend.
|
|
70
|
+
*/
|
|
71
|
+
export function edgeLegend(edges = []) {
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
const crossCounts = new Map();
|
|
74
|
+
let cross = 0;
|
|
75
|
+
for (const e of edges) {
|
|
76
|
+
if (isCiKind(e.kind)) {
|
|
77
|
+
counts.set(e.kind, (counts.get(e.kind) || 0) + 1);
|
|
78
|
+
if (e.crossPipeline) crossCounts.set(e.kind, (crossCounts.get(e.kind) || 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
if (e.crossPipeline) cross++;
|
|
81
|
+
}
|
|
82
|
+
const known = CI_KINDS.filter((k) => counts.has(k));
|
|
83
|
+
const other = [...counts.keys()].filter((k) => !CI_KINDS.includes(k)).sort();
|
|
84
|
+
const rows = [...known, ...other].map((kind) => ({
|
|
85
|
+
kind,
|
|
86
|
+
cross: crossCounts.get(kind) === counts.get(kind),
|
|
87
|
+
label: kind,
|
|
88
|
+
count: counts.get(kind),
|
|
89
|
+
title: KIND_MEANING[kind] || kind,
|
|
90
|
+
}));
|
|
91
|
+
if (cross) rows.push({ kind: null, cross: true, label: "cross-pipeline (dashed)", count: cross, title: CROSS_MEANING });
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { edgeLabel, edgeLegend, edgeTitle } 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
|
+
});
|
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;
|