@copperbox/why 1.0.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.
Files changed (48) hide show
  1. package/README.md +168 -0
  2. package/dist/anchor.d.ts +112 -0
  3. package/dist/anchor.js +697 -0
  4. package/dist/anchors.d.ts +101 -0
  5. package/dist/anchors.js +223 -0
  6. package/dist/audit.d.ts +120 -0
  7. package/dist/audit.js +483 -0
  8. package/dist/blame.d.ts +91 -0
  9. package/dist/blame.js +294 -0
  10. package/dist/bundle.d.ts +78 -0
  11. package/dist/bundle.js +188 -0
  12. package/dist/capture.d.ts +76 -0
  13. package/dist/capture.js +462 -0
  14. package/dist/cli.d.ts +11 -0
  15. package/dist/cli.js +641 -0
  16. package/dist/dig-state.d.ts +46 -0
  17. package/dist/dig-state.js +146 -0
  18. package/dist/dig.d.ts +125 -0
  19. package/dist/dig.js +380 -0
  20. package/dist/discover.d.ts +11 -0
  21. package/dist/discover.js +47 -0
  22. package/dist/doctor.d.ts +135 -0
  23. package/dist/doctor.js +263 -0
  24. package/dist/evidence.d.ts +73 -0
  25. package/dist/evidence.js +425 -0
  26. package/dist/export.d.ts +67 -0
  27. package/dist/export.js +106 -0
  28. package/dist/find-symbol.d.ts +19 -0
  29. package/dist/find-symbol.js +267 -0
  30. package/dist/git.d.ts +17 -0
  31. package/dist/git.js +51 -0
  32. package/dist/init.d.ts +24 -0
  33. package/dist/init.js +100 -0
  34. package/dist/lint.d.ts +40 -0
  35. package/dist/lint.js +161 -0
  36. package/dist/serve-assets.d.ts +10 -0
  37. package/dist/serve-assets.js +55 -0
  38. package/dist/serve.d.ts +53 -0
  39. package/dist/serve.js +226 -0
  40. package/dist/trace-range.d.ts +22 -0
  41. package/dist/trace-range.js +216 -0
  42. package/package.json +44 -0
  43. package/ui/app.js +323 -0
  44. package/ui/graph.js +164 -0
  45. package/ui/highlight.js +202 -0
  46. package/ui/story-panel.d.ts +9 -0
  47. package/ui/story-panel.js +125 -0
  48. package/ui/style.css +229 -0
