@prajwalghate/sourcetruth 0.1.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/src/layout.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // Where each contract sits on the map.
2
+ //
3
+ // Computed here, in Node, and embedded as numbers — never in the browser. Two reasons. The report
4
+ // must be reproducible: two runs on one commit produce byte-identical files, so `diff` between
5
+ // versions shows what the CODE changed rather than what a force simulation happened to settle on.
6
+ // And a layout is a claim about structure ("this is made by that"), so it belongs where it can be
7
+ // tested, not in a script that only runs when someone opens the page.
8
+ //
9
+ // Layered, left to right: things nothing here creates sit on the left, and each contract sits one
10
+ // column right of the furthest thing that makes or calls it. That is the reading order of a
11
+ // protocol — the admin's roots first, the user's contracts in the middle, what they produce last.
12
+ //
13
+ // Deterministic throughout: every ordering has an explicit tie-break, and nothing is random.
14
+
15
+ export const CARD = Object.freeze({ w: 212, h: 84, gapX: 104, gapY: 26 });
16
+
17
+ const byKey = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
18
+
19
+ /**
20
+ * @param nodes [{ id, sort? }] sort defaults to id
21
+ * @param links [{ from, to }] directed; duplicates and self-links are ignored
22
+ * @returns {{ pos: Map<id,{x,y,layer,standalone}>, width, height, reversed: [from,to][], standaloneTop }}
23
+ */
24
+ export function layout(nodes, links, { card = CARD, sweeps = 8 } = {}) {
25
+ const ids = [...nodes]
26
+ .sort((a, b) => byKey(a.sort ?? a.id, b.sort ?? b.id) || byKey(a.id, b.id))
27
+ .map((n) => n.id);
28
+ const known = new Set(ids);
29
+
30
+ const pairs = [];
31
+ const seen = new Set();
32
+ for (const l of links) {
33
+ if (l.from === l.to || !known.has(l.from) || !known.has(l.to)) continue;
34
+ const k = `${l.from}|${l.to}`;
35
+ if (!seen.has(k)) { seen.add(k); pairs.push([l.from, l.to]); }
36
+ }
37
+ const rank = new Map(ids.map((id, i) => [id, i]));
38
+ const succ = new Map(ids.map((id) => [id, []]));
39
+ for (const [a, b] of pairs) succ.get(a).push(b);
40
+ for (const id of ids) succ.get(id).sort((a, b) => rank.get(a) - rank.get(b));
41
+
42
+ // ── 1. break cycles ───────────────────────────────────────────────────────────────────────────
43
+ // A contract that makes another which makes the first back is common (a vault re-creating its
44
+ // risk mirror). Layering needs a DAG, so back edges are reversed — and returned, not hidden.
45
+ // Iterative DFS: a recursive one overflows the stack on a large codebase.
46
+ const indeg0 = new Map(ids.map((id) => [id, 0]));
47
+ for (const [, b] of pairs) indeg0.set(b, indeg0.get(b) + 1);
48
+ const starts = [...ids.filter((id) => indeg0.get(id) === 0), ...ids.filter((id) => indeg0.get(id) > 0)];
49
+ const state = new Map(); // absent = unvisited, 1 = on the DFS stack, 2 = finished
50
+ const dag = new Map(ids.map((id) => [id, new Set()]));
51
+ const reversed = [];
52
+ for (const s of starts) {
53
+ if (state.has(s)) continue;
54
+ state.set(s, 1);
55
+ const stack = [[s, 0]];
56
+ while (stack.length) {
57
+ const top = stack[stack.length - 1];
58
+ const out = succ.get(top[0]);
59
+ if (top[1] < out.length) {
60
+ const w = out[top[1]++];
61
+ const st = state.get(w);
62
+ if (st === 1) {
63
+ dag.get(w).add(top[0]);
64
+ reversed.push([top[0], w]);
65
+ } else {
66
+ dag.get(top[0]).add(w);
67
+ if (st === undefined) { state.set(w, 1); stack.push([w, 0]); }
68
+ }
69
+ } else {
70
+ state.set(top[0], 2);
71
+ stack.pop();
72
+ }
73
+ }
74
+ }
75
+
76
+ // ── 2. columns: longest path from the roots ─────────────────────────────────────────────────
77
+ const dsucc = new Map(ids.map((id) => [id, [...dag.get(id)].sort((a, b) => rank.get(a) - rank.get(b))]));
78
+ const preds = new Map(ids.map((id) => [id, []]));
79
+ for (const id of ids) for (const w of dsucc.get(id)) preds.get(w).push(id);
80
+ const indeg = new Map(ids.map((id) => [id, preds.get(id).length]));
81
+ const layer = new Map(ids.map((id) => [id, 0]));
82
+ const queue = ids.filter((id) => indeg.get(id) === 0);
83
+ for (let qi = 0; qi < queue.length; qi++) {
84
+ const v = queue[qi];
85
+ for (const w of dsucc.get(v)) {
86
+ layer.set(w, Math.max(layer.get(w), layer.get(v) + 1));
87
+ indeg.set(w, indeg.get(w) - 1);
88
+ if (indeg.get(w) === 0) queue.push(w);
89
+ }
90
+ }
91
+
92
+ // Contracts connected to nothing are not roots of anything, and stacking them into the first
93
+ // column would bury the protocol's real starting points. They get their own block underneath.
94
+ const touched = new Set(pairs.flat());
95
+ const standalone = ids.filter((id) => !touched.has(id));
96
+ const layers = [];
97
+ for (const id of ids) if (touched.has(id)) (layers[layer.get(id)] ??= []).push(id);
98
+ for (let i = 0; i < layers.length; i++) layers[i] ??= [];
99
+
100
+ // ── 3. order within a column: barycentre sweeps, to uncross the lines ──────────────────────
101
+ const at = new Map();
102
+ const renumber = (L) => L.forEach((id, i) => at.set(id, (i + 0.5) / L.length));
103
+ layers.forEach(renumber);
104
+ for (let s = 0; s < sweeps; s++) {
105
+ const down = s % 2 === 0;
106
+ const order = layers.map((_, i) => i);
107
+ if (!down) order.reverse();
108
+ for (const li of order) {
109
+ const L = layers[li];
110
+ const bc = new Map(L.map((id) => {
111
+ const near = down ? preds.get(id) : dsucc.get(id);
112
+ return [id, near.length ? near.reduce((acc, n) => acc + at.get(n), 0) / near.length : at.get(id)];
113
+ }));
114
+ L.sort((a, b) => bc.get(a) - bc.get(b) || at.get(a) - at.get(b) || rank.get(a) - rank.get(b));
115
+ renumber(L);
116
+ }
117
+ }
118
+
119
+ // ── 4. coordinates ────────────────────────────────────────────────────────────────────────────
120
+ const pos = new Map();
121
+ const colH = (n) => n * card.h + Math.max(0, n - 1) * card.gapY;
122
+ const H = colH(Math.max(0, ...layers.map((L) => L.length)));
123
+ layers.forEach((L, li) => {
124
+ const top = Math.round((H - colH(L.length)) / 2);
125
+ L.forEach((id, i) => pos.set(id, {
126
+ x: li * (card.w + card.gapX), y: top + i * (card.h + card.gapY), layer: li, standalone: false,
127
+ }));
128
+ });
129
+ const standaloneTop = standalone.length && layers.length ? H + card.gapY * 4 : 0;
130
+ if (standalone.length) {
131
+ const cols = Math.max(layers.length, Math.ceil(Math.sqrt(standalone.length)), 1);
132
+ standalone.forEach((id, i) => pos.set(id, {
133
+ x: (i % cols) * (card.w + card.gapX),
134
+ y: standaloneTop + Math.floor(i / cols) * (card.h + card.gapY),
135
+ layer: -1, standalone: true,
136
+ }));
137
+ }
138
+ let width = 0;
139
+ let height = 0;
140
+ for (const p of pos.values()) {
141
+ width = Math.max(width, p.x + card.w);
142
+ height = Math.max(height, p.y + card.h);
143
+ }
144
+ return { pos, width, height, reversed, standaloneTop };
145
+ }
package/src/model.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // The language-neutral model. Every adapter emits this shape and nothing else.
2
+ //
3
+ // Written BEFORE the second adapter exists, deliberately. A model derived from one language is that
4
+ // language's AST wearing a hat; the EVM adapter would then be bolted on and the abstraction would
5
+ // leak in exactly the places that matter. So the vocabulary below is defined against two languages
6
+ // from the start, and the Daml adapter has to fit it rather than define it.
7
+ //
8
+ // neutral Daml Solidity / EVM
9
+ // ------------ ------------------------- ----------------------------------
10
+ // Unit template contract
11
+ // Entry choice external/public function
12
+ // authority controller modifier, require(msg.sender == …)
13
+ // CREATE create new C(...)
14
+ // CALL exercise external call, delegatecall
15
+ // READ fetch storage read, staticcall
16
+ // DESTROY archive selfdestruct
17
+ // stateEffect consuming + re-creates self mutates its own storage
18
+ // Guard assertMsg / ensure require / revert
19
+ // Hole unresolved edge target unresolved callee address
20
+ //
21
+ // THE HOLE IS THE POINT. Every other tool in this space reports what it found. An auditor's first
22
+ // question is "what could you not see?", and a tool that answers it by silently resolving to its
23
+ // best guess is worse than one that answers nothing. An edge whose target cannot be determined from
24
+ // source is recorded as a hole, with the raw line, and it is never guessed.
25
+
26
+ /** Edge kinds. Deliberately few — anything finer is a language detail and belongs in `raw`. */
27
+ export const EDGE = Object.freeze({
28
+ CREATE: "create",
29
+ CALL: "call",
30
+ READ: "read",
31
+ DESTROY: "destroy",
32
+ });
33
+
34
+ /** What an entry does to the state of its own Unit. */
35
+ export const EFFECT = Object.freeze({
36
+ /** Leaves its Unit untouched (a view, a nonconsuming choice, a `view` function). */
37
+ NONE: "none",
38
+ /** Consumes its Unit and produces a successor — the state machine's step. */
39
+ TRANSITION: "transition",
40
+ /** Consumes its Unit and produces no successor — the state machine's exit. */
41
+ TERMINAL: "terminal",
42
+ });
43
+
44
+ /** An edge from an Entry to some Unit. `target: null` means UNRESOLVED — see the note above. */
45
+ export function edge({ kind, target = null, raw = "", via = null, through = null,
46
+ external = false, self = false, meta = {} }) {
47
+ if (!Object.values(EDGE).includes(kind)) throw new Error(`unknown edge kind: ${kind}`);
48
+ // `via` — the contract-id EXPRESSION the edge went through (`riskCid`, `feed.activeCid`).
49
+ // `through` — the top-level FUNCTION the edge is written in, when a choice reached it by
50
+ // calling a helper rather than writing it inline. Distinct questions; reusing one
51
+ // field for both printed "via riskCid" where a function name belonged.
52
+ return { kind, target, resolved: target !== null, raw: String(raw).trim(), via, through, external, self, meta };
53
+ }
54
+
55
+ /**
56
+ * One callable thing.
57
+ *
58
+ * `authority` is the list of principals that must consent, as the SOURCE names them — not resolved
59
+ * to addresses or parties. "Who can fire this alone" is the single most useful question an auditor
60
+ * asks, and a one-element authority list is the answer.
61
+ */
62
+ export function entry({
63
+ name, unit, module: mod, path, line, endLine = null,
64
+ authority = [], args = [], guards = [], edges = [],
65
+ effect = EFFECT.NONE, returns = null, source = null, doc = null, bodyLine = null,
66
+ }) {
67
+ return {
68
+ name, unit, module: mod, path, line, endLine, bodyLine,
69
+ authority, args, guards, edges, effect, returns,
70
+ /** Raw source of the body, comments INTACT, for display only. Never parsed. */
71
+ source,
72
+ /** A doc comment if the language has them. Display only. Never evidence. */
73
+ doc,
74
+ soloAuthority: authority.length === 1,
75
+ holes: edges.filter((e) => !e.resolved).length,
76
+ };
77
+ }
78
+
79
+ /** One stateful thing that entries act on. */
80
+ export function unit({
81
+ name, module: mod, path, line, endLine = null,
82
+ signatories = [], observers = [], fields = [], invariants = [], keys = [], entries = [],
83
+ }) {
84
+ // `keys` is an identity/uniqueness declaration — Daml's `key`. Languages without the concept
85
+ // leave it empty, the same way Solidity leaves `invariants` empty. A neutral model is allowed
86
+ // concepts not every language uses; what it must not do is name them after one language.
87
+ return { name, module: mod, path, line, endLine, signatories, observers, fields, invariants, keys, entries };
88
+ }
89
+
90
+ /** What an adapter returns. */
91
+ export function model({ language, root, units = [], entries = [], modules = [], notes = [] }) {
92
+ const edges = entries.flatMap((e) => e.edges);
93
+ const holes = entries.flatMap((e) =>
94
+ e.edges.filter((x) => !x.resolved).map((x) => ({ owner: `${e.unit}.${e.name}`, path: e.path, ...x }))
95
+ );
96
+ return {
97
+ language, root, modules, units, entries, holes,
98
+ stats: {
99
+ modules: modules.length,
100
+ units: units.length,
101
+ entries: entries.length,
102
+ edges: edges.length,
103
+ holes: holes.length,
104
+ /** The headline number. Not a score to maximise — a statement of how much is legible. */
105
+ resolution: edges.length === 0 ? null : Math.round((100 * (edges.length - holes.length)) / edges.length),
106
+ soloAuthority: entries.filter((e) => e.soloAuthority).length,
107
+ terminal: entries.filter((e) => e.effect === EFFECT.TERMINAL).length,
108
+ },
109
+ notes,
110
+ };
111
+ }
112
+
113
+ /** The call graph between units, for drawing. Unresolved edges are omitted — they are in `holes`. */
114
+ export function graph(m) {
115
+ const nodes = m.units.map((u) => ({
116
+ id: u.name, module: u.module, signatories: u.signatories, entries: u.entries.length,
117
+ }));
118
+ const seen = new Map();
119
+ for (const e of m.entries) {
120
+ for (const x of e.edges) {
121
+ if (!x.resolved) continue;
122
+ const key = `${e.unit}${x.target}${x.kind}`;
123
+ if (!seen.has(key)) seen.set(key, { from: e.unit, to: x.target, kind: x.kind, via: [], external: x.external });
124
+ seen.get(key).via.push(e.name);
125
+ }
126
+ }
127
+ return { nodes, edges: [...seen.values()] };
128
+ }
129
+
130
+ /**
131
+ * Entries that change state and need AT MOST ONE principal's consent — where review time goes.
132
+ *
133
+ * "At most one", not "exactly one", and the difference is the whole point. Daml makes you declare a
134
+ * controller, so the exposed case is one party acting alone. Solidity's default is no restriction
135
+ * at all: a function with no modifier and no `msg.sender` check is callable by anyone. **Zero
136
+ * consent is strictly more exposed than one**, so filtering on `soloAuthority` would report a real
137
+ * vault's 8 owner-guarded functions and hide its 100 unguarded ones — exactly inverted. Found by
138
+ * measuring a live Solidity codebase, not by reasoning about it.
139
+ *
140
+ * Unguarded entries sort first, for the same reason.
141
+ */
142
+ export function attackSurface(m) {
143
+ return m.entries
144
+ .filter((e) => e.authority.length <= 1 && e.effect !== EFFECT.NONE)
145
+ .map((e) => ({
146
+ unit: e.unit, name: e.name,
147
+ authority: e.authority[0] ?? null, // null means ANYONE — the most open case there is
148
+ unguarded: e.authority.length === 0,
149
+ effect: e.effect,
150
+ path: e.path, line: e.line, holes: e.holes,
151
+ moves: e.edges.filter((x) => x.kind === EDGE.CREATE || x.kind === EDGE.DESTROY).length,
152
+ calls: e.edges.filter((x) => x.kind === EDGE.CALL).length,
153
+ }))
154
+ .sort((a, b) =>
155
+ Number(b.unguarded) - Number(a.unguarded) ||
156
+ b.holes - a.holes || b.moves - a.moves || b.calls - a.calls);
157
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,328 @@
1
+ // One self-contained HTML file: an interactive map on top, the plain listing underneath.
2
+ //
3
+ // Still deliberately NOT a web app. An audit report gets emailed, opened on a plane, attached to a
4
+ // ticket, and read two years later when the tool that made it no longer runs. So: one file, no
5
+ // server, no network, no CDN — the map's script, styles and data are all inlined.
6
+ //
7
+ // What you see first is the MAP (src/client/app.js): who can act, which contracts they touch, and —
8
+ // press play — what one action archives, creates and calls. It exists because a listing of facts,
9
+ // however accurate, left the reader to do the traversal in their head. The page should show it.
10
+ //
11
+ // What sits underneath is the LISTING: every unit, entry, authority, edge, hole and source body as
12
+ // plain HTML. It is the Code tab when JavaScript runs, and the whole page when it does not. The rule
13
+ // from the first version still holds and is still tested — every fact is in the document before any
14
+ // script runs. The map draws from data computed in Node (src/view.mjs); it works nothing out.
15
+ //
16
+ // It stores NO findings and knows nothing about any issue tracker. It shows what the code does and
17
+ // what could not be determined. Deciding what is WRONG is the reader's job, kept in their register.
18
+
19
+ import fs from "node:fs";
20
+ import { attackSurface, graph } from "./model.mjs";
21
+ import { viewData, embed, EFFECT_LABEL } from "./view.mjs";
22
+
23
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
24
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
25
+
26
+ /** Entry anchors are shared with the map, which reads each action's source out of the listing. */
27
+ export const anchor = (unit, name) => `e-${unit}-${name}`.replace(/[^\w-]/g, "_");
28
+
29
+ const CLIENT_JS = fs.readFileSync(new URL("./client/app.js", import.meta.url), "utf8");
30
+ const CLIENT_CSS = fs.readFileSync(new URL("./client/app.css", import.meta.url), "utf8");
31
+
32
+ /** Resolution decides how much of the rest to believe, so it is graded, not scored. */
33
+ function trustNote(r) {
34
+ if (r === null) return "No edges found — nothing to resolve.";
35
+ if (r >= 95) return "Nearly all wiring resolved. The graph below is close to complete.";
36
+ if (r >= 80) return "Most wiring resolved. Read the holes before relying on the graph.";
37
+ if (r >= 50) return "A significant share is unresolved. Treat the graph as partial.";
38
+ return "Most edges are unresolved. The graph is not a reliable picture of this codebase.";
39
+ }
40
+
41
+ // The listing's own styles, scoped to #code so they cannot leak into the map, and built on the
42
+ // map's colour tokens so both views change theme together.
43
+ const LISTING_CSS = `
44
+ #code{--bg:var(--ground);--fg:var(--ink);--dim:var(--muted);--card:var(--surface);--accent:var(--flow);
45
+ --warn:var(--blind);--hole:var(--end);--code:var(--raised);font:15px/1.6 var(--sans);color:var(--fg)}
46
+ #code .wrap{max-width:1100px;margin:0 auto;padding:32px 20px 96px}
47
+ #code code,#code pre,#code .raw{font-family:var(--mono);font-size:.88em}
48
+ #code .code-top h1{margin:0 0 4px;font-size:26px;letter-spacing:-.01em}
49
+ #code .code-top .sub{color:var(--dim);font-size:14px;margin-bottom:22px}
50
+ #code h2{font-size:19px;margin:42px 0 6px;padding-top:14px;border-top:1px solid var(--line)}
51
+ #code h3{font-size:16px;margin:26px 0 8px}
52
+ #code h4{font-size:14px;margin:18px 0 6px;font-weight:600}
53
+ #code .lede{color:var(--dim);margin:4px 0 14px;max-width:70ch}
54
+ #code .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:18px 0 8px}
55
+ #code .stat{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:12px 14px}
56
+ #code .stat .k{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.07em}
57
+ #code .stat .v{font-size:24px;font-weight:600;font-variant-numeric:tabular-nums;margin-top:2px}
58
+ #code .stat .n{color:var(--dim);font-size:12px;margin-top:2px}
59
+ #code .trust{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--accent);
60
+ border-radius:8px;padding:12px 15px;margin:14px 0 4px}
61
+ #code .t{width:100%;border-collapse:collapse;margin:10px 0;font-size:13.5px;display:block;overflow-x:auto}
62
+ #code .t th{text-align:left;font-weight:600;color:var(--dim);font-size:11px;text-transform:uppercase;
63
+ letter-spacing:.06em;padding:7px 10px;border-bottom:1px solid var(--line)}
64
+ #code .t td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
65
+ #code .t.sm td{padding:4px 10px}
66
+ #code .t .num{text-align:right;font-variant-numeric:tabular-nums}
67
+ #code .dim{color:var(--dim)}#code .warn{color:var(--warn);font-weight:600}
68
+ #code .hole{color:var(--hole);font-weight:600}#code .ok{color:var(--make)}
69
+ #code .pill{display:inline-block;padding:1px 7px;border-radius:999px;font-size:11px;
70
+ border:1px solid var(--line);background:var(--code);color:var(--dim);white-space:nowrap}
71
+ #code .pill.solo{color:var(--warn);border-color:var(--warn)}
72
+ #code .pill.anyone{color:var(--power);border-color:var(--power);font-weight:600}
73
+ #code .pill.ext{opacity:.75}
74
+ #code .k-create{color:var(--make)}#code .k-destroy{color:var(--end)}#code .k-call{color:var(--flow)}
75
+ #code .f-terminal{color:var(--end)}#code .f-transition{color:var(--flow)}
76
+ #code .kv{display:flex;gap:10px;padding:2px 0;font-size:13.5px}
77
+ #code .kv>span:first-child{color:var(--dim);min-width:104px;flex-shrink:0}
78
+ #code .unit{background:var(--card);border:1px solid var(--line);border-radius:11px;padding:16px 18px;margin:16px 0;scroll-margin-top:64px}
79
+ #code .entry{border-top:1px solid var(--line);padding-top:12px;margin-top:14px;scroll-margin-top:64px}
80
+ #code details{margin:7px 0}#code summary{cursor:pointer;color:var(--dim);font-size:13px;user-select:none}
81
+ #code summary:hover{color:var(--fg)}
82
+ #code pre.src{background:var(--code);border:1px solid var(--line);border-radius:7px;padding:11px 13px;
83
+ overflow-x:auto;margin:7px 0;line-height:1.5}
84
+ #code .guards code{display:block;background:var(--code);border-radius:5px;padding:4px 8px;margin:3px 0}
85
+ #code a{color:var(--accent);text-decoration:none}#code a:hover{text-decoration:underline}
86
+ #code .toc{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px}
87
+ #code .toc a{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:3px 9px;font-size:12.5px}
88
+ #code .sm-note{font-size:12px;margin-top:6px;max-width:72ch}
89
+ #code .toolbar{position:sticky;top:0;z-index:5;display:flex;gap:14px;align-items:center;flex-wrap:wrap;
90
+ background:var(--bg);border-bottom:1px solid var(--line);padding:10px 0;margin-top:18px}
91
+ .view-code #code .toolbar{top:52px}
92
+ #code .toolbar input[type=search]{flex:1;min-width:220px;background:var(--card);color:var(--fg);
93
+ border:1px solid var(--line);border-radius:7px;padding:7px 11px;font:inherit;font-size:14px}
94
+ #code .toolbar label{display:flex;gap:6px;align-items:center;color:var(--dim);font-size:13px;cursor:pointer}
95
+ #code .toolbar #count{margin-left:auto;font-size:12.5px;font-variant-numeric:tabular-nums}
96
+ #code .noscript{background:var(--blind-soft);color:var(--ink);border-radius:8px;padding:10px 14px;margin:0 0 18px}
97
+ #code footer{margin-top:52px;padding-top:16px;border-top:1px solid var(--line);color:var(--dim);font-size:12.5px}
98
+ @media print{#code .toolbar{display:none}#code details{display:block}#code details>summary{display:none}}
99
+ `;
100
+
101
+ export function render(model, { title = "sourcetruth report", generatedAt = null } = {}) {
102
+ const s = model.stats;
103
+ const surface = attackSurface(model);
104
+ const g = graph(model);
105
+ const view = viewData(model, { title });
106
+ const holesByOwner = new Map();
107
+ for (const h of model.holes) {
108
+ if (!holesByOwner.has(h.owner)) holesByOwner.set(h.owner, []);
109
+ holesByOwner.get(h.owner).push(h);
110
+ }
111
+
112
+ const statCard = (label, value, note = "") =>
113
+ `<div class="stat"><div class="k">${esc(label)}</div><div class="v">${esc(value)}</div>${
114
+ note ? `<div class="n">${esc(note)}</div>` : ""}</div>`;
115
+
116
+ const holesSection = model.holes.length === 0
117
+ ? `<p class="ok">Every edge resolved to a target in source. Nothing was guessed and nothing is missing.</p>`
118
+ : `<p class="lede">These are edges whose target could not be determined from source. They are
119
+ <strong>not guessed and not omitted</strong> — they are the places to read by hand.</p>
120
+ <table class="t">
121
+ <thead><tr><th>Entry</th><th>Kind</th><th>Source line</th><th>File</th></tr></thead>
122
+ <tbody>${[...holesByOwner].map(([owner, hs]) => hs.map((h, i) => `
123
+ <tr>
124
+ <td>${i === 0 ? `<code>${esc(owner)}</code>` : ""}</td>
125
+ <td><span class="pill k-${esc(h.kind)}">${esc(h.kind)}</span></td>
126
+ <td><code class="raw">${esc(h.raw)}</code></td>
127
+ <td class="dim">${esc(h.path)}</td>
128
+ </tr>`).join("")).join("")}</tbody>
129
+ </table>`;
130
+
131
+ const surfaceSection = surface.length === 0
132
+ ? `<p class="dim">No entry can be fired by a single principal and also change state.</p>`
133
+ : `<p class="lede">Entries that change state and need <strong>at most one principal’s consent</strong>.
134
+ <code>anyone</code> means no access check at all — the most open case there is, so those sort first.</p>
135
+ <table class="t">
136
+ <thead><tr><th>Alone</th><th>Entry</th><th>Effect</th><th class="num">Moves</th><th class="num">Holes</th><th>Where</th></tr></thead>
137
+ <tbody>${surface.map((e) => `
138
+ <tr>
139
+ <td>${e.unguarded ? `<span class="pill anyone">anyone</span>` : `<code class="who">${esc(e.authority)}</code>`}</td>
140
+ <td><a href="#${esc(anchor(e.unit, e.name))}"><code>${esc(e.unit)}.${esc(e.name)}</code></a></td>
141
+ <td><span class="pill f-${esc(e.effect)}">${esc(EFFECT_LABEL[e.effect] ?? e.effect)}</span></td>
142
+ <td class="num">${e.moves}</td>
143
+ <td class="num${e.holes ? " warn" : ""}">${e.holes}</td>
144
+ <td class="dim">${esc(e.path)}:${e.line}</td>
145
+ </tr>`).join("")}</tbody>
146
+ </table>`;
147
+
148
+ const entryDetail = (e) => `
149
+ <section class="entry" id="${esc(anchor(e.unit, e.name))}"
150
+ data-name="${esc(`${e.unit}.${e.name}`.toLowerCase())}"
151
+ data-holes="${e.holes}" data-solo="${e.authority.length <= 1 ? 1 : 0}">
152
+ <h4><code>${esc(e.unit)}.${esc(e.name)}</code>
153
+ <span class="pill f-${esc(e.effect)}">${esc(EFFECT_LABEL[e.effect] ?? e.effect)}</span>
154
+ ${e.soloAuthority ? `<span class="pill solo">one principal</span>` : ""}
155
+ ${e.inherited ? `<span class="pill ext">inherited from ${esc(e.inherited)}</span>` : ""}
156
+ ${e.declared ? `<span class="pill ext">declared only</span>` : ""}
157
+ ${e.deployOnly ? `<span class="pill ext">runs once at deployment</span>` : ""}
158
+ ${e.unread?.length ? `<span class="pill warn">relies on unread code: ${esc(e.unread.join(", "))}</span>` : ""}
159
+ </h4>
160
+ <div class="kv"><span>Authority</span><code>${esc(e.authority.join(", ") || "—")}</code></div>
161
+ ${e.returns ? `<div class="kv"><span>Returns</span><code>${esc(e.returns)}</code></div>` : ""}
162
+ <div class="kv"><span>Where</span><code>${esc(e.path)}:${e.line}</code></div>
163
+ ${e.args.length ? `<details><summary>${e.args.length} argument(s)</summary><table class="t sm"><tbody>${
164
+ e.args.map((a) => `<tr><td><code>${esc(a.name)}</code></td><td class="dim"><code>${esc(a.type)}</code></td></tr>`).join("")
165
+ }</tbody></table></details>` : ""}
166
+ ${e.guards.length ? `<details><summary>${e.guards.length} guard(s)</summary><div class="guards">${
167
+ e.guards.map((gd) => `<code class="raw">${esc(gd)}</code>`).join("")
168
+ }</div></details>` : ""}
169
+ ${e.edges.length ? `<details open><summary>${e.edges.length} edge(s)${
170
+ e.holes ? ` — <span class="warn">${e.holes} unresolved</span>` : ""
171
+ }</summary><table class="t sm"><tbody>${
172
+ e.edges.map((x) => `<tr>
173
+ <td><span class="pill k-${esc(x.kind)}">${esc(x.kind)}</span></td>
174
+ <td>${x.resolved
175
+ ? `<a href="#u-${esc(x.target)}"><code>${esc(x.target)}</code></a>${x.external ? ` <span class="pill ext">external</span>` : ""}${
176
+ x.through ? ` <span class="dim">via <code>${esc(x.through)}</code></span>` : ""}`
177
+ : `<span class="hole">could not determine</span>`}</td>
178
+ <td class="dim"><code class="raw">${esc(x.raw)}</code></td>
179
+ </tr>`).join("")
180
+ }</tbody></table></details>` : ""}
181
+ ${e.source ? `<details><summary>source</summary><pre class="src">${esc(e.source)}</pre>
182
+ <p class="dim sm-note">Shown with comments intact. Comments are stripped before parsing and
183
+ were never used to produce anything above.</p></details>` : ""}
184
+ </section>`;
185
+
186
+ const unitsSection = model.units.map((u) => `
187
+ <section class="unit" id="u-${esc(u.name)}" data-name="${esc(u.name.toLowerCase())}">
188
+ <h3>${esc(u.name)} <span class="dim">${esc(u.module)}</span></h3>
189
+ <div class="kv"><span>Signatories</span><code>${esc(u.signatories.join(", ") || "—")}</code></div>
190
+ ${u.observers.length ? `<div class="kv"><span>Observers</span><code>${esc(u.observers.join(", "))}</code></div>` : ""}
191
+ ${u.keys.length ? `<div class="kv"><span>Key</span><code>${esc(u.keys.join(", "))}</code></div>` : ""}
192
+ ${u.invariants.length ? `<div class="kv"><span>Invariant</span><code>${esc(u.invariants.join(", "))}</code></div>` : ""}
193
+ <div class="kv"><span>Where</span><code>${esc(u.path)}:${u.line}</code></div>
194
+ ${u.entries.length ? u.entries.map(entryDetail).join("") : `<p class="dim">No entries.</p>`}
195
+ </section>`).join("");
196
+
197
+ const graphSection = g.edges.length === 0
198
+ ? `<p class="dim">No resolved edges between units.</p>`
199
+ : `<table class="t">
200
+ <thead><tr><th>From</th><th></th><th>To</th><th>Via</th></tr></thead>
201
+ <tbody>${g.edges.map((e) => `
202
+ <tr>
203
+ <td><a href="#u-${esc(e.from)}"><code>${esc(e.from)}</code></a></td>
204
+ <td><span class="pill k-${esc(e.kind)}">${esc(e.kind)}</span></td>
205
+ <td>${e.external ? `<code>${esc(e.to)}</code> <span class="pill ext">external</span>`
206
+ : `<a href="#u-${esc(e.to)}"><code>${esc(e.to)}</code></a>`}</td>
207
+ <td class="dim">${esc(e.via.join(", "))}</td>
208
+ </tr>`).join("")}</tbody>
209
+ </table>
210
+ <p class="dim sm-note">Unresolved edges are deliberately absent here — they are in Holes above.
211
+ A graph that quietly drew a guessed target would be worse than one with a gap in it.</p>`;
212
+
213
+ return `<!doctype html>
214
+ <html lang="en"><head>
215
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
216
+ <title>${esc(title)}</title>
217
+ <script>document.documentElement.classList.add("js")</script>
218
+ <style>
219
+ ${CLIENT_CSS}
220
+ ${LISTING_CSS}
221
+ </style></head>
222
+ <body>
223
+ <div id="app"></div>
224
+
225
+ <main id="code"><div class="wrap">
226
+ <noscript><p class="noscript">The interactive map needs JavaScript. Everything it shows is listed below.</p></noscript>
227
+ <header class="code-top">
228
+ <h1>${esc(title)}</h1>
229
+ <div class="sub">${esc(model.root)} · ${esc(model.language)}${generatedAt ? ` · ${esc(generatedAt)}` : ""}</div>
230
+ </header>
231
+
232
+ <div class="stats">
233
+ ${statCard("Units", s.units)}
234
+ ${statCard("Entries", s.entries)}
235
+ ${statCard("Edges", s.edges)}
236
+ ${statCard("Resolution", s.resolution === null ? "—" : `${s.resolution}%`, `${s.holes} unresolved`)}
237
+ ${statCard("Fireable alone", s.soloAuthority)}
238
+ ${statCard("End state", s.terminal)}
239
+ </div>
240
+ <div class="trust">${esc(trustNote(s.resolution))}</div>
241
+ ${model.notes.map((n) => `<p class="warn">${esc(n)}</p>`).join("")}
242
+
243
+ <div class="toolbar" data-enhance>
244
+ <input id="q" type="search" placeholder="Filter units and entries…" autocomplete="off" spellcheck="false">
245
+ <label><input type="checkbox" id="only-holes"> only entries with holes</label>
246
+ <label><input type="checkbox" id="only-open"> only fireable by one principal</label>
247
+ <span id="count" class="dim"></span>
248
+ </div>
249
+
250
+ <h2 id="holes">Holes — what could not be determined</h2>
251
+ ${holesSection}
252
+
253
+ <h2 id="surface">Surface — what one principal can do alone</h2>
254
+ ${surfaceSection}
255
+
256
+ <h2 id="wiring">Wiring</h2>
257
+ ${graphSection}
258
+
259
+ <h2 id="units">Units</h2>
260
+ <div class="toc">${model.units.map((u) => `<a href="#u-${esc(u.name)}">${esc(u.name)}</a>`).join("")}</div>
261
+ ${unitsSection}
262
+
263
+ <footer>
264
+ Generated by <strong>sourcetruth</strong> from source. Comments were stripped before parsing and
265
+ contributed nothing to this report — where source is shown, it is for reading, not evidence.
266
+ Unresolved edges are reported as holes rather than guessed, so a gap here is a gap in what the
267
+ tool could prove, not an assertion that nothing is there.
268
+ </footer>
269
+ </div></main>
270
+
271
+ <script type="application/json" id="st-data">${embed(view)}</script>
272
+ <script>
273
+ // The listing's filter. PROGRESSIVE ENHANCEMENT ONLY: it hides rows that do not match, and reads,
274
+ // fetches and computes nothing that is not already printed above.
275
+ (function () {
276
+ var q = document.getElementById("q");
277
+ var onlyHoles = document.getElementById("only-holes");
278
+ var onlySolo = document.getElementById("only-open");
279
+ var count = document.getElementById("count");
280
+ var units = [].slice.call(document.querySelectorAll("section.unit"));
281
+ var entries = [].slice.call(document.querySelectorAll("section.entry"));
282
+ var total = entries.length;
283
+
284
+ function apply() {
285
+ var term = (q.value || "").trim().toLowerCase();
286
+ var wantHoles = onlyHoles.checked, wantSolo = onlySolo.checked;
287
+ var shown = 0;
288
+ entries.forEach(function (e) {
289
+ var ok = (!term || e.dataset.name.indexOf(term) !== -1)
290
+ && (!wantHoles || Number(e.dataset.holes) > 0)
291
+ && (!wantSolo || e.dataset.solo === "1");
292
+ e.hidden = !ok;
293
+ if (ok) shown++;
294
+ });
295
+ units.forEach(function (u) {
296
+ var anyVisible = [].slice.call(u.querySelectorAll("section.entry")).some(function (e) { return !e.hidden; });
297
+ var unitMatches = !term || u.dataset.name.indexOf(term) !== -1;
298
+ // A unit whose own name matches stays visible even with every entry filtered out, so a
299
+ // search for a contract still shows you that it exists.
300
+ u.hidden = !(anyVisible || (unitMatches && !wantHoles && !wantSolo));
301
+ });
302
+ count.textContent = shown === total ? total + " entries" : shown + " of " + total + " entries";
303
+ }
304
+ [q, onlyHoles, onlySolo].forEach(function (el) {
305
+ el.addEventListener("input", apply);
306
+ el.addEventListener("change", apply);
307
+ });
308
+ // Deep links must survive filtering: if someone arrives at #e-Vault-Close, clear the filter.
309
+ window.addEventListener("hashchange", function () {
310
+ var t = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
311
+ if (t && t.hidden) { q.value = ""; onlyHoles.checked = false; onlySolo.checked = false; apply(); t.scrollIntoView(); }
312
+ });
313
+ apply();
314
+ })();
315
+ </script>
316
+ <script>
317
+ try {
318
+ ${CLIENT_JS}
319
+ } catch (err) {
320
+ // A broken map must never leave a blank page: drop back to the listing, which needs no script.
321
+ document.documentElement.classList.remove("js");
322
+ console.error("sourcetruth map failed; showing the listing instead", err);
323
+ }
324
+ </script>
325
+ </body></html>`;
326
+ }
327
+
328
+ export default { render };