@davesheffer/hunch 1.41.6 → 1.43.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 +5 -1
- package/dist/cli/index.js +489 -167
- package/dist/constitution/experiment.d.ts +3 -3
- package/dist/constitution/g3.d.ts +1 -1
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/footprint.d.ts +15 -0
- package/dist/core/footprint.js +167 -0
- package/dist/core/groundingLag.d.ts +15 -0
- package/dist/core/groundingLag.js +27 -0
- package/dist/core/hookText.d.ts +4 -0
- package/dist/core/hookText.js +8 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/pipeline.d.ts +28 -0
- package/dist/core/pipeline.js +50 -0
- package/dist/core/publication.js +17 -3
- package/dist/core/shellwrites.d.ts +7 -0
- package/dist/core/shellwrites.js +131 -0
- package/dist/core/siblingfix.d.ts +124 -0
- package/dist/core/siblingfix.js +814 -0
- package/dist/core/taskReportHook.d.ts +1 -1
- package/dist/core/taskReportHook.js +20 -4
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/extractors/git.d.ts +31 -0
- package/dist/extractors/git.js +180 -1
- package/dist/extractors/nativeTreeSitter.d.ts +2 -1
- package/dist/extractors/nativeTreeSitter.js +128 -30
- package/dist/integrations/claudemd.d.ts +20 -2
- package/dist/integrations/claudemd.js +79 -53
- package/dist/integrations/providers.d.ts +9 -5
- package/dist/integrations/providers.js +36 -23
- package/dist/integrations/scaffold.js +2 -1
- package/dist/integrations/team.d.ts +24 -4
- package/dist/integrations/team.js +154 -16
- package/dist/integrations/worktree.d.ts +3 -2
- package/dist/integrations/worktree.js +7 -4
- package/dist/mcp/server.d.ts +489 -0
- package/dist/mcp/server.js +208 -62
- package/dist/mcp/taskReportTools.js +12 -9
- package/dist/mcp/toolset.d.ts +11 -1
- package/dist/mcp/toolset.js +28 -8
- package/dist/store/hunchStore.d.ts +25 -1
- package/dist/store/hunchStore.js +146 -21
- package/dist/store/jsonStore.js +25 -4
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
/** Sibling-fix lessons: carry a fix made to one function over to the
|
|
2
|
+
* same-shaped function it was never applied to.
|
|
3
|
+
*
|
|
4
|
+
* A constraint states the rule ("merge idempotently, never clobber"); the
|
|
5
|
+
* concrete lesson lives in the commit that hardened ONE copy of a pattern. When
|
|
6
|
+
* a near-identical function elsewhere never received that change, an agent
|
|
7
|
+
* extending it inherits the old flaw while believing the rule is satisfied.
|
|
8
|
+
*
|
|
9
|
+
* Deterministic, no records required — git history is the evidence:
|
|
10
|
+
* 1. parse the target file (tree-sitter) and pair each function with indexed
|
|
11
|
+
* functions whose NAME tokens and BODY tokens overlap (Jaccard),
|
|
12
|
+
* 2. `git log -L` both functions; commits that substantively changed the
|
|
13
|
+
* sibling but never touched the target are the divergence,
|
|
14
|
+
* 3. surface a pair only when at least one divergent commit is a fix, with
|
|
15
|
+
* the sibling's current body — its code and comments carry the lesson.
|
|
16
|
+
*
|
|
17
|
+
* Bounded like cochange.ts: few pairs, one wall-clock budget for all git calls,
|
|
18
|
+
* a content-keyed cache under .hunch-cache (a commit that touches neither the
|
|
19
|
+
* file nor its candidate siblings, and renames no function, keeps the answer;
|
|
20
|
+
* an incomplete run is cached as no answer), and any failure yields no lessons — never an error. */
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { writeFileAtomic } from "./io.js";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { parseSource } from "../extractors/parse.js";
|
|
27
|
+
import { languageFor } from "../extractors/languages.js";
|
|
28
|
+
const FN_KINDS = new Set(["function", "method"]);
|
|
29
|
+
const TEST_PATH = /(^|\/)(test|tests|__tests__|spec|fixtures?)\/|\.(test|spec)\.[a-z]+$/i;
|
|
30
|
+
const MIN_NAME_JACCARD = 0.5;
|
|
31
|
+
const MIN_BODY_OVERLAP = 0.5;
|
|
32
|
+
/** Mean of name and body similarity. Measured on this repo's own history:
|
|
33
|
+
* below 0.6 the pairs were mostly unrelated helpers sharing a verb. */
|
|
34
|
+
const MIN_SIMILARITY = 0.6;
|
|
35
|
+
const MIN_BODY_LINES = 3;
|
|
36
|
+
/** Containment is shared / SMALLER set, so a small body is "contained" in any
|
|
37
|
+
* larger one that uses its words: a 6-line console.log renderer (26 tokens)
|
|
38
|
+
* scored 0.7 against the 53-line planner whose output it prints (59 tokens).
|
|
39
|
+
* So both sides need some code of their own (the smallest real copies in this
|
|
40
|
+
* repo, duplicated gitEnv helpers, have 8 tokens), and the pair must be of
|
|
41
|
+
* comparable size: a hardened copy grows by a guard or two, and every pair
|
|
42
|
+
* here 2x or more apart (ratios 0.16-0.5) was a consumer/producer or a thin
|
|
43
|
+
* wrapper, not a copy. */
|
|
44
|
+
const MIN_BODY_TOKENS = 8;
|
|
45
|
+
/** Exclusive: exactly 2x apart (writeVscodeMcp 21 ~ writeMcpJson 42) is not a copy. */
|
|
46
|
+
const MIN_SIZE_RATIO = 0.5;
|
|
47
|
+
const MAX_COMMITS_SHOWN = 4;
|
|
48
|
+
/** Changed lines shown per commit: the change itself is the lesson, a subject
|
|
49
|
+
* like "feat(codex): …" rarely names the behaviour it fixed. */
|
|
50
|
+
const MAX_CHANGE_LINES = 12;
|
|
51
|
+
/** A fix commit's change is the lesson itself: capping it at 12 hid the second
|
|
52
|
+
* half of a two-commit fix (trap-310 v5 carried one commit, missed the other). */
|
|
53
|
+
const MAX_FIX_CHANGE_LINES = 32;
|
|
54
|
+
/** Bumped when the cached lesson shape changes. */
|
|
55
|
+
const CACHE_VERSION = 8;
|
|
56
|
+
/** Cached answers kept; past it the least recently written go (pruned on write). */
|
|
57
|
+
const MAX_CACHE_ENTRIES = 256;
|
|
58
|
+
const INVENTORY_FILE = "inventory.json";
|
|
59
|
+
const INVENTORY_VERSION = 1;
|
|
60
|
+
/** Candidate files whose bodies are parsed (best name match first). */
|
|
61
|
+
const MAX_CANDIDATE_FILES = 8;
|
|
62
|
+
/** Words too common in code to count as shared shape. */
|
|
63
|
+
const STOP = new Set([
|
|
64
|
+
"const", "let", "var", "return", "function", "if", "else", "for", "while", "new", "this", "true", "false",
|
|
65
|
+
"null", "undefined", "typeof", "instanceof", "string", "number", "boolean", "void", "async", "await",
|
|
66
|
+
"export", "import", "from", "def", "self", "none", "fn", "pub", "func", "err", "nil", "the", "and",
|
|
67
|
+
]);
|
|
68
|
+
/** `isHunchProviderHook` → {is, hunch, provider, hook}. */
|
|
69
|
+
export function nameTokens(name) {
|
|
70
|
+
return new Set(name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
71
|
+
.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
72
|
+
}
|
|
73
|
+
/** Identifier-ish tokens of a body's CODE, including those inside regex/string
|
|
74
|
+
* literals. Comment lines are dropped: a fix usually adds an explanation, and
|
|
75
|
+
* its prose would otherwise swamp the shared shape. */
|
|
76
|
+
export function codeTokens(text) {
|
|
77
|
+
const code = text.replace(/\/\*[\s\S]*?\*\//g, "").split(/\r?\n/)
|
|
78
|
+
.filter((line) => !/^\s*(\/\/|#|--|\*)/.test(line)).join("\n");
|
|
79
|
+
const out = new Set();
|
|
80
|
+
for (const word of code.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
|
81
|
+
for (const t of nameTokens(word))
|
|
82
|
+
if (t.length > 2 && !STOP.has(t))
|
|
83
|
+
out.add(t);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Overlap coefficient: shared / smaller set. A hardened copy grows, so
|
|
88
|
+
* containment — not Jaccard — is what "same shape" means for bodies. */
|
|
89
|
+
export function overlap(a, b) {
|
|
90
|
+
if (!a.size || !b.size)
|
|
91
|
+
return 0;
|
|
92
|
+
let shared = 0;
|
|
93
|
+
for (const x of a)
|
|
94
|
+
if (b.has(x))
|
|
95
|
+
shared++;
|
|
96
|
+
return shared / Math.min(a.size, b.size);
|
|
97
|
+
}
|
|
98
|
+
function mentions(body, name) {
|
|
99
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
return new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(body.slice(body.indexOf("\n") + 1));
|
|
101
|
+
}
|
|
102
|
+
export function jaccard(a, b) {
|
|
103
|
+
if (!a.size || !b.size)
|
|
104
|
+
return 0;
|
|
105
|
+
let shared = 0;
|
|
106
|
+
for (const x of a)
|
|
107
|
+
if (b.has(x))
|
|
108
|
+
shared++;
|
|
109
|
+
return shared / (a.size + b.size - shared);
|
|
110
|
+
}
|
|
111
|
+
export function isFixSubject(subject) {
|
|
112
|
+
return /^(fix|hotfix|bugfix)\b|\b(fix(e[sd])?|bug|hotfix|regression)\b/i.test(subject);
|
|
113
|
+
}
|
|
114
|
+
/** Parse `git log -L … --format=%x1eC %H%x1f%s` output. */
|
|
115
|
+
export function parseLineLog(output) {
|
|
116
|
+
const commits = [];
|
|
117
|
+
for (const chunk of output.split("\x1e").slice(1)) {
|
|
118
|
+
const nl = chunk.indexOf("\n");
|
|
119
|
+
const head = nl < 0 ? chunk : chunk.slice(0, nl);
|
|
120
|
+
const m = /^C ([0-9a-f]{40})\x1f(.*)$/.exec(head);
|
|
121
|
+
if (!m)
|
|
122
|
+
continue;
|
|
123
|
+
const commit = { sha: m[1], subject: m[2].trim(), added: [], removed: [], diff: [], span: 0, created: false };
|
|
124
|
+
let hunks = 0, fromNothing = 0;
|
|
125
|
+
for (const line of (nl < 0 ? "" : chunk.slice(nl + 1)).split("\n")) {
|
|
126
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
127
|
+
continue;
|
|
128
|
+
const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
|
|
129
|
+
if (hunk) {
|
|
130
|
+
commit.span = Math.max(commit.span, Number(hunk[1] ?? 1), Number(hunk[2] ?? 1));
|
|
131
|
+
hunks++;
|
|
132
|
+
if (hunk[1] === "0")
|
|
133
|
+
fromNothing++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (line.startsWith("+")) {
|
|
137
|
+
commit.added.push(line.slice(1));
|
|
138
|
+
commit.diff.push(line);
|
|
139
|
+
}
|
|
140
|
+
else if (line.startsWith("-")) {
|
|
141
|
+
commit.removed.push(line.slice(1));
|
|
142
|
+
commit.diff.push(line);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
commit.created = hunks > 0 && fromNothing === hunks;
|
|
146
|
+
commits.push(commit);
|
|
147
|
+
}
|
|
148
|
+
return commits;
|
|
149
|
+
}
|
|
150
|
+
/** A commit whose removed and added lines differ only in whitespace/line
|
|
151
|
+
* endings (a reformat, a CRLF→LF sweep, a re-indent) changed nothing. */
|
|
152
|
+
export function isSubstantive(commit) {
|
|
153
|
+
const norm = (lines) => lines.map((l) => l.replace(/\s+/g, "")).filter(Boolean).sort().join("\n");
|
|
154
|
+
return norm(commit.added) !== norm(commit.removed);
|
|
155
|
+
}
|
|
156
|
+
function gitEnv() {
|
|
157
|
+
const env = {};
|
|
158
|
+
for (const [k, v] of Object.entries(process.env))
|
|
159
|
+
if (!k.startsWith("GIT_"))
|
|
160
|
+
env[k] = v;
|
|
161
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
162
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
163
|
+
return env;
|
|
164
|
+
}
|
|
165
|
+
function git(root, args, env, budget) {
|
|
166
|
+
const ms = Math.min(budget.timeoutMs, budget.deadline - Date.now());
|
|
167
|
+
if (ms <= 0) {
|
|
168
|
+
budget.exhausted = true;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
173
|
+
env, timeout: ms, encoding: "utf8", maxBuffer: 16_000_000, stdio: ["ignore", "pipe", "ignore"],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const e = error;
|
|
178
|
+
if (e.signal || e.code === "ETIMEDOUT")
|
|
179
|
+
budget.exhausted = true;
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Functions of one file AS COMMITTED at `rev` — `git log -L` line numbers
|
|
184
|
+
* refer to that content, not to an in-progress working-tree edit. */
|
|
185
|
+
function functionsAt(root, file, env, budget, rev = "HEAD") {
|
|
186
|
+
const source = git(root, ["show", `${rev}:${file}`], env, budget);
|
|
187
|
+
return source == null ? [] : functionsIn(file, source);
|
|
188
|
+
}
|
|
189
|
+
function functionsIn(file, source) {
|
|
190
|
+
const parsed = parseSource(file, source);
|
|
191
|
+
if (!parsed)
|
|
192
|
+
return [];
|
|
193
|
+
// tree-sitter's node indices here are string offsets (parse.ts slices
|
|
194
|
+
// bodyText the same way), so lines are counted on the string itself.
|
|
195
|
+
const lineStarts = [0];
|
|
196
|
+
for (let i = 0; i < source.length; i++)
|
|
197
|
+
if (source.charCodeAt(i) === 10)
|
|
198
|
+
lineStarts.push(i + 1);
|
|
199
|
+
const lineAt = (index) => {
|
|
200
|
+
let lo = 0, hi = lineStarts.length - 1;
|
|
201
|
+
while (lo < hi) {
|
|
202
|
+
const mid = (lo + hi + 1) >> 1;
|
|
203
|
+
if (lineStarts[mid] <= index)
|
|
204
|
+
lo = mid;
|
|
205
|
+
else
|
|
206
|
+
hi = mid - 1;
|
|
207
|
+
}
|
|
208
|
+
return lo + 1;
|
|
209
|
+
};
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const s of parsed.symbols) {
|
|
212
|
+
if (!FN_KINDS.has(s.kind))
|
|
213
|
+
continue;
|
|
214
|
+
const start = lineAt(s.startByte);
|
|
215
|
+
const end = lineAt(Math.max(s.startByte, s.endByte - 1));
|
|
216
|
+
if (end - start + 1 < MIN_BODY_LINES)
|
|
217
|
+
continue;
|
|
218
|
+
out.push({ name: s.name, start, end, body: source.slice(lineStarts[start - 1], s.endByte) });
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
const MAX_LOG_COMMITS = 40;
|
|
223
|
+
const MAX_REANCHORS = 3;
|
|
224
|
+
/** Wall-clock budget for the no-index function inventory (nearest files first). */
|
|
225
|
+
const INVENTORY_BUDGET_MS = 4_000;
|
|
226
|
+
/** Wall-clock budget for one whole computation. */
|
|
227
|
+
const TOTAL_BUDGET_MS = 5_000;
|
|
228
|
+
/** An incomplete run is retried after this long, at most this many times per cached key. */
|
|
229
|
+
const INCOMPLETE_RETRY_MS = 60_000;
|
|
230
|
+
const MAX_INCOMPLETE_ATTEMPTS = 3;
|
|
231
|
+
/** The function's attributable history, newest first. Where git loses the
|
|
232
|
+
* range at a whole-file rewrite, the rewrite itself is skipped (its hunk says
|
|
233
|
+
* nothing about the function) and the walk RE-ANCHORS: the same-named function
|
|
234
|
+
* nearest the previous range is located in the rewrite's parent and followed
|
|
235
|
+
* from there. `lost` lists those rewrites: each DID touch the file, and may
|
|
236
|
+
* have changed the function too. */
|
|
237
|
+
function lineLog(root, file, fn, env, budget) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const lost = [];
|
|
240
|
+
let rev = "HEAD";
|
|
241
|
+
let range = fn;
|
|
242
|
+
for (let anchor = 0; anchor <= MAX_REANCHORS && out.length < MAX_LOG_COMMITS; anchor++) {
|
|
243
|
+
const raw = git(root, ["log", "--no-color", "-n", String(MAX_LOG_COMMITS), "--format=%x1eC %H%x1f%s", "-L", `${range.start},${range.end}:${file}`, rev], env, budget);
|
|
244
|
+
if (raw == null)
|
|
245
|
+
return null;
|
|
246
|
+
const { kept, lostAt } = attributable(parseLineLog(raw), range.end - range.start + 1);
|
|
247
|
+
out.push(...kept);
|
|
248
|
+
if (!lostAt)
|
|
249
|
+
break;
|
|
250
|
+
lost.push(lostAt);
|
|
251
|
+
const parent = `${lostAt}^`;
|
|
252
|
+
const previous = range.start;
|
|
253
|
+
const again = functionsAt(root, file, env, budget, parent).filter((f) => f.name === fn.name)
|
|
254
|
+
.sort((a, b) => Math.abs(a.start - previous) - Math.abs(b.start - previous))[0];
|
|
255
|
+
if (!again)
|
|
256
|
+
break;
|
|
257
|
+
rev = parent;
|
|
258
|
+
range = again;
|
|
259
|
+
}
|
|
260
|
+
return { commits: out.slice(0, MAX_LOG_COMMITS), lost };
|
|
261
|
+
}
|
|
262
|
+
/** git follows a line range backwards only while it can map it. A whole-file
|
|
263
|
+
* rewrite (a line-ending sweep, a reformat) breaks the mapping: from that
|
|
264
|
+
* commit on, every hunk spans the whole file and nothing in it is attributable
|
|
265
|
+
* to the function. Keep the history before that commit and report where the
|
|
266
|
+
* mapping was lost. */
|
|
267
|
+
export function attributable(commits, fnLines) {
|
|
268
|
+
const limit = fnLines * 3 + 20;
|
|
269
|
+
const kept = [];
|
|
270
|
+
for (const c of commits) {
|
|
271
|
+
if (c.span > limit)
|
|
272
|
+
return { kept, lostAt: c.sha };
|
|
273
|
+
kept.push(c);
|
|
274
|
+
}
|
|
275
|
+
return { kept, lostAt: null };
|
|
276
|
+
}
|
|
277
|
+
/** Path proximity: how many leading directories `p` shares with `target`. */
|
|
278
|
+
function proximityTo(target) {
|
|
279
|
+
const dir = (p) => p.split("/").slice(0, -1);
|
|
280
|
+
const own = dir(target);
|
|
281
|
+
return (p) => {
|
|
282
|
+
const d = dir(p);
|
|
283
|
+
let i = 0;
|
|
284
|
+
while (i < d.length && i < own.length && d[i] === own[i])
|
|
285
|
+
i++;
|
|
286
|
+
return i;
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** HEAD's blob id per tracked path, from one `git ls-tree` call: the content
|
|
290
|
+
* key for every cached answer, so a commit that did not touch a file keeps
|
|
291
|
+
* what was learned about it. */
|
|
292
|
+
function headBlobs(root, env, budget) {
|
|
293
|
+
const raw = git(root, ["ls-tree", "-r", "-z", "--full-tree", "HEAD"], env, budget);
|
|
294
|
+
if (raw == null)
|
|
295
|
+
return null;
|
|
296
|
+
const out = new Map();
|
|
297
|
+
for (const entry of raw.split("\0")) {
|
|
298
|
+
const tab = entry.indexOf("\t");
|
|
299
|
+
const meta = entry.slice(0, tab).split(" ");
|
|
300
|
+
if (tab > 0 && meta[1] === "blob" && meta[2])
|
|
301
|
+
out.set(entry.slice(tab + 1), meta[2]);
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
/** Function names per tracked source file, kept per file by HEAD blob so a
|
|
306
|
+
* commit re-parses only the files it changed. A scan the budget cuts short
|
|
307
|
+
* keeps what it parsed (nearest the target first) and the next call carries
|
|
308
|
+
* on from there. */
|
|
309
|
+
function functionInventory(root, target, blobs, budget, scanMs, useCache = true) {
|
|
310
|
+
const cacheFile = join(root, ".hunch-cache", "siblingfix", INVENTORY_FILE);
|
|
311
|
+
let cached = {};
|
|
312
|
+
if (useCache) {
|
|
313
|
+
try {
|
|
314
|
+
const raw = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
315
|
+
if (raw.version === INVENTORY_VERSION && raw.files && typeof raw.files === "object")
|
|
316
|
+
cached = raw.files;
|
|
317
|
+
}
|
|
318
|
+
catch { /* rebuild */ }
|
|
319
|
+
}
|
|
320
|
+
const proximity = proximityTo(target);
|
|
321
|
+
const files = [...blobs.keys()].filter((f) => f !== target && !TEST_PATH.test(f) && languageFor(f))
|
|
322
|
+
.sort((a, b) => proximity(b) - proximity(a) || a.localeCompare(b));
|
|
323
|
+
const deadline = Math.min(Date.now() + scanMs, budget.deadline);
|
|
324
|
+
const next = {};
|
|
325
|
+
let changed = false;
|
|
326
|
+
for (const f of files) {
|
|
327
|
+
const blob = blobs.get(f);
|
|
328
|
+
const hit = cached[f];
|
|
329
|
+
if (hit?.blob === blob) {
|
|
330
|
+
next[f] = hit;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
changed = true;
|
|
334
|
+
if (Date.now() >= deadline)
|
|
335
|
+
continue;
|
|
336
|
+
let source;
|
|
337
|
+
// An unreadable path is recorded empty, or every call would rewrite the cache.
|
|
338
|
+
try {
|
|
339
|
+
source = readFileSync(join(root, f), "utf8");
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
next[f] = { blob, fns: [] };
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const fns = source.length > 400_000 ? [] : (parseSource(f, source)?.symbols ?? []).filter((s) => FN_KINDS.has(s.kind)).map((s) => ({ name: s.name, kind: s.kind }));
|
|
346
|
+
next[f] = { blob, fns };
|
|
347
|
+
}
|
|
348
|
+
// A path gone from HEAD is dropped too.
|
|
349
|
+
if (useCache && (changed || Object.keys(cached).some((f) => !(f in next) && f !== target))) {
|
|
350
|
+
try {
|
|
351
|
+
mkdirSync(join(root, ".hunch-cache", "siblingfix"), { recursive: true });
|
|
352
|
+
writeFileAtomic(cacheFile, JSON.stringify({ version: INVENTORY_VERSION, files: { ...(cached[target] ? { [target]: cached[target] } : {}), ...next } }));
|
|
353
|
+
}
|
|
354
|
+
catch { /* cache is a convenience */ }
|
|
355
|
+
}
|
|
356
|
+
return Object.entries(next).flatMap(([file, e]) => e.fns.map((fn) => ({ file, name: fn.name, kind: fn.kind })));
|
|
357
|
+
}
|
|
358
|
+
/** Candidate scans run (tests: a cache hit must not run one). */
|
|
359
|
+
export const siblingCounters = { candidateScans: 0, lessonComputes: 0 };
|
|
360
|
+
/** Whether a symbol can ever be matched by `candidatesFor`: shared with
|
|
361
|
+
* `inventoryDigest` so the two can't drift — a symbol the scan ignores must
|
|
362
|
+
* not be able to invalidate a cached digest. */
|
|
363
|
+
function matchable(s) {
|
|
364
|
+
return FN_KINDS.has(s.kind) && !TEST_PATH.test(s.file) && nameTokens(s.name).size >= 2;
|
|
365
|
+
}
|
|
366
|
+
/** What decides the candidate set besides the target's own names: every
|
|
367
|
+
* function name the scan can match. A commit that adds, removes or renames no
|
|
368
|
+
* MATCHABLE function anywhere keeps it, so a cached answer is validated by
|
|
369
|
+
* comparing this digest instead of re-running the scan. */
|
|
370
|
+
function inventoryDigest(ownNames, inventory) {
|
|
371
|
+
const keys = inventory.filter(matchable).map((s) => `${s.file}\u0000${s.name}`).sort();
|
|
372
|
+
return createHash("sha256").update(`${ownNames.join("\u0000")}\u0001${keys.join("\u0001")}`).digest("hex");
|
|
373
|
+
}
|
|
374
|
+
/** Cheap retrieval by name shape: the files holding a function whose name
|
|
375
|
+
* tokens overlap one of the target's, closest names first. Bodies are parsed
|
|
376
|
+
* only for these. Null when the budget ran out mid-scan: a partial candidate
|
|
377
|
+
* set is not an answer. */
|
|
378
|
+
function candidatesFor(ownNames, target, inventory, budget) {
|
|
379
|
+
siblingCounters.candidateScans++;
|
|
380
|
+
const names = new Map();
|
|
381
|
+
const bestName = new Map();
|
|
382
|
+
// Tokens once per distinct name, not once per (own name, symbol) pair.
|
|
383
|
+
const tokensOf = new Map();
|
|
384
|
+
const tokenize = (name) => {
|
|
385
|
+
let tokens = tokensOf.get(name);
|
|
386
|
+
if (!tokens) {
|
|
387
|
+
tokens = nameTokens(name);
|
|
388
|
+
tokensOf.set(name, tokens);
|
|
389
|
+
}
|
|
390
|
+
return tokens;
|
|
391
|
+
};
|
|
392
|
+
const pool = [];
|
|
393
|
+
let step = 0;
|
|
394
|
+
const late = () => (++step & 1023) === 0 && Date.now() >= budget.deadline;
|
|
395
|
+
for (const s of inventory) {
|
|
396
|
+
if (late()) {
|
|
397
|
+
budget.exhausted = true;
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
if (!matchable(s))
|
|
401
|
+
continue;
|
|
402
|
+
pool.push({ s, tokens: tokenize(s.name) });
|
|
403
|
+
}
|
|
404
|
+
for (const name of ownNames) {
|
|
405
|
+
const tokens = tokenize(name);
|
|
406
|
+
if (tokens.size < 2)
|
|
407
|
+
continue;
|
|
408
|
+
for (const { s, tokens: other } of pool) {
|
|
409
|
+
if (late()) {
|
|
410
|
+
budget.exhausted = true;
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
if (s.file === target && s.name === name)
|
|
414
|
+
continue;
|
|
415
|
+
let shared = 0;
|
|
416
|
+
for (const t of tokens)
|
|
417
|
+
if (other.has(t))
|
|
418
|
+
shared++;
|
|
419
|
+
const nameSim = jaccard(tokens, other);
|
|
420
|
+
if (shared < 2 || nameSim < MIN_NAME_JACCARD)
|
|
421
|
+
continue;
|
|
422
|
+
const set = names.get(s.file) ?? new Set();
|
|
423
|
+
set.add(s.name);
|
|
424
|
+
names.set(s.file, set);
|
|
425
|
+
bestName.set(s.file, Math.max(bestName.get(s.file) ?? 0, nameSim));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const proximity = proximityTo(target);
|
|
429
|
+
const files = [...names.keys()]
|
|
430
|
+
.sort((a, b) => bestName.get(b) - bestName.get(a) || proximity(b) - proximity(a) || a.localeCompare(b))
|
|
431
|
+
.slice(0, MAX_CANDIDATE_FILES);
|
|
432
|
+
return { files, names };
|
|
433
|
+
}
|
|
434
|
+
function candidateBlobs(files, blobs) {
|
|
435
|
+
return Object.fromEntries(files.map((f) => [f, blobs.get(f) ?? ""]));
|
|
436
|
+
}
|
|
437
|
+
/** Least recently written entries go once the directory passes its bound;
|
|
438
|
+
* run on write only, so a cache hit never pays for it. */
|
|
439
|
+
function pruneCache(cacheDir) {
|
|
440
|
+
try {
|
|
441
|
+
const names = readdirSync(cacheDir).filter((n) => n.endsWith(".json") && n !== INVENTORY_FILE);
|
|
442
|
+
if (names.length <= MAX_CACHE_ENTRIES)
|
|
443
|
+
return;
|
|
444
|
+
const aged = names.map((n) => ({ n, t: statSync(join(cacheDir, n)).mtimeMs })).sort((a, b) => a.t - b.t);
|
|
445
|
+
for (const { n } of aged.slice(0, aged.length - MAX_CACHE_ENTRIES))
|
|
446
|
+
unlinkSync(join(cacheDir, n));
|
|
447
|
+
}
|
|
448
|
+
catch { /* cache is a convenience */ }
|
|
449
|
+
}
|
|
450
|
+
/** Sibling-fix lessons for one repo-relative file. */
|
|
451
|
+
export function siblingLessonsFor(root, file, symbols, options = {}) {
|
|
452
|
+
const maxPairs = options.maxPairs ?? 4;
|
|
453
|
+
const maxLessons = options.maxLessons ?? 2;
|
|
454
|
+
const budget = { deadline: Date.now() + (options.budgetMs ?? TOTAL_BUDGET_MS), timeoutMs: options.timeoutMs ?? 2_000, exhausted: false };
|
|
455
|
+
const target = file.replace(/\\/g, "/");
|
|
456
|
+
if (TEST_PATH.test(target))
|
|
457
|
+
return [];
|
|
458
|
+
const env = gitEnv();
|
|
459
|
+
const head = git(root, ["rev-parse", "HEAD"], env, budget)?.trim();
|
|
460
|
+
const blobs = head ? headBlobs(root, env, budget) : null;
|
|
461
|
+
const targetBlob = blobs?.get(target);
|
|
462
|
+
if (!head || !blobs || !targetBlob)
|
|
463
|
+
return [];
|
|
464
|
+
const cacheDir = join(root, ".hunch-cache", "siblingfix");
|
|
465
|
+
const cacheFile = join(cacheDir, `${createHash("sha256").update(`${CACHE_VERSION}\n${target}\n${maxPairs}\n${maxLessons}`).digest("hex").slice(0, 16)}.json`);
|
|
466
|
+
const scanMs = Math.min(options.inventoryBudgetMs ?? INVENTORY_BUDGET_MS, budget.deadline - Date.now());
|
|
467
|
+
// HEAD is scanned even when an index exists: a derived index can lag HEAD
|
|
468
|
+
// or hold a partial subset, and a missing sibling means a missed lesson.
|
|
469
|
+
// Indexed names only add candidates; bodies always come from HEAD.
|
|
470
|
+
const inventory = [...functionInventory(root, target, blobs, budget, scanMs, options.cache !== false), ...symbols];
|
|
471
|
+
const now = options.now ?? Date.now();
|
|
472
|
+
let attempts = 0;
|
|
473
|
+
if (options.cache !== false && existsSync(cacheFile)) {
|
|
474
|
+
try {
|
|
475
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
476
|
+
// Valid while the target, its candidate set and every candidate's
|
|
477
|
+
// content are what the answer was computed from, and the history it
|
|
478
|
+
// read is still ours: HEAD moved only forward from where it was computed.
|
|
479
|
+
// The candidate set is not recomputed here: same names in, same set out.
|
|
480
|
+
const targetOk = cached.targetBlob === targetBlob && Array.isArray(cached.ownNames)
|
|
481
|
+
&& !!cached.candidates && typeof cached.candidates === "object";
|
|
482
|
+
const digestSame = targetOk && cached.inventory === inventoryDigest(cached.ownNames, inventory);
|
|
483
|
+
const contentSame = targetOk
|
|
484
|
+
&& Object.entries(cached.candidates).every(([f, blob]) => (blobs.get(f) ?? "") === blob);
|
|
485
|
+
const descends = targetOk && contentSame && typeof cached.head === "string"
|
|
486
|
+
&& (cached.head === head || git(root, ["merge-base", "--is-ancestor", cached.head, head], env, budget) !== null);
|
|
487
|
+
if (descends && digestSame && cached.complete)
|
|
488
|
+
return cached.lessons;
|
|
489
|
+
// The digest moved (some function, anywhere matchable, was added,
|
|
490
|
+
// removed or renamed) but the candidate set it actually drives may not
|
|
491
|
+
// have: fall back ONCE to recomputing candidates and compare the result
|
|
492
|
+
// against what the cached answer used. Equal candidate content means the
|
|
493
|
+
// cached lessons are still right; only the digest itself is stale, so it
|
|
494
|
+
// is rewritten and the next call is a pure digest hit again.
|
|
495
|
+
if (descends && targetOk && !digestSame && cached.complete) {
|
|
496
|
+
const retried = candidatesFor(cached.ownNames, target, inventory, budget);
|
|
497
|
+
if (retried) {
|
|
498
|
+
const retriedBlobs = candidateBlobs(retried.files, blobs);
|
|
499
|
+
if (JSON.stringify(retriedBlobs) === JSON.stringify(cached.candidates)) {
|
|
500
|
+
const healed = { ...cached, inventory: inventoryDigest(cached.ownNames, inventory) };
|
|
501
|
+
try {
|
|
502
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
503
|
+
writeFileAtomic(cacheFile, JSON.stringify(healed));
|
|
504
|
+
}
|
|
505
|
+
catch { /* cache is a convenience */ }
|
|
506
|
+
return cached.lessons;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// No match (or the retry hit the budget): fall through to a full miss.
|
|
510
|
+
}
|
|
511
|
+
// Attempts count per HEAD: a new commit earns an incomplete run a fresh try.
|
|
512
|
+
if (descends && digestSame && cached.head === head) {
|
|
513
|
+
// An incomplete run is "no answer" for a while, not for good: a
|
|
514
|
+
// momentarily loaded machine must not silence the lesson, and a
|
|
515
|
+
// history that is always too slow stops being retried after a few tries.
|
|
516
|
+
attempts = cached.attempts ?? MAX_INCOMPLETE_ATTEMPTS;
|
|
517
|
+
if (attempts >= MAX_INCOMPLETE_ATTEMPTS || now - (cached.at ?? 0) < INCOMPLETE_RETRY_MS)
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch { /* recompute */ }
|
|
522
|
+
}
|
|
523
|
+
const own = functionsAt(root, target, env, budget);
|
|
524
|
+
const ownNames = own.map((f) => f.name);
|
|
525
|
+
const candidates = candidatesFor(ownNames, target, inventory, budget);
|
|
526
|
+
const computed = candidates
|
|
527
|
+
? computeLessons(root, target, own, candidates, env, budget, maxPairs, maxLessons)
|
|
528
|
+
: { lessons: [], complete: false };
|
|
529
|
+
const complete = computed.complete && !budget.exhausted;
|
|
530
|
+
if (options.cache !== false) {
|
|
531
|
+
const entry = {
|
|
532
|
+
head, targetBlob, ownNames, inventory: inventoryDigest(ownNames, inventory), candidates: candidateBlobs(candidates?.files ?? [], blobs),
|
|
533
|
+
complete, lessons: complete ? computed.lessons : [], ...(complete ? {} : { attempts: attempts + 1, at: now }),
|
|
534
|
+
};
|
|
535
|
+
try {
|
|
536
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
537
|
+
writeFileAtomic(cacheFile, JSON.stringify(entry));
|
|
538
|
+
pruneCache(cacheDir);
|
|
539
|
+
}
|
|
540
|
+
catch { /* cache is a convenience */ }
|
|
541
|
+
}
|
|
542
|
+
// A git call that failed or timed out is not an answer.
|
|
543
|
+
return complete ? computed.lessons : [];
|
|
544
|
+
}
|
|
545
|
+
function computeLessons(root, target, own, candidates, env, budget, maxPairs, maxLessons) {
|
|
546
|
+
siblingCounters.lessonComputes++;
|
|
547
|
+
let complete = true;
|
|
548
|
+
if (!own.length)
|
|
549
|
+
return { lessons: [], complete };
|
|
550
|
+
const pairs = [];
|
|
551
|
+
for (const siblingFile of candidates.files) {
|
|
552
|
+
if (budget.deadline <= Date.now())
|
|
553
|
+
return { lessons: [], complete: false };
|
|
554
|
+
const names = candidates.names.get(siblingFile);
|
|
555
|
+
const fns = siblingFile === target ? own : functionsAt(root, siblingFile, env, budget);
|
|
556
|
+
for (const sibling of fns) {
|
|
557
|
+
if (!names.has(sibling.name))
|
|
558
|
+
continue;
|
|
559
|
+
for (const fn of own) {
|
|
560
|
+
if (siblingFile === target && fn.name === sibling.name)
|
|
561
|
+
continue;
|
|
562
|
+
const nameSim = jaccard(nameTokens(fn.name), nameTokens(sibling.name));
|
|
563
|
+
if (nameSim < MIN_NAME_JACCARD)
|
|
564
|
+
continue;
|
|
565
|
+
// A function that calls the other delegates to it; it is not a copy.
|
|
566
|
+
if (mentions(fn.body, sibling.name) || mentions(sibling.body, fn.name))
|
|
567
|
+
continue;
|
|
568
|
+
const a = codeTokens(fn.body), b = codeTokens(sibling.body);
|
|
569
|
+
const small = Math.min(a.size, b.size);
|
|
570
|
+
if (small < MIN_BODY_TOKENS || small / Math.max(a.size, b.size) <= MIN_SIZE_RATIO)
|
|
571
|
+
continue;
|
|
572
|
+
const bodySim = overlap(a, b);
|
|
573
|
+
if (bodySim < MIN_BODY_OVERLAP)
|
|
574
|
+
continue;
|
|
575
|
+
const similarity = Math.round(((nameSim + bodySim) / 2) * 100) / 100;
|
|
576
|
+
if (similarity < MIN_SIMILARITY)
|
|
577
|
+
continue;
|
|
578
|
+
pairs.push({ target: fn, sibling, siblingFile, similarity });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
pairs.sort((a, b) => b.similarity - a.similarity || a.siblingFile.localeCompare(b.siblingFile) || a.sibling.name.localeCompare(b.sibling.name));
|
|
583
|
+
const lessons = [];
|
|
584
|
+
const ownHistory = new Map();
|
|
585
|
+
for (const pair of pairs.slice(0, maxPairs)) {
|
|
586
|
+
if (lessons.length >= maxLessons)
|
|
587
|
+
break;
|
|
588
|
+
if (budget.deadline <= Date.now()) {
|
|
589
|
+
complete = false;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
const key = `${pair.target.name}@${pair.target.start}`;
|
|
593
|
+
if (!ownHistory.has(key)) {
|
|
594
|
+
const log = lineLog(root, target, pair.target, env, budget);
|
|
595
|
+
// A rewrite where tracking was lost touched the file: count it as touched.
|
|
596
|
+
ownHistory.set(key, log ? { touched: new Set([...log.commits.map((c) => c.sha), ...log.lost]), incomplete: log.lost.length > 0 } : null);
|
|
597
|
+
}
|
|
598
|
+
const history = ownHistory.get(key);
|
|
599
|
+
if (!history) {
|
|
600
|
+
complete = false;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const siblingLog = lineLog(root, pair.siblingFile, pair.sibling, env, budget);
|
|
604
|
+
if (!siblingLog) {
|
|
605
|
+
complete = false;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// Divergence: substantive changes to the sibling that never touched the
|
|
609
|
+
// target. Shared commits already carried their change to both copies.
|
|
610
|
+
// The sibling's creation is not a change made to it.
|
|
611
|
+
let divergent = siblingLog.commits.filter((c) => !history.touched.has(c.sha) && !c.created && isSubstantive(c));
|
|
612
|
+
// A reverted fix is no longer in the sibling, and its revert carries the
|
|
613
|
+
// fix's subject: the pair cancels out.
|
|
614
|
+
if (divergent.some((c) => isFixSubject(c.subject))) {
|
|
615
|
+
const reverted = revertedPairs(root, siblingLog.commits, env, budget);
|
|
616
|
+
if (!reverted) {
|
|
617
|
+
complete = false;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
divergent = divergent.filter((c) => !reverted.has(c.sha));
|
|
621
|
+
}
|
|
622
|
+
// A commit that also modified the target file was made with this file in
|
|
623
|
+
// hand: whoever wrote the fix saw it and may have carried the change in
|
|
624
|
+
// another form. Where the target's own walk lost tracking this is also the
|
|
625
|
+
// only evidence left. A same-file pair always shares the file, so there it
|
|
626
|
+
// applies only to a lost walk.
|
|
627
|
+
if (divergent.length && (history.incomplete || pair.siblingFile !== target)) {
|
|
628
|
+
const modifiedTarget = new Set();
|
|
629
|
+
for (const c of divergent) {
|
|
630
|
+
const files = git(root, ["show", "--no-color", "--name-only", "--format=", c.sha, "--", target], env, budget);
|
|
631
|
+
if (files == null) {
|
|
632
|
+
complete = false;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
if (files.trim())
|
|
636
|
+
modifiedTarget.add(c.sha);
|
|
637
|
+
}
|
|
638
|
+
if (!complete)
|
|
639
|
+
continue;
|
|
640
|
+
divergent = divergent.filter((c) => !modifiedTarget.has(c.sha));
|
|
641
|
+
}
|
|
642
|
+
if (!divergent.some((c) => isFixSubject(c.subject)))
|
|
643
|
+
continue;
|
|
644
|
+
const commits = divergent.slice(0, MAX_COMMITS_SHOWN).map((c) => ({ sha: c.sha, subject: c.subject, fix: isFixSubject(c.subject), change: changeLines(c.diff, isFixSubject(c.subject)), tests: commitTests(root, c.sha, env, budget) }));
|
|
645
|
+
lessons.push({
|
|
646
|
+
symbol: pair.target.name,
|
|
647
|
+
file: target,
|
|
648
|
+
line: pair.target.start,
|
|
649
|
+
sibling: pair.sibling.name,
|
|
650
|
+
siblingFile: pair.siblingFile,
|
|
651
|
+
siblingStart: pair.sibling.start,
|
|
652
|
+
siblingEnd: pair.sibling.end,
|
|
653
|
+
similarity: pair.similarity,
|
|
654
|
+
commits,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return { lessons, complete };
|
|
658
|
+
}
|
|
659
|
+
/** Commits of a newest-first range that cancel out. Reverts toggle: a commit
|
|
660
|
+
* whose message names a prior commit with git's default `This reverts commit
|
|
661
|
+
* <sha>` cancels every live sha it names (not just the nearest one); a revert
|
|
662
|
+
* of a REVERT (git's `Revert "Revert …"` / `Reapply …`) restores what that
|
|
663
|
+
* revert cancelled, and reverting the reapply cancels it again. A revert
|
|
664
|
+
* commit is itself always cancelled, even when none of its refs match a
|
|
665
|
+
* commit in this log (its target lies outside the window): it must never
|
|
666
|
+
* surface as a fix. Null when the messages could not be read. */
|
|
667
|
+
function revertedPairs(root, commits, env, budget) {
|
|
668
|
+
const cancelled = new Set();
|
|
669
|
+
if (!commits.length)
|
|
670
|
+
return cancelled;
|
|
671
|
+
const raw = git(root, ["show", "-s", "--no-color", "--format=%x1e%H%x1f%B", ...commits.map((c) => c.sha)], env, budget);
|
|
672
|
+
if (raw == null)
|
|
673
|
+
return null;
|
|
674
|
+
const message = new Map();
|
|
675
|
+
for (const chunk of raw.split("\x1e").slice(1)) {
|
|
676
|
+
const sep = chunk.indexOf("\x1f");
|
|
677
|
+
if (sep > 0)
|
|
678
|
+
message.set(chunk.slice(0, sep).trim(), chunk.slice(sep + 1));
|
|
679
|
+
}
|
|
680
|
+
// What each revert did, per original (non-revert) sha: true = it cancelled that
|
|
681
|
+
// sha, false = it restored it. Per sha, because one revert can do both.
|
|
682
|
+
const effect = new Map();
|
|
683
|
+
// Oldest first: a revert can only act on a commit that came before it.
|
|
684
|
+
const seen = [];
|
|
685
|
+
for (const c of [...commits].reverse()) {
|
|
686
|
+
const body = message.get(c.sha) ?? "";
|
|
687
|
+
const refs = [...body.matchAll(/This reverts commit ([0-9a-f]{7,40})/g)].map((m) => m[1]);
|
|
688
|
+
seen.push(c.sha);
|
|
689
|
+
// A revert whose body lost the `This reverts commit` line (a squash merge that
|
|
690
|
+
// dropped it) still names itself in git's subject; it just cancels nothing.
|
|
691
|
+
if (!refs.length && !/^(?:Revert|Reapply) "/.test(body))
|
|
692
|
+
continue;
|
|
693
|
+
const mine = new Map();
|
|
694
|
+
for (const target of seen) {
|
|
695
|
+
if (target === c.sha || !refs.some((ref) => target.startsWith(ref)))
|
|
696
|
+
continue;
|
|
697
|
+
const prior = effect.get(target);
|
|
698
|
+
if (!prior) {
|
|
699
|
+
mine.set(target, true);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
// Reverting a revert undoes each of its effects individually; a fix this
|
|
703
|
+
// commit ALSO names directly stays cancelled (git reverts both changes).
|
|
704
|
+
for (const [sha, didCancel] of prior) {
|
|
705
|
+
if (!refs.some((ref) => sha.startsWith(ref)))
|
|
706
|
+
mine.set(sha, !didCancel);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (const [sha, cancel] of mine) {
|
|
710
|
+
if (cancel)
|
|
711
|
+
cancelled.add(sha);
|
|
712
|
+
else
|
|
713
|
+
cancelled.delete(sha);
|
|
714
|
+
}
|
|
715
|
+
effect.set(c.sha, mine);
|
|
716
|
+
// A revert is never itself a fix lesson.
|
|
717
|
+
cancelled.add(c.sha);
|
|
718
|
+
}
|
|
719
|
+
return cancelled;
|
|
720
|
+
}
|
|
721
|
+
const MAX_TESTS_SHOWN = 2;
|
|
722
|
+
/** Test files a commit changed (best effort: a failed call just shows none). */
|
|
723
|
+
function commitTests(root, sha, env, budget) {
|
|
724
|
+
const files = git(root, ["show", "--no-color", "--name-only", "--format=", sha], env, budget);
|
|
725
|
+
return (files ?? "").split("\n").map((f) => f.trim()).filter((f) => f && TEST_PATH.test(f)).slice(0, MAX_TESTS_SHOWN);
|
|
726
|
+
}
|
|
727
|
+
/** Stable identity for hook dedupe: which fixes were surfaced, not the wording. */
|
|
728
|
+
export function siblingLessonsIdentity(lessons) {
|
|
729
|
+
return lessons.map((l) => `${l.symbol}~${l.siblingFile}:${l.sibling}@${l.commits.map((c) => c.sha.slice(0, 12)).join(",")}`).join(";");
|
|
730
|
+
}
|
|
731
|
+
/** A commit's diff inside the function, blank-only lines dropped, capped
|
|
732
|
+
* (a fix commit gets the larger cap). */
|
|
733
|
+
export function changeLines(diff, fix = false) {
|
|
734
|
+
const max = fix ? MAX_FIX_CHANGE_LINES : MAX_CHANGE_LINES;
|
|
735
|
+
const lines = diff.filter((l) => l.slice(1).trim()).map((l) => l.replace(/\s+$/, ""));
|
|
736
|
+
return lines.length > max ? [...lines.slice(0, max), ` … ${lines.length - max} more changed line(s)`] : lines;
|
|
737
|
+
}
|
|
738
|
+
const CUT = /^\s*… (\d+) more changed line\(s\)$/;
|
|
739
|
+
/** A capped change, rendered: the cut marker moves out of the diff fence and
|
|
740
|
+
* says how to read the rest, so a truncated fix is fetched, not skipped. */
|
|
741
|
+
function renderChange(c, siblingFile) {
|
|
742
|
+
if (!c.change.length)
|
|
743
|
+
return [];
|
|
744
|
+
const last = c.change.at(-1);
|
|
745
|
+
const cut = CUT.exec(last);
|
|
746
|
+
const shown = cut ? c.change.slice(0, -1) : c.change;
|
|
747
|
+
return [
|
|
748
|
+
" ```diff", ...shown.map((line) => ` ${line}`), " ```",
|
|
749
|
+
...(cut ? [` ${cut[1]} more changed line(s) not shown are part of this change; read them before carrying it: git show ${c.sha.slice(0, 8)} -- ${siblingFile}`] : []),
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
export const SIBLING_HEADING = "## ⚠ Fix not carried to this function";
|
|
753
|
+
/** "Pre-existing" and "outside this task" are how an agent that agrees with the
|
|
754
|
+
* lesson still leaves it (trap-310 v4: gap confirmed, reported, not fixed).
|
|
755
|
+
* The change in hand runs through the copy, so only "cannot reach it" or
|
|
756
|
+
* "already handled" count as not applying. */
|
|
757
|
+
export const NOT_A_REASON = "\"Pre-existing\" or \"outside this task\" does not count: the change you are making runs through this copy, so leaving it ships the gap again inside your change.";
|
|
758
|
+
export function renderSiblingLessons(lessons, callers = new Map(), invariants = []) {
|
|
759
|
+
if (!lessons.length)
|
|
760
|
+
return "";
|
|
761
|
+
const blocks = lessons.map((l) => {
|
|
762
|
+
const via = callers.get(l.symbol) ?? [];
|
|
763
|
+
const tests = [...new Set(l.commits.flatMap((c) => c.tests ?? []))];
|
|
764
|
+
return [
|
|
765
|
+
`- \`${l.symbol}\` (${l.file}:${l.line}) has the same shape as \`${l.sibling}\` (${l.siblingFile}:${l.siblingStart}, similarity ${l.similarity}). The sibling was later changed; git shows this copy never received the change:`,
|
|
766
|
+
...l.commits.flatMap((c) => [` ${c.sha.slice(0, 8)} ${c.subject}`, ...renderChange(c, l.siblingFile)]),
|
|
767
|
+
...(l.commits.length > 1 ? [` All ${l.commits.length} commits above are part of what this copy lacks; carrying one of them leaves the rest of the gap.`] : []),
|
|
768
|
+
...(tests.length ? [` The fix came with tests in ${tests.join(", ")}; the same input against \`${l.symbol}\` needs its own test.`] : []),
|
|
769
|
+
...(via.length ? [` In this file ${via.slice(0, 3).map((v) => `\`${v}\``).join(", ")} call${via.length === 1 ? "s" : ""} \`${l.symbol}\`, so a change here runs through the copy that lacks it.`] : []),
|
|
770
|
+
...(invariants.length ? [` This file is covered by blocking invariant${invariants.length === 1 ? "" : "s"} ${invariants.slice(0, 2).map((c) => `${c.id} ("${c.statement.length > 140 ? `${c.statement.slice(0, 139)}…` : c.statement}")`).join("; ")}: if the gap is real, it violates ${invariants.length === 1 ? "it" : "them"}.`] : []),
|
|
771
|
+
` Treat this as part of the task. If the input the sibling now handles can reach \`${l.symbol}\`, carry the change and a test for it. It does not apply only if that input cannot reach \`${l.symbol}\` or is already handled another way; then say which, in your final answer. ${NOT_A_REASON} Full history: git log -L ${l.siblingStart},${l.siblingEnd}:${l.siblingFile}`,
|
|
772
|
+
].join("\n");
|
|
773
|
+
});
|
|
774
|
+
return `${SIBLING_HEADING}\nMatched by code shape and git history, so check before copying: the change may already be present in another form.\n${blocks.join("\n")}`;
|
|
775
|
+
}
|
|
776
|
+
/** The working-tree function named `symbol` in `file` (never HEAD: the agent's
|
|
777
|
+
* own edits are what the reminder has to see). */
|
|
778
|
+
function workingFunctions(root, file) {
|
|
779
|
+
try {
|
|
780
|
+
return functionsIn(file, readFileSync(join(root, file), "utf8"));
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/** Hash of the function's current body, or null when it cannot be found. A
|
|
787
|
+
* changed hash means the agent touched the function after the lesson. */
|
|
788
|
+
export function functionBodyHash(root, file, symbol) {
|
|
789
|
+
const fn = workingFunctions(root, file).find((f) => f.name === symbol);
|
|
790
|
+
return fn ? createHash("sha256").update(fn.body).digest("hex").slice(0, 16) : null;
|
|
791
|
+
}
|
|
792
|
+
function callersIn(fns, symbol) {
|
|
793
|
+
return [...new Set(fns.filter((f) => f.name !== symbol && mentions(f.body, symbol)).map((f) => f.name))];
|
|
794
|
+
}
|
|
795
|
+
/** Grounding-path entry: the rendered block plus its dedupe identity for one
|
|
796
|
+
* repo-relative file. Never throws — a parser load failure or a git error
|
|
797
|
+
* means no sibling lessons, not a failed edit hook. */
|
|
798
|
+
export function siblingGrounding(root, file, symbols, invariants = []) {
|
|
799
|
+
const none = { text: "", identity: "", lessons: [], callers: new Map() };
|
|
800
|
+
try {
|
|
801
|
+
if (!existsSync(join(root, file)))
|
|
802
|
+
return none;
|
|
803
|
+
const lessons = siblingLessonsFor(root, file, symbols);
|
|
804
|
+
if (!lessons.length)
|
|
805
|
+
return none;
|
|
806
|
+
const fns = workingFunctions(root, file);
|
|
807
|
+
const callers = new Map(lessons.map((l) => [l.symbol, callersIn(fns, l.symbol)]));
|
|
808
|
+
return { text: renderSiblingLessons(lessons, callers, invariants), identity: siblingLessonsIdentity(lessons), lessons, callers };
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
return none;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
//# sourceMappingURL=siblingfix.js.map
|