@intentius/behold 0.24.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.
- package/AGENTS.md +341 -2
- package/demos.json +1 -1
- package/dist/cli.js +19290 -9373
- 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 +5 -4
- package/web/app.js +318 -6
- package/web/history-picker.js +85 -0
- package/web/history-picker.test.js +77 -0
- package/web/index.html +57 -0
- package/web/releases.js +121 -0
- package/web/sql-yodeler-lane.js +129 -0
- package/web/terragucci-estate.js +328 -0
- package/web/terragucci-estate.test.js +114 -0
- package/web/terragucci-lifecycle.js +235 -0
- package/web/terragucci-lifecycle.test.js +95 -0
- package/web/terragucci-progress.js +127 -0
- package/web/terragucci-progress.test.js +59 -0
- package/web/terragucci-timeline.js +195 -0
- package/web/terragucci-timeline.test.js +56 -0
- package/web/workspace-gates.js +29 -0
- package/web/yodel-report.js +99 -0
- package/web/yodel-report.test.js +18 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// #506: the timeline lane, from terragucci's audit record. app.js fetches
|
|
2
|
+
// /api/terragucci/timeline once (the whole project, newest first) and hands
|
|
3
|
+
// the answer here: the Terragucci tab shows every entry, a card's inspect pane
|
|
4
|
+
// the entries of its root. The record holds no per-resource entry, so a card's
|
|
5
|
+
// lane is its root's.
|
|
6
|
+
//
|
|
7
|
+
// An entry is dated and attributed as the record has it, and links its run's
|
|
8
|
+
// report through the same file route the marks use. An absent record is said
|
|
9
|
+
// in words: it is not "nothing happened".
|
|
10
|
+
|
|
11
|
+
import { ago, clock, hrefOf } from "./terragucci.js";
|
|
12
|
+
|
|
13
|
+
const KIND_WORDS = {
|
|
14
|
+
"approval-requested": "approval requested",
|
|
15
|
+
approval: "approved",
|
|
16
|
+
"approval-revoked": "approval revoked",
|
|
17
|
+
"override-requested": "override requested",
|
|
18
|
+
override: "policy overridden",
|
|
19
|
+
"override-revoked": "override revoked",
|
|
20
|
+
apply: "apply",
|
|
21
|
+
refused: "refused",
|
|
22
|
+
migration: "state migration",
|
|
23
|
+
unlock: "lock released",
|
|
24
|
+
"state-export": "state exported",
|
|
25
|
+
"ephemeral-apply": "ephemeral copy applied",
|
|
26
|
+
"ephemeral-destroy": "ephemeral copy destroyed",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The tone an entry is drawn in: a refusal or a failure stands out, a wait is pending, the rest is quiet. */
|
|
30
|
+
export function toneOf(entry) {
|
|
31
|
+
if (entry.kind === "refused" || entry.result === "failed" || entry.result === "denied") return "failed";
|
|
32
|
+
if (entry.result === "waiting" || entry.kind === "approval-requested" || entry.kind === "override-requested") return "wave";
|
|
33
|
+
if (entry.kind.endsWith("-revoked")) return "drift";
|
|
34
|
+
return "ok";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One entry as a line: "approved wave-2 by dana: unsigned · plan 4be1…". */
|
|
38
|
+
export function entryLine(entry) {
|
|
39
|
+
const what = KIND_WORDS[entry.kind] || entry.kind;
|
|
40
|
+
const by = entry.who ? ` by ${entry.who}` : "";
|
|
41
|
+
const result = entry.result && entry.result !== entry.kind ? `: ${entry.result}` : "";
|
|
42
|
+
const digest = entry.digest ? ` · plan ${entry.digest.replace(/^(jcs1-)?sha256:/, "").slice(0, 12)}` : "";
|
|
43
|
+
return `${what} ${entry.what}${by}${result}${digest}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The root a card sits in, by terragucci's path, from /api/terragucci's roots: a card id is `<member>/<root name>/…`. */
|
|
47
|
+
export function rootOfCard(marksAnswer, nodeId) {
|
|
48
|
+
if (!marksAnswer || !marksAnswer.roots || !nodeId) return null;
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const r of marksAnswer.roots) {
|
|
51
|
+
const prefix = `${r.member}/${r.name}/`;
|
|
52
|
+
if (nodeId.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, path: r.path };
|
|
53
|
+
}
|
|
54
|
+
return best ? best.path : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The lane's rows, newest first, of `root` when given. Each row: its clock, its line, its tone, and its links. */
|
|
58
|
+
export function laneRows(answer, { root = null, now = new Date() } = {}) {
|
|
59
|
+
if (!answer || !answer.entries) return [];
|
|
60
|
+
const entries = root ? answer.entries.filter((e) => (e.roots || []).includes(root)) : answer.entries;
|
|
61
|
+
return entries.map((e) => {
|
|
62
|
+
const links = [];
|
|
63
|
+
if (e.run && e.run.report) links.push({ text: "report", href: hrefOf(answer, e.run.report) });
|
|
64
|
+
if (e.run && e.run.job_url) links.push({ text: "job", href: e.run.job_url });
|
|
65
|
+
if (e.evidence && e.evidence.source === "ledger" && /^https?:\/\//.test(e.evidence.url || "")) links.push({ text: "ledger commit", href: e.evidence.url });
|
|
66
|
+
return {
|
|
67
|
+
id: e.id,
|
|
68
|
+
when: `${clock(e.at, now)}, ${ago(e.at, now)}`,
|
|
69
|
+
text: entryLine(e),
|
|
70
|
+
tone: toneOf(e),
|
|
71
|
+
title: [e.at, e.roots && e.roots.length ? `roots: ${e.roots.join(", ")}` : "", e.run && e.run.commit ? `commit ${e.run.commit}` : "", e.run && e.run.pull_request ? `PR #${e.run.pull_request}` : ""].filter(Boolean).join("\n"),
|
|
72
|
+
links,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The line over the lane: whose record, how many entries, or that the source has none. */
|
|
78
|
+
export function laneLegend(answer, { root = null, shown = 0 } = {}) {
|
|
79
|
+
if (!answer) return "audit record not read";
|
|
80
|
+
if (answer.error) return answer.error;
|
|
81
|
+
if (answer.absent) return answer.absent;
|
|
82
|
+
if (!shown) return root ? `the audit record names no entry for ${root}` : `the audit record holds no entry for ${answer.project || "this project"}`;
|
|
83
|
+
const more = !root && answer.total > answer.entries.length ? ` of ${answer.total}` : "";
|
|
84
|
+
return `${shown}${more} entr${shown === 1 ? "y" : "ies"} from terragucci's audit record${root ? ` for ${root}` : ""}, newest first`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The lane as DOM: a legend line and one row per entry. Browser-only (the tests take the pure functions above). */
|
|
88
|
+
export function laneElement(answer, opts = {}) {
|
|
89
|
+
const rows = laneRows(answer, opts);
|
|
90
|
+
const box = document.createElement("div");
|
|
91
|
+
box.className = "tg-timeline";
|
|
92
|
+
const head = document.createElement("div");
|
|
93
|
+
head.className = answer && answer.error ? "tg-refusal" : "tg-legend";
|
|
94
|
+
head.textContent = laneLegend(answer, { root: opts.root || null, shown: rows.length });
|
|
95
|
+
if (answer && answer.remedy) head.title = answer.remedy;
|
|
96
|
+
box.appendChild(head);
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
const row = document.createElement("div");
|
|
99
|
+
row.className = `tg-entry tg-${r.tone}`;
|
|
100
|
+
row.title = r.title;
|
|
101
|
+
const when = document.createElement("span");
|
|
102
|
+
when.className = "tg-when";
|
|
103
|
+
when.textContent = r.when;
|
|
104
|
+
const text = document.createElement("span");
|
|
105
|
+
text.textContent = ` ${r.text}`;
|
|
106
|
+
row.append(when, text);
|
|
107
|
+
for (const l of r.links) {
|
|
108
|
+
const a = document.createElement("a");
|
|
109
|
+
a.textContent = l.text;
|
|
110
|
+
a.href = l.href;
|
|
111
|
+
a.target = "_blank";
|
|
112
|
+
a.rel = "noopener";
|
|
113
|
+
row.append(" · ", a);
|
|
114
|
+
}
|
|
115
|
+
box.appendChild(row);
|
|
116
|
+
}
|
|
117
|
+
return box;
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { entryLine, laneLegend, laneRows, rootOfCard, toneOf } from "./terragucci-timeline.js";
|
|
3
|
+
|
|
4
|
+
// #506: the lane's words, from answers shaped like /api/terragucci/timeline.
|
|
5
|
+
const NOW = new Date("2026-10-08T13:31:00Z");
|
|
6
|
+
const answer = {
|
|
7
|
+
project: "github.com/acme/shop",
|
|
8
|
+
files: "/api/terragucci/file?key=",
|
|
9
|
+
record: { key: "audit.jsonl", present: true },
|
|
10
|
+
total: 3,
|
|
11
|
+
limit: 200,
|
|
12
|
+
entries: [
|
|
13
|
+
{ id: "c", kind: "apply", at: "2026-10-08T09:31:00.000Z", who: null, what: "wave-2", wave: 2, digest: "jcs1-sha256:2e0fba16ce38925adf41", result: "waiting", roots: ["envs/staging/orders"], run: { report: "github.com/acme/shop/r2/report.html", job_url: "https://ci/9131" }, evidence: { source: "report" } },
|
|
14
|
+
{ id: "b", kind: "approval", at: "2026-10-08T09:20:00.000Z", who: "dana", what: "wave-1", digest: null, result: "unsigned", roots: ["envs/dev/orders"], run: {}, evidence: { source: "ledger", url: "https://github.com/acme/shop/commit/7f1e" } },
|
|
15
|
+
{ id: "a", kind: "refused", at: "2026-10-06T09:00:00.000Z", who: "lee", what: "wave-1", digest: null, result: "changed-after-approval", roots: ["envs/dev/orders"], run: {}, evidence: { source: "report" } },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("the timeline lane (#506)", () => {
|
|
20
|
+
it("words an entry by its kind, who and result", () => {
|
|
21
|
+
expect(entryLine(answer.entries[0])).toBe("apply wave-2: waiting · plan 2e0fba16ce38");
|
|
22
|
+
expect(entryLine(answer.entries[1])).toBe("approved wave-1 by dana: unsigned");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("draws refusals and failures as failed, waits as pending", () => {
|
|
26
|
+
expect(answer.entries.map(toneOf)).toEqual(["wave", "ok", "failed"]);
|
|
27
|
+
expect(toneOf({ kind: "approval-revoked", result: "revoked" })).toBe("drift");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("dates every row and links its report through the file route, its job and its ledger commit", () => {
|
|
31
|
+
const rows = laneRows(answer, { now: NOW });
|
|
32
|
+
expect(rows.map((r) => r.when)).toEqual(["09:31 UTC, 4h ago", "09:20 UTC, 4h ago", "2026-10-06 09:00 UTC, 2d ago"]);
|
|
33
|
+
expect(rows[0].links).toEqual([
|
|
34
|
+
{ text: "report", href: "/api/terragucci/file?key=github.com%2Facme%2Fshop%2Fr2%2Freport.html" },
|
|
35
|
+
{ text: "job", href: "https://ci/9131" },
|
|
36
|
+
]);
|
|
37
|
+
expect(rows[1].links).toEqual([{ text: "ledger commit", href: "https://github.com/acme/shop/commit/7f1e" }]);
|
|
38
|
+
// In an export the key is relative to the bucket.
|
|
39
|
+
expect(laneRows({ ...answer, files: "../../" }, { now: NOW })[0].links[0].href).toBe("../../github.com/acme/shop/r2/report.html");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps a root's entries for a card's inspect pane", () => {
|
|
43
|
+
expect(laneRows(answer, { root: "envs/dev/orders", now: NOW }).map((r) => r.id)).toEqual(["b", "a"]);
|
|
44
|
+
const marks = { roots: [{ member: "tg-example", name: "envs-dev-orders", path: "envs/dev/orders" }, { member: "tg-example", name: "envs-dev", path: "envs/dev" }] };
|
|
45
|
+
expect(rootOfCard(marks, "tg-example/envs-dev-orders/module.service/aws_sqs_queue.jobs")).toBe("envs/dev/orders");
|
|
46
|
+
expect(rootOfCard(marks, "other/x")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("says when the source has no record, rather than that nothing happened", () => {
|
|
50
|
+
expect(laneLegend({ ...answer, entries: [], record: { present: false }, absent: "/r has no audit.jsonl: …" })).toBe("/r has no audit.jsonl: …");
|
|
51
|
+
expect(laneLegend({ ...answer, entries: [] }, { shown: 0 })).toBe("the audit record holds no entry for github.com/acme/shop");
|
|
52
|
+
expect(laneLegend(answer, { root: "envs/prod/search", shown: 0 })).toBe("the audit record names no entry for envs/prod/search");
|
|
53
|
+
expect(laneLegend({ ...answer, total: 250 }, { shown: 3 })).toBe("3 of 250 entries from terragucci's audit record, newest first");
|
|
54
|
+
expect(laneLegend({ error: "audit.jsonl in /r, line 2, is not JSON.", code: "terragucci-report" })).toContain("line 2");
|
|
55
|
+
});
|
|
56
|
+
});
|
package/web/workspace-gates.js
CHANGED
|
@@ -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
|
+
});
|