@intentius/behold 0.27.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 CHANGED
@@ -9133,6 +9133,11 @@ var init_workspace_kinds_schema = __esm({
9133
9133
  },
9134
9134
  fields: {
9135
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."
9136
9141
  }
9137
9142
  },
9138
9143
  patternProperties: {
@@ -15776,7 +15781,9 @@ function estateFace(irIn, boxesIn, opts = {}) {
15776
15781
  const face = cardFaces(irIn, boxesIn, opts.prefixes ? { prefixes: opts.prefixes } : {});
15777
15782
  const ir = face.ir;
15778
15783
  if (Object.keys(face.overrides).length) faceOverrides.set(ir, face.overrides);
15779
- const boxes = boxesIn ? Object.fromEntries(Object.entries(boxesIn).map(([key, ids]) => [key, ids.map((id) => face.display.get(id) ?? id)])) : void 0;
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;
15780
15787
  const realBand = moduleBandOf(irIn);
15781
15788
  const toReal = new Map(ir.nodes.map((n, i) => [n.id, irIn.nodes[i].id]));
15782
15789
  const bandOf = realBand ? (id) => realBand(toReal.get(id) ?? id) : void 0;
@@ -15786,13 +15793,14 @@ function estateFace(irIn, boxesIn, opts = {}) {
15786
15793
  ...opts.nodesep !== void 0 ? { nodesep: opts.nodesep } : {},
15787
15794
  ...opts.ranksep !== void 0 ? { ranksep: opts.ranksep } : {}
15788
15795
  }) : { ir, grids: /* @__PURE__ */ new Map() };
15789
- 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 };
15790
15797
  }
15791
15798
  function packEstateBoxes(layout, face, opts = {}) {
15792
15799
  const boxes = face.boxes;
15793
15800
  if (!boxes) return;
15794
- const boxGrids = packBoxComponents(layout, face.ir, boxes, face.bandOf);
15795
- 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);
15796
15804
  addBandBoxes(layout, new Map([...boxGrids, ...face.chains.grids]), footprints(face.ir, face.overrides));
15797
15805
  applyBadges(layout.groups, opts.badges, opts.marks);
15798
15806
  }
@@ -15826,17 +15834,27 @@ function renderGraph(irIn, opts = {}) {
15826
15834
  const groupsIn = irIn.groups;
15827
15835
  const pipelines = groupsIn.byPipeline && Object.keys(groupsIn.byPipeline).length ? groupsIn.byPipeline : void 0;
15828
15836
  const boxKey = groupsIn.byWave ? "byWave" : opts.boxes ?? (pipelines ? "byPipeline" : void 0);
15829
- const face = estateFace(irIn, boxKey ? groupsIn[boxKey] : void 0);
15837
+ const nested = boxKey === "byStack" ? pipelines : void 0;
15838
+ const face = estateFace(irIn, boxKey ? groupsIn[boxKey] : void 0, nested ? { subgroups: nested } : {});
15830
15839
  const ir = face.ir;
15831
15840
  const boxes = face.boxes;
15832
15841
  const chains = face.chains;
15833
- const layout = layoutIr(chains.ir, { fit: true, overrides: face.overrides, ...boxes ? { groups: boxes } : {} });
15842
+ const layout = layoutIr(chains.ir, {
15843
+ fit: true,
15844
+ overrides: face.overrides,
15845
+ ...boxes ? { groups: boxes } : {},
15846
+ ...face.subgroups ? { subgroups: face.subgroups } : {}
15847
+ });
15834
15848
  if (opts.radial && !boxes) radializeLayout(layout, groupKeyByNode(ir), footprints(ir, face.overrides));
15835
15849
  else if (!boxes) packComponents(layout, ir);
15836
15850
  packEstateBoxes(layout, face, { members: boxKey === "byStack", ...opts.groupBadges ? { badges: opts.groupBadges } : {} });
15837
- if (boxKey === "byPipeline" && pipelines) {
15851
+ if ((boxKey === "byPipeline" || nested) && pipelines) {
15838
15852
  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];
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
+ }
15840
15858
  }
