@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 CHANGED
@@ -5129,13 +5129,15 @@ function dropForeignDeclarations(ir, projectDir) {
5129
5129
  }
5130
5130
  groups[axis] = kept;
5131
5131
  }
5132
- return {
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
- if (detail !== void 0 && detail < 3) {
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 notes = [primary, edgelessNote(zoom, ir, detail, edgelessInstead)].filter((n) => n !== void 0);
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
- if (!kept.has(e.from) || !kept.has(e.to) || seen.has(`${e.from}|${e.to}`)) continue;
14130
- seen.add(`${e.from}|${e.to}`);
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
- if (!kept.has(e.from) || !kept.has(e.to) || seen.has(`${e.from}|${e.to}`)) continue;
14159
- seen.add(`${e.from}|${e.to}`);
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 boxKey = groupsIn.byWave ? "byWave" : opts.boxes;
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
- [estateLensNote, tf.note, cdNote, sameNote].filter(Boolean).join(" \xB7 ") || (multi ? void 0 : notesFor(srcZoom, expanded, srcCompositeEdgesAttached, void 0, opts.detail ?? 2, cdNote)),
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.26.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.17",
45
- "@intentius/chant-k8s-client": "^0.124.17",
46
- "@intentius/pinhole": "^0.3.8",
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
- // (src/logical.ts tags them). Shown as a native SVG <title> tooltip on hover;
4383
- // the value also drives a dashed style for data-dependency links (index.html).
4384
- const EDGE_REASON = {
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 = `${from} → ${to}${via ? "\n" + (EDGE_REASON[via] || via) : ""}`;
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);
@@ -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
+ });
@@ -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;