@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.
- package/README.md +168 -0
- package/dist/anchor.d.ts +112 -0
- package/dist/anchor.js +697 -0
- package/dist/anchors.d.ts +101 -0
- package/dist/anchors.js +223 -0
- package/dist/audit.d.ts +120 -0
- package/dist/audit.js +483 -0
- package/dist/blame.d.ts +91 -0
- package/dist/blame.js +294 -0
- package/dist/bundle.d.ts +78 -0
- package/dist/bundle.js +188 -0
- package/dist/capture.d.ts +76 -0
- package/dist/capture.js +462 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +641 -0
- package/dist/dig-state.d.ts +46 -0
- package/dist/dig-state.js +146 -0
- package/dist/dig.d.ts +125 -0
- package/dist/dig.js +380 -0
- package/dist/discover.d.ts +11 -0
- package/dist/discover.js +47 -0
- package/dist/doctor.d.ts +135 -0
- package/dist/doctor.js +263 -0
- package/dist/evidence.d.ts +73 -0
- package/dist/evidence.js +425 -0
- package/dist/export.d.ts +67 -0
- package/dist/export.js +106 -0
- package/dist/find-symbol.d.ts +19 -0
- package/dist/find-symbol.js +267 -0
- package/dist/git.d.ts +17 -0
- package/dist/git.js +51 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +100 -0
- package/dist/lint.d.ts +40 -0
- package/dist/lint.js +161 -0
- package/dist/serve-assets.d.ts +10 -0
- package/dist/serve-assets.js +55 -0
- package/dist/serve.d.ts +53 -0
- package/dist/serve.js +226 -0
- package/dist/trace-range.d.ts +22 -0
- package/dist/trace-range.js +216 -0
- package/package.json +44 -0
- package/ui/app.js +323 -0
- package/ui/graph.js +164 -0
- package/ui/highlight.js +202 -0
- package/ui/story-panel.d.ts +9 -0
- package/ui/story-panel.js +125 -0
- package/ui/style.css +229 -0
package/dist/anchor.js
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
// `why anchor` (DESIGN.md §4): an anchor is a claim — "at commit as_of, this
|
|
2
|
+
// concept was about these lines" — and this module re-evaluates every claim
|
|
3
|
+
// against HEAD. Resolution order: symbol-first, then blame-trace, then either
|
|
4
|
+
// `unverified` (the path is live but as_of gives nothing to trace from) or
|
|
5
|
+
// honestly `lost`. Nothing here may emit a plausible-but-unverified span: no
|
|
6
|
+
// failure mode falls through to a guess.
|
|
7
|
+
//
|
|
8
|
+
// Reading history through `as_of` is gated on ancestry (`historyOrigin`),
|
|
9
|
+
// writing `as_of` prefers the newest *surviving* commit but only where the
|
|
10
|
+
// span verifiably holds (`stampFor`), and an orphaned `as_of` on a claim that
|
|
11
|
+
// re-verifies at HEAD without it is repaired to a durable commit
|
|
12
|
+
// (`repairOrphan`). All three exist because a squash merge discards the branch
|
|
13
|
+
// commit an anchor was verified at — see DESIGN.md §4 "as_of and squash
|
|
14
|
+
// merges".
|
|
15
|
+
//
|
|
16
|
+
// Writes go through okf-mcp's updateConcept so only the `why.anchors` entries
|
|
17
|
+
// change — narrative bodies and every other frontmatter key (timestamp
|
|
18
|
+
// included) are untouchable by machinery.
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { dirname } from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { updateConcept } from "@copperbox/okf-mcp";
|
|
23
|
+
import { anchorSpan, normalizePath, parseLineRange } from "./blame.js";
|
|
24
|
+
import { isPlainMap } from "./bundle.js";
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
/** An anchor run that must stop cleanly (exit 1), not crash. */
|
|
27
|
+
export class AnchorError extends Error {
|
|
28
|
+
}
|
|
29
|
+
// --- Git plumbing --------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* A read-only view of the repository at HEAD, with per-run caches. Anchor
|
|
32
|
+
* paths are repo-root-relative, so everything resolves from the toplevel.
|
|
33
|
+
* Exported for `why doctor`, whose as_of-freshness check is the same view.
|
|
34
|
+
*/
|
|
35
|
+
export class GitView {
|
|
36
|
+
root;
|
|
37
|
+
headFull;
|
|
38
|
+
headShort;
|
|
39
|
+
headContent = new Map();
|
|
40
|
+
renameMaps = new Map();
|
|
41
|
+
commitShas = new Map();
|
|
42
|
+
ancestry = new Map();
|
|
43
|
+
revContent = new Map();
|
|
44
|
+
survivingBaseCache;
|
|
45
|
+
constructor(root, headFull, headShort) {
|
|
46
|
+
this.root = root;
|
|
47
|
+
this.headFull = headFull;
|
|
48
|
+
this.headShort = headShort;
|
|
49
|
+
}
|
|
50
|
+
static async open(startDir) {
|
|
51
|
+
let root;
|
|
52
|
+
try {
|
|
53
|
+
root = (await run(startDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new AnchorError(`${startDir} is not inside a git work tree — anchors resolve against the repo enclosing the bundle`);
|
|
57
|
+
}
|
|
58
|
+
let headFull;
|
|
59
|
+
try {
|
|
60
|
+
headFull = (await run(root, ["rev-parse", "HEAD"])).trim();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new AnchorError(`${root} has no commits yet — nothing for anchors to resolve against`);
|
|
64
|
+
}
|
|
65
|
+
const headShort = (await run(root, ["rev-parse", "--short", "HEAD"])).trim();
|
|
66
|
+
return new GitView(root, headFull, headShort);
|
|
67
|
+
}
|
|
68
|
+
/** File content at HEAD, or undefined when the path does not exist there. */
|
|
69
|
+
async fileAtHead(path) {
|
|
70
|
+
if (!this.headContent.has(path)) {
|
|
71
|
+
let content;
|
|
72
|
+
try {
|
|
73
|
+
content = await run(this.root, ["show", `HEAD:${path}`]);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
content = undefined;
|
|
77
|
+
}
|
|
78
|
+
this.headContent.set(path, content);
|
|
79
|
+
}
|
|
80
|
+
return this.headContent.get(path);
|
|
81
|
+
}
|
|
82
|
+
/** Full sha of a commit-ish, or undefined when it does not resolve. */
|
|
83
|
+
async commitSha(rev) {
|
|
84
|
+
if (!this.commitShas.has(rev)) {
|
|
85
|
+
let sha;
|
|
86
|
+
try {
|
|
87
|
+
sha = (await run(this.root, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`])).trim();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
sha = undefined;
|
|
91
|
+
}
|
|
92
|
+
this.commitShas.set(rev, sha);
|
|
93
|
+
}
|
|
94
|
+
return this.commitShas.get(rev);
|
|
95
|
+
}
|
|
96
|
+
/** Whether `sha` is an ancestor of (or equal to) HEAD. */
|
|
97
|
+
async isAncestor(sha) {
|
|
98
|
+
if (!this.ancestry.has(sha)) {
|
|
99
|
+
this.ancestry.set(sha, await this.contains("HEAD", sha));
|
|
100
|
+
}
|
|
101
|
+
return this.ancestry.get(sha);
|
|
102
|
+
}
|
|
103
|
+
async contains(ref, sha) {
|
|
104
|
+
try {
|
|
105
|
+
await run(this.root, ["merge-base", "--is-ancestor", sha, ref]);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* An `as_of` usable as a history origin, or undefined. Usable means it both
|
|
114
|
+
* resolves *and* is an ancestor of HEAD. Ancestry is not pedantry: git will
|
|
115
|
+
* happily diff or blame between two commits that share no history, so an
|
|
116
|
+
* `as_of` a squash merge discarded still answers rename questions in a clone
|
|
117
|
+
* that kept the branch it lived on, and answers nothing in a clone that did
|
|
118
|
+
* not. Ancestry is the only property of an `as_of` that every clone of the
|
|
119
|
+
* same history agrees on, so it is the gate for reading history through one.
|
|
120
|
+
*/
|
|
121
|
+
async historyOrigin(asOf) {
|
|
122
|
+
if (asOf === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
const sha = await this.commitSha(asOf);
|
|
125
|
+
if (sha === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
return (await this.isAncestor(sha)) ? sha : undefined;
|
|
128
|
+
}
|
|
129
|
+
/** File content at an arbitrary commit, or undefined when absent there. */
|
|
130
|
+
async fileAt(rev, path) {
|
|
131
|
+
const key = `${rev}:${path}`;
|
|
132
|
+
if (!this.revContent.has(key)) {
|
|
133
|
+
let content;
|
|
134
|
+
try {
|
|
135
|
+
content = await run(this.root, ["show", `${rev}:${path}`]);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
content = undefined;
|
|
139
|
+
}
|
|
140
|
+
this.revContent.set(key, content);
|
|
141
|
+
}
|
|
142
|
+
return this.revContent.get(key);
|
|
143
|
+
}
|
|
144
|
+
async shortSha(rev) {
|
|
145
|
+
return (await run(this.root, ["rev-parse", "--short", rev])).trim();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The newest commit certain to outlive this branch: the merge-base with the
|
|
149
|
+
* integration branch git records as the remote's default
|
|
150
|
+
* (`refs/remotes/origin/HEAD`). Undefined when there is no such record —
|
|
151
|
+
* nothing to be certain about — or when HEAD is already contained in it, in
|
|
152
|
+
* which case HEAD survives and is the honest stamp. See `stampFor`.
|
|
153
|
+
*/
|
|
154
|
+
async survivingBase() {
|
|
155
|
+
if (this.survivingBaseCache === undefined) {
|
|
156
|
+
this.survivingBaseCache = { sha: await this.computeSurvivingBase() };
|
|
157
|
+
}
|
|
158
|
+
return this.survivingBaseCache.sha;
|
|
159
|
+
}
|
|
160
|
+
async computeSurvivingBase() {
|
|
161
|
+
let ref;
|
|
162
|
+
try {
|
|
163
|
+
ref = (await run(this.root, ["symbolic-ref", "refs/remotes/origin/HEAD"])).trim();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined; // no recorded integration branch — HEAD is all we know
|
|
167
|
+
}
|
|
168
|
+
if (await this.contains(ref, this.headFull))
|
|
169
|
+
return undefined; // HEAD survives as-is
|
|
170
|
+
try {
|
|
171
|
+
return (await run(this.root, ["merge-base", "HEAD", ref])).trim();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return undefined; // unrelated histories — nothing shared to fall back to
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Where git's rename/copy detection says `path` went between `asOf` and
|
|
179
|
+
* HEAD — the "git history connects them" gate for following a symbol into
|
|
180
|
+
* a different file (DESIGN.md §4 step 1).
|
|
181
|
+
*/
|
|
182
|
+
async renamedTo(asOf, path) {
|
|
183
|
+
if (!this.renameMaps.has(asOf)) {
|
|
184
|
+
let map;
|
|
185
|
+
try {
|
|
186
|
+
const out = await run(this.root, ["diff", "--name-status", "-M", "-C", asOf, "HEAD"]);
|
|
187
|
+
map = new Map();
|
|
188
|
+
for (const line of out.split("\n")) {
|
|
189
|
+
const [status, from, to] = line.split("\t");
|
|
190
|
+
if (status && from && to && (status.startsWith("R") || status.startsWith("C"))) {
|
|
191
|
+
map.set(from, to);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
map = undefined; // asOf unresolvable — no history to connect through
|
|
197
|
+
}
|
|
198
|
+
this.renameMaps.set(asOf, map);
|
|
199
|
+
}
|
|
200
|
+
return this.renameMaps.get(asOf)?.get(path);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Blame-trace (DESIGN.md §4 step 2): follow the anchored lines forward from
|
|
204
|
+
* `asOf` with reverse blame. Lines annotated with HEAD's sha still exist at
|
|
205
|
+
* HEAD — their positions (and file, since blame follows whole-file renames)
|
|
206
|
+
* are the new claim. No surviving lines → the trace honestly fails.
|
|
207
|
+
*/
|
|
208
|
+
async traceLines(path, range, asOf) {
|
|
209
|
+
let out;
|
|
210
|
+
try {
|
|
211
|
+
out = await run(this.root, [
|
|
212
|
+
"blame",
|
|
213
|
+
"--reverse",
|
|
214
|
+
"--porcelain",
|
|
215
|
+
`-L${range.start},${range.end}`,
|
|
216
|
+
`${asOf}..HEAD`,
|
|
217
|
+
"--",
|
|
218
|
+
path,
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return undefined; // bad range at asOf, unrelated history, etc.
|
|
223
|
+
}
|
|
224
|
+
const filenames = new Map();
|
|
225
|
+
const survivors = [];
|
|
226
|
+
let current;
|
|
227
|
+
for (const line of out.split("\n")) {
|
|
228
|
+
const header = /^([0-9a-f]{40}) (\d+) \d+(?: \d+)?$/.exec(line);
|
|
229
|
+
if (header) {
|
|
230
|
+
current = header[1];
|
|
231
|
+
if (current === this.headFull)
|
|
232
|
+
survivors.push(Number(header[2]));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (current !== undefined && line.startsWith("filename ")) {
|
|
236
|
+
filenames.set(current, line.slice("filename ".length));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (survivors.length === 0)
|
|
240
|
+
return undefined;
|
|
241
|
+
return {
|
|
242
|
+
path: filenames.get(this.headFull) ?? path,
|
|
243
|
+
lines: { start: Math.min(...survivors), end: Math.max(...survivors) },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function run(cwd, args) {
|
|
248
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
249
|
+
cwd,
|
|
250
|
+
encoding: "utf8",
|
|
251
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
252
|
+
});
|
|
253
|
+
return stdout;
|
|
254
|
+
}
|
|
255
|
+
// --- Symbol resolution (grep heuristic) ----------------------------------
|
|
256
|
+
//
|
|
257
|
+
// The fallback DESIGN.md §4 names until the tree-sitter resolver lands. It
|
|
258
|
+
// never guesses: zero matches or more than one definition-looking line both
|
|
259
|
+
// fail the symbol step, and resolution falls through to blame-trace.
|
|
260
|
+
const DEF_KEYWORDS = new Set([
|
|
261
|
+
"fn", "func", "function", "def", "class", "struct", "enum", "trait",
|
|
262
|
+
"interface", "impl", "type", "const", "static", "let", "var", "val",
|
|
263
|
+
]);
|
|
264
|
+
const COMMENT_LINE = /^\s*(?:\/\/|#|;|\*|--)/;
|
|
265
|
+
function isDefinitionLine(line, symbol) {
|
|
266
|
+
if (COMMENT_LINE.test(line))
|
|
267
|
+
return false;
|
|
268
|
+
const word = new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(symbol)}(?![A-Za-z0-9_$])`);
|
|
269
|
+
const at = line.search(word);
|
|
270
|
+
if (at < 0)
|
|
271
|
+
return false;
|
|
272
|
+
const before = line.slice(0, at);
|
|
273
|
+
if (before.split(/[^A-Za-z0-9_$]+/).some((token) => DEF_KEYWORDS.has(token)))
|
|
274
|
+
return true;
|
|
275
|
+
// Assignment/config form: the symbol opens the line and is immediately
|
|
276
|
+
// bound — `request_deadline = 47`, `acquire_shared:`, `handler(...)`.
|
|
277
|
+
return before.trim() === "" && /^\s*[:=(]/.test(line.slice(at + symbol.length));
|
|
278
|
+
}
|
|
279
|
+
function escapeRegExp(text) {
|
|
280
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
281
|
+
}
|
|
282
|
+
/** End of a `{}`-delimited block opened on the definition line or the next. */
|
|
283
|
+
function braceBlockEnd(lines, start) {
|
|
284
|
+
let depth = 0;
|
|
285
|
+
let opened = false;
|
|
286
|
+
for (let i = start; i < lines.length; i++) {
|
|
287
|
+
if (!opened && i > start + 1)
|
|
288
|
+
return undefined;
|
|
289
|
+
for (const ch of lines[i]) {
|
|
290
|
+
if (ch === "{") {
|
|
291
|
+
depth++;
|
|
292
|
+
opened = true;
|
|
293
|
+
}
|
|
294
|
+
else if (ch === "}" && opened) {
|
|
295
|
+
depth--;
|
|
296
|
+
if (depth === 0)
|
|
297
|
+
return i;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return undefined; // unbalanced — refuse to guess a span
|
|
302
|
+
}
|
|
303
|
+
/** Last line more indented than the definition (Python-style block). */
|
|
304
|
+
function indentBlockEnd(lines, start) {
|
|
305
|
+
const indent = indentOf(lines[start]);
|
|
306
|
+
let end = start;
|
|
307
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
308
|
+
if (lines[i].trim() === "")
|
|
309
|
+
continue;
|
|
310
|
+
if (indentOf(lines[i]) <= indent)
|
|
311
|
+
break;
|
|
312
|
+
end = i;
|
|
313
|
+
}
|
|
314
|
+
return end;
|
|
315
|
+
}
|
|
316
|
+
function indentOf(line) {
|
|
317
|
+
return line.length - line.trimStart().length;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The 1-based line span of `symbol`'s definition in `content`, or undefined
|
|
321
|
+
* when the symbol is absent or ambiguous. Exported for issue #10's torture
|
|
322
|
+
* harness and for the doctor's diagnostics.
|
|
323
|
+
*/
|
|
324
|
+
export function findSymbolSpan(content, symbol) {
|
|
325
|
+
const lines = content.split("\n");
|
|
326
|
+
const definitions = [];
|
|
327
|
+
for (let i = 0; i < lines.length; i++) {
|
|
328
|
+
if (isDefinitionLine(lines[i], symbol))
|
|
329
|
+
definitions.push(i);
|
|
330
|
+
}
|
|
331
|
+
if (definitions.length !== 1)
|
|
332
|
+
return undefined;
|
|
333
|
+
const start = definitions[0];
|
|
334
|
+
const end = braceBlockEnd(lines, start) ?? indentBlockEnd(lines, start);
|
|
335
|
+
return { start: start + 1, end: end + 1 };
|
|
336
|
+
}
|
|
337
|
+
/** DESIGN.md §4 resolution order for one anchor. */
|
|
338
|
+
async function resolveClaim(git, anchor) {
|
|
339
|
+
const path = normalizePath(anchor.path);
|
|
340
|
+
const range = anchor.lines === undefined ? undefined : parseLineRange(anchor.lines);
|
|
341
|
+
const origin = await git.historyOrigin(anchor.as_of);
|
|
342
|
+
// 1. Symbol-first: same file, then a git-connected rename target. A found
|
|
343
|
+
// symbol re-lines the claim; a whole-file anchor keeps its granularity.
|
|
344
|
+
if (anchor.symbol !== undefined) {
|
|
345
|
+
const found = await findSymbolAtHead(git, anchor, path, origin);
|
|
346
|
+
if (found)
|
|
347
|
+
return { kind: "span", span: found };
|
|
348
|
+
}
|
|
349
|
+
// Whole-file anchors (no lines) claim the file itself: existence is the
|
|
350
|
+
// whole claim — unless a symbol was set and is now gone, which step 1
|
|
351
|
+
// already failed to find, and asserting "live" would outrun the evidence.
|
|
352
|
+
if (anchor.lines === undefined) {
|
|
353
|
+
if (anchor.symbol !== undefined)
|
|
354
|
+
return { kind: "lost" };
|
|
355
|
+
if ((await git.fileAtHead(path)) !== undefined)
|
|
356
|
+
return { kind: "span", span: { path } };
|
|
357
|
+
if (origin !== undefined) {
|
|
358
|
+
const renamed = await git.renamedTo(origin, path);
|
|
359
|
+
if (renamed !== undefined && (await git.fileAtHead(renamed)) !== undefined) {
|
|
360
|
+
return { kind: "span", span: { path: renamed } };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { kind: "lost" };
|
|
364
|
+
}
|
|
365
|
+
// 2. Blame-trace the recorded lines from as_of forward.
|
|
366
|
+
if (range === undefined)
|
|
367
|
+
return { kind: "lost" };
|
|
368
|
+
if (origin === undefined) {
|
|
369
|
+
// No usable origin to trace from. The file still being at HEAD confirms
|
|
370
|
+
// the anchor is not lost; the lines stay exactly as recorded, unverified.
|
|
371
|
+
return (await git.fileAtHead(path)) !== undefined ? { kind: "unverified" } : { kind: "lost" };
|
|
372
|
+
}
|
|
373
|
+
if (origin === git.headFull) {
|
|
374
|
+
// The claim is already about HEAD; verify it instead of tracing.
|
|
375
|
+
const content = await git.fileAtHead(path);
|
|
376
|
+
if (content === undefined)
|
|
377
|
+
return { kind: "lost" };
|
|
378
|
+
return range.end <= content.split("\n").length
|
|
379
|
+
? { kind: "span", span: { path, lines: range } }
|
|
380
|
+
: { kind: "lost" };
|
|
381
|
+
}
|
|
382
|
+
const traced = await git.traceLines(path, range, origin);
|
|
383
|
+
return traced === undefined ? { kind: "lost" } : { kind: "span", span: traced };
|
|
384
|
+
}
|
|
385
|
+
async function findSymbolAtHead(git, anchor, path, origin) {
|
|
386
|
+
const candidates = [path];
|
|
387
|
+
if ((await git.fileAtHead(path)) === undefined && origin !== undefined) {
|
|
388
|
+
const renamed = await git.renamedTo(origin, path);
|
|
389
|
+
if (renamed !== undefined)
|
|
390
|
+
candidates.push(renamed);
|
|
391
|
+
}
|
|
392
|
+
for (const candidate of candidates) {
|
|
393
|
+
const content = await git.fileAtHead(candidate);
|
|
394
|
+
if (content === undefined)
|
|
395
|
+
continue;
|
|
396
|
+
const span = findSymbolSpan(content, anchor.symbol);
|
|
397
|
+
if (span) {
|
|
398
|
+
return anchor.lines === undefined ? { path: candidate } : { path: candidate, lines: span };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The commit to record as `as_of` for a re-anchored claim.
|
|
405
|
+
*
|
|
406
|
+
* HEAD is the commit the span was actually verified against, so HEAD is the
|
|
407
|
+
* default and is always truthful. But when HEAD sits on a branch that will not
|
|
408
|
+
* reach the integration branch verbatim — a squash merge rewrites the whole
|
|
409
|
+
* branch into one new commit — a truthful HEAD stamp is about to become an
|
|
410
|
+
* `as_of` that names nothing. So prefer the newest surviving commit, but only
|
|
411
|
+
* when the very same span verifiably holds there.
|
|
412
|
+
*
|
|
413
|
+
* The verification is the point, not a nicety. For a span this branch just
|
|
414
|
+
* changed, the merge-base is precisely where the span is *not* valid, and
|
|
415
|
+
* `git blame --reverse` reads `-L` against the `as_of` revision — so stamping
|
|
416
|
+
* an unverified merge-base would silently re-point the anchor at whatever text
|
|
417
|
+
* occupied those line numbers back then. That is the silently-wrong anchor
|
|
418
|
+
* this module exists to prevent, so an unverifiable merge-base loses to HEAD
|
|
419
|
+
* every time, orphan or not. `resolveClaim` degrades such an orphan to
|
|
420
|
+
* `unverified` rather than `lost`, and `why doctor` reports the gap.
|
|
421
|
+
*/
|
|
422
|
+
async function stampFor(git, span) {
|
|
423
|
+
const base = await git.survivingBase();
|
|
424
|
+
if (base === undefined)
|
|
425
|
+
return git.headShort;
|
|
426
|
+
return (await spanHoldsAt(git, base, span)) ? await git.shortSha(base) : git.headShort;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Whether `span` names the same code at `rev` that it names at HEAD.
|
|
430
|
+
*
|
|
431
|
+
* The question is identity, not resemblance. Comparing the text at those line
|
|
432
|
+
* numbers answers the wrong one: a span this branch shifted can land on numbers
|
|
433
|
+
* that coincidentally held byte-identical text at `rev` — a duplicated config
|
|
434
|
+
* block, boilerplate, a repeated import — and stamping `rev` then re-points the
|
|
435
|
+
* anchor at that other code. So ask git, with the same reverse blame the
|
|
436
|
+
* resolver uses: an `as_of` is honest exactly when re-resolving from it
|
|
437
|
+
* reproduces this span. That equivalence is also what keeps `why anchor`
|
|
438
|
+
* idempotent — a stamp it writes is one the next run re-derives, rather than
|
|
439
|
+
* one the next run re-traces to the wrong code and calls `lost`.
|
|
440
|
+
*
|
|
441
|
+
* A whole-file anchor claims its path, so the path existing at `rev` is the
|
|
442
|
+
* whole claim; there is no span to re-derive.
|
|
443
|
+
*/
|
|
444
|
+
async function spanHoldsAt(git, rev, span) {
|
|
445
|
+
const there = await git.fileAt(rev, span.path);
|
|
446
|
+
if (there === undefined)
|
|
447
|
+
return false;
|
|
448
|
+
if (span.lines === undefined)
|
|
449
|
+
return true;
|
|
450
|
+
const traced = await git.traceLines(span.path, span.lines, rev);
|
|
451
|
+
return (traced !== undefined &&
|
|
452
|
+
traced.path === span.path &&
|
|
453
|
+
traced.lines.start === span.lines.start &&
|
|
454
|
+
traced.lines.end === span.lines.end);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The repaired result for an otherwise-current anchor whose recorded `as_of`
|
|
458
|
+
* is orphaned, or undefined when there is nothing to repair — or nothing
|
|
459
|
+
* durable to repair it with.
|
|
460
|
+
*
|
|
461
|
+
* A readable `as_of` — a clean ancestor of HEAD — is provenance: it means the
|
|
462
|
+
* span survived unchanged since that commit, and it is never rewritten
|
|
463
|
+
* (.why/decisions/as-of-is-provenance.md). An orphaned `as_of` means nothing:
|
|
464
|
+
* no clone of the integration branch resolves it (typically a squash merge
|
|
465
|
+
* discarded the branch commit it names), and because the anchor still resolves
|
|
466
|
+
* cleanly, no re-anchoring would ever touch it — the one `why doctor` finding
|
|
467
|
+
* nothing could clear. Repair is honest exactly because a claim that reaches
|
|
468
|
+
* the `current` classification with an unusable origin was verified at HEAD
|
|
469
|
+
* without reading `as_of` at all: the path exists, or the symbol was re-found.
|
|
470
|
+
* (A bare line claim with an unusable origin is `unverified` and never gets
|
|
471
|
+
* here — re-stamping it would assert lines nothing verified.)
|
|
472
|
+
*
|
|
473
|
+
* The stamp must be durable, or the next squash recreates the orphan. Where
|
|
474
|
+
* `survivingBase` is undefined — HEAD is on the integration branch, or git
|
|
475
|
+
* records no integration branch and HEAD is all there is — HEAD is that stamp.
|
|
476
|
+
* On a branch that has diverged from the integration branch it is the
|
|
477
|
+
* merge-base, and only when the span verifiably holds there; otherwise repair
|
|
478
|
+
* declines and leaves the orphan to the post-merge run on the integration
|
|
479
|
+
* branch, rather than write a branch HEAD the squash will discard and
|
|
480
|
+
* re-orphan.
|
|
481
|
+
*/
|
|
482
|
+
async function repairOrphan(git, anchor, span) {
|
|
483
|
+
if (anchor.as_of === undefined)
|
|
484
|
+
return undefined; // nothing recorded, nothing to repair
|
|
485
|
+
if ((await git.historyOrigin(anchor.as_of)) !== undefined)
|
|
486
|
+
return undefined; // readable provenance — keep it
|
|
487
|
+
const base = await git.survivingBase();
|
|
488
|
+
if (base !== undefined && !(await spanHoldsAt(git, base, span)))
|
|
489
|
+
return undefined;
|
|
490
|
+
// Everything but the unreadable as_of was confirmed at HEAD, so everything
|
|
491
|
+
// but as_of is kept exactly as written.
|
|
492
|
+
const after = { ...anchor, as_of: base === undefined ? git.headShort : await git.shortSha(base) };
|
|
493
|
+
return { before: anchor, after, outcome: "repaired", changed: true, recovered: false };
|
|
494
|
+
}
|
|
495
|
+
function formatLines(range) {
|
|
496
|
+
return range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
|
|
497
|
+
}
|
|
498
|
+
function sameLines(before, after) {
|
|
499
|
+
if (before === undefined || after === undefined)
|
|
500
|
+
return before === undefined && after === undefined;
|
|
501
|
+
const parsed = parseLineRange(before);
|
|
502
|
+
return parsed !== undefined && parsed.start === after.start && parsed.end === after.end;
|
|
503
|
+
}
|
|
504
|
+
async function classify(git, anchor, claim) {
|
|
505
|
+
if (claim.kind === "lost") {
|
|
506
|
+
// Lost keeps every last-known value (forensics + the recovery path);
|
|
507
|
+
// only the state flips, and only once.
|
|
508
|
+
const after = { ...anchor, state: "lost" };
|
|
509
|
+
return { before: anchor, after, outcome: "lost", changed: anchor.state !== "lost", recovered: false };
|
|
510
|
+
}
|
|
511
|
+
if (claim.kind === "unverified") {
|
|
512
|
+
// The path is confirmed at HEAD but as_of gives nothing to trace from, so
|
|
513
|
+
// the recorded lines can be neither re-lined nor honestly rewritten. Leave
|
|
514
|
+
// the entry byte-for-byte as written and let `why doctor` name the gap.
|
|
515
|
+
return { before: anchor, after: anchor, outcome: "unverified", changed: false, recovered: false };
|
|
516
|
+
}
|
|
517
|
+
const span = claim.span;
|
|
518
|
+
const wasLive = (anchor.state ?? "live") === "live";
|
|
519
|
+
if (span.path === normalizePath(anchor.path) && sameLines(anchor.lines, span.lines) && wasLive) {
|
|
520
|
+
return ((await repairOrphan(git, anchor, span)) ?? {
|
|
521
|
+
before: anchor,
|
|
522
|
+
after: anchor,
|
|
523
|
+
outcome: "current",
|
|
524
|
+
changed: false,
|
|
525
|
+
recovered: false,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const after = { path: span.path };
|
|
529
|
+
if (anchor.symbol !== undefined)
|
|
530
|
+
after.symbol = anchor.symbol;
|
|
531
|
+
if (span.lines !== undefined)
|
|
532
|
+
after.lines = formatLines(span.lines);
|
|
533
|
+
after.as_of = await stampFor(git, span);
|
|
534
|
+
after.state = "live";
|
|
535
|
+
return {
|
|
536
|
+
before: anchor,
|
|
537
|
+
after,
|
|
538
|
+
outcome: span.path === normalizePath(anchor.path) ? "resolved" : "moved",
|
|
539
|
+
changed: true,
|
|
540
|
+
recovered: !wasLive,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function findConcept(bundle, idOrPath) {
|
|
544
|
+
const id = idOrPath.replace(/\.md$/, "");
|
|
545
|
+
return bundle.concepts.get(id) ?? [...bundle.concepts.values()].find((c) => c.path === idOrPath);
|
|
546
|
+
}
|
|
547
|
+
/** Re-evaluate every anchor claim in the bundle against HEAD. Pure read. */
|
|
548
|
+
export async function resolveAnchors(bundle, options = {}) {
|
|
549
|
+
const git = await GitView.open(dirname(bundle.root));
|
|
550
|
+
let concepts = [...bundle.concepts.values()];
|
|
551
|
+
if (options.concept !== undefined) {
|
|
552
|
+
const scoped = findConcept(bundle, options.concept);
|
|
553
|
+
if (scoped === undefined) {
|
|
554
|
+
throw new AnchorError(`no concept "${options.concept}" in the bundle at ${bundle.root}`);
|
|
555
|
+
}
|
|
556
|
+
concepts = [scoped];
|
|
557
|
+
}
|
|
558
|
+
// A concept whose anchor data has schema problems is never rewritten:
|
|
559
|
+
// regenerating its list from the typed view would silently drop the
|
|
560
|
+
// malformed entries. `why lint` owns reporting those.
|
|
561
|
+
const problematic = new Set(bundle.diagnostics.filter((d) => d.field.startsWith("why.anchors")).map((d) => d.path));
|
|
562
|
+
const results = [];
|
|
563
|
+
const skipped = [];
|
|
564
|
+
for (const concept of concepts) {
|
|
565
|
+
if (problematic.has(concept.path)) {
|
|
566
|
+
skipped.push(concept.id);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
for (const [index, anchor] of concept.why.anchors.entries()) {
|
|
570
|
+
const claim = await resolveClaim(git, anchor);
|
|
571
|
+
results.push({ conceptId: concept.id, index, ...(await classify(git, anchor, claim)) });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { head: git.headShort, results, skipped };
|
|
575
|
+
}
|
|
576
|
+
// --- Writes ----------------------------------------------------------------
|
|
577
|
+
/** Anchor entry in DESIGN.md's key order; single lines stay bare numbers. */
|
|
578
|
+
function anchorEntry(anchor) {
|
|
579
|
+
const entry = { path: anchor.path };
|
|
580
|
+
if (anchor.symbol !== undefined)
|
|
581
|
+
entry.symbol = anchor.symbol;
|
|
582
|
+
if (anchor.lines !== undefined)
|
|
583
|
+
entry.lines = /^\d+$/.test(anchor.lines) ? Number(anchor.lines) : anchor.lines;
|
|
584
|
+
if (anchor.as_of !== undefined)
|
|
585
|
+
entry.as_of = anchor.as_of;
|
|
586
|
+
if (anchor.state !== undefined)
|
|
587
|
+
entry.state = anchor.state;
|
|
588
|
+
return entry;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Persist the changed anchors through okf-mcp's updateConcept: a frontmatter
|
|
592
|
+
* patch of the `why` map with `keepTimestamp`, so the body and every other
|
|
593
|
+
* key survive byte-for-byte. Returns the concept ids written.
|
|
594
|
+
*/
|
|
595
|
+
export async function writeAnchorUpdates(bundle, report) {
|
|
596
|
+
const changedByConcept = new Map();
|
|
597
|
+
for (const result of report.results) {
|
|
598
|
+
if (!result.changed)
|
|
599
|
+
continue;
|
|
600
|
+
const group = changedByConcept.get(result.conceptId) ?? [];
|
|
601
|
+
group.push(result);
|
|
602
|
+
changedByConcept.set(result.conceptId, group);
|
|
603
|
+
}
|
|
604
|
+
const written = [];
|
|
605
|
+
for (const [conceptId, changes] of changedByConcept) {
|
|
606
|
+
const concept = bundle.concepts.get(conceptId);
|
|
607
|
+
const rawWhy = isPlainMap(concept.frontmatter.why) ? concept.frontmatter.why : {};
|
|
608
|
+
// Unchanged entries keep their raw form (indexes align 1:1 because
|
|
609
|
+
// concepts with anchor diagnostics were skipped during resolution).
|
|
610
|
+
const anchors = Array.isArray(rawWhy.anchors) ? [...rawWhy.anchors] : [];
|
|
611
|
+
for (const change of changes)
|
|
612
|
+
anchors[change.index] = anchorEntry(change.after);
|
|
613
|
+
await updateConcept(bundle.okf, conceptId, {
|
|
614
|
+
frontmatter: { why: { ...rawWhy, anchors } },
|
|
615
|
+
keepTimestamp: true,
|
|
616
|
+
});
|
|
617
|
+
written.push(conceptId);
|
|
618
|
+
}
|
|
619
|
+
return written;
|
|
620
|
+
}
|
|
621
|
+
// --- Rendering -------------------------------------------------------------
|
|
622
|
+
function detailFor(result) {
|
|
623
|
+
switch (result.outcome) {
|
|
624
|
+
case "current":
|
|
625
|
+
return anchorSpan(result.before);
|
|
626
|
+
case "lost":
|
|
627
|
+
return `${anchorSpan(result.before)} (last known${result.before.as_of === undefined ? "" : `, as_of ${result.before.as_of}`})`;
|
|
628
|
+
case "unverified":
|
|
629
|
+
return `${anchorSpan(result.before)} (path live; as_of ${result.before.as_of ?? "unset"} is not an ancestor of HEAD)`;
|
|
630
|
+
case "repaired":
|
|
631
|
+
return `${anchorSpan(result.before)} (orphaned as_of ${result.before.as_of} → ${result.after.as_of})`;
|
|
632
|
+
default:
|
|
633
|
+
return `${anchorSpan(result.before)} → ${anchorSpan(result.after)}`;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const OUTCOME_LABELS = {
|
|
637
|
+
current: "already current",
|
|
638
|
+
resolved: "resolved",
|
|
639
|
+
moved: "moved",
|
|
640
|
+
repaired: "repaired as_of",
|
|
641
|
+
unverified: "unverified as_of",
|
|
642
|
+
lost: "lost",
|
|
643
|
+
};
|
|
644
|
+
export function renderAnchorReport(report, mode) {
|
|
645
|
+
const lines = [];
|
|
646
|
+
const counts = { current: 0, resolved: 0, moved: 0, repaired: 0, unverified: 0, lost: 0 };
|
|
647
|
+
for (const result of report.results)
|
|
648
|
+
counts[result.outcome]++;
|
|
649
|
+
const total = report.results.length;
|
|
650
|
+
lines.push(`why anchor: ${total} anchor${total === 1 ? "" : "s"} checked against HEAD ${report.head}`);
|
|
651
|
+
if (total > 0) {
|
|
652
|
+
lines.push("");
|
|
653
|
+
const idWidth = Math.max(...report.results.map((r) => r.conceptId.length));
|
|
654
|
+
for (const result of report.results) {
|
|
655
|
+
const label = OUTCOME_LABELS[result.outcome] + (result.recovered ? " (recovered)" : "");
|
|
656
|
+
lines.push(` ${label.padEnd(21)} ${result.conceptId.padEnd(idWidth)} ${detailFor(result)}`);
|
|
657
|
+
}
|
|
658
|
+
lines.push("");
|
|
659
|
+
// Repaired and unverified surface only when non-zero: both are rare and
|
|
660
|
+
// worth reading, and a permanent `0` would train the eye past them.
|
|
661
|
+
const tally = [`${counts.moved} moved`, `${counts.resolved} resolved`];
|
|
662
|
+
if (counts.repaired > 0)
|
|
663
|
+
tally.push(`${counts.repaired} repaired as_of`);
|
|
664
|
+
tally.push(`${counts.lost} lost`);
|
|
665
|
+
if (counts.unverified > 0)
|
|
666
|
+
tally.push(`${counts.unverified} unverified as_of`);
|
|
667
|
+
tally.push(`${counts.current} already current`);
|
|
668
|
+
lines.push(tally.join(" · "));
|
|
669
|
+
}
|
|
670
|
+
for (const conceptId of report.skipped) {
|
|
671
|
+
lines.push(`skipped ${conceptId}: its why.anchors have schema problems — run \`why lint\``);
|
|
672
|
+
}
|
|
673
|
+
const changed = report.results.filter((r) => r.changed).length;
|
|
674
|
+
const destroyed = report.results.filter((r) => r.outcome === "lost" && r.changed).length;
|
|
675
|
+
if (mode.check && mode.allowDrift === true) {
|
|
676
|
+
// The PR gate's reading: this branch's HEAD is about to be squashed away,
|
|
677
|
+
// so telling the author to run `why anchor` here would stamp an as_of that
|
|
678
|
+
// names nothing (DESIGN.md §4). Drift is the why-anchor job's problem;
|
|
679
|
+
// only an anchor this change destroyed is the author's.
|
|
680
|
+
if (destroyed > 0) {
|
|
681
|
+
lines.push(`${destroyed} anchor${destroyed === 1 ? "" : "s"} lost — the code ${destroyed === 1 ? "it claims" : "they claim"} is gone. Update the concept${destroyed === 1 ? "" : "s"} or re-dig; no re-anchoring can recover ${destroyed === 1 ? "it" : "them"}.`);
|
|
682
|
+
}
|
|
683
|
+
const drifted = changed - destroyed;
|
|
684
|
+
if (drifted > 0) {
|
|
685
|
+
lines.push(`${drifted} anchor${drifted === 1 ? "" : "s"} drifted — the why-anchor job re-stamps ${drifted === 1 ? "it" : "them"} from main after the merge (nothing written)`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
else if (mode.check) {
|
|
689
|
+
if (changed > 0) {
|
|
690
|
+
lines.push(`${changed} anchor${changed === 1 ? "" : "s"} out of date — run \`why anchor\` to update (nothing written)`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else if (mode.written.length > 0) {
|
|
694
|
+
lines.push(`updated why.anchors in ${mode.written.length} concept${mode.written.length === 1 ? "" : "s"}`);
|
|
695
|
+
}
|
|
696
|
+
return lines;
|
|
697
|
+
}
|