15841
15859
  const svg = renderSvg(ir, layout, {
15842
15860
  fit: true,
@@ -15849,9 +15867,16 @@ function renderGraph(irIn, opts = {}) {
15849
15867
  }
15850
15868
  function pipelineTitles(ir, byPipeline) {
15851
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
+ };
15852
15877
  return Object.fromEntries(
15853
- Object.keys(byPipeline).map((key) => {
15854
- const name = nodes.get(key)?.attrs?.["name"];
15878
+ Object.entries(byPipeline).map(([key, ids]) => {
15879
+ const name = (nodes.get(key) ?? workflowOf(ids))?.attrs?.["name"];
15855
15880
  return [key, typeof name === "string" && name.trim() ? name : key];
15856
15881
  })
15857
15882
  );
@@ -15901,6 +15926,39 @@ function addBandBoxes(layout, grids, sizes) {
15901
15926
  boxes.push(...added);
15902
15927
  }
15903
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
+ }
15904
15962
  function clampInside(r, box) {
15905
15963
  const inset = 6;
15906
15964
  const left = box.x - box.w / 2 + inset;
@@ -16216,7 +16274,8 @@ function packComponents(layout, ir) {
16216
16274
  layout.height = Math.max(...nodes.map((n) => n.y + halfH(n))) + pad;
16217
16275
  }
16218
16276
  function packMemberBoxes(layout, ir, members) {
16219
- const boxes = layout.groups ?? [];
16277
+ const allBoxes = layout.groups ?? [];
16278
+ const boxes = allBoxes.filter((b) => b.parent === void 0);
16220
16279
  if (boxes.length < 4) return;
16221
16280
  const nodes = layout.nodes;
16222
16281
  const size = footprints(ir);
@@ -16332,7 +16391,7 @@ function gridComponent(one, sizes) {
16332
16391
  const rows = Math.ceil(order.length / cols);
16333
16392
  return { local, w: Math.min(cols, order.length) * (cell.w + sep12.x) - sep12.x, h: rows * (cell.h + sep12.y) - sep12.y };
16334
16393
  }
16335
- function packBoxComponents(layout, ir, boxes, bandOf) {
16394
+ function packBoxComponents(layout, ir, boxes, bandOf, subgroups) {
16336
16395
  const grids = /* @__PURE__ */ new Map();
16337
16396
  const groupBoxes = layout.groups ?? [];
16338
16397
  const nodes = layout.nodes;
@@ -16363,6 +16422,10 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
16363
16422
  const b = idxOf.get(e.to);
16364
16423
  if (a !== void 0 && b !== void 0) parent[find(a)] = find(b);
16365
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
+ }
16366
16429
  const comps = /* @__PURE__ */ new Map();
16367
16430
  own.forEach((n, i) => (comps.get(find(i)) ?? comps.set(find(i), []).get(find(i))).push(n));
16368
16431
  if (comps.size < 2) continue;
@@ -16375,12 +16438,19 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
16375
16438
  edges: ir.edges.filter((e) => inside.has(e.from) && inside.has(e.to)),
16376
16439
  groups: {}
16377
16440
  };
16378
- let one = layoutIr(sub, { fit: true });
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 } : {} });
16379
16449
  if (one.width > one.height * BOX_ASPECT_MAX) {
16380
- const sideways = layoutIr(sub, { fit: true, rankdir: "LR" });
16450
+ const sideways = layoutIr(sub, { fit: true, rankdir: "LR", ...nests ? { groups: nest } : {} });
16381
16451
  if (offAspect(sideways) < offAspect(one)) one = sideways;
16382
16452
  }
16383
- 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 };
16384
16454
  laid.set(root, { ...placed, ns });
16385
16455
  }
16386
16456
  const order = [void 0];
@@ -16396,8 +16466,9 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
16396
16466
  const all = [...laid.values()];
16397
16467
  const area = all.reduce((s, c) => s + (c.w + COMPONENT_GAP) * (c.h + COMPONENT_GAP), 0);
