@intentius/behold 0.26.0 → 0.28.0

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