@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,55 @@
1
+ // The `why serve` SPA assets: ui/ sources bundled by esbuild into one JS and
2
+ // one CSS artifact, held in memory and served from there — the okf-mcp
3
+ // `graph html` philosophy (embedded assets, zero CDN/network). Building when
4
+ // the server starts (rather than at package build) keeps the assets exactly
5
+ // in sync with ui/ whether the CLI runs from src/ via tsx or from dist/, and
6
+ // test/serve.test.ts pins the self-containment guarantee: no http(s)://
7
+ // reference may appear in any built asset.
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { build } from "esbuild";
11
+ /** The UI could not be bundled — operational, not a bug in the bundle. */
12
+ export class AssetError extends Error {
13
+ }
14
+ // src/ and dist/ both sit one level below the package root, so this resolves
15
+ // from either layout.
16
+ const UI_DIR = fileURLToPath(new URL("../ui/", import.meta.url));
17
+ /** The page shell; app.js builds the whole UI into #app. */
18
+ const PAGE = `<!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <title>why</title>
24
+ <link rel="stylesheet" href="app.css">
25
+ </head>
26
+ <body>
27
+ <div id="app"></div>
28
+ <script src="app.js" defer></script>
29
+ </body>
30
+ </html>
31
+ `;
32
+ /** Bundle ui/ into self-contained in-memory assets. */
33
+ export async function buildUiAssets() {
34
+ const result = await build({
35
+ entryPoints: [join(UI_DIR, "app.js"), join(UI_DIR, "style.css")],
36
+ bundle: true,
37
+ minify: true,
38
+ write: false,
39
+ format: "iife",
40
+ outdir: UI_DIR, // never written — write: false; only names the outputs
41
+ logLevel: "silent",
42
+ });
43
+ let js = "";
44
+ let css = "";
45
+ for (const file of result.outputFiles) {
46
+ if (file.path.endsWith(".js"))
47
+ js = file.text;
48
+ else if (file.path.endsWith(".css"))
49
+ css = file.text;
50
+ }
51
+ if (js === "" || css === "") {
52
+ throw new AssetError("esbuild produced no JS/CSS output for ui/ — the package's ui/ directory is missing or empty");
53
+ }
54
+ return { html: PAGE, js, css };
55
+ }
@@ -0,0 +1,53 @@
1
+ import { type Server } from "node:http";
2
+ /** A request or the server cannot be served honestly; carries the HTTP status. */
3
+ export declare class ServeError extends Error {
4
+ readonly httpStatus: number;
5
+ constructor(message: string, httpStatus?: number);
6
+ }
7
+ /** Major versions of the serve-only payloads — policy in docs/ui-contract.md. */
8
+ export declare const FILES_SCHEMA_VERSION = 1;
9
+ export declare const GITBLAME_SCHEMA_VERSION = 1;
10
+ /** `GET /api/files` (schemas/files.schema.json). */
11
+ export interface FilesReport {
12
+ schemaVersion: typeof FILES_SCHEMA_VERSION;
13
+ files: string[];
14
+ }
15
+ export interface GitBlameLine {
16
+ /** Full sha of the commit that last touched this line. */
17
+ sha: string;
18
+ author: string;
19
+ /** Author date, YYYY-MM-DD in the author's timezone. */
20
+ date: string;
21
+ /** First line of the commit message. */
22
+ summary: string;
23
+ /** The line's content at HEAD, without its newline. */
24
+ text: string;
25
+ }
26
+ /** `GET /api/blame?path=…` (schemas/gitblame.schema.json). */
27
+ export interface GitBlameReport {
28
+ schemaVersion: typeof GITBLAME_SCHEMA_VERSION;
29
+ path: string;
30
+ /** Full sha the blame ran at — the coverage payload carries the same. */
31
+ head: string;
32
+ lines: GitBlameLine[];
33
+ }
34
+ /** Every tracked file at HEAD. Untracked files have no story to show. */
35
+ export declare function buildFileList(repo: string): FilesReport;
36
+ /**
37
+ * `git blame --porcelain HEAD` for one file, parsed to one entry per line in
38
+ * file order. Blaming HEAD (not the working tree) keeps line numbers agreeing
39
+ * with the coverage payload computed at the same HEAD.
40
+ */
41
+ export declare function buildGitBlame(repo: string, path: string): GitBlameReport;
42
+ export interface ServeOptions {
43
+ /** Port to bind on 127.0.0.1; default 0 = a random free port. */
44
+ port?: number;
45
+ }
46
+ export interface RunningWhyServer {
47
+ server: Server;
48
+ port: number;
49
+ url: string;
50
+ close(): Promise<void>;
51
+ }
52
+ /** Start the UI server on 127.0.0.1. The caller owns printing the URL. */
53
+ export declare function startWhyServer(bundleRoot: string, options?: ServeOptions): Promise<RunningWhyServer>;
package/dist/serve.js ADDED
@@ -0,0 +1,226 @@
1
+ // `why serve` (issue 502): a localhost-only, read-only window onto the bundle
2
+ // — git blame and why blame side by side. The server is deliberately thin:
3
+ // every endpoint wraps the same library function the CLI uses (buildUiIndex,
4
+ // buildBlameReport, buildGraph, buildDoctorReport) and emits a payload from
5
+ // the UI data contract (docs/ui-contract.md); the SPA is a dumb renderer over
6
+ // those payloads. No endpoint mutates the bundle or the repo: the anchor
7
+ // index is loaded with `write: false`, git calls are read-only plumbing, and
8
+ // non-GET methods are refused outright.
9
+ import { createServer } from "node:http";
10
+ import { dirname } from "node:path";
11
+ import { loadAnchorIndex } from "./anchors.js";
12
+ import { BlameTargetError, buildBlameReport, normalizePath } from "./blame.js";
13
+ import { loadBundle } from "./bundle.js";
14
+ import { buildDoctorReport, buildDoctorSummary } from "./doctor.js";
15
+ import { buildGraph, buildUiIndex, ExportError } from "./export.js";
16
+ import { git } from "./git.js";
17
+ import { buildUiAssets } from "./serve-assets.js";
18
+ /** A request or the server cannot be served honestly; carries the HTTP status. */
19
+ export class ServeError extends Error {
20
+ httpStatus;
21
+ constructor(message, httpStatus = 500) {
22
+ super(message);
23
+ this.httpStatus = httpStatus;
24
+ }
25
+ }
26
+ /** Major versions of the serve-only payloads — policy in docs/ui-contract.md. */
27
+ export const FILES_SCHEMA_VERSION = 1;
28
+ export const GITBLAME_SCHEMA_VERSION = 1;
29
+ /** Every tracked file at HEAD. Untracked files have no story to show. */
30
+ export function buildFileList(repo) {
31
+ const result = git(repo, ["ls-files", "-z"]);
32
+ if (result.status !== 0) {
33
+ throw new ServeError(`git ls-files failed in ${repo}: ${result.stderr.trim()}`);
34
+ }
35
+ return {
36
+ schemaVersion: FILES_SCHEMA_VERSION,
37
+ files: result.stdout.split("\0").filter((path) => path !== ""),
38
+ };
39
+ }
40
+ /** Author epoch + `+HHMM`-style zone → the author's local calendar date. */
41
+ function isoDate(epochSeconds, tz) {
42
+ const match = /^([+-])(\d{2})(\d{2})$/.exec(tz);
43
+ const offset = match
44
+ ? (match[1] === "-" ? -1 : 1) * (Number(match[2]) * 3600 + Number(match[3]) * 60)
45
+ : 0;
46
+ return new Date((epochSeconds + offset) * 1000).toISOString().slice(0, 10);
47
+ }
48
+ /**
49
+ * `git blame --porcelain HEAD` for one file, parsed to one entry per line in
50
+ * file order. Blaming HEAD (not the working tree) keeps line numbers agreeing
51
+ * with the coverage payload computed at the same HEAD.
52
+ */
53
+ export function buildGitBlame(repo, path) {
54
+ const head = git(repo, ["rev-parse", "HEAD"]);
55
+ if (head.status !== 0) {
56
+ throw new ServeError("no resolvable HEAD — serve blame inside a git repository with at least one commit");
57
+ }
58
+ const blame = git(repo, ["blame", "--porcelain", "HEAD", "--", path]);
59
+ if (blame.status !== 0) {
60
+ throw new ServeError(`git blame HEAD -- ${path}: ${blame.stderr.trim()}`, 404);
61
+ }
62
+ // Porcelain interleaves commit headers, metadata (only on a commit's first
63
+ // appearance), and tab-prefixed content lines, already in file order.
64
+ const commits = new Map();
65
+ const lines = [];
66
+ let sha = "";
67
+ for (const raw of blame.stdout.split("\n")) {
68
+ if (raw.startsWith("\t")) {
69
+ const commit = commits.get(sha);
70
+ if (commit === undefined) {
71
+ throw new ServeError(`git blame emitted a content line before any commit header for ${path}`);
72
+ }
73
+ lines.push({
74
+ sha,
75
+ author: commit.author,
76
+ date: isoDate(commit.time, commit.tz),
77
+ summary: commit.summary,
78
+ text: raw.slice(1),
79
+ });
80
+ continue;
81
+ }
82
+ const header = /^([0-9a-f]{40,64}) \d+ \d+/.exec(raw);
83
+ if (header) {
84
+ sha = header[1];
85
+ if (!commits.has(sha))
86
+ commits.set(sha, { author: "", time: 0, tz: "", summary: "" });
87
+ continue;
88
+ }
89
+ const commit = commits.get(sha);
90
+ if (commit === undefined)
91
+ continue; // preamble before the first header
92
+ if (raw.startsWith("author "))
93
+ commit.author = raw.slice("author ".length);
94
+ else if (raw.startsWith("author-time "))
95
+ commit.time = Number(raw.slice("author-time ".length));
96
+ else if (raw.startsWith("author-tz "))
97
+ commit.tz = raw.slice("author-tz ".length);
98
+ else if (raw.startsWith("summary "))
99
+ commit.summary = raw.slice("summary ".length);
100
+ }
101
+ return { schemaVersion: GITBLAME_SCHEMA_VERSION, path, head: head.stdout.trim(), lines };
102
+ }
103
+ // --- HTTP server ---------------------------------------------------------------
104
+ /** Localhost only — remote access is out of scope by design, not by option. */
105
+ const HOST = "127.0.0.1";
106
+ function sendJson(res, status, payload) {
107
+ res.writeHead(status, {
108
+ "content-type": "application/json; charset=utf-8",
109
+ "cache-control": "no-store",
110
+ });
111
+ res.end(`${JSON.stringify(payload, null, 2)}\n`);
112
+ }
113
+ function sendAsset(res, contentType, body) {
114
+ res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" });
115
+ res.end(body);
116
+ }
117
+ function requiredParam(url, name) {
118
+ const value = url.searchParams.get(name);
119
+ if (value === null || value === "") {
120
+ throw new ServeError(`missing required query parameter "${name}"`, 400);
121
+ }
122
+ return value;
123
+ }
124
+ /** `path` (+ optional 1-based `start`/`end`) → the story target span. */
125
+ function storyTarget(url) {
126
+ const target = { path: normalizePath(requiredParam(url, "path")) };
127
+ const start = url.searchParams.get("start");
128
+ const end = url.searchParams.get("end") ?? start;
129
+ if (start === null)
130
+ return target;
131
+ const lines = { start: Number(start), end: Number(end) };
132
+ if (!Number.isInteger(lines.start) ||
133
+ !Number.isInteger(lines.end) ||
134
+ lines.start < 1 ||
135
+ lines.end < lines.start) {
136
+ throw new ServeError(`"start"/"end" must be a 1-based low-high line range, got ${start}-${end}`, 400);
137
+ }
138
+ target.lines = lines;
139
+ return target;
140
+ }
141
+ /**
142
+ * The JSON API. Bundle-backed endpoints reload the bundle per request, so a
143
+ * concept edit or a new HEAD shows on the next refresh instead of serving a
144
+ * silently stale story; `write: false` keeps the index read-only on disk.
145
+ */
146
+ async function apiPayload(bundleRoot, repo, url) {
147
+ switch (url.pathname) {
148
+ case "/api/files":
149
+ return buildFileList(repo);
150
+ case "/api/blame":
151
+ return buildGitBlame(repo, requiredParam(url, "path"));
152
+ case "/api/coverage": {
153
+ const bundle = await loadBundle(bundleRoot);
154
+ const { index } = await loadAnchorIndex(bundle, { write: false });
155
+ return buildUiIndex(bundle, index);
156
+ }
157
+ case "/api/story": {
158
+ const bundle = await loadBundle(bundleRoot);
159
+ const { index } = await loadAnchorIndex(bundle, { write: false });
160
+ return buildBlameReport(bundle, storyTarget(url), index);
161
+ }
162
+ case "/api/graph":
163
+ return buildGraph(await loadBundle(bundleRoot));
164
+ case "/api/doctor":
165
+ return buildDoctorSummary(await buildDoctorReport(await loadBundle(bundleRoot)));
166
+ default:
167
+ return undefined;
168
+ }
169
+ }
170
+ async function handle(bundleRoot, repo, assets, req, res) {
171
+ if (req.method !== "GET" && req.method !== "HEAD") {
172
+ sendJson(res, 405, { error: "read-only server — GET only" });
173
+ return;
174
+ }
175
+ const url = new URL(req.url ?? "/", `http://${HOST}`);
176
+ switch (url.pathname) {
177
+ case "/":
178
+ sendAsset(res, "text/html; charset=utf-8", assets.html);
179
+ return;
180
+ case "/app.js":
181
+ sendAsset(res, "text/javascript; charset=utf-8", assets.js);
182
+ return;
183
+ case "/app.css":
184
+ sendAsset(res, "text/css; charset=utf-8", assets.css);
185
+ return;
186
+ }
187
+ try {
188
+ const payload = await apiPayload(bundleRoot, repo, url);
189
+ if (payload === undefined) {
190
+ sendJson(res, 404, { error: `no such endpoint: ${url.pathname}` });
191
+ return;
192
+ }
193
+ sendJson(res, 200, payload);
194
+ }
195
+ catch (e) {
196
+ if (e instanceof ServeError)
197
+ sendJson(res, e.httpStatus, { error: e.message });
198
+ else if (e instanceof BlameTargetError)
199
+ sendJson(res, 400, { error: e.message });
200
+ else if (e instanceof ExportError)
201
+ sendJson(res, 500, { error: e.message });
202
+ else
203
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
204
+ }
205
+ }
206
+ /** Start the UI server on 127.0.0.1. The caller owns printing the URL. */
207
+ export async function startWhyServer(bundleRoot, options = {}) {
208
+ const repo = dirname(bundleRoot);
209
+ const assets = await buildUiAssets();
210
+ const server = createServer((req, res) => {
211
+ handle(bundleRoot, repo, assets, req, res).catch(() => res.destroy());
212
+ });
213
+ await new Promise((resolve, reject) => {
214
+ server.once("error", (e) => reject(new ServeError(`cannot bind ${HOST}:${options.port ?? 0} — ${e.message}`)));
215
+ server.listen(options.port ?? 0, HOST, resolve);
216
+ });
217
+ const { port } = server.address();
218
+ return {
219
+ server,
220
+ port,
221
+ url: `http://${HOST}:${port}/`,
222
+ close: () => new Promise((resolve, reject) => {
223
+ server.close((e) => (e ? reject(e) : resolve()));
224
+ }),
225
+ };
226
+ }
@@ -0,0 +1,22 @@
1
+ export interface LineRange {
2
+ /** 1-based, inclusive. */
3
+ start: number;
4
+ end: number;
5
+ }
6
+ export interface RangeAnchor {
7
+ /** Repo-relative POSIX path, valid at `asOf`. */
8
+ path: string;
9
+ lines: LineRange;
10
+ /** Commit-ish at which `path` + `lines` were valid. Must be an ancestor of HEAD. */
11
+ asOf: string;
12
+ }
13
+ export type LostReason = "file-deleted" | "content-rewritten";
14
+ export type TraceResult = {
15
+ lost: false;
16
+ path: string;
17
+ lines: LineRange;
18
+ } | {
19
+ lost: true;
20
+ reason: LostReason;
21
+ };
22
+ export declare function traceRange(repo: string, anchor: RangeAnchor): TraceResult;
@@ -0,0 +1,216 @@
1
+ // Blame-trace resolver (DESIGN.md §4, resolution order step 2).
2
+ //
3
+ // Given an anchor claim {path, lines, as_of} — "at commit as_of, this concept
4
+ // was about these lines" — compute where those lines live at HEAD, or prove
5
+ // they are gone. Pure library function: no bundle knowledge, read-only git
6
+ // commands only.
7
+ //
8
+ // Approach: `git log -L` cannot express this query (it interprets both the
9
+ // range and the path at the *newest* revision, not at as_of, so it mistraces
10
+ // or drops the anchor). Instead we walk a parent→child commit chain from as_of to
11
+ // HEAD (see commitChain) and apply each step's zero-context diff hunks to the
12
+ // tracked range:
13
+ //
14
+ // - hunks entirely above the range shift it;
15
+ // - hunks overlapping the range kill the overlapped lines;
16
+ // - renames (git diff --find-renames) update the tracked path;
17
+ // - a delete of the tracked path is `lost: "file-deleted"`;
18
+ // - all lines killed is `lost: "content-rewritten"`.
19
+ //
20
+ // Shrink policy for partially-edited ranges: every anchored line is tracked
21
+ // individually; the result is the *bounding span* of the lines that survived
22
+ // (first surviving line .. last surviving line at HEAD). Interior edits and
23
+ // insertions therefore widen or preserve the span rather than splitting the
24
+ // anchor — an anchor on a function whose body was partially edited should
25
+ // still cover the function. Lines at the edges only survive-or-die; the span
26
+ // never includes unrelated code above or below the original claim.
27
+ //
28
+ // Never-silently-wrong guarantee: before returning a live result, every
29
+ // surviving line's content at HEAD is compared byte-for-byte with its content
30
+ // at as_of. Any line that fails verification is dropped; if none survive the
31
+ // check, the result is `lost: "content-rewritten"`. A wrong range cannot be
32
+ // emitted by hunk-math bugs alone — it would have to survive this check too.
33
+ import { gitOrThrow, resolveAsOfCommit, showLines } from "./git.js";
34
+ /** How `path` changed between `from` and `to`, or null if untouched. */
35
+ function fileChange(repo, from, to, path) {
36
+ const out = gitOrThrow(repo, ["diff", "--name-status", "--find-renames", "-z", from, to]);
37
+ const tokens = out.split("\0");
38
+ let i = 0;
39
+ while (i < tokens.length && tokens[i] !== "") {
40
+ const status = tokens[i++];
41
+ const kind = status[0];
42
+ if (kind === "R" || kind === "C") {
43
+ const oldPath = tokens[i++];
44
+ const newPath = tokens[i++];
45
+ if (kind === "R" && oldPath === path)
46
+ return { status: "R", newPath };
47
+ }
48
+ else {
49
+ const p = tokens[i++];
50
+ if (p === path && (kind === "M" || kind === "D" || kind === "T")) {
51
+ return { status: kind, newPath: null };
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * Zero-context hunks for `path` (old side) between `from` and `to`.
59
+ * The diff is pathspec-limited to the old and new names, then the section
60
+ * whose old path matches is selected — pathspec alone could match another
61
+ * file that was renamed onto one of these names in the same commit.
62
+ */
63
+ function hunksFor(repo, from, to, oldPath, newPath) {
64
+ const paths = oldPath === newPath ? [oldPath] : [oldPath, newPath];
65
+ const out = gitOrThrow(repo, [
66
+ "diff",
67
+ "--unified=0",
68
+ "--find-renames",
69
+ from,
70
+ to,
71
+ "--",
72
+ ...paths,
73
+ ]);
74
+ const sections = out.split(/^(?=diff --git )/m);
75
+ for (const section of sections) {
76
+ const fromRename = section.match(/^rename from (.*)$/m)?.[1];
77
+ const fromMinus = section.match(/^--- a\/(.*)$/m)?.[1];
78
+ if ((fromRename ?? fromMinus) !== oldPath)
79
+ continue;
80
+ const hunks = [];
81
+ const re = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/gm;
82
+ let m;
83
+ while ((m = re.exec(section)) !== null) {
84
+ hunks.push({
85
+ oldStart: Number(m[1]),
86
+ oldCount: m[2] === undefined ? 1 : Number(m[2]),
87
+ newCount: m[3] === undefined ? 1 : Number(m[3]),
88
+ });
89
+ }
90
+ return hunks;
91
+ }
92
+ return [];
93
+ }
94
+ /**
95
+ * Apply one commit-step's hunks to the tracked lines. All hunk positions
96
+ * refer to the pre-step file, so deltas are computed against each line's
97
+ * pre-step `cur` and applied at once.
98
+ */
99
+ function applyHunks(lines, hunks) {
100
+ const survivors = [];
101
+ for (const line of lines) {
102
+ let delta = 0;
103
+ let killed = false;
104
+ for (const h of hunks) {
105
+ if (h.oldCount === 0) {
106
+ // Pure insertion after old line `oldStart` (0 = top of file).
107
+ if (h.oldStart < line.cur)
108
+ delta += h.newCount;
109
+ continue;
110
+ }
111
+ const oldEnd = h.oldStart + h.oldCount - 1;
112
+ if (line.cur >= h.oldStart && line.cur <= oldEnd) {
113
+ killed = true;
114
+ break;
115
+ }
116
+ if (line.cur > oldEnd)
117
+ delta += h.newCount - h.oldCount;
118
+ }
119
+ if (!killed)
120
+ survivors.push({ orig: line.orig, cur: line.cur + delta });
121
+ }
122
+ return survivors;
123
+ }
124
+ /**
125
+ * Parent→child commit chain from asOf (exclusive) to HEAD (inclusive), each
126
+ * element a child of the one before it, preferring first parents.
127
+ *
128
+ * `rev-list --first-parent asOf..HEAD` is not usable here: it lists HEAD's
129
+ * first-parent chain, so when asOf is reachable from HEAD only through a
130
+ * merge's *second* parent (anchor created on a feature branch, branch later
131
+ * merged — the normal workflow), consecutive listed commits are not parent
132
+ * and child of each other, and diffing them reads branch divergence as edits:
133
+ * a false `lost` verdict for content that is alive at HEAD. Instead, walk
134
+ * backwards from HEAD through the ancestry-path graph, descending at each
135
+ * merge into the first parent that still leads to asOf.
136
+ */
137
+ function commitChain(repo, asOfSha) {
138
+ const out = gitOrThrow(repo, ["rev-list", "--ancestry-path", "--parents", `${asOfSha}..HEAD`]);
139
+ // Commits that are both descendants of asOf and ancestors of HEAD, each
140
+ // mapped to its parents (which may lie outside that set).
141
+ const parentsOf = new Map();
142
+ for (const line of out.split("\n")) {
143
+ if (line === "")
144
+ continue;
145
+ const [sha, ...parents] = line.split(" ");
146
+ parentsOf.set(sha, parents);
147
+ }
148
+ if (parentsOf.size === 0)
149
+ return []; // asOf is HEAD itself
150
+ const chain = [];
151
+ let cur = gitOrThrow(repo, ["rev-parse", "HEAD"]).trim();
152
+ while (cur !== asOfSha) {
153
+ chain.push(cur);
154
+ const next = parentsOf.get(cur)?.find((p) => p === asOfSha || parentsOf.has(p));
155
+ if (next === undefined) {
156
+ // Unreachable: resolveAsOfCommit guarantees asOf is an ancestor of
157
+ // HEAD, so every commit on an ancestry path has a parent on one.
158
+ throw new Error(`no parent chain from HEAD back to as_of ${asOfSha}`);
159
+ }
160
+ cur = next;
161
+ }
162
+ return chain.reverse();
163
+ }
164
+ export function traceRange(repo, anchor) {
165
+ const { path, lines, asOf } = anchor;
166
+ if (!Number.isInteger(lines.start) ||
167
+ !Number.isInteger(lines.end) ||
168
+ lines.start < 1 ||
169
+ lines.end < lines.start) {
170
+ throw new Error(`invalid line range ${lines.start}-${lines.end} (need 1 <= start <= end)`);
171
+ }
172
+ const asOfSha = resolveAsOfCommit(repo, asOf);
173
+ const origLines = showLines(repo, asOfSha, path);
174
+ if (origLines === null) {
175
+ throw new Error(`path "${path}" not found at as_of ${asOf}`);
176
+ }
177
+ if (lines.end > origLines.length) {
178
+ throw new Error(`range ${lines.start}-${lines.end} out of bounds: "${path}" has ${origLines.length} lines at as_of ${asOf}`);
179
+ }
180
+ let tracked = [];
181
+ for (let n = lines.start; n <= lines.end; n++)
182
+ tracked.push({ orig: n, cur: n });
183
+ let curPath = path;
184
+ const commits = commitChain(repo, asOfSha);
185
+ let prev = asOfSha;
186
+ for (const commit of commits) {
187
+ const change = fileChange(repo, prev, commit, curPath);
188
+ if (change !== null) {
189
+ if (change.status === "D")
190
+ return { lost: true, reason: "file-deleted" };
191
+ // Typechange (file became a symlink/submodule): the lines no longer
192
+ // exist as text content.
193
+ if (change.status === "T")
194
+ return { lost: true, reason: "content-rewritten" };
195
+ const newPath = change.newPath ?? curPath;
196
+ tracked = applyHunks(tracked, hunksFor(repo, prev, commit, curPath, newPath));
197
+ curPath = newPath;
198
+ if (tracked.length === 0)
199
+ return { lost: true, reason: "content-rewritten" };
200
+ }
201
+ prev = commit;
202
+ }
203
+ // Verification pass: the never-silently-wrong backstop. Only lines whose
204
+ // HEAD content matches their as_of content count as survivors.
205
+ const headLines = showLines(repo, "HEAD", curPath);
206
+ if (headLines === null)
207
+ return { lost: true, reason: "file-deleted" };
208
+ const verified = tracked.filter((l) => l.cur >= 1 && l.cur <= headLines.length && headLines[l.cur - 1] === origLines[l.orig - 1]);
209
+ if (verified.length === 0)
210
+ return { lost: true, reason: "content-rewritten" };
211
+ return {
212
+ lost: false,
213
+ path: curPath,
214
+ lines: { start: verified[0].cur, end: verified[verified.length - 1].cur },
215
+ };
216
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@copperbox/why",
3
+ "version": "1.0.0",
4
+ "description": "Decision archaeology for codebases — recover, anchor, and audit the why behind code.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "why": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "ui"
13
+ ],
14
+ "scripts": {
15
+ "why": "tsx src/cli.ts",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "tsx --test test/*.test.ts",
18
+ "test:torture": "tsx --test test/torture/torture.test.ts",
19
+ "test:e2e": "tsx --test test/e2e/*.e2e.test.ts",
20
+ "test:vscode": "tsx --test vscode-why/test/*.test.ts",
21
+ "verify": "npm run typecheck && npm run test && npm run test:vscode",
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "sandcastle": "tsx .sandcastle/main.mts",
24
+ "sandcastle:loop": "scripts/sandcastle-loop.sh",
25
+ "sandcastle:gate": "tsx .sandcastle/gatekeeper.mts",
26
+ "sandcastle:auto": "scripts/autonomous-loop.sh"
27
+ },
28
+ "devDependencies": {
29
+ "@copperbox/sandcastle-workflow": "^0.4.2",
30
+ "@types/jsdom": "^28.0.3",
31
+ "@types/node": "^22.10.0",
32
+ "ajv": "^8.20.0",
33
+ "jsdom": "^29.1.1",
34
+ "tsx": "^4.19.0",
35
+ "typescript": "^5.7.0",
36
+ "yaml": "^2.9.0"
37
+ },
38
+ "dependencies": {
39
+ "@copperbox/okf-mcp": "^0.19.1",
40
+ "esbuild": "0.28.1",
41
+ "tree-sitter-wasms": "0.1.13",
42
+ "web-tree-sitter": "0.24.7"
43
+ }
44
+ }