@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
@@ -0,0 +1,425 @@
1
+ // Evidence-pack assembly (DESIGN.md §6 step 2): turn one episode from
2
+ // `why dig --episodes` into a single markdown document holding everything a
3
+ // reconstruction agent may cite — full commit messages, PR and issue threads
4
+ // via `gh`, per-file-clipped diffs, and local exported context — so the agent
5
+ // never fetches on its own. Packs are deterministic (no timestamps, stable
6
+ // ordering, stable formatting) because they get cached and diffed. Anything
7
+ // unreachable degrades to an explicit `[unavailable: …]` marker and anything
8
+ // dropped for size to a `[clipped: …]` marker — never a silent gap.
9
+ import { spawnSync } from "node:child_process";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { basename, join } from "node:path";
12
+ export class EvidenceError extends Error {
13
+ }
14
+ /** Default total pack budget (~50k tokens): bounded context for one agent run. */
15
+ export const DEFAULT_MAX_CHARS = 200_000;
16
+ /** Default cap on any single file's diff (or local evidence file) inside a pack. */
17
+ export const DEFAULT_PER_FILE_CHARS = 8_000;
18
+ export const runCommand = (cmd, args, cwd) => {
19
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
20
+ if (r.error) {
21
+ if (r.error.code === "ENOENT") {
22
+ return { status: 127, stdout: "", stderr: `${cmd}: command not found` };
23
+ }
24
+ throw new Error(`failed to run ${cmd}: ${r.error.message}`);
25
+ }
26
+ return { status: r.status ?? 1, stdout: r.stdout, stderr: r.stderr };
27
+ };
28
+ /**
29
+ * Parse `why dig --episodes` output tolerantly: a bare array or an
30
+ * `{episodes: [...]}` wrapper; commits as objects (`sha` required) or bare
31
+ * sha strings; files as strings or `{path}` objects with churn fields;
32
+ * PR/issue references as numbers or digit strings. Anything that cannot be
33
+ * an episode is an EvidenceError, never a silently-empty pack.
34
+ */
35
+ export function readEpisodes(raw, source) {
36
+ let json;
37
+ try {
38
+ json = JSON.parse(raw);
39
+ }
40
+ catch (e) {
41
+ throw new EvidenceError(`${source}: not valid JSON: ${e instanceof Error ? e.message : e}`);
42
+ }
43
+ let list;
44
+ if (Array.isArray(json)) {
45
+ list = json;
46
+ }
47
+ else if (isRecord(json) && Array.isArray(json.episodes)) {
48
+ list = json.episodes;
49
+ }
50
+ else {
51
+ throw new EvidenceError(`${source}: expected an array of episodes or {"episodes": [...]}`);
52
+ }
53
+ return list.map((entry, i) => readEpisode(entry, `${source}: episode ${i}`));
54
+ }
55
+ function readEpisode(entry, where) {
56
+ if (!isRecord(entry))
57
+ throw new EvidenceError(`${where}: not an object`);
58
+ const commits = readCommits(entry.commits, where);
59
+ let id;
60
+ if (typeof entry.id === "string" && entry.id !== "") {
61
+ id = entry.id;
62
+ }
63
+ else if (typeof entry.id === "number") {
64
+ id = String(entry.id);
65
+ }
66
+ else {
67
+ id = commits[0].sha.slice(0, 7);
68
+ }
69
+ return {
70
+ id,
71
+ commits,
72
+ files: readFiles(entry.files, where),
73
+ prs: readRefs(entry.prs, `${where}: prs`),
74
+ issues: readRefs(entry.issues, `${where}: issues`),
75
+ };
76
+ }
77
+ function readCommits(value, where) {
78
+ if (!Array.isArray(value) || value.length === 0) {
79
+ throw new EvidenceError(`${where}: an episode needs a non-empty "commits" array`);
80
+ }
81
+ return value.map((c, i) => {
82
+ if (typeof c === "string" && c !== "")
83
+ return { sha: c };
84
+ if (isRecord(c) && typeof c.sha === "string" && c.sha !== "") {
85
+ return typeof c.message === "string" ? { sha: c.sha, message: c.message } : { sha: c.sha };
86
+ }
87
+ throw new EvidenceError(`${where}: commit ${i} has no sha`);
88
+ });
89
+ }
90
+ function readFiles(value, where) {
91
+ if (value === undefined)
92
+ return [];
93
+ if (!Array.isArray(value))
94
+ throw new EvidenceError(`${where}: "files" must be an array`);
95
+ const paths = value.map((f, i) => {
96
+ if (typeof f === "string" && f !== "")
97
+ return f;
98
+ if (isRecord(f) && typeof f.path === "string" && f.path !== "")
99
+ return f.path;
100
+ throw new EvidenceError(`${where}: file ${i} has no path`);
101
+ });
102
+ return [...new Set(paths)].sort();
103
+ }
104
+ function readRefs(value, where) {
105
+ if (value === undefined)
106
+ return [];
107
+ if (!Array.isArray(value))
108
+ throw new EvidenceError(`${where} must be an array of numbers`);
109
+ const refs = value.map((n) => {
110
+ if (typeof n === "number" && Number.isInteger(n) && n > 0)
111
+ return n;
112
+ if (typeof n === "string" && /^[0-9]+$/.test(n))
113
+ return Number(n);
114
+ throw new EvidenceError(`${where}: "${n}" is not a PR/issue number`);
115
+ });
116
+ return [...new Set(refs)].sort((a, b) => a - b);
117
+ }
118
+ function isRecord(v) {
119
+ return typeof v === "object" && v !== null && !Array.isArray(v);
120
+ }
121
+ const FENCE = "````";
122
+ export async function buildEvidencePack(episode, options) {
123
+ const runner = options.runner ?? runCommand;
124
+ const maxChars = options.maxChars ?? DEFAULT_MAX_CHARS;
125
+ const perFile = options.perFileChars ?? DEFAULT_PER_FILE_CHARS;
126
+ const { repo } = options;
127
+ const clipped = [];
128
+ const unavailable = [];
129
+ const note = (marker) => {
130
+ unavailable.push(marker);
131
+ return `[unavailable: ${marker}]`;
132
+ };
133
+ const blocks = [];
134
+ blocks.push({ text: renderHeader(episode), desc: "pack header" });
135
+ // Commits: the full message from git is the ground truth; the episode's
136
+ // copy is only a fallback when the sha is gone from this clone.
137
+ episode.commits.forEach((commit, i) => {
138
+ const sha7 = commit.sha.slice(0, 7);
139
+ const shown = runner("git", ["show", "-s", "--format=%an <%ae>%n%as%n%B", commit.sha], repo);
140
+ let body;
141
+ if (shown.status === 0) {
142
+ const [author, date, ...message] = shown.stdout.split("\n");
143
+ body = [`- author: ${author}`, `- date: ${date}`, "", message.join("\n").trimEnd()].join("\n");
144
+ }
145
+ else {
146
+ body = note(`commit ${commit.sha} — not found in this repository`);
147
+ if (commit.message !== undefined) {
148
+ body += `\n\nMessage as recorded by --episodes:\n\n${commit.message.trimEnd()}`;
149
+ }
150
+ }
151
+ blocks.push({
152
+ text: `${i === 0 ? "## Commits\n\n" : ""}### commit ${sha7}\n\n${body}\n`,
153
+ desc: `commit ${sha7}`,
154
+ });
155
+ });
156
+ // Remote things: one explicit degradation reason covers every fetch. With
157
+ // no remote there is nothing gh could resolve, so it is never invoked.
158
+ const hasRefs = episode.prs.length > 0 || episode.issues.length > 0;
159
+ const remote = hasRefs ? runner("git", ["remote", "get-url", "origin"], repo) : undefined;
160
+ const noRemote = remote !== undefined && remote.status !== 0 ? "no git remote configured" : undefined;
161
+ episode.prs.forEach((n, i) => {
162
+ const heading = i === 0 ? "## Pull requests\n\n" : "";
163
+ const body = noRemote
164
+ ? note(`PR #${n} — ${noRemote}`)
165
+ : renderThread(runner, repo, "pr", n, note);
166
+ blocks.push({ text: `${heading}${body}\n`, desc: `PR #${n}` });
167
+ });
168
+ episode.issues.forEach((n, i) => {
169
+ const heading = i === 0 ? "## Issues\n\n" : "";
170
+ const body = noRemote
171
+ ? note(`issue #${n} — ${noRemote}`)
172
+ : renderThread(runner, repo, "issue", n, note);
173
+ blocks.push({ text: `${heading}${body}\n`, desc: `issue #${n}` });
174
+ });
175
+ if (options.evidenceDir !== undefined) {
176
+ const locals = await collectLocalEvidence(episode, options.evidenceDir, perFile, clipped);
177
+ if (locals.length === 0) {
178
+ blocks.push({
179
+ text: `## Local evidence\n\nNo files in ${options.evidenceDir} matched this episode's PR/issue numbers or touched paths.\n`,
180
+ desc: "local evidence",
181
+ });
182
+ }
183
+ else {
184
+ locals.forEach((text, i) => {
185
+ blocks.push({ text: `${i === 0 ? "## Local evidence\n\n" : ""}${text}`, desc: "local evidence" });
186
+ });
187
+ }
188
+ }
189
+ // Diffs go last: the bulkiest, least rationale-dense evidence — under
190
+ // budget pressure prose survives and code is what gets clipped.
191
+ let sawDiff = false;
192
+ for (const commit of episode.commits) {
193
+ const sha7 = commit.sha.slice(0, 7);
194
+ // -m --first-parent: a merge commit diffs against its first parent (the
195
+ // PR's net effect); plain commits are unaffected.
196
+ const shown = runner("git", ["show", "-m", "--first-parent", "--format=", "--patch", "--no-color", commit.sha], repo);
197
+ if (shown.status !== 0)
198
+ continue; // the commit block above already carries the unavailable marker
199
+ let firstOfCommit = true;
200
+ for (const fileDiff of splitPerFile(shown.stdout)) {
201
+ const heading = `${sawDiff ? "" : "## Diffs\n\n"}${firstOfCommit ? `### diff of commit ${sha7}\n\n` : ""}`;
202
+ sawDiff = true;
203
+ firstOfCommit = false;
204
+ const body = clipText(fileDiff.text, perFile, `diff of ${fileDiff.path} in ${sha7}`, clipped);
205
+ blocks.push({
206
+ text: `${heading}${FENCE}diff\n${body}\n${FENCE}\n`,
207
+ desc: `diff of ${fileDiff.path} in ${sha7}`,
208
+ });
209
+ }
210
+ }
211
+ const markdown = assemble(blocks, maxChars, clipped);
212
+ return { episodeId: episode.id, markdown, clipped, unavailable };
213
+ }
214
+ function renderHeader(episode) {
215
+ const lines = [
216
+ `# Evidence pack: ${episode.id}`,
217
+ "",
218
+ `- commits: ${episode.commits.map((c) => c.sha.slice(0, 7)).join(", ")}`,
219
+ ];
220
+ if (episode.files.length > 0)
221
+ lines.push(`- files touched: ${episode.files.join(", ")}`);
222
+ const refs = [
223
+ ...episode.prs.map((n) => `PR #${n}`),
224
+ ...episode.issues.map((n) => `issue #${n}`),
225
+ ];
226
+ if (refs.length > 0)
227
+ lines.push(`- references: ${refs.join(", ")}`);
228
+ return `${lines.join("\n")}\n`;
229
+ }
230
+ function renderThread(runner, repo, kind, n, note) {
231
+ const label = kind === "pr" ? `PR #${n}` : `issue #${n}`;
232
+ const fields = kind === "pr" ? "title,body,author,comments,reviews" : "title,body,author,comments";
233
+ const r = runner("gh", [kind, "view", String(n), "--json", fields], repo);
234
+ if (r.status === 127)
235
+ return note(`${label} — gh is not installed or not on PATH`);
236
+ if (r.status !== 0) {
237
+ const reason = r.stderr.split("\n").find((l) => l.trim() !== "")?.trim() ?? `gh exited ${r.status}`;
238
+ return note(`${label} — gh ${kind} view failed: ${reason}`);
239
+ }
240
+ let data;
241
+ try {
242
+ const parsed = JSON.parse(r.stdout);
243
+ if (!isRecord(parsed))
244
+ throw new Error("not an object");
245
+ data = parsed;
246
+ }
247
+ catch {
248
+ return note(`${label} — gh ${kind} view returned unparseable output`);
249
+ }
250
+ const title = typeof data.title === "string" ? data.title : "(no title)";
251
+ const author = isRecord(data.author) && typeof data.author.login === "string" ? data.author.login : "unknown";
252
+ const body = typeof data.body === "string" && data.body.trim() !== "" ? data.body.trim() : "(no description)";
253
+ const lines = [`### ${kind === "pr" ? "PR" : "Issue"} #${n} — ${title}`, "", `by @${author}`, "", body];
254
+ const comments = asComments(data.comments);
255
+ if (comments.length > 0) {
256
+ lines.push("", "Comments:", "");
257
+ for (const c of comments)
258
+ lines.push(renderComment(c));
259
+ }
260
+ if (kind === "pr") {
261
+ // gh's `reviews` carry the review-thread verdicts and their top-level
262
+ // bodies; bodyless "COMMENTED" entries are noise and are dropped.
263
+ const reviews = asComments(data.reviews).filter((review) => (review.body ?? "").trim() !== "" ||
264
+ (review.state !== undefined && review.state !== "COMMENTED"));
265
+ if (reviews.length > 0) {
266
+ lines.push("", "Reviews:", "");
267
+ for (const c of reviews)
268
+ lines.push(renderComment(c));
269
+ }
270
+ }
271
+ return `${lines.join("\n")}\n`;
272
+ }
273
+ /** The comment/review shape `gh` returns, tolerantly filtered. */
274
+ export function asComments(value) {
275
+ return Array.isArray(value) ? value.filter(isRecord) : [];
276
+ }
277
+ function renderComment(c) {
278
+ const login = c.author?.login ?? "unknown";
279
+ const date = (c.createdAt ?? c.submittedAt ?? "").slice(0, 10);
280
+ const state = c.state !== undefined && c.state !== "COMMENTED" ? ` [${c.state.toLowerCase()}]` : "";
281
+ const body = (c.body ?? "").trim() || "(no comment)";
282
+ const indented = body.split("\n").map((l) => ` ${l}`).join("\n");
283
+ return `- **${login}**${state}${date === "" ? "" : ` (${date})`}:\n${indented}`;
284
+ }
285
+ // --- Local evidence (--evidence-dir) -----------------------------------------
286
+ /**
287
+ * A file matches an episode when its *name* mentions one of the episode's
288
+ * PR/issue numbers (digit-bounded, so `notes-30.md` never matches #3) or a
289
+ * touched path's basename — with extension, or the bare stem when it is at
290
+ * least 3 characters (so a stem like `db` can't match everything).
291
+ */
292
+ export function matchEvidenceFile(name, episode) {
293
+ const mentionsNumber = (n) => new RegExp(`(?<![0-9])${n}(?![0-9])`).test(name);
294
+ const reasons = [];
295
+ for (const n of episode.prs) {
296
+ if (mentionsNumber(n))
297
+ reasons.push(`PR #${n}`);
298
+ }
299
+ for (const n of episode.issues) {
300
+ if (mentionsNumber(n))
301
+ reasons.push(`issue #${n}`);
302
+ }
303
+ const lower = name.toLowerCase();
304
+ for (const path of episode.files) {
305
+ const base = basename(path).toLowerCase();
306
+ const stem = base.replace(/\.[^.]+$/, "");
307
+ if (lower.includes(base) || (stem.length >= 3 && lower.includes(stem))) {
308
+ reasons.push(`path ${path}`);
309
+ }
310
+ }
311
+ return reasons;
312
+ }
313
+ async function collectLocalEvidence(episode, dir, perFile, clipped) {
314
+ const rels = [];
315
+ try {
316
+ await listFiles(dir, "", rels);
317
+ }
318
+ catch (e) {
319
+ throw new EvidenceError(`--evidence-dir ${dir}: ${e instanceof Error ? e.message : e}`);
320
+ }
321
+ rels.sort();
322
+ const out = [];
323
+ for (const rel of rels) {
324
+ const reasons = matchEvidenceFile(basename(rel), episode);
325
+ if (reasons.length === 0)
326
+ continue;
327
+ const provenance = `[source: ${join(dir, rel)} — matched ${reasons.join(", ")}]`;
328
+ const raw = await readFile(join(dir, rel));
329
+ const text = raw.toString("utf8");
330
+ const body = text.includes("\u0000")
331
+ ? `[unavailable: ${rel} — not UTF-8 text, content omitted]`
332
+ : clipText(text.trimEnd(), perFile, `local evidence ${rel}`, clipped);
333
+ out.push(`### ${rel}\n\n${provenance}\n\n${body}\n`);
334
+ }
335
+ return out;
336
+ }
337
+ async function listFiles(dir, prefix, out) {
338
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
339
+ if (entry.name.startsWith("."))
340
+ continue;
341
+ const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
342
+ if (entry.isDirectory())
343
+ await listFiles(join(dir, entry.name), rel, out);
344
+ else
345
+ out.push(rel);
346
+ }
347
+ }
348
+ function splitPerFile(patch) {
349
+ const out = [];
350
+ const lines = patch.split("\n");
351
+ let current;
352
+ let path = "unknown";
353
+ // `current` always starts with its `diff --git` line, so defined means non-empty.
354
+ const flush = () => {
355
+ if (current !== undefined) {
356
+ out.push({ path, text: current.join("\n").trimEnd() });
357
+ }
358
+ };
359
+ for (const line of lines) {
360
+ const m = /^diff --git a\/.* b\/(.*)$/.exec(line);
361
+ if (m !== null) {
362
+ flush();
363
+ current = [line];
364
+ path = m[1];
365
+ }
366
+ else if (current !== undefined) {
367
+ current.push(line);
368
+ }
369
+ }
370
+ flush();
371
+ return out;
372
+ }
373
+ // --- Budgets -----------------------------------------------------------------
374
+ /** Truncate one file-sized text at `max`, recording the clip both ways. */
375
+ function clipText(text, max, desc, clipped) {
376
+ if (text.length <= max)
377
+ return text;
378
+ clipped.push(`${desc}: showing ${max} of ${text.length} chars`);
379
+ return `${text.slice(0, max)}\n[clipped: ${desc} — showing ${max} of ${text.length} chars]`;
380
+ }
381
+ /**
382
+ * Join blocks under the total budget. The first block that would overflow is
383
+ * truncated in place with a marker (re-closing an open diff fence so the
384
+ * document stays well-formed); everything after it collapses into one final
385
+ * omission marker. Only the markers themselves may exceed `maxChars`.
386
+ */
387
+ function assemble(blocks, maxChars, clipped) {
388
+ const parts = [];
389
+ let used = 0;
390
+ let exhausted = false;
391
+ const omitted = [];
392
+ for (const block of blocks) {
393
+ const text = block.text.endsWith("\n") ? `${block.text}\n` : `${block.text}\n\n`;
394
+ if (exhausted) {
395
+ omitted.push(block.desc);
396
+ continue;
397
+ }
398
+ if (used + text.length <= maxChars) {
399
+ parts.push(text);
400
+ used += text.length;
401
+ continue;
402
+ }
403
+ exhausted = true;
404
+ const marker = `[clipped: --max-chars ${maxChars} reached — ${block.desc} truncated]`;
405
+ const keep = maxChars - used - marker.length - 2;
406
+ if (keep <= 0) {
407
+ omitted.push(block.desc);
408
+ continue;
409
+ }
410
+ let kept = text.slice(0, keep).trimEnd();
411
+ const fences = kept.split(FENCE).length - 1;
412
+ if (fences % 2 === 1)
413
+ kept += `\n${FENCE}`;
414
+ parts.push(`${kept}\n${marker}\n\n`);
415
+ clipped.push(`${block.desc} truncated at --max-chars ${maxChars}`);
416
+ }
417
+ if (omitted.length > 0) {
418
+ const shown = omitted.slice(0, 8);
419
+ const more = omitted.length - shown.length;
420
+ const list = shown.join("; ") + (more > 0 ? `; and ${more} more` : "");
421
+ parts.push(`[clipped: --max-chars ${maxChars} reached — omitted: ${list}]\n`);
422
+ clipped.push(`omitted ${omitted.length} block(s) at --max-chars ${maxChars}: ${list}`);
423
+ }
424
+ return parts.join("").trimEnd().concat("\n");
425
+ }
@@ -0,0 +1,67 @@
1
+ import { type AnchorIndex, type LineRange } from "./anchors.js";
2
+ import { type Glyph } from "./blame.js";
3
+ import type { Confidence, WhyBundle } from "./bundle.js";
4
+ /** An export cannot make its honesty guarantees — operational, not a bug. */
5
+ export declare class ExportError extends Error {
6
+ }
7
+ /** Major versions of the payloads — see docs/ui-contract.md for the policy. */
8
+ export declare const COVERAGE_SCHEMA_VERSION = 1;
9
+ export declare const GRAPH_SCHEMA_VERSION = 1;
10
+ export declare const EXPORT_TARGETS: readonly ["ui-index", "graph"];
11
+ export type ExportTarget = (typeof EXPORT_TARGETS)[number];
12
+ /** One live anchor claim, resolved to the concept it paints the span for. */
13
+ export interface CoverageSpan {
14
+ conceptId: string;
15
+ type: string;
16
+ status?: string;
17
+ confidence?: Confidence;
18
+ /** Status glyph — same vocabulary `why blame` renders (docs/ui-contract.md). */
19
+ glyph: Glyph;
20
+ /** Absent = a whole-file claim. */
21
+ lines?: LineRange;
22
+ }
23
+ export interface CoverageFile {
24
+ path: string;
25
+ /** Whole-file claims first, then ranged spans by start — overlaps possible. */
26
+ spans: CoverageSpan[];
27
+ }
28
+ export interface CoverageReport {
29
+ schemaVersion: typeof COVERAGE_SCHEMA_VERSION;
30
+ /** The HEAD the anchor index was computed at — compare to detect staleness. */
31
+ head: string;
32
+ files: CoverageFile[];
33
+ }
34
+ /**
35
+ * The per-file coverage map, from the anchor index at HEAD. Only live,
36
+ * parseable claims paint spans: `lost` anchors are last-known locations, not
37
+ * live claims, and an anchor whose `lines` value does not parse cannot
38
+ * verifiably cover any span (`why lint` W103 reports it) — both are excluded
39
+ * rather than guessed at.
40
+ */
41
+ export declare function buildUiIndex(bundle: WhyBundle, index: AnchorIndex): CoverageReport;
42
+ export type GraphRelation = "becauseOf" | "insteadOf" | "supersededBy" | "ledTo";
43
+ export interface GraphNode {
44
+ id: string;
45
+ type: string;
46
+ title: string;
47
+ status?: string;
48
+ confidence?: Confidence;
49
+ happened_on?: string;
50
+ }
51
+ export interface GraphEdge {
52
+ from: string;
53
+ to: string;
54
+ relation: GraphRelation;
55
+ }
56
+ export interface GraphReport {
57
+ schemaVersion: typeof GRAPH_SCHEMA_VERSION;
58
+ nodes: GraphNode[];
59
+ edges: GraphEdge[];
60
+ }
61
+ /**
62
+ * The bundle as nodes and typed edges, suitable for direct rendering. Only
63
+ * links in the four §3 edge sections become edges, and only when they resolve
64
+ * to a concept in the bundle — an unresolved edge link is `why lint`'s
65
+ * broken-link finding, not a renderable edge.
66
+ */
67
+ export declare function buildGraph(bundle: WhyBundle): GraphReport;
package/dist/export.js ADDED
@@ -0,0 +1,106 @@
1
+ // `why export` — the UI data contract payloads (docs/ui-contract.md): the
2
+ // per-file coverage map (`ui-index`) and the bundle graph, both versioned so
3
+ // dumb renderers can consume them without re-deriving semantics. The story
4
+ // payload is `why blame --json` (src/blame.ts); this module owns the other
5
+ // two. Schemas live in schemas/ and are validated against real outputs in
6
+ // test/ui-contract.test.ts.
7
+ import { deriveTitle } from "@copperbox/okf-mcp";
8
+ import { indexedConcept } from "./anchors.js";
9
+ import { glyphFor } from "./blame.js";
10
+ /** An export cannot make its honesty guarantees — operational, not a bug. */
11
+ export class ExportError extends Error {
12
+ }
13
+ /** Major versions of the payloads — see docs/ui-contract.md for the policy. */
14
+ export const COVERAGE_SCHEMA_VERSION = 1;
15
+ export const GRAPH_SCHEMA_VERSION = 1;
16
+ export const EXPORT_TARGETS = ["ui-index", "graph"];
17
+ function spanOrder(a, b) {
18
+ return ((a.lines?.start ?? 0) - (b.lines?.start ?? 0) ||
19
+ (a.lines?.end ?? 0) - (b.lines?.end ?? 0) ||
20
+ a.conceptId.localeCompare(b.conceptId));
21
+ }
22
+ /**
23
+ * The per-file coverage map, from the anchor index at HEAD. Only live,
24
+ * parseable claims paint spans: `lost` anchors are last-known locations, not
25
+ * live claims, and an anchor whose `lines` value does not parse cannot
26
+ * verifiably cover any span (`why lint` W103 reports it) — both are excluded
27
+ * rather than guessed at.
28
+ */
29
+ export function buildUiIndex(bundle, index) {
30
+ const head = index.head;
31
+ if (head === undefined) {
32
+ throw new ExportError("ui-index carries the repo HEAD so consumers can detect staleness, and no HEAD resolved here — run inside a git repository with at least one commit");
33
+ }
34
+ const files = [];
35
+ for (const [path, bucket] of index.paths) {
36
+ const spans = [];
37
+ for (const entry of [...bucket.wholeFile, ...bucket.intervals]) {
38
+ const concept = indexedConcept(bundle, entry.conceptId);
39
+ const span = {
40
+ conceptId: entry.conceptId,
41
+ type: concept.frontmatter.type,
42
+ glyph: glyphFor(concept.frontmatter.type, concept.why.status),
43
+ };
44
+ if (concept.why.status !== undefined)
45
+ span.status = concept.why.status;
46
+ if (concept.why.confidence !== undefined)
47
+ span.confidence = concept.why.confidence;
48
+ if (entry.range)
49
+ span.lines = { start: entry.range.start, end: entry.range.end };
50
+ spans.push(span);
51
+ }
52
+ if (spans.length === 0)
53
+ continue; // only lost/unparseable claims on this path
54
+ spans.sort(spanOrder);
55
+ files.push({ path, spans });
56
+ }
57
+ files.sort((a, b) => a.path.localeCompare(b.path));
58
+ return { schemaVersion: COVERAGE_SCHEMA_VERSION, head, files };
59
+ }
60
+ /** Edge-section heading → contract relation name (DESIGN.md §3). */
61
+ const GRAPH_RELATIONS = new Map([
62
+ ["because of", "becauseOf"],
63
+ ["instead of", "insteadOf"],
64
+ ["superseded by", "supersededBy"],
65
+ ["led to", "ledTo"],
66
+ ]);
67
+ /**
68
+ * The bundle as nodes and typed edges, suitable for direct rendering. Only
69
+ * links in the four §3 edge sections become edges, and only when they resolve
70
+ * to a concept in the bundle — an unresolved edge link is `why lint`'s
71
+ * broken-link finding, not a renderable edge.
72
+ */
73
+ export function buildGraph(bundle) {
74
+ const nodes = [...bundle.concepts.values()]
75
+ .map((concept) => {
76
+ const node = {
77
+ id: concept.id,
78
+ type: concept.frontmatter.type,
79
+ title: deriveTitle(concept),
80
+ };
81
+ if (concept.why.status !== undefined)
82
+ node.status = concept.why.status;
83
+ if (concept.why.confidence !== undefined)
84
+ node.confidence = concept.why.confidence;
85
+ if (concept.why.happened_on !== undefined)
86
+ node.happened_on = concept.why.happened_on;
87
+ return node;
88
+ })
89
+ .sort((a, b) => a.id.localeCompare(b.id));
90
+ const edges = [];
91
+ const seen = new Set();
92
+ for (const concept of bundle.concepts.values()) {
93
+ for (const link of concept.links) {
94
+ const relation = link.section === undefined ? undefined : GRAPH_RELATIONS.get(link.section.toLowerCase());
95
+ if (relation === undefined || link.resolvedId === undefined)
96
+ continue;
97
+ const key = `${concept.id}\u0000${relation}\u0000${link.resolvedId}`;
98
+ if (seen.has(key))
99
+ continue; // the same link written twice is one edge
100
+ seen.add(key);
101
+ edges.push({ from: concept.id, to: link.resolvedId, relation });
102
+ }
103
+ }
104
+ edges.sort((a, b) => a.from.localeCompare(b.from) || a.relation.localeCompare(b.relation) || a.to.localeCompare(b.to));
105
+ return { schemaVersion: GRAPH_SCHEMA_VERSION, nodes, edges };
106
+ }
@@ -0,0 +1,19 @@
1
+ import type { LineRange } from "./trace-range.js";
2
+ export interface SymbolAnchor {
3
+ /** Repo-relative POSIX path, valid at `asOf`. */
4
+ path: string;
5
+ symbol: string;
6
+ /** Commit-ish at which `path` held the symbol. Must be an ancestor of HEAD. */
7
+ asOf: string;
8
+ }
9
+ export type SymbolConfidence = "syntactic" | "heuristic";
10
+ export type FindSymbolResult = {
11
+ found: true;
12
+ path: string;
13
+ lines: LineRange;
14
+ confidence: SymbolConfidence;
15
+ } | {
16
+ found: false;
17
+ reason: "not-found" | "ambiguous";
18
+ };
19
+ export declare function findSymbol(repo: string, anchor: SymbolAnchor): Promise<FindSymbolResult>;