@intentius/behold 0.25.0 → 0.26.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.
@@ -28,8 +28,12 @@ export function laneLegend(lane, now = new Date(), polled = null) {
28
28
  }
29
29
  const how = lane.via === "fetch" ? "fetched from origin" : "the checkout's origin/chant/lifecycle, as of its last git fetch (behold's fetch failed)";
30
30
  return {
31
- text: `chant/lifecycle at ${short(lane.commit.sha)}, committed ${ago(lane.commit.committed, now)} · ${when}, ${how}`,
32
- title: [lane.note, lane.fetch && lane.fetch.error ? `fetch: ${lane.fetch.error}` : ""].filter(Boolean).join("\n"),
31
+ text: `chant/lifecycle at ${short(lane.commit.sha)}, committed ${ago(lane.commit.committed, now)} · ${when}, ${how}${lane.approval && lane.approval.inferred ? ` · approval mode ${lane.approval.mode} at base, for the waves whose requests record none` : ""}`,
32
+ title: [
33
+ lane.note,
34
+ lane.approval ? `approvals counted: ${lane.approval.mode} (${lane.approval.source}${lane.approval.base ? `, base ${short(lane.approval.base)}` : ""})${lane.approval.inferred ? `; ${lane.approval.inferred}` : ""}${lane.approval.note ? `; ${lane.approval.note}` : ""}` : "",
35
+ lane.fetch && lane.fetch.error ? `fetch: ${lane.fetch.error}` : "",
36
+ ].filter(Boolean).join("\n"),
33
37
  tone: lane.via === "fetch" ? "quiet" : "stale",
34
38
  };
35
39
  }
@@ -38,28 +42,86 @@ const TITLE = {
38
42
  waiting: (g) => `wave ${g.wave} waits for an approval`,
39
43
  expired: (g) => `wave ${g.wave}'s request for an approval expired`,
40
44
  approved: (g) => `wave ${g.wave} approved by ${g.approval.by}, not applied yet`,
41
- applied: (g) => `wave ${g.wave} applied`,
45
+ applied: (g) => (g.applied && !g.applied.result ? `wave ${g.wave} applied (outcome not recorded)` : `wave ${g.wave} applied`),
42
46
  failed: (g) => `wave ${g.wave}'s apply failed`,
47
+ refused: (g) => `wave ${g.wave} refused: an approval stands for other plans`,
48
+ "refused-by-policy": (g) => `wave ${g.wave} refused by policy: ${(g.denials || []).length} run${(g.denials || []).length === 1 ? "" : "s"} denied, no override counts`,
49
+ held: (g) => `wave ${g.wave} held on ${g.heldOn.op}/${g.heldOn.gate}${g.heldOn.heldBy ? ` by hook ${g.heldOn.heldBy.hook}` : ""}`,
43
50
  };
44
51
 
