diff --git a/vscode-extension/src/history/buildHistory.ts b/vscode-extension/src/history/buildHistory.ts index bfabe04..929efde 100644 --- a/vscode-extension/src/history/buildHistory.ts +++ b/vscode-extension/src/history/buildHistory.ts @@ -1,5 +1,6 @@ import { BuildHistoryFailureReason, BuildHistoryResult, Change, CommitInfo, DiffHunk, History, TaskletMessage, TaskletQuestion } from "./types"; import { + bleuSimilarity, getActiveTracyId, getCommitTree, getDiff, @@ -8,7 +9,8 @@ import { groupChangesByFile, isAiChange, mapLinesToTree, - runGit + runGit, + SIMILARITY_THRESHOLD } from "../utils"; const DELIMITER = "||#--TRACY--#||"; @@ -376,12 +378,31 @@ async function extractSnapshot( agentSource, } = buildTaskletMessages(snapshot.description); let diffFromTree = index > 0 ? chain[index - 1].treeHash : baseTree; + // chain[index-1] (array-adjacent) is only a reliable stand-in for "this + // snapshot's actual parent" on a simple linear chain. getTracyChain() + // does a BFS over possibly-multiple parents to support squash-merged + // chains (see its doc comment above), so on a chain with a merge point, + // array-adjacent entries can be siblings from different branches rather + // than parent/child. Default to the array-adjacent check and only + // override it below once the real single parent is resolved. + let diffBaseIsAiAuthored = index > 0 && isAiChange(chain[index - 1]); if (snapshot.parentHash) { - const parentTree = await getCommitTree(repoPath, snapshot.parentHash); - - if (parentTree) { - diffFromTree = parentTree; + const parentHashes = snapshot.parentHash.split(" ").filter(Boolean); + + // A multi-parent parentHash (merge commit) isn't resolvable by + // getCommitTree (git rejects the space-joined string as a single + // revision), so diffFromTree already silently falls back to the + // array-adjacent tree above for that case — keep diffBaseIsAiAuthored + // consistent with whatever diffFromTree actually ends up being. + if (parentHashes.length === 1) { + const parentTree = await getCommitTree(repoPath, parentHashes[0]); + + if (parentTree) { + diffFromTree = parentTree; + const actualParent = chain.find(c => c.hash === parentHashes[0]); + diffBaseIsAiAuthored = actualParent ? isAiChange(actualParent) : false; + } } } @@ -391,16 +412,12 @@ async function extractSnapshot( const hunks = fileChangesMap.get(filePath) || []; // Only filter by significance when diffing against non-AI content - // (the real User->AI case). chain[0] is the last real, on-branch - // commit (pushed by getTracyChain() before it stops walking), so the - // first AI edit is at index 1, not 0 — "index > 0" alone isn't - // enough. Skipping the filter for AI->AI hops matters because a - // hunk diffed against the AI's OWN prior edit is very often - // textually close to it (small follow-up prompts, or just the - // unchanged surrounding context dominating the score), which isn't - // the "insignificant" case this filter exists to catch. - const diffsAgainstPriorAiEdit = index > 0 && isAiChange(chain[index - 1]); - const significantHunks = diffsAgainstPriorAiEdit ? hunks : hunks.filter(h => h.isSignificant); + // (the real User->AI case). Skipping the filter for AI->AI hops + // matters because a hunk diffed against the AI's OWN prior edit is + // very often textually close to it (small follow-up prompts, or + // just the unchanged surrounding context dominating the score), + // which isn't the "insignificant" case this filter exists to catch. + const significantHunks = diffBaseIsAiAuthored ? hunks : hunks.filter(h => h.isSignificant); const linesAtSnapshot: number[] = []; for (const hunk of significantHunks) { for (let i = 0; i < hunk.newCount; i++) { @@ -603,15 +620,257 @@ async function buildUncommittedChanges( return { uncommittedChanges: chainChanges.flat(), lastTracyTip }; } -// Drops lines that fall inside a significant modified or deleted hunk -// Insignificant hunks preserve AI attribution - ALL new lines from the hunk are kept +// Bounds the BLEU fallback pass below to a fixed-size window per old line +// rather than searching every remaining candidate. Gating on the total +// unmatched-old x unmatched-new product (an earlier version of this cap) +// meant that once a hunk had enough purely-unmatched lines — e.g. a +// uniform field rename applied throughout, ~450 lines on each side, none +// of which exact-match — the ENTIRE fallback got skipped, dropping +// attribution for a whole hunk that's exactly the case this fallback +// exists for. A per-line window keeps cost bounded (linear in hunk size) +// without that all-or-nothing cliff: every unmatched old line still gets +// a real, bounded search. +const FUZZY_MATCH_WINDOW = 500; +// Once a match's score has gone unbeaten for this many consecutive +// offsets (and already clears the significance threshold), stop +// searching — the common case (an in-place rename, no reordering) finds +// its match at or near offset 0 and gains nothing from scanning the rest +// of the window. +const FUZZY_MATCH_EARLY_EXIT_PATIENCE = 20; +// A tied candidate's text occurring at most this many times among the +// hunk's new lines is treated as a rare, discrete duplicate (worth +// reaching across the gap's drift for — see the long comment at its use +// site). More occurrences than this is treated as uniform/templated +// content instead, where reaching for a specific one has no basis. +const FUZZY_MATCH_RARE_DUPLICATE_LIMIT = 4; + +// Aligns a hunk's old lines to its new lines one-to-one and in order, +// instead of matching each tracked line independently — independent +// per-line lookups all resolve a duplicated line (`}`, `return;`, +// identical log lines, common in an "insignificant" hunk) to the SAME +// first occurrence, colliding distinct duplicates onto one new position +// and losing attribution for the rest, or letting a later match overwrite +// an earlier one that legitimately owns a different occurrence. +// +// Exact (whitespace-insensitive) matches are found via an O(n+m) hash +// lookup — grouping new-line indices by content and consuming each +// group in order as old lines are walked — which respects both order and +// duplicate multiplicity (two `}` lines in the old text land on two +// different `}` lines in the new text, not both on the first one) without +// the O(n*m) time and space a full LCS table would cost. A large hunk +// (reformatting or bulk-renaming an equally large file) can easily reach +// thousands of lines, where an O(n*m) table would mean hundreds of MB to +// GBs of allocation. Whatever's left over falls back to the best +// remaining BLEU-similar candidate, bounded by FUZZY_MATCH_SEARCH_CAP. +function alignHunkLines(hunk: DiffHunk): Map { + const oldLines = hunk.removedLines ?? []; + const addedLines = hunk.addedLines ?? []; + const normalize = (s: string) => s.replace(/\s+/g, ''); + + const newIndicesByContent = new Map(); + addedLines.forEach((text, newIndex) => { + const key = normalize(text); + const bucket = newIndicesByContent.get(key); + if (bucket) { + bucket.push(newIndex); + } else { + newIndicesByContent.set(key, [newIndex]); + } + }); + + // Consumed via a per-bucket cursor rather than bucket.shift(): shift() + // shifts every remaining element down by one, so repeatedly shifting the + // SAME bucket (a hunk with many identical lines — `}`, blank lines, + // templated log statements — is exactly when this bucket gets consumed + // over and over) is O(k) per call, O(k^2) total for that bucket alone. + // A cursor makes each consumption O(1) regardless of bucket size. + const bucketCursors = new Map(); + const alignment = new Map(); + const usedNewIndices = new Set(); + const unmatchedOldIndices: number[] = []; + oldLines.forEach((text, oldIndex) => { + const key = normalize(text); + const bucket = newIndicesByContent.get(key); + const cursor = bucketCursors.get(key) ?? 0; + if (bucket && cursor < bucket.length) { + const newIndex = bucket[cursor]; + bucketCursors.set(key, cursor + 1); + alignment.set(oldIndex, newIndex); + usedNewIndices.add(newIndex); + } else { + unmatchedOldIndices.push(oldIndex); + } + }); + + const unmatchedNewIndices: number[] = []; + for (let newIndex = 0; newIndex < addedLines.length; newIndex++) { + if (!usedNewIndices.has(newIndex)) { + unmatchedNewIndices.push(newIndex); + } + } + + // How many other old lines share this exact (whitespace-insensitive) + // text — used below to decide, per old line, whether reaching across + // the gap's drift for a tied candidate is warranted at all. + const oldContentCounts = new Map(); + oldLines.forEach(text => { + const key = normalize(text); + oldContentCounts.set(key, (oldContentCounts.get(key) ?? 0) + 1); + }); + + if (unmatchedOldIndices.length > 0 && unmatchedNewIndices.length > 0) { + const remainingNewIndices = new Set(unmatchedNewIndices); + const oldCount = oldLines.length; + const newCount = addedLines.length; + + // Anchors: the exact matches already found above, used to LOCALLY + // estimate an unmatched line's expected position instead of scaling + // it against the hunk as a whole. A single global scale is wrong by + // however many lines were inserted/deleted before this point in the + // hunk — e.g. ~50 lines inserted right before a large renamed block + // shifts every renamed line's true position by ~50, but a global + // estimate only accounts for a fraction of that, landing the search + // window's center nowhere near the real match deep inside the block. + // Anchored to the exact matches immediately surrounding each gap + // instead — which, being exact, already reflect the true offset at + // that point exactly — the very first candidate checked is already + // close to correct. unmatchedOldIndices is walked in ascending order, + // so a single forward-moving pointer through the sorted anchors is + // enough; no need to re-search from the start each time. + const anchorOldIndices = Array.from(alignment.keys()).sort((a, b) => a - b); + let anchorPointer = 0; + + for (const oldIndex of unmatchedOldIndices) { + while (anchorPointer < anchorOldIndices.length && anchorOldIndices[anchorPointer] < oldIndex) { + anchorPointer++; + } + const prevAnchorOld = anchorPointer > 0 ? anchorOldIndices[anchorPointer - 1] : -1; + const prevAnchorNew = anchorPointer > 0 ? alignment.get(prevAnchorOld)! : -1; + const nextAnchorOld = anchorPointer < anchorOldIndices.length ? anchorOldIndices[anchorPointer] : oldCount; + const nextAnchorNew = anchorPointer < anchorOldIndices.length ? alignment.get(nextAnchorOld)! : newCount; + const span = nextAnchorOld - prevAnchorOld; + + // How many net lines were inserted (positive) or deleted (negative) + // within this specific anchor-bounded gap (the whole hunk, when + // there are no anchors at all — exactly the "big rename hunk, + // nothing exact-matches" case). + const signedLocalDrift = (nextAnchorNew - prevAnchorNew) - (nextAnchorOld - prevAnchorOld); + + // This old line's own text tells us whether reaching for that drift + // is warranted at all. If it's rare among the old lines (occurs + // only a handful of times), a real "moved/renamed block vs. one + // stray duplicate elsewhere" situation is what's actually going on + // — see the repro two commits ago: 50 lines inserted before a + // renamed block, reusing indices 0..49 so exactly ONE decoy exists + // per rare index — and it's worth both (a) assuming the gap's whole + // drift already applies by this point (the proportional estimate + // below assumes the shift is spread evenly across the gap; if it's + // actually concentrated at one end, that assumption is wrong for + // lines near that end) and (b) reaching across the gap's drift on a + // tie to find the candidate that accounts for it. + // + // If instead this exact text repeats many times over (a large + // uniform/templated block — many AI-written lines that just happen + // to be identical), assuming or reaching for the gap's drift has no + // basis: there's no way to tell, from content alone, which + // occurrence is the line's own, and guessing risks walking straight + // into a same-looking but unrelated region (e.g. lines appended + // after the AI's own block, which is exactly as "textually similar" + // to this line as the AI's own text is to itself). The estimate + // then assumes NO shift at all (matching this line's own position + // 1:1 against the nearest anchor), and ties prefer whichever + // candidate is closest to that estimate instead of reaching toward + // the drift. + const isRareContent = (oldContentCounts.get(normalize(oldLines[oldIndex])) ?? 1) <= FUZZY_MATCH_RARE_DUPLICATE_LIMIT; + const estimatedNewIndex = isRareContent + ? (span > 0 ? Math.round(prevAnchorNew + ((oldIndex - prevAnchorOld) * (nextAnchorNew - prevAnchorNew)) / span) : prevAnchorNew + 1) + : prevAnchorNew + (oldIndex - prevAnchorOld); + const tieTarget = isRareContent ? signedLocalDrift : 0; + const patience = isRareContent + ? Math.min(FUZZY_MATCH_WINDOW, Math.max(FUZZY_MATCH_EARLY_EXIT_PATIENCE, Math.abs(signedLocalDrift))) + : FUZZY_MATCH_EARLY_EXIT_PATIENCE; + + // Stop early once a confidently-good match has been sitting + // unbeaten for a while — without this, every line would always + // scan the full window even after finding an obviously-correct + // match at offset 0 (the common case for an in-place rename that + // doesn't reorder anything), which is what actually made this loop + // slow in practice, not the window bound itself. For rare content, + // patience scales with the drift so a search can't settle before at + // least reaching the position the gap's own line-count change + // implies. + let bestIndex = -1; + let bestScore = -1; + let bestTieDistance = Infinity; + let noImprovementStreak = 0; + for (let offset = 0; offset <= FUZZY_MATCH_WINDOW; offset++) { + const candidates = offset === 0 + ? [{ index: estimatedNewIndex, signedOffset: 0 }] + : [ + { index: estimatedNewIndex - offset, signedOffset: -offset }, + { index: estimatedNewIndex + offset, signedOffset: offset }, + ]; + + // A strict improvement both updates the pick AND counts as + // progress (resets the patience counter below). An exact tie only + // updates the pick if it's closer to tieTarget than the current + // pick. A tie never counts as progress either way: a hunk where + // many old lines each have many identically-scoring candidates + // (e.g. a uniform rename repeated verbatim across thousands of + // lines) would otherwise have every tie reset the counter, + // defeating the patience-based exit and forcing a full-window + // scan for every line. + let improved = false; + for (const { index: candidate, signedOffset } of candidates) { + if (candidate < 0 || candidate >= newCount || !remainingNewIndices.has(candidate)) { + continue; + } + const score = bleuSimilarity(oldLines[oldIndex], addedLines[candidate]); + const tieDistance = Math.abs(signedOffset - tieTarget); + if (score > bestScore) { + bestScore = score; + bestIndex = candidate; + bestTieDistance = tieDistance; + improved = true; + } else if (score === bestScore && tieDistance < bestTieDistance) { + bestIndex = candidate; + bestTieDistance = tieDistance; + } + } + + noImprovementStreak = improved ? 0 : noImprovementStreak + 1; + if (bestScore > SIMILARITY_THRESHOLD && noImprovementStreak >= patience) { + break; + } + } + + if (bestIndex !== -1 && bestScore > SIMILARITY_THRESHOLD) { + alignment.set(oldIndex, bestIndex); + remainingNewIndices.delete(bestIndex); + } + } + } + + return alignment; +} + +// Drops lines that fall inside a significant modified or deleted hunk. +// Insignificant hunks preserve AI attribution only for the specific new +// line this old line's content aligns to (see alignHunkLines) — not every +// new line in the hunk. // Pure insertions (oldCount = 0) never consume old lines, only shift subsequent ones // Also returns the new-tree positions of lines consumed by significant hunks so the // caller can record them as "ghost" attribution for the previous owner. function consumeAndShift(lines: number[], hunks: DiffHunk[]): { survivors: number[]; consumedNewPositions: number[] } { const survivingLines = new Set(); - const processedInsignificantHunks = new Set(); const consumedHunks = new Set(); + // Memoized per hunk within this call: alignHunkLines is a pure function + // of the hunk's own content, so every tracked line landing in the same + // hunk (whether from this tasklet's own lines, or a separate + // consumeAndShift call for a different tasklet touching the same hunk) + // resolves against the same one-to-one mapping — duplicate lines land on + // distinct occurrences instead of colliding on the first match. + const hunkAlignments = new Map>(); const sortedLines = [...lines].sort((a, b) => a - b); const sortedHunks = [...hunks].sort((a, b) => a.oldStart - b.oldStart); @@ -628,19 +887,25 @@ function consumeAndShift(lines: number[], hunks: DiffHunk[]): { survivors: numbe if (containingHunk) { if (containingHunk.isSignificant) { - // Significant hunk: consume the line (user override) + // Significant hunk: consume the line (user override). Ghost-tracked + // across the whole replaced block below, since a real rewrite has + // no specific "successor line" to point to. consumedHunks.add(containingHunk); continue; - } else { - // Insignificant hunk: preserve AI attribution for ALL new lines in the hunk - // Only process each hunk once to avoid duplicates - if (!processedInsignificantHunks.has(containingHunk)) { - for (let i = 0; i < containingHunk.newCount; i++) { - survivingLines.add(containingHunk.newStart + i); - } - processedInsignificantHunks.add(containingHunk); - } - // The current line is "absorbed" - no need to add individually + } + + // Insignificant hunk: only credit the specific new line this old + // line aligns to. If nothing in the hunk resembles it anymore, drop + // it rather than guessing — deliberately not ghost-tracked either, + // to avoid the same blanket-crediting problem this function exists + // to avoid. + if (!hunkAlignments.has(containingHunk)) { + hunkAlignments.set(containingHunk, alignHunkLines(containingHunk)); + } + const newIndex = hunkAlignments.get(containingHunk)!.get(line - containingHunk.oldStart); + + if (newIndex !== undefined) { + survivingLines.add(containingHunk.newStart + newIndex); } } else { // Line not in any hunk, apply shifts from all hunks before this line diff --git a/vscode-extension/src/test/buildHistory.test.ts b/vscode-extension/src/test/buildHistory.test.ts index 3abe399..61007f3 100644 --- a/vscode-extension/src/test/buildHistory.test.ts +++ b/vscode-extension/src/test/buildHistory.test.ts @@ -209,3 +209,820 @@ suite('buildHistory significance filtering across a tracy-local chain', () => { ); }); }); + +suite('buildHistory does not blanket-credit a whole hunk to one tasklet', () => { + test('a human commit bundling a tiny AI-line tweak with an unrelated adjacent comment only credits the AI line', async () => { + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' # end of discount check', + ' return total', + '', + ].join('\n')); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + // tasklet1 (AI): inserts the discount-check line — a pure insertion, + // attributed outright. + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) > 10:', + ' # end of discount check', + ' return total', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'add a discount check', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Finalize tasklet1's chain into a real commit, the way the extension + // does on `git commit` — tracy-id note + refs/tracy/ pointing at + // the hidden AI commit. + execSync('git add -A && git commit -q -m "add discount check (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // A later, separate human commit tweaks the AI-written line AND makes + // a trivial punctuation edit to the immediately adjacent comment (never + // AI-attributed), with no unchanged line between them — git bundles + // both into ONE hunk that's still similar enough overall to be + // "insignificant". Before the fix, the whole hunk's new lines + // (including the unrelated comment) were blanket-credited to tasklet1. + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) >= 10:', + ' # end of discount check.', + ' return total', + '', + ].join('\n')); + execSync('git add -A && git commit -q -m "human tweak both lines"', { cwd: dir }); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'tasklet-1 should still be attributed to the discount-check line'); + assert.deepStrictEqual( + tasklet!.lines, + [3], + 'only the AI-written line should be credited — the adjacent comment (never AI-authored) must not be swept in' + ); + }); +}); + +suite('buildHistory resolves significance against the real parent, not array adjacency', () => { + test('a squash-merged, two-branch chain still filters a tiny edit whose real parent is the base commit', async () => { + // getTracyChain() does a BFS over both parents of a squash-merge + // commit, so array-adjacent chain entries can be siblings from + // different branches rather than parent/child. Branch A's tiny edit + // (> 10 -> >= 10) has the real base commit as its parent and should be + // filtered as an insignificant User->AI change — even though branch + // B's unrelated AI commit can land array-adjacent to it after the BFS + // traversal reverses. + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) > 10:', + ' total *= 0.9', + ' return total', + '', + 'def helper():', + ' z = None', + ' return z', + '', + ].join('\n')); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + // Branch A: a tiny, near-identical edit off the base commit. + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) >= 10:', + ' total *= 0.9', + ' return total', + '', + 'def helper():', + ' z = None', + ' return z', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const commitA = commitAiEdit(dir, baseCommit, 'tasklet-A1', 'sessA', 'tiny tweak on branch A', 1000); + + // Branch B: a substantial, unrelated edit, ALSO off the base commit. + execSync(`git read-tree ${baseCommit}^{tree}`, { cwd: dir }); + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) > 10:', + ' total *= 0.9', + ' return total', + '', + 'def helper():', + ' z = compute_something_entirely_different()', + ' return z', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const commitB = commitAiEdit(dir, baseCommit, 'tasklet-B1', 'sessB', 'unrelated edit on branch B', 2000); + + // Synthetic merge commit combining both branches, mimicking the + // post-rewrite squash-merge hook described in getTracyChain()'s doc + // comment. + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) >= 10:', + ' total *= 0.9', + ' return total', + '', + 'def helper():', + ' z = compute_something_entirely_different()', + ' return z', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const mergeTree = execSync('git write-tree', { cwd: dir, encoding: 'utf8' }).trim(); + const mergeCommit = execSync(`git commit-tree ${mergeTree} -p ${commitA} -p ${commitB} -m "merge chains"`, { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, GIT_AUTHOR_NAME: 'Tracybot', GIT_AUTHOR_EMAIL: 'tracybot@local', GIT_COMMITTER_NAME: 'Tracybot', GIT_COMMITTER_EMAIL: 'tracybot@local' }, + }).trim(); + + execSync(`git update-ref refs/tracy-local/aaaa1111 ${mergeCommit}`, { cwd: dir }); + execSync('git config tracy.current-id aaaa1111', { cwd: dir }); + execSync(`git reset -q --mixed ${baseCommit}`, { cwd: dir }); + fs.writeFileSync(filePath, [ + 'def calculate_total(items):', + ' total = 0', + ' if len(items) >= 10:', + ' total *= 0.9', + ' return total', + '', + 'def helper():', + ' z = compute_something_entirely_different()', + ' return z', + '', + ].join('\n')); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const taskletA = file?.tasklets.find(t => t.taskletId === 'tasklet-A1'); + const taskletB = file?.tasklets.find(t => t.taskletId === 'tasklet-B1'); + + assert.ok( + !taskletA || taskletA.lines.length === 0, + 'branch A\'s tiny edit must be filtered — its real parent is the base commit, not branch B\'s AI snapshot' + ); + assert.ok(taskletB, 'branch B\'s substantial edit should still be attributed'); + assert.ok(taskletB!.lines.length > 0, 'branch B\'s edit should still own a live line'); + }); +}); + +suite('buildHistory aligns duplicate lines within a hunk one-to-one', () => { + test('two identical AI-written lines bundled into one insignificant hunk both keep distinct attribution', async () => { + // A naive per-line lookup (findIndex over the hunk's new lines) always + // resolves a duplicated line to the SAME first occurrence — both old + // duplicates collapse onto one new position, and the Set-based + // survivor collection silently drops the second one. A proper + // one-to-one, order-preserving alignment must keep them distinct. + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, [ + 'def process(items):', + ' return items', + '', + ].join('\n')); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + // tasklet1 (AI): inserts a loop with two identical log lines — a pure + // insertion, both attributed outright. + fs.writeFileSync(filePath, [ + 'def process(items):', + ' for item in items:', + ' if item.valid:', + ' log.debug("done")', + ' else:', + ' log.debug("done")', + ' return items', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'add a validation loop with debug logging', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + + execSync('git add -A && git commit -q -m "add validation loop (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // A later, separate human commit reindents the whole block AND renames + // a field, forcing git to bundle both duplicate lines into ONE hunk + // that's still similar enough overall to be "insignificant". + fs.writeFileSync(filePath, [ + 'def process(items):', + ' for item in items:', + ' if item.is_valid:', + ' log.debug("done")', + ' else:', + ' log.debug("done")', + ' return items', + '', + ].join('\n')); + execSync('git add -A && git commit -q -m "human reindents and renames"', { cwd: dir }); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'tasklet-1 should still be attributed to some of the loop'); + assert.deepStrictEqual( + tasklet!.lines, + [2, 4, 5, 6], + 'both duplicate log lines (4 and 6) must independently survive — neither should collapse onto the other' + ); + }); + + test('two different tasklets each owning one occurrence of a duplicated line keep their own distinct positions', async () => { + // consumeAndShift is called separately per tasklet's own Change + // (propagateChanges maps over each Change independently), so this + // also exercises the cross-call case: two separate invocations against + // the same hunk must still agree on which duplicate maps to which + // tasklet, instead of one tasklet's line overwriting the other's. + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, [ + 'def process(a, b):', + ' pass', + '', + ].join('\n')); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + // tasklet1 (AI): adds branch a with a log line. + fs.writeFileSync(filePath, [ + 'def process(a, b):', + ' if a:', + ' log.info(\'processed\')', + ' pass', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const aiCommit1 = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'log when a is processed', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit1}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "log when a is processed (AI assisted)"', { cwd: dir }); + const commitA = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit1}`, { cwd: dir }); + + // tasklet2 (AI, a SEPARATE later chain): adds an IDENTICAL log line for + // branch b. + fs.writeFileSync(filePath, [ + 'def process(a, b):', + ' if a:', + ' log.info(\'processed\')', + ' if b:', + ' log.info(\'processed\')', + ' pass', + '', + ].join('\n')); + execSync('git add app.py', { cwd: dir }); + const aiCommit2 = commitAiEdit(dir, commitA, 'tasklet-2', 'sess2', 'also log when b is processed', 2000); + execSync(`git update-ref refs/tracy-local/bbbb2222 ${aiCommit2}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "also log when b is processed (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: bbbb2222" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/bbbb2222 ${aiCommit2}`, { cwd: dir }); + + // Human commit: reindents the whole region, bundling both duplicates + // into one hunk. + fs.writeFileSync(filePath, [ + 'def process(a, b):', + ' if a:', + ' log.info(\'processed\')', + ' if b:', + ' log.info(\'processed\')', + ' pass', + '', + ].join('\n')); + execSync('git add -A && git commit -q -m "human reindents"', { cwd: dir }); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet1 = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + const tasklet2 = file?.tasklets.find(t => t.taskletId === 'tasklet-2'); + + assert.deepStrictEqual(tasklet1?.lines, [2, 3], 'tasklet-1 should keep its own branch-a lines'); + assert.deepStrictEqual(tasklet2?.lines, [4, 5], 'tasklet-2 should keep its own branch-b lines, not tasklet-1\'s'); + }); +}); + +suite('buildHistory stays fast and memory-bounded on a large single hunk', () => { + test('reformatting a large AI-generated file (one big insignificant hunk) resolves quickly without excess memory growth', async function () { + // A full-file reformat (e.g. running a formatter over an AI-generated + // file) touches every line, so git can produce ONE hunk spanning the + // whole file — thousands of lines on both sides. An O(n*m) alignment + // (a full LCS table) would allocate on the order of n*m number slots + // for that single hunk: harmless at a few hundred lines, but hundreds + // of MB to GB once a hunk reaches the thousands, which a large + // generated file crosses easily. The alignment must stay roughly + // linear in hunk size instead. + this.timeout(20000); + + const lineCount = 5000; + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, '# placeholder\n'); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + const buildLines = (indent: string) => { + const out = ['def process():']; + for (let i = 0; i < lineCount; i++) { + out.push(`${indent}log.debug("line ${i}")`); + } + out.push(`${indent}return None`); + out.push(''); + return out.join('\n'); + }; + + fs.writeFileSync(filePath, buildLines(' ')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'generate a large function', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "generate large function (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Human commit: reindent the entire block — every line changes, so the + // whole thing lands in one hunk that's still similarity-wise + // "insignificant" (whitespace is stripped before comparison). + fs.writeFileSync(filePath, buildLines(' ')); + execSync('git add -A && git commit -q -m "human reindents the whole file"', { cwd: dir }); + + const heapBefore = process.memoryUsage().heapUsed; + const startedAt = Date.now(); + const result = await buildHistory(dir); + const elapsedMs = Date.now() - startedAt; + const heapGrowthMb = (process.memoryUsage().heapUsed - heapBefore) / (1024 * 1024); + + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'the large reformatted function should still be attributed'); + assert.strictEqual( + tasklet!.lines.length, + lineCount + 2, + 'every reformatted line (plus the def and return lines) should still resolve to its own distinct position' + ); + + assert.ok( + elapsedMs < 5000, + `expected a ${lineCount}-line single-hunk alignment to resolve in well under 5s, took ${elapsedMs}ms — an O(n*m) alignment would scale quadratically here` + ); + assert.ok( + heapGrowthMb < 150, + `expected heap growth well under 150MB for a ${lineCount}-line hunk, saw ${heapGrowthMb.toFixed(1)}MB — an O(n*m) alignment table would allocate on the order of ${lineCount}^2 number slots` + ); + }); + + test('a large hunk where every line is identical resolves without quadratic slowdown', async function () { + // The previous test's lines are all distinct, so it can't catch a + // different cost: consuming a per-content bucket of matched new-line + // indices via Array.prototype.shift() is O(k) per call — shift() + // shifts every remaining element down by one — so repeatedly shifting + // the SAME bucket (which is exactly what happens when a hunk has many + // identical lines: `}`, blank lines, templated log statements) costs + // O(k^2) for that bucket alone, even though the rest of the alignment + // is linear. This needs a much larger line count than the previous + // test before that quadratic term dominates the (still-linear) cost + // of everything else in the pipeline (diff parsing, file I/O). + this.timeout(60000); + + const lineCount = 300000; + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, '# placeholder\n'); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + const buildLines = (indent: string) => { + const out = ['def process():']; + for (let i = 0; i < lineCount; i++) { + out.push(`${indent}log.debug("tick")`); + } + out.push(`${indent}return None`); + out.push(''); + return out.join('\n'); + }; + + fs.writeFileSync(filePath, buildLines(' ')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'generate a large function with repeated log lines', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "generate large function (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Human commit: reindent the entire block of identical lines — one + // giant hunk, one giant content bucket. + fs.writeFileSync(filePath, buildLines(' ')); + execSync('git add -A && git commit -q -m "human reindents the whole file"', { cwd: dir }); + + const startedAt = Date.now(); + const result = await buildHistory(dir); + const elapsedMs = Date.now() - startedAt; + + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'the large reformatted function should still be attributed'); + assert.strictEqual( + tasklet!.lines.length, + lineCount + 2, + 'every reformatted line (plus the def and return lines) should still resolve to its own distinct position' + ); + + // Measured on a dev machine: a cursor-based bucket consumption + // resolves this in ~6s; the pre-fix Array.shift()-based consumption + // took ~11.7s for the same input. The bound below leaves generous + // margin over the fixed-code time for slower CI hardware while still + // sitting below the pre-fix time. + assert.ok( + elapsedMs < 10000, + `expected a ${lineCount}-line single-hunk alignment with all-identical content to resolve in well under 10s, took ${elapsedMs}ms — repeatedly Array.shift()-ing the same content bucket is O(k^2) for a bucket of this size` + ); + }); + + test('a large uniform rename with no exact matches still attributes every line, not just the untouched ones', async function () { + // A prior version of the BLEU fallback gated the ENTIRE pass on the + // total unmatched-old x unmatched-new product: past a fixed cap, it + // skipped fuzzy matching altogether rather than searching less. A + // uniform rename applied throughout a large AI-generated block (every + // line references the renamed identifier, so NOTHING exact-matches + // after the rename) crosses that cap easily and lost attribution for + // the whole hunk — not a precision trade-off, a functional regression, + // since this is exactly the case the fallback exists to handle. + this.timeout(30000); + + const lineCount = 500; // 500*500 = 250,000, over the old 200,000 cap + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, '# placeholder\n'); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + const buildLines = (fieldName: string) => { + const out = ['def process(items):']; + for (let i = 0; i < lineCount; i++) { + out.push(` record${i} = load(${fieldName}=${i})`); + } + out.push(' return None'); + out.push(''); + return out.join('\n'); + }; + + fs.writeFileSync(filePath, buildLines('user_id')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'generate a large function using user_id', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "generate large function (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Human commit: rename user_id -> userId everywhere. Every line + // changes (nothing exact-matches), but each is still highly similar + // to its own counterpart. + fs.writeFileSync(filePath, buildLines('userId')); + execSync('git add -A && git commit -q -m "human renames user_id to userId everywhere"', { cwd: dir }); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'the large renamed function should still be attributed'); + assert.strictEqual( + tasklet!.lines.length, + lineCount + 2, + 'every renamed line (plus the def and return lines) should still resolve to its own position — not just the two untouched lines' + ); + }); + + test('lines inserted before a large rename do not pull AI-attributed lines onto the wrong occurrence', async function () { + // Code review finding: a fixed-size proportional estimate assumes the + // hunk's own line-count change is spread evenly across it. If it's + // actually concentrated at one end — e.g. a block of lines inserted + // right before a large renamed section — an old line near that end + // gets an estimate that's off by roughly the insertion size, and if + // something else in the hunk happens to be near-duplicate content + // (templated code very often is), the search can settle on that wrong + // occurrence via early exit before ever reaching the real one, which + // sits further out but still within the search window. + // + // This reproduces it precisely: 50 lines are inserted right before + // tasklet-1's 500-line renamed block, reusing indices 0..49 with + // content that becomes BYTE-IDENTICAL to the first 50 lines of + // tasklet-1's own (renamed) block — the sharpest version of "templated + // lines that are highly similar to each other." A naive estimate + // lands the search right on the inserted duplicate first. + this.timeout(20000); + + const lineCount = 500; + const insertedCount = 50; + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, '# placeholder\n'); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + const buildAiLines = () => { + const out = ['def process(items):']; + for (let i = 0; i < lineCount; i++) { + out.push(` record${i} = load(user_id=${i})`); + } + out.push(' return None'); + out.push(''); + return out.join('\n'); + }; + + fs.writeFileSync(filePath, buildAiLines()); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'generate a large function using user_id', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "generate large function (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Human commit: insert 50 lines reusing indices 0..49 right before the + // block, and rename user_id -> userId across the real block too. After + // the rename, indices 0..49 exist twice in the new file — once as the + // inserted lines, once as the true (shifted) AI content, byte- + // identical to each other. + const buildRenamedLines = () => { + const out = ['def process(items):']; + for (let i = 0; i < insertedCount; i++) { + out.push(` record${i} = load(userId=${i})`); + } + for (let i = 0; i < lineCount; i++) { + out.push(` record${i} = load(userId=${i})`); + } + out.push(' return None'); + out.push(''); + return out.join('\n'); + }; + fs.writeFileSync(filePath, buildRenamedLines()); + execSync('git add -A && git commit -q -m "human inserts duplicate-numbered lines and renames user_id to userId"', { cwd: dir }); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'the large renamed function should still be attributed'); + + // The inserted duplicate occupies new lines [2, 1 + insertedCount]. + // None of tasklet-1's own lines should land there. + const wrongRangeStart = 2; + const wrongRangeEnd = 1 + insertedCount; + const misattributed = tasklet!.lines.filter(l => l >= wrongRangeStart && l <= wrongRangeEnd); + assert.deepStrictEqual( + misattributed, + [], + `expected none of tasklet-1's lines to land in the inserted-duplicate range [${wrongRangeStart},${wrongRangeEnd}], got ${JSON.stringify(misattributed)}` + ); + }); + + test('a large hunk of uniformly-renamed identical lines resolves without stalling or drifting off position', async function () { + // Two code review findings share this one repro. A hunk where every + // old line is identical to every other (a real pattern: the same + // templated statement repeated verbatim thousands of times) and gets + // uniformly renamed, with no lines inserted or deleted, means every + // old line's fuzzy search sees thousands of candidates that ALL score + // identically: + // + // 1. Preferring the farther-explored candidate on an exact tie (added + // so a real match isn't lost to an earlier, wrong-but-equally- + // scoring one) originally marked every tie as "progress", resetting + // the patience counter — so patience never accumulated and every + // line scanned the full search window. Reproduced a ~13x slowdown + // (~2s fixed vs ~26s before) at 5,000 lines. + // 2. Once ties stopped resetting patience, "always prefer the + // farther-explored tie" was still wrong when nothing actually + // shifted (no size change in this hunk, so the natural position for + // each line is unchanged) — it systematically walked every line's + // pick toward the edge of the search window, losing the earliest + // lines instead of matching them where they already were. + this.timeout(20000); + + const lineCount = 5000; + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, '# placeholder\n'); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + const buildLines = (fieldName: string) => { + const out = ['def process():']; + for (let i = 0; i < lineCount; i++) { + out.push(` record = load(${fieldName}=value)`); + } + out.push(' return None'); + out.push(''); + return out.join('\n'); + }; + + fs.writeFileSync(filePath, buildLines('user_id')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'generate a large function using user_id repeatedly', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "generate large function (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Human commit: rename user_id -> userId across every (identical) + // line at once. + fs.writeFileSync(filePath, buildLines('userId')); + execSync('git add -A && git commit -q -m "human renames user_id to userId everywhere"', { cwd: dir }); + + const startedAt = Date.now(); + const result = await buildHistory(dir); + const elapsedMs = Date.now() - startedAt; + + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'the large renamed function should still be attributed'); + // With every line identical to every other, which specific line + // "belongs" to which is genuinely ambiguous — a handful can be lost + // at the margins without it being a correctness bug. The overwhelming + // majority resolving is what matters here. + assert.ok( + tasklet!.lines.length > lineCount * 0.95, + `expected the vast majority of ${lineCount} identical renamed lines to still resolve, got ${tasklet!.lines.length}` + ); + + // Count alone isn't enough: a systematic bias toward farther-explored + // ties can preserve nearly the full count while shifting every one of + // them off their natural (unshifted) position — losing the earliest + // lines instead of a random scatter. There's no size change here + // (oldCount === newCount), so the natural, unshifted range is exactly + // [1, lineCount + 2] (the def line through the return line). A shift + // shows up as the observed minimum climbing well above 1. + const sortedLines = tasklet!.lines.slice().sort((a, b) => a - b); + assert.ok( + sortedLines[0] <= 3, + `expected attribution to start at or near line 1 (no size change here, so nothing should need to shift) — the lowest attributed line was ${sortedLines[0]}, suggesting matches drifted toward one end of the search window` + ); + + assert.ok( + elapsedMs < 8000, + `expected a ${lineCount}-line hunk of identical, uniformly-renamed lines to resolve in well under 8s, took ${elapsedMs}ms — ties resetting the early-exit patience counter would force a full-window scan for every line` + ); + }); + + test('uniformly-renamed identical lines do not drift into unrelated lines appended right after the block', async function () { + // Code review finding, and a mirror of the previous test's own + // reasoning turned back on it: fixing the "drift toward the edge of + // the window" bug meant biasing ties toward the offset the gap's + // line-count change implies. But that bias is a single number for + // the WHOLE gap — it has no way to know WHERE within the gap an + // insertion actually happened. Here, 50 non-AI lines (identical to + // the AI's own, entirely uninformative content — no unique index + // anywhere) are appended AFTER the AI's block instead of inserted + // before it. The AI's own 1,000 lines need no shift at all, but a + // drift target that assumes the gap's whole +50 change applies + // uniformly to every line would still walk lines near the end of the + // block into the appended (non-AI) region, since that region is + // exactly as similar to the AI's own text as the AI's own text is to + // itself. + this.timeout(20000); + + const lineCount = 1000; + const appendedCount = 50; + const dir = makeTempDir(); + execSync('git init -q', { cwd: dir }); + execSync('git config user.email test@example.com', { cwd: dir }); + execSync('git config user.name Test', { cwd: dir }); + + const filePath = path.join(dir, 'app.py'); + fs.writeFileSync(filePath, '# placeholder\n'); + execSync('git add app.py && git commit -q -m init', { cwd: dir }); + const baseCommit = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim(); + + const buildAiLines = (fieldName: string) => { + const out = ['def process():']; + for (let i = 0; i < lineCount; i++) { + out.push(` record = load(${fieldName}=value)`); + } + out.push(' return None'); + out.push(''); + return out.join('\n'); + }; + + fs.writeFileSync(filePath, buildAiLines('user_id')); + execSync('git add app.py', { cwd: dir }); + const aiCommit = commitAiEdit(dir, baseCommit, 'tasklet-1', 'sess1', 'generate a large function using user_id repeatedly', 1000); + execSync(`git update-ref refs/tracy-local/aaaa1111 ${aiCommit}`, { cwd: dir }); + execSync('git add -A && git commit -q -m "generate large function (AI assisted)"', { cwd: dir }); + execSync('git notes add -m "tracy-id: aaaa1111" HEAD', { cwd: dir }); + execSync(`git update-ref refs/tracy/aaaa1111 ${aiCommit}`, { cwd: dir }); + + // Human commit: rename user_id -> userId across the AI's block AND + // append 50 more identical (non-AI) lines right after it. + const buildRenamedWithAppend = () => { + const out = ['def process():']; + for (let i = 0; i < lineCount; i++) { + out.push(' record = load(userId=value)'); + } + for (let i = 0; i < appendedCount; i++) { + out.push(' record = load(userId=value)'); + } + out.push(' return None'); + out.push(''); + return out.join('\n'); + }; + fs.writeFileSync(filePath, buildRenamedWithAppend()); + execSync('git add -A && git commit -q -m "human renames and appends more identical lines after (non-AI)"', { cwd: dir }); + + const result = await buildHistory(dir); + assert.strictEqual(result.ok, true); + if (!result.ok) { return; } + + const file = result.history.files.find(f => f.path === 'app.py'); + const tasklet = file?.tasklets.find(t => t.taskletId === 'tasklet-1'); + assert.ok(tasklet, 'the AI block should still be attributed'); + + // The AI's block occupies new lines [2, 1 + lineCount]. The appended, + // non-AI tail occupies [2 + lineCount, 1 + lineCount + appendedCount]. + // None of tasklet-1's lines should land in the tail. + const tailStart = 2 + lineCount; + const tailEnd = 1 + lineCount + appendedCount; + const misattributed = tasklet!.lines.filter(l => l >= tailStart && l <= tailEnd); + assert.deepStrictEqual( + misattributed, + [], + `expected none of tasklet-1's lines to land in the appended, non-AI tail [${tailStart},${tailEnd}], got ${JSON.stringify(misattributed)}` + ); + }); +}); diff --git a/vscode-extension/src/utils.ts b/vscode-extension/src/utils.ts index 577d182..f895b4c 100644 --- a/vscode-extension/src/utils.ts +++ b/vscode-extension/src/utils.ts @@ -285,6 +285,12 @@ export async function getDiff( return fileChanges; } +// Character-level 4-gram BLEU similarity between two arbitrary text blocks +// (single lines or whole hunks). 1.0 = identical, lower = more different. +export function bleuSimilarity(oldText: string, newText: string): number { + return bleu(tokenizeHunk(oldText), tokenizeHunk(newText), 4); +} + // Compute significance of a hunk by comparing old and new content using BLEU function computeHunkSignificance(oldLines: string[], newLines: string[]): { isSignificant: boolean; bleuScore: number | null } { if (oldLines.length === 0 && newLines.length === 0) { @@ -301,9 +307,7 @@ function computeHunkSignificance(oldLines: string[], newLines: string[]): { isSi return { isSignificant: true, bleuScore: null }; } - const oldContent = oldLines.join("\n"); - const newContent = newLines.join("\n"); - const score = bleu(tokenizeHunk(oldContent), tokenizeHunk(newContent), 4); + const score = bleuSimilarity(oldLines.join("\n"), newLines.join("\n")); return { isSignificant: score <= SIMILARITY_THRESHOLD, bleuScore: score }; }