@intentius/behold 0.25.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.
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.121.0"
7
+ "version": "0.124.0"
8
8
  }
9
9
  ],
10
10
  "members": [
@@ -90,7 +90,7 @@ install them, so that every user who serves an ordinary chant project does not
90
90
  carry an HCL parser they will never run:
91
91
 
92
92
  ```sh
93
- npm install --no-save @intentius/chant-lexicon-terraform@^0.121.0 @cdktn/hcl2json@^0.24.0
93
+ npm install --no-save @intentius/chant-lexicon-terraform@^0.124.0 @cdktn/hcl2json@^0.24.0
94
94
  ```
95
95
 
96
96
  Without them the demo refuses before it serves, naming both packages, the
@@ -101,7 +101,7 @@ the packages `chant.workspace.json` pins, looked up from the workspace's own
101
101
  `node_modules` at exactly the pinned version. A demo copy has no
102
102
  `node_modules`, so `behold demo` links the lexicon behold resolved into the
103
103
  copy and sets the copy's pin to that version (#484). It says so as it loads:
104
- `linked @intentius/chant-lexicon-terraform 0.121.0 into the copy`. Serving
104
+ `linked @intentius/chant-lexicon-terraform 0.124.0 into the copy`. Serving
105
105
  your own declared workspace is different: install the pinned lexicon in its
106
106
  root, the way `chant workspace check` asks.
107
107
 
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.121.0"
7
+ "version": "0.124.0"
8
8
  }
9
9
  ],
10
10
  "members": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.25.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,14 +41,14 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@hono/node-server": "^2.0.5",
44
- "@intentius/chant": "^0.121.1",
45
- "@intentius/chant-k8s-client": "^0.121.1",
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
  },
50
50
  "peerDependencies": {
51
- "@intentius/chant-lexicon-terraform": "^0.121.0",
51
+ "@intentius/chant-lexicon-terraform": "^0.124.0",
52
52
  "@cdktn/hcl2json": "^0.24.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -60,6 +60,7 @@
60
60
  }
61
61
  },
62
62
  "devDependencies": {
63
+ "@intentius/chant-lexicon-sql": "^0.124.13",
63
64
  "@types/node": "^26.4.1",
64
65
  "esbuild": "^0.28.0",
65
66
  "playwright": "^1.62.1",
package/web/app.js CHANGED
@@ -13,11 +13,15 @@ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } f
13
13
  import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
14
14
  import { gateCards } from "./workspace-gates.js";
15
15
  import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
16
- import { laneElement as tgLane, rootOfCard as tgRootOfCard } from "./terragucci-timeline.js";
16
+ import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
17
17
  import { renderLane } from "./terragucci-lifecycle.js";
18
+ import { renderReleases } from "./releases.js";
19
+ import { renderYodelLane } from "./sql-yodeler-lane.js";
18
20
  import { initTerragucciEstate } from "./terragucci-estate.js";
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";
19
22
  import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
20
23
  import { seconds, whyQuery, whyView } from "./why.js";
24
+ import { edgeLabel, edgeLegend, edgeTitle, isCiKind } from "./edge-kind.js";
21
25
  import { readCostLine } from "./read-cost.js";
22
26
  import { mountHistoryPicker } from "./history-picker.js";
23
27
  import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
@@ -94,6 +98,7 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
94
98
  // which commit, approved by whom — and, when the ledger's run id cannot be
95
99
  // followed, the reason in words rather than a guessed link.
96
100
  import { releaseRows } from "./release.js";
101
+ import { danglingFor, danglingRows, danglingHeading, DANGLING_EXPLAINED } from "./dangling.js";
97
102
  import { comparisonRow, comparisonSummary } from "./cross-env.js";
98
103
  initTheme();
99
104
  // #476: framed in a host pane. The flag is a class on <html>, so the CSS that
@@ -908,6 +913,21 @@ function inspect(node) {
908
913
  panel.appendChild(p);
909
914
  }
910
915
  }
916
+ // #526: SQL Yodeler's drift on this card, each dated by its file.
917
+ const yrMarks = yrJoined && yrJoined.cards[node.id];
918
+ if (yrMarks && yrMarks.length) {
919
+ const h = document.createElement("h3");
920
+ h.textContent = "SQL Yodeler drift (from files, not live)";
921
+ panel.appendChild(h);
922
+ const now = new Date();
923
+ for (const m of yrMarks) {
924
+ const p = document.createElement("p");
925
+ p.className = "tg-inspect";
926
+ p.textContent = yrMarkLine(m, now);
927
+ p.title = m.changes.map((c) => `${c.path || ""} ${c.kind || ""}: declared ${JSON.stringify(c.declared ?? null)}, live ${JSON.stringify(c.live ?? null)}`).join("\n");
928
+ panel.appendChild(p);
929
+ }
930
+ }
911
931
  // #511: the newest apply's progress on this card, dated by the records read.
