@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.
@@ -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
+ });