package/ui/app.js ADDED
@@ -0,0 +1,323 @@
1
+ // The `why serve` SPA shell: hash-routed views (file tree + blame gutter,
2
+ // graph, doctor list) over the UI data contract payloads. Dumb renderer by
3
+ // design — confidence, hedging, and health semantics all arrive precomputed
4
+ // from the JSON API; nothing here re-derives them (docs/ui-contract.md).
5
+
6
+ import { renderGraph, TYPE_COLORS } from "./graph.js";
7
+ import { appendTokens, langForPath, tokenize } from "./highlight.js";
8
+ import { renderStoryPanel } from "./story-panel.js";
9
+
10
+ const doc = document;
11
+
12
+ const state = {
13
+ files: [],
14
+ /** path → coverage spans (schemas/coverage.schema.json). */
15
+ coverage: new Map(),
16
+ coverageHead: "",
17
+ doctor: null,
18
+ stopGraph: null,
19
+ };
20
+
21
+ async function api(path) {
22
+ const res = await fetch(path);
23
+ if (!res.ok) {
24
+ const body = await res.json().catch(() => ({}));
25
+ throw new Error(body.error ?? `${res.status} on ${path}`);
26
+ }
27
+ return res.json();
28
+ }
29
+
30
+ function h(tag, className, text) {
31
+ const node = doc.createElement(tag);
32
+ if (className) node.className = className;
33
+ if (text !== undefined) node.textContent = text;
34
+ return node;
35
+ }
36
+
37
+ function clear(node) {
38
+ while (node.firstChild) node.removeChild(node.firstChild);
39
+ }
40
+
41
+ async function refreshCoverage() {
42
+ const coverage = await api("/api/coverage");
43
+ state.coverage = new Map(coverage.files.map((file) => [file.path, file.spans]));
44
+ state.coverageHead = coverage.head;
45
+ }
46
+
47
+ // --- Header ------------------------------------------------------------------
48
+
49
+ const CHIP_SPECS = [
50
+ ["lostAnchors", "lost anchors"],
51
+ ["reviewByPastDue", "overdue reviews"],
52
+ ["openQuestions", "open questions"],
53
+ ];
54
+
55
+ function renderChips(container) {
56
+ clear(container);
57
+ if (!state.doctor) return;
58
+ const sections = new Map(state.doctor.sections.map((section) => [section.key, section]));
59
+ for (const [key, label] of CHIP_SPECS) {
60
+ const section = sections.get(key);
61
+ if (!section) continue;
62
+ const tone = section.count === 0 ? "ok" : section.severity;
63
+ const chip = h("a", `chip chip-${tone}`, `${section.count} ${label}`);
64
+ chip.href = "#/doctor";
65
+ container.append(chip);
66
+ }
67
+ }
68
+
69
+ // --- File tree -----------------------------------------------------------------
70
+
71
+ function buildTree(paths) {
72
+ const root = { dirs: new Map(), files: [] };
73
+ for (const path of paths) {
74
+ const parts = path.split("/");
75
+ let node = root;
76
+ for (const part of parts.slice(0, -1)) {
77
+ if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
78
+ node = node.dirs.get(part);
79
+ }
80
+ node.files.push({ name: parts[parts.length - 1], path });
81
+ }
82
+ return root;
83
+ }
84
+
85
+ function renderTree(node, openDepth) {
86
+ const list = h("ul", "tree");
87
+ for (const [name, child] of [...node.dirs.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
88
+ const item = h("li");
89
+ const details = h("details");
90
+ if (openDepth > 0) details.open = true;
91
+ details.append(h("summary", "dir", name));
92
+ details.append(renderTree(child, openDepth - 1));
93
+ item.append(details);
94
+ list.append(item);
95
+ }
96
+ for (const file of node.files.sort((a, b) => a.name.localeCompare(b.name))) {
97
+ const item = h("li");
98
+ const link = h("a", "file", file.name);
99
+ link.href = `#/file/${encodeURIComponent(file.path)}`;
100
+ if (state.coverage.has(file.path)) link.append(h("span", "covered-dot", "●"));
101
+ item.append(link);
102
+ list.append(item);
103
+ }
104
+ return list;
105
+ }
106
+
107
+ // --- File view ------------------------------------------------------------------
108
+
109
+ /** Rough age for the git column: newest-commit recency at a glance. */
110
+ function age(dateStr) {
111
+ const days = Math.max(0, Math.floor((Date.now() - Date.parse(dateStr)) / 86400000));
112
+ if (days < 30) return `${days}d`;
113
+ if (days < 365) return `${Math.floor(days / 30)}mo`;
114
+ return `${Math.floor(days / 365)}y`;
115
+ }
116
+
117
+ function covering(spans, line) {
118
+ return spans.filter(
119
+ (span) => span.lines === undefined || (span.lines.start <= line && line <= span.lines.end),
120
+ );
121
+ }
122
+
123
+ /** The gutter treatment for one line: scar tissue and open questions stand
124
+ * apart; everything else colors by confidence (docs/ui-contract.md). */
125
+ function stripeClass(spans) {
126
+ if (spans.length === 0) return "";
127
+ if (spans.some((span) => span.type === "constraint" && span.status === "expired")) return "why-expired";
128
+ if (spans.some((span) => span.type === "question")) return "why-question";
129
+ return `why-conf-${spans[0].confidence ?? "none"}`;
130
+ }
131
+
132
+ async function showFile(path, fileView, storyPanel) {
133
+ clear(fileView);
134
+ fileView.append(h("h2", "file-path", path));
135
+ let blame;
136
+ try {
137
+ blame = await api(`/api/blame?path=${encodeURIComponent(path)}`);
138
+ } catch (e) {
139
+ fileView.append(h("p", "error", e.message));
140
+ return;
141
+ }
142
+ // HEAD moved since the coverage snapshot — refresh rather than paint stale spans.
143
+ if (blame.head !== state.coverageHead) await refreshCoverage();
144
+ const spans = state.coverage.get(path) ?? [];
145
+
146
+ const table = h("table", "code");
147
+ const lang = langForPath(path);
148
+ let hlState = null; // threaded across lines so a block comment can span rows
149
+ let prevSha = "";
150
+ blame.lines.forEach((line, i) => {
151
+ const n = i + 1;
152
+ const row = h("tr");
153
+ row.append(h("td", "num", String(n)));
154
+ const gitCell = h("td", "git");
155
+ if (line.sha !== prevSha) {
156
+ gitCell.textContent = `${line.author} · ${age(line.date)}`;
157
+ gitCell.title = `${line.sha.slice(0, 10)} ${line.date} — ${line.summary}`;
158
+ row.classList.add("group-start");
159
+ }
160
+ prevSha = line.sha;
161
+ row.append(gitCell);
162
+ const here = covering(spans, n);
163
+ const why = h("td", `why ${stripeClass(here)}`);
164
+ if (here.length > 0) {
165
+ why.textContent = here[0].glyph;
166
+ why.title = here.map((span) => span.conceptId).join("\n");
167
+ row.classList.add("covered");
168
+ }
169
+ row.append(why);
170
+ const textCell = h("td", "text");
171
+ const { tokens, state } = tokenize(line.text, lang, hlState);
172
+ hlState = state;
173
+ appendTokens(doc, textCell, tokens);
174
+ row.append(textCell);
175
+ row.onclick = () => showStory(path, n, storyPanel);
176
+ table.append(row);
177
+ });
178
+ fileView.append(table);
179
+ }
180
+
181
+ async function showStory(path, line, storyPanel) {
182
+ clear(storyPanel);
183
+ try {
184
+ const story = await api(`/api/story?path=${encodeURIComponent(path)}&start=${line}&end=${line}`);
185
+ storyPanel.append(renderStoryPanel(doc, story));
186
+ } catch (e) {
187
+ storyPanel.append(h("p", "error", e.message));
188
+ }
189
+ }
190
+
191
+ // --- Views -----------------------------------------------------------------------
192
+
193
+ function filesView(main, filePath) {
194
+ const layout = h("div", "layout");
195
+ const sidebar = h("aside", "sidebar");
196
+ sidebar.append(renderTree(buildTree(state.files), 2));
197
+ const fileView = h("section", "file-view");
198
+ const storyPanel = h("aside", "story-panel");
199
+ storyPanel.append(h("p", "hint", "Click a line to see the story behind it."));
200
+ layout.append(sidebar, fileView, storyPanel);
201
+ main.append(layout);
202
+ if (filePath) void showFile(filePath, fileView, storyPanel);
203
+ else fileView.append(h("p", "hint", "Pick a file — ● marks files with a recorded why."));
204
+ }
205
+
206
+ async function graphView(main) {
207
+ const wrap = h("section", "graph-view");
208
+ const legend = h("div", "legend");
209
+ for (const [type, color] of Object.entries(TYPE_COLORS)) {
210
+ const entry = h("span", "legend-entry", type);
211
+ const dot = h("span", "legend-dot", "●");
212
+ dot.style.color = color;
213
+ entry.prepend(dot);
214
+ legend.append(entry);
215
+ }
216
+ wrap.append(legend);
217
+ const canvas = doc.createElement("canvas");
218
+ wrap.append(canvas);
219
+ main.append(wrap);
220
+ canvas.width = Math.max(wrap.clientWidth - 16, 480);
221
+ canvas.height = Math.max(doc.documentElement.clientHeight - 180, 420);
222
+ try {
223
+ const graph = await api("/api/graph");
224
+ state.stopGraph = renderGraph(canvas, graph);
225
+ } catch (e) {
226
+ wrap.append(h("p", "error", e.message));
227
+ }
228
+ }
229
+
230
+ async function doctorView(main) {
231
+ const wrap = h("section", "doctor-view");
232
+ main.append(wrap);
233
+ try {
234
+ const doctor = await api("/api/doctor");
235
+ state.doctor = doctor;
236
+ renderChips(doc.getElementById("chips"));
237
+ const headline = doctor.healthy ? "healthy" : "unhealthy";
238
+ wrap.append(
239
+ h(
240
+ "h2",
241
+ `doctor-headline ${doctor.healthy ? "ok" : "red"}`,
242
+ `why doctor: ${doctor.concepts} concepts — ${doctor.red} red, ${doctor.yellow} yellow (${headline})`,
243
+ ),
244
+ );
245
+ for (const section of doctor.sections) {
246
+ const block = h("section", "doctor-section");
247
+ const tone = section.count === 0 ? "ok" : section.severity;
248
+ block.append(h("h3", `doctor-title tone-${tone}`, `${section.title} — ${section.count}`));
249
+ if (section.skipped) block.append(h("p", "hint", `not checked: ${section.skipped}`));
250
+ if (section.items.length > 0) {
251
+ const list = h("ul", "doctor-items");
252
+ for (const item of section.items) list.append(h("li", "doctor-item", item));
253
+ block.append(list);
254
+ }
255
+ wrap.append(block);
256
+ }
257
+ } catch (e) {
258
+ wrap.append(h("p", "error", e.message));
259
+ }
260
+ }
261
+
262
+ // --- Shell + routing ---------------------------------------------------------------
263
+
264
+ function route() {
265
+ const main = doc.getElementById("main");
266
+ clear(main);
267
+ if (state.stopGraph) {
268
+ state.stopGraph();
269
+ state.stopGraph = null;
270
+ }
271
+ const hash = decodeURIComponent(location.hash);
272
+ const segment = hash.split("/")[1] ?? "";
273
+ const activeTab = segment === "file" ? "" : segment; // a file view is the Files tab
274
+ for (const link of doc.querySelectorAll("nav a")) {
275
+ link.classList.toggle("active", link.dataset.route === activeTab);
276
+ }
277
+ if (hash.startsWith("#/file/")) filesView(main, hash.slice("#/file/".length));
278
+ else if (hash === "#/graph") void graphView(main);
279
+ else if (hash === "#/doctor") void doctorView(main);
280
+ else filesView(main, null);
281
+ }
282
+
283
+ async function boot() {
284
+ const app = doc.getElementById("app");
285
+ const header = h("header", "topbar");
286
+ header.append(h("span", "brand", "why"));
287
+ const nav = h("nav");
288
+ for (const [label, href, route] of [
289
+ ["Files", "#/", ""],
290
+ ["Graph", "#/graph", "graph"],
291
+ ["Doctor", "#/doctor", "doctor"],
292
+ ]) {
293
+ const link = h("a", "tab", label);
294
+ link.href = href;
295
+ link.dataset.route = route;
296
+ nav.append(link);
297
+ }
298
+ header.append(nav);
299
+ const chips = h("span", "chips");
300
+ chips.id = "chips";
301
+ header.append(chips);
302
+ const main = h("main");
303
+ main.id = "main";
304
+ app.append(header, main);
305
+
306
+ try {
307
+ const [files, , doctor] = await Promise.all([
308
+ api("/api/files"),
309
+ refreshCoverage(),
310
+ api("/api/doctor"),
311
+ ]);
312
+ state.files = files.files;
313
+ state.doctor = doctor;
314
+ } catch (e) {
315
+ main.append(h("p", "error", e.message));
316
+ return;
317
+ }
318
+ renderChips(chips);
319
+ window.addEventListener("hashchange", route);
320
+ route();
321
+ }
322
+
323
+ void boot();
package/ui/graph.js ADDED
@@ -0,0 +1,164 @@
1
+ // Graph tab: the bundle graph payload (schemas/graph.schema.json) drawn with
2
+ // a hand-rolled canvas force simulation — the okf-mcp `graph html` approach
3
+ // (embedded, zero dependencies), reimplemented here against the contract's
4
+ // nodes/edges shape. Type-colored nodes, relation-labeled edges, drag to pin.
5
+
6
+ export const TYPE_COLORS = {
7
+ decision: "#5b9cf5",
8
+ constraint: "#e2a336",
9
+ attempt: "#8d97a5",
10
+ incident: "#e05d5d",
11
+ question: "#b57edc",
12
+ };
13
+
14
+ const RADIUS = 9;
15
+
16
+ function initNodes(graph, width, height) {
17
+ // Deterministic ring seeding — stable layouts across reloads beat jitter.
18
+ return graph.nodes.map((node, i) => {
19
+ const angle = (2 * Math.PI * i) / graph.nodes.length;
20
+ const ring = Math.min(width, height) / 4;
21
+ return {
22
+ ...node,
23
+ x: width / 2 + ring * Math.cos(angle),
24
+ y: height / 2 + ring * Math.sin(angle),
25
+ vx: 0,
26
+ vy: 0,
27
+ pinned: false,
28
+ };
29
+ });
30
+ }
31
+
32
+ function tick(nodes, edges, width, height) {
33
+ for (let i = 0; i < nodes.length; i++) {
34
+ const a = nodes[i];
35
+ // Pairwise repulsion.
36
+ for (let j = i + 1; j < nodes.length; j++) {
37
+ const b = nodes[j];
38
+ const dx = a.x - b.x;
39
+ const dy = a.y - b.y;
40
+ const d2 = Math.max(dx * dx + dy * dy, 25);
41
+ const force = 2600 / d2;
42
+ const d = Math.sqrt(d2);
43
+ a.vx += (dx / d) * force;
44
+ a.vy += (dy / d) * force;
45
+ b.vx -= (dx / d) * force;
46
+ b.vy -= (dy / d) * force;
47
+ }
48
+ // Gravity toward the center.
49
+ a.vx += (width / 2 - a.x) * 0.005;
50
+ a.vy += (height / 2 - a.y) * 0.005;
51
+ }
52
+ // Edge springs.
53
+ for (const edge of edges) {
54
+ const dx = edge.to.x - edge.from.x;
55
+ const dy = edge.to.y - edge.from.y;
56
+ const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
57
+ const force = (d - 130) * 0.02;
58
+ edge.from.vx += (dx / d) * force;
59
+ edge.from.vy += (dy / d) * force;
60
+ edge.to.vx -= (dx / d) * force;
61
+ edge.to.vy -= (dy / d) * force;
62
+ }
63
+ for (const node of nodes) {
64
+ if (node.pinned) {
65
+ node.vx = 0;
66
+ node.vy = 0;
67
+ continue;
68
+ }
69
+ node.vx *= 0.85;
70
+ node.vy *= 0.85;
71
+ node.x = Math.min(Math.max(node.x + node.vx, RADIUS * 2), width - RADIUS * 2);
72
+ node.y = Math.min(Math.max(node.y + node.vy, RADIUS * 2), height - RADIUS * 2);
73
+ }
74
+ }
75
+
76
+ function draw(ctx, nodes, edges, width, height) {
77
+ ctx.clearRect(0, 0, width, height);
78
+ ctx.font = "11px system-ui, sans-serif";
79
+ for (const edge of edges) {
80
+ ctx.strokeStyle = "#5a6472";
81
+ ctx.lineWidth = 1;
82
+ ctx.beginPath();
83
+ ctx.moveTo(edge.from.x, edge.from.y);
84
+ ctx.lineTo(edge.to.x, edge.to.y);
85
+ ctx.stroke();
86
+ // Arrowhead toward the target.
87
+ const dx = edge.to.x - edge.from.x;
88
+ const dy = edge.to.y - edge.from.y;
89
+ const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
90
+ const tipX = edge.to.x - (dx / d) * (RADIUS + 3);
91
+ const tipY = edge.to.y - (dy / d) * (RADIUS + 3);
92
+ ctx.fillStyle = "#5a6472";
93
+ ctx.beginPath();
94
+ ctx.moveTo(tipX, tipY);
95
+ ctx.lineTo(tipX - (dx / d) * 7 - (dy / d) * 3.5, tipY - (dy / d) * 7 + (dx / d) * 3.5);
96
+ ctx.lineTo(tipX - (dx / d) * 7 + (dy / d) * 3.5, tipY - (dy / d) * 7 - (dx / d) * 3.5);
97
+ ctx.fill();
98
+ // Relation label at the midpoint.
99
+ ctx.fillStyle = "#8b95a5";
100
+ ctx.textAlign = "center";
101
+ ctx.fillText(edge.relation, (edge.from.x + edge.to.x) / 2, (edge.from.y + edge.to.y) / 2 - 4);
102
+ }
103
+ for (const node of nodes) {
104
+ ctx.beginPath();
105
+ ctx.arc(node.x, node.y, RADIUS, 0, 2 * Math.PI);
106
+ ctx.fillStyle = TYPE_COLORS[node.type] ?? "#8d97a5";
107
+ ctx.fill();
108
+ if (node.status === "expired" || node.status === "superseded") {
109
+ ctx.strokeStyle = "#ff6b6b";
110
+ ctx.lineWidth = 2.5;
111
+ ctx.stroke();
112
+ }
113
+ ctx.fillStyle = "#dde3ec";
114
+ ctx.textAlign = "center";
115
+ ctx.fillText(node.title, node.x, node.y + RADIUS + 13);
116
+ }
117
+ }
118
+
119
+ /** Run the simulation on `canvas`; returns a stop() for teardown. */
120
+ export function renderGraph(canvas, graph) {
121
+ const ctx = canvas.getContext("2d");
122
+ const width = canvas.width;
123
+ const height = canvas.height;
124
+ const nodes = initNodes(graph, width, height);
125
+ const byId = new Map(nodes.map((node) => [node.id, node]));
126
+ const edges = graph.edges.map((edge) => ({
127
+ from: byId.get(edge.from),
128
+ to: byId.get(edge.to),
129
+ relation: edge.relation,
130
+ }));
131
+
132
+ let dragging = null;
133
+ const pos = (event) => {
134
+ const rect = canvas.getBoundingClientRect();
135
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
136
+ };
137
+ canvas.onmousedown = (event) => {
138
+ const { x, y } = pos(event);
139
+ dragging = nodes.find((n) => (n.x - x) ** 2 + (n.y - y) ** 2 <= (RADIUS + 4) ** 2) ?? null;
140
+ if (dragging) dragging.pinned = true;
141
+ };
142
+ canvas.onmousemove = (event) => {
143
+ if (!dragging) return;
144
+ const { x, y } = pos(event);
145
+ dragging.x = x;
146
+ dragging.y = y;
147
+ };
148
+ canvas.onmouseup = () => {
149
+ if (dragging) dragging.pinned = false;
150
+ dragging = null;
151
+ };
152
+
153
+ let running = true;
154
+ const frame = () => {
155
+ if (!running) return;
156
+ tick(nodes, edges, width, height);
157
+ draw(ctx, nodes, edges, width, height);
158
+ requestAnimationFrame(frame);
159
+ };
160
+ requestAnimationFrame(frame);
161
+ return () => {
162
+ running = false;
163
+ };
164
+ }
@@ -0,0 +1,202 @@
1
+ // Hand-rolled, dependency-free syntax highlighting for the file view — the same
2
+ // self-contained/zero-CDN philosophy as the graph's canvas force sim (ui/graph.js).
3
+ // Highlighting is pure presentation: it colors the code text the blame payload
4
+ // already carries and asserts nothing about the *why*, so it lives in the client
5
+ // renderer rather than in the UI data contract.
6
+ //
7
+ // `tokenize` is a small, stateless-per-call lexer that a caller drives line by
8
+ // line, threading the returned `state` back in so a block comment can span rows
9
+ // (blame gives us one line at a time). It is deliberately best-effort: unknown
10
+ // languages, multi-line strings, and Rust lifetimes vs char literals are handled
11
+ // gracefully but not perfectly. Every character of the input is emitted in some
12
+ // token, so joining the token texts always reproduces the line verbatim.
13
+
14
+ /** Extensions → a language key in LANGS, or null for "render as plain text". */
15
+ const LANG_BY_EXTENSION = {
16
+ ".ts": "ts", ".mts": "ts", ".cts": "ts", ".tsx": "ts",
17
+ ".js": "ts", ".mjs": "ts", ".cjs": "ts", ".jsx": "ts",
18
+ ".rs": "rust",
19
+ ".py": "python", ".pyi": "python",
20
+ ".go": "go",
21
+ ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".hpp": "c", ".cxx": "c",
22
+ ".java": "c", ".cs": "c",
23
+ ".json": "json",
24
+ };
25
+
26
+ const words = (s) => new Set(s.split(/\s+/).filter(Boolean));
27
+
28
+ // A language is a lexer config, not a grammar: comment/string delimiters plus a
29
+ // keyword set. `char` enables Rust-style char-literal detection so lifetimes
30
+ // (`'a`) are not mistaken for an unterminated string.
31
+ const LANGS = {
32
+ ts: {
33
+ line: "//", block: ["/*", "*/"], quotes: ["\"", "'", "`"],
34
+ keywords: words(`abstract any as async await boolean break case catch class const continue
35
+ debugger declare default delete do else enum export extends false finally for from function
36
+ get if implements import in infer instanceof interface is keyof let module namespace never new
37
+ null number object of override private protected public readonly return satisfies set static
38
+ string super switch symbol this throw true try type typeof undefined unknown var void while
39
+ with yield`),
40
+ },
41
+ rust: {
42
+ line: "//", block: ["/*", "*/"], quotes: ["\""], char: true,
43
+ keywords: words(`as async await bool break char const continue crate dyn else enum extern false
44
+ fn for i8 i16 i32 i64 i128 if impl in isize let loop match mod move mut pub ref return self
45
+ Self static str struct super trait true type u8 u16 u32 u64 u128 union unsafe use usize where
46
+ while f32 f64`),
47
+ },
48
+ python: {
49
+ line: "#", block: null, quotes: ["\"", "'"],
50
+ keywords: words(`and as assert async await break class continue def del elif else except False
51
+ finally for from global if import in is lambda None nonlocal not or pass raise return True try
52
+ while with yield self`),
53
+ },
54
+ go: {
55
+ line: "//", block: ["/*", "*/"], quotes: ["\"", "`", "'"], char: true,
56
+ keywords: words(`break case chan const continue default defer else fallthrough for func go goto
57
+ if import interface map package range return select struct switch type var bool byte error
58
+ false float32 float64 int int8 int16 int32 int64 nil rune string true uint uintptr`),
59
+ },
60
+ c: {
61
+ line: "//", block: ["/*", "*/"], quotes: ["\"", "'"],
62
+ keywords: words(`auto bool break case catch char class const constexpr continue default delete
63
+ do double else enum extern false final float for friend goto if inline int long namespace new
64
+ nullptr operator override private protected public register return short signed sizeof static
65
+ struct switch template this throw true try typedef typename union unsigned using virtual void
66
+ volatile while`),
67
+ },
68
+ json: { line: null, block: null, quotes: ["\""], keywords: words("true false null") },
69
+ };
70
+
71
+ const isIdentStart = (ch) => /[A-Za-z_$]/.test(ch);
72
+ const isIdent = (ch) => /[A-Za-z0-9_$]/.test(ch);
73
+ const isDigit = (ch) => ch >= "0" && ch <= "9";
74
+
75
+ /** File extension of a repo-relative path, lowercased, including the dot. */
76
+ function extname(path) {
77
+ const base = path.slice(path.lastIndexOf("/") + 1);
78
+ const dot = base.lastIndexOf(".");
79
+ return dot <= 0 ? "" : base.slice(dot).toLowerCase();
80
+ }
81
+
82
+ /** The language key for a path, or null when we have no lexer for it. */
83
+ export function langForPath(path) {
84
+ return LANG_BY_EXTENSION[extname(path)] ?? null;
85
+ }
86
+
87
+ /**
88
+ * Lex one line into `{ text, cls }` runs. `cls` is a token class ("kw", "str",
89
+ * "com", "num", "type", "fn") or null for plain text. `state` carries a pending
90
+ * block comment across lines; pass the returned `state` into the next line.
91
+ */
92
+ export function tokenize(text, lang, state) {
93
+ const spec = lang && LANGS[lang];
94
+ if (!spec) return { tokens: text === "" ? [] : [{ text, cls: null }], state: null };
95
+
96
+ const tokens = [];
97
+ let plain = "";
98
+ const flush = () => {
99
+ if (plain !== "") tokens.push({ text: plain, cls: null });
100
+ plain = "";
101
+ };
102
+ const push = (t, cls) => {
103
+ flush();
104
+ tokens.push({ text: t, cls });
105
+ };
106
+
107
+ let i = 0;
108
+ // Resume an open block comment from the previous line.
109
+ if (state && state.block && spec.block) {
110
+ const close = text.indexOf(spec.block[1]);
111
+ if (close === -1) {
112
+ return { tokens: [{ text, cls: "com" }], state: { block: true } };
113
+ }
114
+ push(text.slice(0, close + spec.block[1].length), "com");
115
+ i = close + spec.block[1].length;
116
+ }
117
+
118
+ while (i < text.length) {
119
+ const ch = text[i];
120
+ const rest = text.slice(i);
121
+
122
+ if (spec.line && rest.startsWith(spec.line)) {
123
+ push(rest, "com");
124
+ i = text.length;
125
+ break;
126
+ }
127
+ if (spec.block && rest.startsWith(spec.block[0])) {
128
+ const close = text.indexOf(spec.block[1], i + spec.block[0].length);
129
+ if (close === -1) {
130
+ push(rest, "com");
131
+ return { tokens, state: { block: true } };
132
+ }
133
+ const end = close + spec.block[1].length;
134
+ push(text.slice(i, end), "com");
135
+ i = end;
136
+ continue;
137
+ }
138
+ // Rust/Go char vs lifetime: only lex `'` as a string when it looks like a
139
+ // char literal; otherwise it is a lifetime/label and stays plain.
140
+ if (spec.char && ch === "'") {
141
+ const m = /^'(\\.|[^'\\])'/.exec(rest);
142
+ if (m) {
143
+ push(m[0], "str");
144
+ i += m[0].length;
145
+ continue;
146
+ }
147
+ plain += ch;
148
+ i++;
149
+ continue;
150
+ }
151
+ if (spec.quotes.includes(ch)) {
152
+ let j = i + 1;
153
+ while (j < text.length) {
154
+ if (text[j] === "\\") { j += 2; continue; }
155
+ if (text[j] === ch) { j++; break; }
156
+ j++;
157
+ }
158
+ push(text.slice(i, j), "str");
159
+ i = j;
160
+ continue;
161
+ }
162
+ if (isDigit(ch) || (ch === "." && isDigit(text[i + 1] ?? ""))) {
163
+ let j = i;
164
+ while (j < text.length && /[0-9a-fA-FxXoObB._]/.test(text[j])) j++;
165
+ push(text.slice(i, j), "num");
166
+ i = j;
167
+ continue;
168
+ }
169
+ if (isIdentStart(ch)) {
170
+ let j = i;
171
+ while (j < text.length && isIdent(text[j])) j++;
172
+ const word = text.slice(i, j);
173
+ let k = j;
174
+ while (k < text.length && (text[k] === " " || text[k] === "\t")) k++;
175
+ let cls = null;
176
+ if (spec.keywords.has(word)) cls = "kw";
177
+ else if (/^[A-Z]/.test(word)) cls = "type";
178
+ else if (text[k] === "(") cls = "fn";
179
+ push(word, cls);
180
+ i = j;
181
+ continue;
182
+ }
183
+ plain += ch;
184
+ i++;
185
+ }
186
+ flush();
187
+ return { tokens, state: null };
188
+ }
189
+
190
+ /** Append `tokenize` output into `cell`; classed runs become spans, plain text
191
+ * stays a text node so `white-space: pre` preserves it exactly. */
192
+ export function appendTokens(doc, cell, tokens) {
193
+ for (const { text, cls } of tokens) {
194
+ if (cls === null) cell.append(doc.createTextNode(text));
195
+ else {
196
+ const span = doc.createElement("span");
197
+ span.className = `tok-${cls}`;
198
+ span.textContent = text;
199
+ cell.append(span);
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,9 @@
1
+ // Types for the story-panel renderer, so the DOM smoke test type-checks
2
+ // without enabling allowJs for the whole UI. The story parameter is the
3
+ // payload of schemas/story.schema.json (`why blame --json`).
4
+
5
+ import type { BlameReport } from "../src/blame.ts";
6
+
7
+ export function glyphFor(type: string, status: string | undefined): "●" | "⚠" | "?";
8
+ export function formatTarget(target: BlameReport["target"]): string;
9
+ export function renderStoryPanel(doc: Document, story: BlameReport): HTMLElement;