52
+ /**
53
+ * Who holds a wave on another gate: the hook the fact names (`heldBy`,
54
+ * chant#3886), with the moment it ran at and its run; for a fact written
55
+ * before chant named it, the fact's description, which the hook wrote.
56
+ */
57
+ export function holderWords(heldOn) {
58
+ if (!heldOn) return "";
59
+ const h = heldOn.heldBy;
60
+ if (h) return `held by hook ${h.hook} at ${h.moment}${h.target ? ` for ${h.target}` : ""}`;
61
+ return heldOn.description || "";
62
+ }
63
+
64
+ const NOTE = {
65
+ approve: "An approval is a signed git record bound to this plan digest: run the line at your shell. behold does not approve.",
66
+ answer: "This gate asks a decision point: answer it at your shell. behold does not answer.",
67
+ override: "An override is a person's git record with a reason, bound to this plan and these rules: run the line at your shell. behold does not override.",
68
+ };
69
+
70
+ /** The mode a wave is decided under: the one its request recorded (chant#3861), else the one read at base. */
71
+ export function modeWords(g) {
72
+ if (!g.approvalMode) return "";
73
+ return g.approvalMode.from === "pending" ? `asked under ${g.approvalMode.mode}` : `approval mode ${g.approvalMode.mode}, read at base: its request records none`;
74
+ }
75
+
76
+ /** Who approved, and how: signed, relayed, from a pull request's review. */
77
+ function approvalWords(a, now) {
78
+ const how = [
79
+ a.signed ? "signed" : "",
80
+ a.via ? `via ${a.via}` : "",
81
+ a.pr !== undefined ? `PR #${a.pr}${a.head ? ` at ${short(a.head)}` : ""}` : "",
82
+ a.reviewers && a.reviewers.length ? `reviewed by ${a.reviewers.join(", ")}` : "",
83
+ a.relayedBy ? `relayed by ${a.relayedBy}` : "",
84
+ ].filter(Boolean);
85
+ return `${a.by}${how.length ? ` (${how.join(", ")})` : ""} ${clock(a.at, now)}, ${ago(a.at, now)}`;
86
+ }
87
+
45
88
  /** Each wave's gate as a card: its state, who and when, the digest, the commit it was read from, and for a waiting wave the line to run. No button. */