16398
16468
  const targetW = Math.max(Math.max(...all.map((c) => c.w)), Math.sqrt(area * ROW_ASPECT));
16399
- const originX = box.x - box.w / 2 + BAND_PAD;
16400
- const originY = box.y - box.h / 2 + TITLE;
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);
16401
16472
  let shelfX = 0;
16402
16473
  let shelfY = 0;
16403
16474
  let shelfH = 0;
@@ -16429,10 +16500,18 @@ function packBoxComponents(layout, ir, boxes, bandOf) {
16429
16500
  }
16430
16501
  bands.push({ key: band, ids: ids2 });
16431
16502
  }
16432
- const x0 = Math.min(...own.map((n) => n.x - halfW(n))) - BAND_PAD;
16433
- const x1 = Math.max(...own.map((n) => n.x + halfW(n))) + BAND_PAD;
16434
- const y0 = Math.min(...own.map((n) => n.y - halfH(n))) - TITLE;
16435
- const y1 = Math.max(...own.map((n) => n.y + halfH(n))) + BAND_PAD;
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
+ }
16436
16515
  box.x = (x0 + x1) / 2;
16437
16516
  box.y = (y0 + y1) / 2;
16438
16517
  box.w = x1 - x0;
@@ -19486,6 +19565,17 @@ async function ledgerIn(root, dir) {
19486
19565
  return { members, prefix: memberLedgerPrefix(members) };
19487
19566
  }
19488
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
+
19489
19579
  // node_modules/@intentius/chant/src/lifecycle/git.ts
19490
19580
  var STATE_BRANCH = "chant/lifecycle";