912
932
  const tgProg = tgCardProgress(tgProgress && tgProgress.cards && tgProgress.cards[node.id]);
913
933
  if (tgProg) {
@@ -1142,6 +1162,19 @@ function inspect(node) {
1142
1162
  }
1143
1163
  }
1144
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
+
1145
1178
  // CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
1146
1179
  // component DAG projected — waves = stages, components = jobs, `dependsOn` =
1147
1180
  // `needs:`. Read one way it's the deployment, read the other it's the
@@ -2815,10 +2848,47 @@ function renderPanelModel() {
2815
2848
  host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
2816
2849
  }
2817
2850
  }
2851
+ renderEdgeLegend(host, ir);
2818
2852
  renderBehaviourRefusal(host);
2819
2853
  renderBehaviourDiagnostics(host);
2820
2854
  }
2821
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
+
2822
2892
  /**
2823
2893
  * The refusal, where the legend would be (#401).
2824
2894
  *
@@ -3250,6 +3320,7 @@ function markDriftedCards(ir) {
3250
3320
  let tgAnswer = null;
3251
3321
  let tgHost = null;
3252
3322
  let tgTimeline = null; // #506: /api/terragucci/timeline, the audit record's lane
3323
+ let tgHistory = null; // #522: /api/lifecycle/history, the gate ledgers' history
3253
3324
  async function initTerragucci() {
3254
3325
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3255
3326
  if (!project.terragucciReports) return;
@@ -3299,6 +3370,10 @@ async function loadTerragucci({ fresh = false } = {}) {
3299
3370
  tgTimeline = await apiFetch(`/api/terragucci/timeline${fresh ? "?fresh=1" : ""}`)
3300
3371
  .then((r) => r.json())
3301
3372
  .catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
3373
+ // #522: the gate ledgers' history, with or in place of the audit record.
3374
+ tgHistory = await apiFetch(`/api/lifecycle/history${fresh ? "?fresh=1" : ""}`)
3375
+ .then((r) => (r.ok ? r.json() : null))
3376
+ .catch(() => null);
3302
3377
  renderTerragucciPanel();
3303
3378
  markTerragucciCards();
3304
3379
  if (!staticMode) await loadTgProgress({ fresh });
@@ -3456,11 +3531,16 @@ function renderTerragucciPanel() {
3456
3531
  ].filter(Boolean).join(" · ");
3457
3532
  host.appendChild(um);
3458
3533
  }
3459
- if (tgTimeline) {
3534
+ if (tgTimeline || tgHistory) {
3460
3535
  const h = document.createElement("div");
3461
3536
  h.className = "tg-timeline-title";
3462
3537
  h.textContent = "Timeline";
3463
- host.append(h, tgLane(tgTimeline));
3538
+ host.append(h);
3539
+ // #522: the audit record, and the ledger's history beside it, each
3540
+ // labelled by its source. Without an audit.jsonl the record's lane is its
3541
+ // one "has no audit.jsonl" sentence and the ledger carries the history.
3542
+ if (tgTimeline) host.append(tgLane(tgTimeline));
3543
+ if (tgHistory) host.append(tgHistoryLane(tgHistory));
3464
3544
  }
3465
3545
  if (!staticMode) {
3466
3546
  const again = button("Read the reports again", "", () => {
@@ -3471,6 +3551,188 @@ function renderTerragucciPanel() {
3471
3551
  }
3472
3552
  }
3473
3553
  initTerragucci();
3554
+ // #525: each environment's newest release from the release ledger on
3555
+ // chant/lifecycle, with a cell whose schema the environment before it does
3556
+ // not hold marked. Dated and read-only; a static export carries its snapshot.
3557
+ let relHost = null;
3558
+ async function initReleases() {
3559
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3560
+ if (!project.releaseLedger) return;
3561
+ await loadReleases();
3562
+ }
3563
+ async function loadReleases({ fresh = false } = {}) {
3564
+ const a = await apiFetch(`/api/lifecycle/releases${fresh ? "?fresh=1" : ""}`)
3565
+ .then((r) => r.json())
3566
+ .catch((e) => ({ error: String(e) }));
3567
+ if (!relHost) {
3568
+ relHost = addPanelTab("releases", "Releases", "Each environment's newest release from the release ledger on chant/lifecycle, dated. Not live.");
3569
+ if (!relHost) return;
3570
+ relHost.id = "tab-releases";
3571
+ }
3572
+ renderReleases(relHost, a, new Date(), { link: tgLink });
3573
+ if (!staticMode) {
3574
+ const again = button("Read again", "", () => loadReleases({ fresh: true }));
3575
+ again.title = "Read chant/lifecycle again and redraw.";
3576
+ relHost.appendChild(again);
3577
+ }
3578
+ }
3579
+ initReleases();
3580
+
3581
+
3582
+ // --- #526: SQL Yodeler's report and drift, from files ------------------------
3583
+ // /api/yodel-report reads `yodel report --json` and drift JSON the person or
3584
+ // their pipeline wrote, checked against SQL Yodeler's schemas. Drift marks are
3585
+ // dated corner tags on the cards they name; environments and gaps go in the
3586
+ // panel. Nothing is live, and behold never runs yodel.
3587
+ let yrAnswer = null;
3588
+ let yrJoined = null;
3589
+ let yrHost = null;
3590
+ async function initYodelReport() {
3591
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3592
+ if (!project.yodelReport) return;
3593
+ await loadYodelReport();
3594
+ }
3595
+ async function loadYodelReport() {
3596
+ yrAnswer = await apiFetch("/api/yodel-report")
3597
+ .then((r) => r.json())
3598
+ .catch((e) => ({ error: String(e), code: "yodel-report", remedy: "" }));
3599
+ markYodelCards();
3600
+ }
3601
+ function markYodelCards() {
3602
+ if (!yrAnswer) return;
3603
+ const svg = document.querySelector("#graph svg");
3604
+ if (svg) {
3605
+ for (const old of svg.querySelectorAll("[data-yr-mark]")) old.remove();
3606
+ const ids = [...svg.querySelectorAll("[data-node-id]")].map((g) => g.getAttribute("data-node-id"));
3607
+ yrJoined = yrAnswer.error ? null : yrJoin(yrAnswer, ids);
3608
+ const now = new Date();
3609
+ for (const [id, marks] of Object.entries(yrJoined ? yrJoined.cards : {})) {
3610
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
3611
+ if (!g) continue;
3612
+ const rect = g.querySelector("rect");
3613
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
3614
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
3615
+ tag.setAttribute("data-yr-mark", "1");
3616
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
3617
+ tag.setAttribute("y", String((box ? box.y : 0) + 14));
3618
+ tag.setAttribute("text-anchor", "end");
3619
+ tag.setAttribute("font-size", "11");
3620
+ tag.setAttribute("font-weight", "600");
3621
+ tag.setAttribute("fill", "var(--degraded)");
3622
+ tag.textContent = yrCorner(marks, now);
3623
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
3624
+ title.setAttribute("data-yr-mark", "title");
3625
+ title.textContent = marks.map((m) => yrMarkLine(m, now)).join("\n") + "\n(from SQL Yodeler drift files, not live)";
3626
+ g.append(title, tag);
3627
+ }
3628
+ }
3629
+ renderYodelPanel();
3630
+ }
3631
+ function renderYodelPanel() {
3632
+ if (!yrHost) {
3633
+ yrHost = addPanelTab("yodel", "SQL Yodeler", "SQL Yodeler's report and drift, read from files: each environment's applied migrations and gaps, dated. Not live.");
3634
+ if (!yrHost) return;
3635
+ yrHost.id = "tab-yodel";
3636
+ }
3637
+ const host = yrHost;
3638
+ host.replaceChildren();
3639
+ const a = yrAnswer;
3640
+ const line = (cls, text, title) => {
3641
+ const d = document.createElement("div");
3642
+ d.className = cls;
3643
+ d.textContent = text;
3644
+ if (title) d.title = title;
3645
+ host.appendChild(d);
3646
+ return d;
3647
+ };
3648
+ if (!a || a.error) {
3649
+ line("tg-refusal", a ? `${a.code}: ${a.error}` : "SQL Yodeler report not read");
3650
+ if (a && a.remedy) line("tg-legend", a.remedy);
3651
+ return;
3652
+ }
3653
+ const now = new Date();
3654
+ const lg = yrLegend(a, now);
3655
+ line("tg-legend", lg.text, lg.title);
3656
+ for (const e of a.environments) {
3657
+ const h = document.createElement("h3");
3658
+ h.textContent = e.op ? `${e.name} (op ${e.op.name}, gate ${e.op.gate})` : e.name;
3659
+ host.appendChild(h);
3660
+ for (const t of yrEnvLines(e, now)) line("tg-inspect", t);
3661
+ line("tg-legend", `from ${yrDated(e.dated, now)}`);
3662
+ drawAudit((a.audit || []).filter((x) => x.environment === e.name));
3663
+ }
3664
+ // #537: audit entries for an environment the report lists no row for.
3665
+ const named = new Set(a.environments.map((e) => e.name));
3666
+ const loose = (a.audit || []).filter((x) => !named.has(x.environment));
3667
+ if (loose.length) {
3668
+ const h = document.createElement("h3");
3669
+ h.textContent = "audit, environments the report lists no row for";
3670
+ host.appendChild(h);
3671
+ drawAudit(loose, true);
3672
+ }
3673
+ function drawAudit(entries, withEnv = false) {
3674
+ if (!entries.length) return;
3675
+ const sub = document.createElement("div");
3676
+ sub.className = "tg-legend";
3677
+ sub.textContent = `audit, ${entries.length} entr${entries.length === 1 ? "y" : "ies"}, oldest first`;
3678
+ host.appendChild(sub);
3679
+ for (const x of entries) {
3680
+ const l = yrAuditLines(x, now);
3681
+ line("tg-inspect", withEnv ? `${x.environment}: ${l.text}` : l.text, l.source);
3682
+ line("tg-legend", l.source);
3683
+ }
3684
+ }
3685
+ for (const d of a.drift) {
3686
+ const h = document.createElement("h3");
3687
+ h.textContent = `${d.environment} drift`;
3688
+ host.appendChild(h);
3689
+ line("tg-inspect", d.drifted ? `${d.changed} changed, ${d.missing} missing` : "no drift in what could be read");
3690
+ for (const u of d.unobserved) line("tg-inspect", `not read: ${u.name}${u.reason ? ` (${u.reason})` : ""}`);
3691
+ line("tg-legend", `from ${yrDated(d.dated, now)}${d.server ? ` · ${d.server}` : ""}`);
3692
+ }
3693
+ const unmatched = (yrJoined && yrJoined.unmatched) || [];
3694
+ if (unmatched.length) {
3695
+ const h = document.createElement("h3");
3696
+ h.textContent = "drift on no card";
3697
+ host.appendChild(h);
3698
+ for (const m of unmatched) line("tg-inspect", `${m.object}: ${yrMarkLine(m, now)}`);
3699
+ }
3700
+ if (a.ignored.length) line("tg-legend", `not read (neither a report nor drift): ${a.ignored.join(", ")}`);
3701
+ if (!staticMode) host.appendChild(button("Read the files again", "", () => loadYodelReport()));
3702
+ }
3703
+ initYodelReport();
3704
+
3705
+ // --- #524: a SQL Yodeler repo's lane over chant/lifecycle -------------------
3706
+ // /api/sql-yodeler/lifecycle: each environment's wave gate and migrations-op
3707
+ // gate, in the order of applies, with the approve line to copy. Below it,
3708
+ // the gate ledgers' history (#522). Read-only and dated; never live.
3709
+ let ylHost = null;
3710
+ async function initYodelLane() {
3711
+ // #537: a static export carries the lane as a snapshot (src/export.ts), so it draws here too.
3712
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3713
+ if (!project.sqlYodeler) return;
3714
+ await loadYodelLane();
3715
+ }
3716
+ async function loadYodelLane({ fresh = false } = {}) {
3717
+ const q = fresh ? "?fresh=1" : "";
3718
+ const [lane, history] = await Promise.all([
3719
+ apiFetch(`/api/sql-yodeler/lifecycle${q}`).then((r) => r.json()).catch((e) => ({ error: String(e) })),
3720
+ apiFetch(`/api/lifecycle/history${q}`).then((r) => (r.ok ? r.json() : null)).catch(() => null),
3721
+ ]);
3722
+ if (!ylHost) {
3723
+ ylHost = addPanelTab("yodel-lane", "Pipeline", "SQL Yodeler's gates on chant/lifecycle: each environment's wave and migrations op, in the order of applies, dated. Not live.");
3724
+ if (!ylHost) return;
3725
+ ylHost.id = "tab-yodel-lane";
3726
+ }
3727
+ ylHost.replaceChildren();
3728
+ renderYodelLane(ylHost, lane, new Date(), { button, copy: copyToClipboard, link: tgLink });
3729
+ if (history) ylHost.appendChild(tgHistoryLane(history));
3730
+ if (staticMode) return;
3731
+ const again = button("Read again", "", () => loadYodelLane({ fresh: true }));
3732
+ again.title = "Fetch chant/lifecycle again and redraw.";
3733
+ ylHost.appendChild(again);
3734
+ }
3735
+ initYodelLane();
3474
3736
  // #509: a control repo draws its projects from estate.json and their run views.
3475
3737
  initTerragucciEstate({ fetchUrl: apiFetch, copy: copyToClipboard });
3476
3738
 
@@ -4162,24 +4424,42 @@ async function loadReconcile() {
4162
4424
  }
4163
4425
  }
4164
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
+
4165
4447
  // Edge hover-highlight (helps trace one edge through an overlapping bundle — see
4166
4448
  // index.html's `.pin-edge-line` rules). pinhole already paints a fat transparent
4167
4449
  // hit-path per edge, so the CSS `:hover` does the visual work; this adds two
4168
4450
  // things CSS can't: raise the hovered edge to the top of the paint order (SVG has
4169
4451
  // no z-index) so it's not buried, and light up every edge touching a hovered node
4170
4452
  // (the `.edge-hi` class) so hovering a card traces all its connections.
4171
- // Human-readable "why does this edge exist" from the IR edge's `viaAttr`
4172
- // (src/logical.ts tags them). Shown as a native SVG <title> tooltip on hover;
4173
- // the value also drives a dashed style for data-dependency links (index.html).
4174
- const EDGE_REASON = {
4175
- "security-group ingress": "security-group ingress — traffic is allowed here",
4176
- "data dependency": "data dependency — reads/uses this store",
4177
- };
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).
4178
4457
  function wireEdgeHighlight(svgEl, ir) {
4179
4458
  const edges = [...svgEl.querySelectorAll("g[data-edge-from]")];
4180
4459
  if (!edges.length) return;
4181
4460
  // Reason per edge, keyed by from|to (and its reverse, since pinhole may paint
4182
- // 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.
4183
4463
  const viaOf = new Map();
4184
4464
  for (const e of ir.edges || []) {
4185
4465
  if (!e.viaAttr) continue;
@@ -4189,10 +4469,13 @@ function wireEdgeHighlight(svgEl, ir) {
4189
4469
  for (const g of edges) {
4190
4470
  const from = g.getAttribute("data-edge-from");
4191
4471
  const to = g.getAttribute("data-edge-to");
4192
- const via = viaOf.get(`${from}|${to}`);
4472
+ const via = g.getAttribute("data-edge-via") || viaOf.get(`${from}|${to}`);
4193
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);
4194
4477
  const title = document.createElementNS(SVGNS, "title");
4195
- title.textContent = `${from} → ${to}${via ? "\n" + (EDGE_REASON[via] || via) : ""}`;
4478
+ title.textContent = edgeTitle(from, to, { kind, via, cross });
4196
4479
  g.insertBefore(title, g.firstChild);
4197
4480
  }
4198
4481
  const raise = (g) => g.parentNode && g.parentNode.appendChild(g);
@@ -4379,6 +4662,7 @@ function render(ir, svg, m) {
4379
4662
  markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
4380
4663
  markPendingCards(ir); // #422: and the cards whose member has not answered yet
4381
4664
  markTerragucciCards(); // #490: and what terragucci's reports found, dated
4665
+ markYodelCards(); // #526: and SQL Yodeler's drift, dated
4382
4666
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
4383
4667
  renderDial();
4384
4668
  }
@@ -6221,12 +6505,14 @@ async function initActions() {
6221
6505
  // them right now (`/api/project`'s executor block).
6222
6506
  executorInfo = project.executor || {};
6223
6507
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6224
- if (project.terragucci) {
6225
- terragucciRepo = project.terragucci;
6508
+ // #517: a SQL Yodeler repo is guarded the same way.
6509
+ const guarded = project.terragucci || project.sqlYodeler;
6510
+ if (guarded) {
6511
+ terragucciRepo = guarded;
6226
6512
  previewMode = true;
6227
6513
  const pill = document.createElement("span");
6228
- pill.textContent = "● applied by terragucci";
6229
- pill.title = `${project.terragucci.error}\n\n${project.terragucci.remedy}`;
6514
+ pill.textContent = project.terragucci ? "● applied by terragucci" : "● migrated by SQL Yodeler";
6515
+ pill.title = `${guarded.error}\n\n${guarded.remedy}`;
6230
6516
  pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
6231
6517
  bar.appendChild(pill);
6232
6518
  return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
@@ -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
+ }