@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.
- package/AGENTS.md +246 -7
- package/demos.json +1 -1
- package/dist/cli.js +10118 -1770
- package/example-choudoufu-estate/chant.workspace.json +1 -1
- package/example-terraform-estate/README.md +2 -2
- package/example-terraform-estate/chant.workspace.json +1 -1
- package/package.json +6 -5
- package/web/app.js +303 -17
- package/web/dangling.js +29 -0
- package/web/dangling.test.js +34 -0
- package/web/edge-kind.js +93 -0
- package/web/edge-kind.test.js +70 -0
- package/web/index.html +50 -0
- package/web/releases.js +121 -0
- package/web/sql-yodeler-lane.js +129 -0
- package/web/terragucci-lifecycle.js +103 -11
- package/web/terragucci-lifecycle.test.js +36 -1
- package/web/terragucci-timeline.js +77 -0
- package/web/workspace-gates.js +29 -0
- package/web/yodel-report.js +99 -0
- package/web/yodel-report.test.js +18 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@hono/node-server": "^2.0.5",
|
|
44
|
-
"@intentius/chant": "^0.
|
|
45
|
-
"@intentius/chant-k8s-client": "^0.
|
|
46
|
-
"@intentius/pinhole": "^0.
|
|
44
|
+
"@intentius/chant": "^0.124.20",
|
|
45
|
+
"@intentius/chant-k8s-client": "^0.124.20",
|
|
46
|
+
"@intentius/pinhole": "^0.4.0",
|
|
47
47
|
"ajv": "^8.20.0",
|
|
48
48
|
"hono": "^4.12.34"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@intentius/chant-lexicon-terraform": "^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
|
|
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
|
-
//
|
|
4173
|
-
//
|
|
4174
|
-
|
|
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 =
|
|
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
|
-
|
|
6225
|
-
|
|
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 = `${
|
|
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
|
package/web/dangling.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// #554: a CI reference whose target this project does not declare, such as
|
|
2
|
+
// another repository's reusable workflow or a local composite action. chant
|
|
3
|
+
// keeps each one on `ir.dangling` (chant#3912) with the value as written, and
|
|
4
|
+
// behold draws no edge for it, so the card holding it is where it is listed.
|
|
5
|
+
// The pure half, tested without a DOM the way web/release.js is.
|
|
6
|
+
|
|
7
|
+
/** The dangling references `nodeId` holds, in the order chant listed them. */
|
|
8
|
+
export function danglingFor(ir, nodeId) {
|
|
9
|
+
const all = ir && Array.isArray(ir.dangling) ? ir.dangling : [];
|
|
10
|
+
return all.filter((d) => d && d.from === nodeId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One `[label, value]` row per reference: the edge kind it would have been
|
|
14
|
+
* and where on the card it was written, then the value exactly as written. A
|
|
15
|
+
* reference that leaves the pipeline says so. */
|
|
16
|
+
export function danglingRows(refs) {
|
|
17
|
+
return refs.map((d) => {
|
|
18
|
+
const label = d.path ? `${d.kind} · ${d.path}` : String(d.kind);
|
|
19
|
+
return [label, d.crossPipeline ? `${d.value} (cross-pipeline)` : String(d.value)];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The section heading, with the count. */
|
|
24
|
+
export function danglingHeading(refs) {
|
|
25
|
+
return `not declared here · ${refs.length}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The line under the rows, saying why no edge is drawn for them. */
|
|
29
|
+
export const DANGLING_EXPLAINED = "these targets are not declared in this project, so no edge is drawn to them";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { danglingFor, danglingRows, danglingHeading } from "../web/dangling.js";
|
|
3
|
+
|
|
4
|
+
// #554: the shape chant 0.124.20 writes (chant#3912) for a job calling another
|
|
5
|
+
// repo's reusable workflow and a step using a local composite action.
|
|
6
|
+
const IR = {
|
|
7
|
+
nodes: [{ id: "build" }, { id: "release" }, { id: "ci" }],
|
|
8
|
+
edges: [],
|
|
9
|
+
dangling: [
|
|
10
|
+
{ from: "build", kind: "calls", path: "steps.uses", value: "./.github/actions/setup" },
|
|
11
|
+
{ from: "release", kind: "calls", path: "uses", value: "octo/shared/.github/workflows/release.yml@main", crossPipeline: true },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("dangling references on a card (#554)", () => {
|
|
16
|
+
it("lists only the references the card holds", () => {
|
|
17
|
+
expect(danglingFor(IR, "build").map((d) => d.value)).toEqual(["./.github/actions/setup"]);
|
|
18
|
+
expect(danglingFor(IR, "ci")).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is empty on an IR without the field, or with none", () => {
|
|
22
|
+
expect(danglingFor({ nodes: [], edges: [] }, "build")).toEqual([]);
|
|
23
|
+
expect(danglingFor(null, "build")).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("shows kind, path and the value as written, and marks a cross-pipeline one", () => {
|
|
27
|
+
expect(danglingRows(danglingFor(IR, "build"))).toEqual([["calls · steps.uses", "./.github/actions/setup"]]);
|
|
28
|
+
expect(danglingRows(danglingFor(IR, "release"))).toEqual([["calls · uses", "octo/shared/.github/workflows/release.yml@main (cross-pipeline)"]]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("counts them in the heading", () => {
|
|
32
|
+
expect(danglingHeading(IR.dangling)).toBe("not declared here · 2");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/web/edge-kind.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// #553: an edge's kind, in words. chant 0.124.20 (chant#3912) gives every IR
|
|
2
|
+
// edge a `kind` (ref | needs | calls | triggers | includes | extends) and marks
|
|
3
|
+
// an edge that leaves its workflow or pipeline `crossPipeline`. pinhole 0.4.0
|
|
4
|
+
// stamps both on the drawn edge and its chip as `data-edge-kind` (left off for
|
|
5
|
+
// "ref") and `data-edge-cross="true"`; index.html styles them, and this module
|
|
6
|
+
// writes the chip text, the hover tooltip and the Model tab's edge rows.
|
|
7
|
+
// Pure, so web/edge-kind.test.js can pin the words.
|
|
8
|
+
|
|
9
|
+
/** The CI kinds, in the order the legend lists them. */
|
|
10
|
+
export const CI_KINDS = ["needs", "calls", "triggers", "includes", "extends"];
|
|
11
|
+
|
|
12
|
+
/** What each CI kind means, for the tooltip and the legend row's title. */
|
|
13
|
+
export const KIND_MEANING = {
|
|
14
|
+
needs: "waits for the other job to finish",
|
|
15
|
+
calls: "runs the other workflow as a reusable workflow",
|
|
16
|
+
triggers: "starts the other workflow or pipeline",
|
|
17
|
+
includes: "pulls the other file into this pipeline",
|
|
18
|
+
extends: "takes the other job's settings",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The sentence a cross-pipeline edge adds, here and on the legend row. */
|
|
22
|
+
export const CROSS_MEANING = "cross-pipeline: leaves its own workflow or pipeline";
|
|
23
|
+
|
|
24
|
+
// The reason sentences for behold's own `ref` edges (src/logical.ts tags them).
|
|
25
|
+
const REF_REASON = {
|
|
26
|
+
"security-group ingress": "security-group ingress — traffic is allowed here",
|
|
27
|
+
"data dependency": "data dependency — reads/uses this store",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A missing or "ref" kind is a reference; anything else is a CI kind. */
|
|
31
|
+
export function isCiKind(kind) {
|
|
32
|
+
return !!kind && kind !== "ref";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The chip text, as chant's Mermaid and DOT writers label the edge
|
|
37
|
+
* (graph-mermaid.ts `edgeLabel`): a CI edge by its kind, plus the attribute
|
|
38
|
+
* when that says something more (`needs (stage)`, `calls (uses)`); a
|
|
39
|
+
* reference by the attribute it flows through. Empty when there is nothing
|
|
40
|
+
* to say.
|
|
41
|
+
*/
|
|
42
|
+
export function edgeLabel({ kind, via, toAttr } = {}) {
|
|
43
|
+
if (!isCiKind(kind)) return [via, toAttr].filter(Boolean).join(" → ");
|
|
44
|
+
return via && via !== kind ? `${kind} (${via})` : kind;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The hover tooltip: who points at whom on the first line, then the kind
|
|
49
|
+
* (with its meaning) for a CI edge or the reason for a reference, then the
|
|
50
|
+
* cross-pipeline sentence when the edge carries it.
|
|
51
|
+
*/
|
|
52
|
+
export function edgeTitle(from, to, { kind, via, cross } = {}) {
|
|
53
|
+
const lines = [`${from} → ${to}`];
|
|
54
|
+
if (isCiKind(kind)) {
|
|
55
|
+
const meaning = KIND_MEANING[kind];
|
|
56
|
+
lines.push(meaning ? `${edgeLabel({ kind, via })}: ${meaning}` : edgeLabel({ kind, via }));
|
|
57
|
+
} else if (via) {
|
|
58
|
+
lines.push(REF_REASON[via] || via);
|
|
59
|
+
}
|
|
60
|
+
if (cross) lines.push(CROSS_MEANING);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Model tab's edge rows: one per CI kind the graph holds, with its count,
|
|
66
|
+
* then one for the cross-pipeline edges. A kind whose every edge is
|
|
67
|
+
* cross-pipeline (GitHub's `calls` and `triggers`) is drawn dashed on its row
|
|
68
|
+
* too, as it is on the graph. Empty when the graph has neither, so an
|
|
69
|
+
* infrastructure graph shows no edge legend.
|
|
70
|
+
*/
|
|
71
|
+
export function edgeLegend(edges = []) {
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
const crossCounts = new Map();
|
|
74
|
+
let cross = 0;
|
|
75
|
+
for (const e of edges) {
|
|
76
|
+
if (isCiKind(e.kind)) {
|
|
77
|
+
counts.set(e.kind, (counts.get(e.kind) || 0) + 1);
|
|
78
|
+
if (e.crossPipeline) crossCounts.set(e.kind, (crossCounts.get(e.kind) || 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
if (e.crossPipeline) cross++;
|
|
81
|
+
}
|
|
82
|
+
const known = CI_KINDS.filter((k) => counts.has(k));
|
|
83
|
+
const other = [...counts.keys()].filter((k) => !CI_KINDS.includes(k)).sort();
|
|
84
|
+
const rows = [...known, ...other].map((kind) => ({
|
|
85
|
+
kind,
|
|
86
|
+
cross: crossCounts.get(kind) === counts.get(kind),
|
|
87
|
+
label: kind,
|
|
88
|
+
count: counts.get(kind),
|
|
89
|
+
title: KIND_MEANING[kind] || kind,
|
|
90
|
+
}));
|
|
91
|
+
if (cross) rows.push({ kind: null, cross: true, label: "cross-pipeline (dashed)", count: cross, title: CROSS_MEANING });
|
|
92
|
+
return rows;
|
|
93
|
+
}
|