19491
19581
  async function ledgerDir(dir, opts) {
@@ -19502,7 +19592,7 @@ async function readBlobFromPath(environment, filename, opts) {
19502
19592
  const rt = getRuntime();
19503
19593
  const dir = await ledgerDir(environment, opts);
19504
19594
  const result = await rt.spawn(
19505
- ["git", "show", `${STATE_BRANCH}:${dir}/${filename}`],
19595
+ ["git", "show", `${await ledgerRevision(opts?.cwd)}:${dir}/${filename}`],
19506
19596
  { cwd: opts?.cwd }
19507
19597
  );
19508
19598
  if (result.exitCode !== 0) return null;
@@ -19527,7 +19617,7 @@ async function listLedgerTrees(opts) {
19527
19617
  const rt = getRuntime();
19528
19618
  const base = await ledgerDir("", opts);
19529
19619
  const rootResult = await rt.spawn(
19530
- ["git", "ls-tree", "--full-tree", base === "" ? STATE_BRANCH : `${STATE_BRANCH}:${base}`],
19620
+ ["git", "ls-tree", "--full-tree", base === "" ? tip : `${tip}:${base}`],
19531
19621
  { cwd: opts?.cwd }
19532
19622
  );
19533
19623
  if (rootResult.exitCode !== 0) return [];
@@ -19538,7 +19628,12 @@ async function listLedgerTrees(opts) {
19538
19628
  }
19539
19629
  return trees;
19540
19630
  }
19631
+ var C_LOCALE_ENV = { LC_ALL: "C", LANGUAGE: "" };
19541
19632
  async function getStateBranchTip(cwd) {
19633
+ if (ledgerRecordingOff()) {
19634
+ const tip = overlayTip(await overlayKey(cwd));
19635
+ if (tip !== void 0) return tip;
19636
+ }
19542
19637
  const rt = getRuntime();
19543
19638
  const result = await rt.spawn(
19544
19639
  ["git", "rev-parse", "--verify", `refs/heads/${STATE_BRANCH}`],
@@ -19547,6 +19642,20 @@ async function getStateBranchTip(cwd) {
19547
19642
  if (result.exitCode !== 0) return null;
19548
19643
  return result.stdout.trim();
19549
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
+ }
19550
19659
 
19551
19660
  // node_modules/@intentius/chant/src/content-digest.ts
19552
19661
  import { createHash as createHash6 } from "node:crypto";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.27.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.20",
45
- "@intentius/chant-k8s-client": "^0.124.20",
46
- "@intentius/pinhole": "^0.4.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,7 +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
+ import { edgeKey, edgeLabel, edgeLegend, edgeTitle, isCiKind, viaIndex } from "./edge-kind.js";
25
25
  import { readCostLine } from "./read-cost.js";
26
26
  import { mountHistoryPicker } from "./history-picker.js";
27
27
  import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
@@ -4457,21 +4457,16 @@ function relabelEdgeChip(chip, kind, via) {
4457
4457
  function wireEdgeHighlight(svgEl, ir) {
4458
4458
  const edges = [...svgEl.querySelectorAll("g[data-edge-from]")];
4459
4459
  if (!edges.length) return;
4460
- // Reason per edge, keyed by from|to (and its reverse, since pinhole may paint
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.
4463
- const viaOf = new Map();
4464
- for (const e of ir.edges || []) {
4465
- if (!e.viaAttr) continue;
4466
- viaOf.set(`${e.from}|${e.to}`, e.viaAttr);
4467
- viaOf.set(`${e.to}|${e.from}`, e.viaAttr);
4468
- }
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 || []);
4469
4464
  for (const g of edges) {
4470
4465
  const from = g.getAttribute("data-edge-from");
4471
4466
  const to = g.getAttribute("data-edge-to");
4472
- const via = g.getAttribute("data-edge-via") || viaOf.get(`${from}|${to}`);
4473
- if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
4474
4467
  const kind = g.getAttribute("data-edge-kind") || "ref";
4468
+ const via = g.getAttribute("data-edge-via") || viaOf.get(edgeKey({ from, to, kind }));
4469
+ if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
4475
4470
  const cross = g.getAttribute("data-edge-cross") === "true";
4476
4471
  if (!g.querySelector("path")) relabelEdgeChip(g, kind, via);
4477
4472
  const title = document.createElementNS(SVGNS, "title");
@@ -5280,17 +5275,24 @@ function userRect(el, toUser) {
5280
5275
  */
5281
5276
  function edgeLabelOf(g) {
5282
5277
  if (!(g.getAttribute("data-edge-via") || "").trim()) return null;
5283
- const from = g.getAttribute("data-edge-from");
5284
- const to = g.getAttribute("data-edge-to");
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);
5285
5281
  for (const cand of g.parentNode ? g.parentNode.children : []) {
5286
5282
  if (cand === g || cand.tagName.toLowerCase() !== "g") continue;
5287
- if (cand.getAttribute("data-edge-from") !== from || cand.getAttribute("data-edge-to") !== to) continue;
5283
+ if (!cand.hasAttribute("data-edge-from") || edgeKeyOf(cand) !== key) continue;
5288
5284
  if (cand.hasAttribute("data-node-id") || cand.querySelector("path")) continue;
5289
5285
  return cand;
5290
5286
  }
5291
5287
  return null;
5292
5288
  }
5293
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
+
5294
5296
  /** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
5295
5297
  function indexLayout(svgEl) {
5296
5298
  const toUser = userSpaceMapper(svgEl);
package/web/edge-kind.js CHANGED
@@ -32,6 +32,37 @@ export function isCiKind(kind) {
32
32
  return !!kind && kind !== "ref";
33
33
  }
34
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
+
35
66
  /**
36
67
  * The chip text, as chant's Mermaid and DOT writers label the edge
37
68
  * (graph-mermaid.ts `edgeLabel`): a CI edge by its kind, plus the attribute
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { edgeLabel, edgeLegend, edgeTitle } from "./edge-kind.js";
2
+ import { edgeKey, edgeLabel, edgeLegend, edgeTitle, viaIndex } from "./edge-kind.js";
3
3
 
4
4
  // The edges chant 0.124.20's GitHub lexicon writes (lexicons/github/src/
5
5
  // graph-edges.ts) and one from its GitLab lexicon.
@@ -68,3 +68,33 @@ describe("edgeLegend (#553)", () => {
68
68
  expect(edgeLegend([ref, { from: "a", to: "b", kind: "ref" }])).toEqual([]);
69
69
  });
70
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
+ });