46
89
  export function laneGateCards(lane, now = new Date()) {
47
90
  if (!lane || !lane.found) return [];
48
91
  return lane.gates.map((g) => {
49
92
  const facts = [];
50
- if (g.pending) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
51
- if (g.approval) facts.push(`approved by ${g.approval.by}${g.approval.signed ? " (signed)" : ""}${g.approval.via ? ` via ${g.approval.via}` : ""} ${clock(g.approval.at, now)}, ${ago(g.approval.at, now)}`);
52
- if (g.applied) facts.push(`${g.applied.result === "failed" ? "apply failed" : "applied"} ${clock(g.applied.finished || g.applied.at, now)}, ${ago(g.applied.finished || g.applied.at, now)}`);
93
+ const own = g.state !== "refused-by-policy" && g.state !== "held";
94
+ if (g.pending && own) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
95
+ if (g.pending && g.pending.description && own) facts.push(g.pending.description);
96
+ if (g.pending && g.pending.answer && g.state === "waiting") facts.push(`asks decision point ${g.pending.answer.point}`);
97
+ if (g.heldOn) facts.push(`held ${clock(g.heldOn.since, now)}, ${ago(g.heldOn.since, now)}; ${g.heldOn.op}/${g.heldOn.gate} waits${g.heldOn.digest ? ` for plan ${short(g.heldOn.digest)}` : ""}${g.heldOn.answer ? ` on decision point ${g.heldOn.answer.point}` : ""}`);
98
+ if (holderWords(g.heldOn)) facts.push(holderWords(g.heldOn));
99
+ if (g.approval) facts.push(`${g.state === "refused" ? `approved for plan ${short(g.refused && g.refused.digest) || "(none)"} by` : "approved by"} ${approvalWords(g.approval, now)}`);
100
+ if (g.applied) facts.push(`${g.applied.result === "failed" ? "apply failed" : "applied"} ${clock(g.applied.finished || g.applied.at, now)}, ${ago(g.applied.finished || g.applied.at, now)}${g.applied.review && g.applied.review.pr !== undefined ? ` under the review of PR #${g.applied.review.pr}` : ""}`);
53
101
  if (g.digest) facts.push(`plan ${short(g.digest)}`);
102
+ if (g.pending && g.pending.members && g.pending.members.length) facts.push(`${g.pending.members.length} root${g.pending.members.length === 1 ? "" : "s"}: ${g.pending.members.map((m) => m.member).join(", ")}`);
103
+ if (g.pending && g.pending.neverOverMcp) facts.push("never approved over MCP");
104
+ if (modeWords(g)) facts.push(modeWords(g));
105
+ if (g.identity) facts.push(`identity.gates at base: only a signed approval counts${g.identity.class ? `, from class ${g.identity.class}` : ""}`);
106
+ if (g.spent) facts.push(`the approval of plan ${short(g.spent.planDigest)} by ${g.spent.by} was spent by its apply`);
107
+ const uncounted = (g.uncounted || []).map((u) => `${u.approval.by}'s approval of this plan does not count: ${u.reason}`);
108
+ const overrides = (g.denials || []).map((d) => ({
109
+ text: `${d.member}: plan ${short(d.plan)} denied by ${d.rules.join(", ")} ${clock(d.at, now)}, ${ago(d.at, now)}${d.moved ? `; ${d.moved.by} overrode an earlier plan or other rules` : ""}${d.refusals.length ? `; ${d.refusals.join("; ")}` : ""}`,
110
+ command: d.command,
111
+ ...(d.url ? { job: d.url } : {}),
112
+ }));
113
+ const commandNote = (g.state === "held" ? g.heldOn && g.heldOn.answer : g.pending && g.pending.answer) ? NOTE.answer : NOTE.approve;
54
114
  return {
55
115
  wave: g.wave,
56
116
  state: g.state,
57
- tone: g.state === "waiting" ? "wave" : g.state === "failed" ? "failed" : g.state === "expired" ? "none" : "ok",
117
+ tone: g.state === "waiting" || g.state === "held" ? "wave" : g.state === "failed" || g.state === "refused" || g.state === "refused-by-policy" ? "failed" : g.state === "expired" ? "none" : "ok",
58
118
  title: TITLE[g.state] ? TITLE[g.state](g) : `wave ${g.wave}: ${g.state}`,
59
119
  facts: facts.join(" · "),
120
+ ...(uncounted.length ? { uncounted } : {}),
121
+ ...(overrides.length ? { overrides, overrideNote: NOTE.override } : {}),
60
122
  source: sourceWords(g.source, now),
61
- ...(g.command ? { command: g.command, note: "An approval is a signed git record bound to this plan digest: run the line at your shell. behold does not approve." } : {}),
62
- ...(g.pending && g.pending.url ? { job: g.pending.url } : {}),
123
+ ...(g.command ? { command: g.command, note: commandNote } : {}),
124
+ ...(g.heldOn && g.heldOn.url ? { job: g.heldOn.url } : g.pending && g.pending.url ? { job: g.pending.url } : {}),
63
125
  };
64
126
  });
65
127
  }
@@ -108,13 +170,43 @@ export function renderLane(host, lane, polled, now, ui) {
108
170
  const src = document.createElement("div");
109
171
  src.className = "ws-gate-note";
110
172
  src.textContent = `from ${g.source}`;
111
- box.append(title, facts, src);
173
+ box.append(title, facts);
174
+ for (const u of g.uncounted || []) {
175
+ const why = document.createElement("div");
176
+ why.className = "ws-gate-note";
177
+ why.textContent = u;
178
+ box.appendChild(why);
179
+ }
180
+ box.appendChild(src);
181
+ for (const o of g.overrides || []) {
182
+ const what = document.createElement("div");
183
+ what.className = "ws-gate-note";
184
+ what.textContent = o.text;
185
+ const cmd = document.createElement("code");
186
+ cmd.className = "tg-command";
187
+ cmd.textContent = o.command;
188
+ const copy = ui.button("Copy", "", () => ui.copy(o.command, copy));
189
+ copy.title = "Copy the line. Run it at your shell, with your reason: the override is yours.";
190
+ box.append(what, cmd, copy);
191
+ if (o.job) {
192
+ const links = document.createElement("div");
193
+ links.className = "ws-gate-note";
194
+ links.appendChild(ui.link("job", o.job));
195
+ box.appendChild(links);
196
+ }
197
+ }
198
+ if (g.overrideNote) {
199
+ const n = document.createElement("div");
200
+ n.className = "ws-gate-note";
201
+ n.textContent = g.overrideNote;
202
+ box.appendChild(n);
203
+ }
112
204
  if (g.command) {
113
205
  const cmd = document.createElement("code");
114
206
  cmd.className = "tg-command";
115
207
  cmd.textContent = g.command;
116
208
  const copy = ui.button("Copy", "", () => ui.copy(g.command, copy));
117
- copy.title = "Copy the line. Run it at your shell: the approval is yours, signed, in git.";
209
+ copy.title = "Copy the line. Run it at your shell: the approval or answer is yours.";
118
210
  const note = document.createElement("div");
119
211
  note.className = "ws-gate-note";
120
212
  note.textContent = g.note;
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { laneGateCards, laneLegend, laneLockRows, sourceWords } from "./terragucci-lifecycle.js";
2
+ import { holderWords, laneGateCards, laneLegend, laneLockRows, sourceWords } from "./terragucci-lifecycle.js";
3
3
 
4
4
  const NOW = new Date("2026-10-09T12:00:00Z");
5
5
  const source = { branch: "chant/lifecycle", commit: "1a2b3c4d5e6f7a8b", committed: "2026-10-09T10:00:00Z", path: "_gates/tf-apply.jsonl" };
@@ -52,6 +52,41 @@ describe("the chant/lifecycle lane, dated (#505)", () => {
52
52
  expect(two.tone).toBe("wave");
53
53
  });
54
54
 
55
+ it("shows the mode a wave asked under, and says when it was read at base instead (chant#3861)", () => {
56
+ const [one, two] = lane().gates;
57
+ const g = lane({
58
+ approval: { mode: "ledger", source: "the default", inferred: "a wave whose pending fact records no approval mode ..." },
59
+ gates: [{ ...one, approvalMode: { mode: "sealed", from: "pending" } }, { ...two, approvalMode: { mode: "ledger", from: "base" } }],
60
+ });
61
+ const [a, b] = laneGateCards(g, NOW);
62
+ expect(a.facts).toContain("asked under sealed");
63
+ expect(b.facts).toContain("approval mode ledger, read at base: its request records none");
64
+ expect(laneLegend(g, NOW).text).toContain("approval mode ledger at base, for the waves whose requests record none");
65
+ expect(laneLegend(lane({ approval: { mode: "ledger", source: "the default" } }), NOW).text).not.toContain("approval mode");
66
+ });
67
+
68
+ it("names an identity.gates gate's class without a caveat, and a hold by its fact (chant#3862, chant#3839)", () => {
69
+ const [one] = lane().gates;
70
+ const held = { ...one, state: "held", approval: undefined, heldOn: { op: "tf-migrate", gate: "move-db", since: "2026-10-09T10:00:00Z", description: "migrate the orders table first" }, identity: { class: "human" } };
71
+ const [c] = laneGateCards(lane({ gates: [held] }), NOW);
72
+ expect(c.facts).toContain("only a signed approval counts, from class human");
73
+ expect(c.facts).not.toContain("chant#3862");
74
+ expect(c.facts).toContain("held 10:00 UTC, 2h ago; tf-migrate/move-db waits");
75
+ expect(c.facts).toContain("migrate the orders table first");
76
+ expect(c.title).toBe("wave 1 held on tf-migrate/move-db");
77
+ });
78
+
79
+ it("names the hook that holds a wave from the fact's heldBy, before its description (chant#3886)", () => {
80
+ const [one] = lane().gates;
81
+ const heldOn = { op: "tf-migrate", gate: "move-db", since: "2026-10-09T10:00:00Z", heldBy: { hook: "migrate", moment: "after-plan", target: "vpc" }, description: "migrate the orders table first" };
82
+ const [c] = laneGateCards(lane({ gates: [{ ...one, state: "held", approval: undefined, heldOn }] }), NOW);
83
+ expect(c.title).toBe("wave 1 held on tf-migrate/move-db by hook migrate");
84
+ expect(c.facts).toContain("held by hook migrate at after-plan for vpc");
85
+ expect(c.facts).not.toContain("migrate the orders table first");
86
+ expect(holderWords({ ...heldOn, heldBy: { hook: "migrate", moment: "after-plan" } })).toBe("held by hook migrate at after-plan");
87
+ expect(holderWords({ ...heldOn, heldBy: undefined })).toBe("migrate the orders table first");
88
+ });
89
+
55
90
  it("words a lock with its holder, its age and its source", () => {
56
91
  const [l] = laneLockRows(lane(), NOW);
57
92
  expect(l.text).toBe("locked by PR #12 (carol, by an apply) 07:00 UTC, 5h ago");
@@ -116,3 +116,80 @@ export function laneElement(answer, opts = {}) {
116
116
  }
117
117
  return box;
118
118
  }
119
+
120
+ // #522: the gate ledgers' history on chant/lifecycle, from
121
+ // /api/lifecycle/history (chant's gate-audit over `git log -p`). Drawn beside
122
+ // the audit record, or alone when the source has no audit.jsonl, and labelled
123
+ // "ledger" so the two are never mistaken for each other.
124
+
125
+ const HISTORY_WORDS = {
126
+ "approval-requested": "approval requested",
127
+ approved: "approved",
128
+ "override-requested": "override requested",
129
+ "override-recorded": "policy overridden",
130
+ applied: "applied",
131
+ "override-spent": "override spent",
132
+ };
133
+
134
+ /** One ledger entry as a line: "approved wave-2 by dana: unsigned · plan 4be1…". */
135
+ export function historyLine(entry) {
136
+ const what = entry.kind === "revoked" ? `${entry.subject || "approval"} revoked` : HISTORY_WORDS[entry.kind] || entry.kind;
137
+ const op = entry.op && entry.op !== "tf-apply" ? ` (${entry.op})` : "";
138
+ const by = entry.actor ? ` by ${entry.actor}` : "";
139
+ const seal = entry.seal && (entry.kind === "approved" || entry.kind === "override-recorded") ? `: ${entry.seal}` : "";
140
+ const digest = entry.digest ? ` · plan ${entry.digest.replace(/^(jcs1-)?sha256:/, "").slice(0, 12)}` : "";
141
+ return `${what} ${entry.gate}${op}${by}${seal}${digest}`;
142
+ }
143
+
144
+ /** The history's rows, newest first: clock, line, tone, and the line's file, commit and committer in the title. */
145
+ export function historyRows(answer, { now = new Date() } = {}) {
146
+ if (!answer || !answer.entries) return [];
147
+ return answer.entries.map((e) => {
148
+ const s = e.source || {};
149
+ const commit = s.commit ? `${s.change === "removed" ? "removed" : "added"} in ${s.commit.slice(0, 12)}${s.committer ? ` by ${s.committer}` : ""}${s.committed ? ` at ${s.committed}` : ""}` : "";
150
+ return {
151
+ id: e.id,
152
+ when: `${clock(e.at, now)}, ${ago(e.at, now)}`,
153
+ text: historyLine(e),
154
+ tone: e.kind === "revoked" ? "drift" : e.kind.endsWith("-requested") ? "wave" : "ok",
155
+ title: [e.at, `${s.path}${s.line ? `:${s.line}` : ""}`, commit, e.note || ""].filter(Boolean).join("\n"),
156
+ };
157
+ });
158
+ }
159
+
160
+ /** The line over the history: its source, how many entries, where it was read from and how old. */
161
+ export function historyLegend(answer, { shown = 0 } = {}) {
162
+ if (!answer) return "ledger history not read";
163
+ if (answer.error) return answer.error;
164
+ if (!answer.found) return `ledger: ${answer.note}`;
165
+ if (!shown) return `ledger: no gate record on ${answer.branch}`;
166
+ const more = answer.total > shown ? ` of ${answer.total}` : "";
167
+ const via = answer.via === "checkout-ref" ? " (the checkout's copy: the fetch failed)" : "";
168
+ const tip = answer.commit ? `, tip committed ${answer.commit.committed}` : "";
169
+ return `ledger: ${shown}${more} entr${shown === 1 ? "y" : "ies"} from the gate ledgers on ${answer.branch}${via}${tip}, newest first`;
170
+ }
171
+
172
+ /** The history as DOM. Browser-only (the tests take the pure functions above). */
173
+ export function historyElement(answer, opts = {}) {
174
+ const rows = historyRows(answer, opts);
175
+ const box = document.createElement("div");
176
+ box.className = "tg-timeline";
177
+ const head = document.createElement("div");
178
+ head.className = answer && answer.error ? "tg-refusal" : "tg-legend";
179
+ head.textContent = historyLegend(answer, { shown: rows.length });
180
+ if (answer && answer.note) head.title = answer.note;
181
+ box.appendChild(head);
182
+ for (const r of rows) {
183
+ const row = document.createElement("div");
184
+ row.className = `tg-entry tg-${r.tone}`;
185
+ row.title = r.title;
186
+ const when = document.createElement("span");
187
+ when.className = "tg-when";
188
+ when.textContent = r.when;
189
+ const text = document.createElement("span");
190
+ text.textContent = ` ${r.text}`;
191
+ row.append(when, text);
192
+ box.appendChild(row);
193
+ }
194
+ return box;
195
+ }
@@ -18,6 +18,7 @@ export function gateCards(answer, { embed = false, member = null } = {}) {
18
18
  const all = (answer && answer.gates) || [];
19
19
  const shown = member ? all.filter((g) => g.member === member) : all;
20
20
  const cards = shown.map((g) => {
21
+ if (g.override) return overrideCard(g);
21
22
  const facts = [`${g.approvals.length} of ${g.needed} approval${g.needed === 1 ? "" : "s"}`];
22
23
  if (g.env) facts.push(`env ${g.env}`);
23
24
  if (g.planDigest) facts.push(`plan ${shortDigest(g.planDigest)}`);
@@ -38,6 +39,34 @@ export function gateCards(answer, { embed = false, member = null } = {}) {
38
39
  return { cards, elsewhere, refusal: (answer && answer.refusal) || null };
39
40
  }
40
41
 
42
+ /**
43
+ * #519: a policy denial. chant's override needs the denied rules and a
44
+ * person's reason, so the card shows them and the command to run, and never a
45
+ * button, framed or not. An override that counts reads as overridden until a
46
+ * run applies it, then as spent.
47
+ */
48
+ export function overrideCard(g) {
49
+ const o = g.override;
50
+ const facts = [];
51
+ facts.push(o.rules.length ? `denied by ${o.rules.join(", ")}` : "denied by rules the ledger did not name");
52
+ if (o.plan) facts.push(`plan ${shortDigest(o.plan)}`);
53
+ if (o.digest) facts.push(`override digest ${shortDigest(o.digest)}`);
54
+ if (g.recordedAt) facts.push(`denied ${g.recordedAt}`);
55
+ if (o.source) facts.push(`from ${o.source}`);
56
+ const card = { key: g.key, member: g.member, facts: facts.join(" · "), command: o.command, override: o.state };
57
+ if (o.state === "spent") {
58
+ card.title = `${g.member}: the override of ${o.subject} is spent`;
59
+ card.note = `a run applied under it${o.spentAt ? ` at ${o.spentAt}` : ""}; a new denial needs a new override`;
60
+ } else if (o.state === "overridden") {
61
+ card.title = `${g.member}: ${o.subject} is overridden`;
62
+ card.note = "an override counts for this plan; the next run applies it once";
63
+ } else {
64
+ card.title = `${g.member}: the policy denied ${o.subject}`;
65
+ card.note = `a reason is required. Override it at your shell: ${o.command}`;
66
+ }
67
+ return card;
68
+ }
69
+
41
70
  function shortDigest(d) {
42
71
  const s = String(d).replace(/^sha256:/, "");
43
72
  return s.length > 12 ? s.slice(0, 12) : s;
@@ -0,0 +1,99 @@
1
+ // #526: SQL Yodeler's report and drift, read from files by /api/yodel-report.
2
+ // Pure helpers; app.js places the marks and draws the panel. A mark is a
3
+ // dated corner tag, never a fill: none of this is live.
4
+ import { ago, clock } from "./terragucci.js";
5
+
6
+ /** The ids of the cards an object names: the id itself, or a member's `<member>/<name>`. */
7
+ export function cardsFor(object, ids) {
8
+ return ids.filter((id) => id === object || id.endsWith("/" + object));
9
+ }
10
+
11
+ /** Join the read's marks onto the drawn card ids: marks by card, and the objects no card is. */
12
+ export function joinMarks(answer, ids) {
13
+ const cards = {};
14
+ const unmatched = [];
15
+ for (const m of (answer && answer.marks) || []) {
16
+ const hit = cardsFor(m.object, ids);
17
+ if (hit.length === 0) unmatched.push(m);
18
+ for (const id of hit) (cards[id] ||= []).push(m);
19
+ }
20
+ return { cards, unmatched };
21
+ }
22
+
23
+ /** When and from where, in words: "drift-prod.json, written 06:04 UTC, 4h ago". */
24
+ export function datedWords(dated, now = new Date()) {
25
+ const how = dated.by === "generatedAt" ? "generated" : "written";
26
+ return `${dated.file}, ${how} ${clock(dated.at, now)}, ${ago(dated.at, now)}`;
27
+ }
28
+
29
+ /** One mark as a line: "prod: changed out of band (ttl) · drift-prod.json, written 06:04 UTC, 4h ago". */
30
+ export function markLine(m, now = new Date()) {
31
+ const paths = m.changes.map((c) => c.path).filter(Boolean);
32
+ return `${m.environment}: ${m.words}${paths.length ? ` (${paths.join(", ")})` : ""} · ${datedWords(m.dated, now)}`;
33
+ }
34
+
35
+ /** The corner tag for a card's marks: "drift prod · 4h", the age of the newest. */
36
+ export function cornerText(marks, now = new Date()) {
37
+ const envs = [...new Set(marks.map((m) => m.environment))].join(", ");
38
+ const newest = marks.map((m) => m.dated.at).sort().pop();
39
+ const s = Math.max(0, (now.getTime() - Date.parse(newest)) / 1000);
40
+ const age = s < 3600 ? `${Math.max(1, Math.round(s / 60))}m` : s < 172800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`;
41
+ return `drift ${envs} · ${age}`;
42
+ }
43
+
44
+ /** The panel's lines for one environment, from the report. */
45
+ export function environmentLines(e, now = new Date()) {
46
+ const lines = [];
47
+ if (e.error) lines.push(`could not be read: ${e.error}`);
48
+ const newest = e.applied[e.applied.length - 1];
49
+ lines.push(
50
+ `${e.applied.length} applied` +
51
+ (newest ? `, newest ${newest.id}${newest.at ? ` at ${clock(newest.at, now)}` : ""}${newest.by ? ` by ${newest.by}` : ""}` : ""),
52
+ );
53
+ for (const m of e.other) lines.push(`${m.id}: ${m.state}${m.inDirectory ? "" : " (not in the directory)"}`);
54
+ for (const g of e.gaps) lines.push(`gap (${g.kind}): ${g.detail}`);
55
+ for (const n of e.notes) lines.push(`note: ${n}`);
56
+ return lines;
57
+ }
58
+
59
+ /** The panel's head: what was read, how it was checked, and when. */
60
+ export function legend(answer, now = new Date()) {
61
+ const v = answer.validation;
62
+ const checked = v.from === "package" ? `@intentius/sql-yodeler ${v.sqlYodeler}'s schemas` : `behold's pinned copy of SQL Yodeler's schemas (${v.commit})`;
63
+ const what = answer.report ? `${answer.report.file}, generated ${clock(answer.report.generatedAt, now)}, ${ago(answer.report.generatedAt, now)}` : "no report";
64
+ return { text: `SQL Yodeler, not live · ${what} · ${answer.drift.length} drift file${answer.drift.length === 1 ? "" : "s"}`, title: `Read from ${answer.source}, checked against ${checked}. behold never runs yodel or reads the database.` };
65
+ }
66
+
67
+ // #537: the report's audit entries. Each is dated by its own `at` and says
68
+ // where SQL Yodeler read it: a gate ledger on chant/lifecycle, or a row of
69
+ // the environment's history table.
70
+ const AUDIT_WORDS = {
71
+ "approval-requested": "approval asked",
72
+ approved: "approved",
73
+ override: "overridden",
74
+ silenced: "silenced",
75
+ applied: "applied",
76
+ refused: "refused",
77
+ failed: "failed",
78
+ repaired: "repaired",
79
+ reverted: "reverted",
80
+ step: "step",
81
+ };
82
+
83
+ /** Where an entry came from: "ledger <l>, <op>/<gate>" or "<history> row <seq>, run <run>". */
84
+ export function auditSource(a) {
85
+ const s = a.source || {};
86
+ if (s.ledger !== undefined) return `chant/lifecycle ledger ${s.ledger}, ${s.op}/${s.gate}`;
87
+ if (s.history !== undefined) return `${s.history} row ${s.seq}, run ${s.run}`;
88
+ return "an unnamed source";
89
+ }
90
+
91
+ /** One entry as a line and its source line. Pure. */
92
+ export function auditLines(a, now = new Date()) {
93
+ const what = [AUDIT_WORDS[a.kind] || a.kind, a.migration || "", a.by ? `by ${a.by}` : ""].filter(Boolean).join(" ");
94
+ const extra = [a.detail || "", a.digest ? `plan ${String(a.digest).replace(/^(jcs1-)?sha256:/, "").slice(0, 12)}` : "", a.wave ? `wave gate ${a.wave.gate} ${a.wave.status === "approved" ? "approved" : "needed no approval"}` : ""].filter(Boolean);
95
+ return {
96
+ text: `${what}${extra.length ? ` (${extra.join(", ")})` : ""} · ${clock(a.at, now)}, ${ago(a.at, now)}`,
97
+ source: `from ${auditSource(a)}, in ${a.file}`,
98
+ };
99
+ }
@@ -0,0 +1,18 @@
1
+ import { join } from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { readYodelReport, yodelValidator } from "../src/yodel-report.ts";
4
+ import { cardsFor, joinMarks, markLine } from "./yodel-report.js";
5
+
6
+ const fx = join(import.meta.dirname, "..", "src", "__fixtures__", "yodel-report");
7
+
8
+ describe("yodel report marks on cards (#526)", () => {
9
+ it("places drift on the example's table cards and lists the rest", () => {
10
+ const r = readYodelReport(join(fx, "good"), yodelValidator([]));
11
+ const j = joinMarks(r, ["shop/orders", "shop/shopSchema"]);
12
+ expect(Object.keys(j.cards)).toEqual(["shop/orders"]);
13
+ expect(j.unmatched.map((m) => m.object)).toEqual(["ordersStatusIdx"]);
14
+ expect(cardsFor("orders", ["orders", "x/orders", "ordersX"])).toEqual(["orders", "x/orders"]);
15
+ expect(markLine(j.cards["shop/orders"][0], new Date("2026-10-11T10:00:00Z"))).toContain("prod: changed out of band (columns[2].default) · drift-prod.json, written");
16
+ });
17
+
18
+ });