@lumy-pack/line-lore 0.0.7 → 0.0.9

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 CHANGED
@@ -25,9 +25,12 @@ yarn add @lumy-pack/line-lore
25
25
  ### CLI Usage
26
26
 
27
27
  ```bash
28
- # Trace a single line to its PR
28
+ # Trace a single line to its originating PR
29
29
  npx @lumy-pack/line-lore trace src/auth.ts -L 42
30
30
 
31
+ # Trace the last meaningful change to a line
32
+ npx @lumy-pack/line-lore trace src/auth.ts -L 42 --mode change
33
+
31
34
  # Trace a line range
32
35
  npx @lumy-pack/line-lore trace src/config.ts -L 10,50
33
36
 
@@ -61,14 +64,21 @@ npx @lumy-pack/line-lore trace src/auth.ts -L 42 --quiet
61
64
  ```typescript
62
65
  import { trace, graph, health, clearCache } from '@lumy-pack/line-lore';
63
66
 
64
- // Trace a line to its PR
65
- const result = await trace({
67
+ // Trace a line to its originating PR (default mode)
68
+ const originResult = await trace({
69
+ file: 'src/auth.ts',
70
+ line: 42,
71
+ });
72
+
73
+ // Trace the last meaningful change to a line
74
+ const changeResult = await trace({
66
75
  file: 'src/auth.ts',
67
76
  line: 42,
77
+ mode: 'change',
68
78
  });
69
79
 
70
80
  // Find the PR node
71
- const prNode = result.nodes.find(n => n.type === 'pull_request');
81
+ const prNode = originResult.nodes.find(n => n.type === 'pull_request');
72
82
  if (prNode) {
73
83
  console.log(`PR #${prNode.prNumber}: ${prNode.prTitle}`);
74
84
  }
@@ -89,9 +99,11 @@ console.log(`Git version: ${report.gitVersion}`);
89
99
 
90
100
  ## How It Works
91
101
 
92
- @lumy-pack/line-lore executes a 4-stage deterministic pipeline:
102
+ @lumy-pack/line-lore executes a deterministic pipeline with two trace modes:
93
103
 
94
- 1. **Line → Commit (Blame)**: Git blame with `-C -C -M` flags to detect renames and copies
104
+ 1. **Line → Commit (Blame)**:
105
+ - `origin`: `git blame -w -C -C -M` — follows copy/move history across renames; whitespace-only changes ignored
106
+ - `change`: `git blame -w` — finds the last meaningful local change; ignores whitespace but does **not** track renames/copies, so a rename commit itself is attributed as the change
95
107
  2. **Cosmetic Detection**: AST structural comparison to skip formatting-only changes
96
108
  3. **Commit → Merge Commit**: Ancestry-path traversal + patch-id matching to resolve merge commits
97
109
  4. **Merge Commit → PR**: Commit message parsing + platform API lookup (filters unmerged PRs)
@@ -111,19 +123,21 @@ Strategy 1 — Cache ─────────────────── c
111
123
  │ hit? → return cached PRInfo
112
124
  │ miss + --cache-only? → return null (skip all fallbacks)
113
125
 
114
- Strategy 2 — Ancestry-path + Message cost: 1 git-log
115
- 1st: git log --merges --ancestry-path --first-parent sha..HEAD
116
- 2nd: (fallback) full ancestry-path without --first-parent
126
+ Strategy 2 — Platform API ──────────── cost: 1 HTTP request
127
+ gh api repos/{owner}/{repo}/commits/{sha}/pulls
128
+ Filter: merged PRs only (mergedAt != null)
129
+ │ found? → return PRInfo
130
+
131
+ Strategy 3 — Ancestry-path + Message ─ cost: 1-2 git-log traversals
132
+ │ Search verified merge candidates:
133
+ │ • first-parent ancestry path first
134
+ │ • full ancestry path second
117
135
  │ Parse merge subject with 3 regex patterns:
118
136
  │ • /Merge pull request #(\d+)/ — GitHub merge commit
119
137
  │ • /\(#(\d+)\)\s*$/ — Squash merge convention
120
- │ • /!(\d+)\s*$/ — GitLab merge commit
121
- │ If PR# found + adapter available enrich via API
122
- found? return PRInfo
123
-
124
- Strategy 3 — Platform API ──────────── cost: 1 HTTP request
125
- │ gh api repos/{owner}/{repo}/commits/{sha}/pulls
126
- │ Filter: merged PRs only (mergedAt != null)
138
+ │ • /See merge request ...!(\d+)$/ — GitLab merge commit
139
+ │ If message has no PR number and API is available:
140
+ query the merge commit SHA directly
127
141
  │ found? → return PRInfo
128
142
 
129
143
  Strategy 4 — Patch-ID matching ─────── cost: streaming 500+ commits
@@ -136,7 +150,7 @@ Strategy 4 — Patch-ID matching ─────── cost: streaming 500+ comm
136
150
  All failed → null
137
151
  ```
138
152
 
139
- **Why this order?** The chain is sorted by cost. Most repositories use merge or squash workflows, so Strategy 2 resolves >90% of lookups with zero API calls. Strategy 3 (single HTTP) is cheaper than Strategy 4 (streaming hundreds of commit diffs), so API is tried before patch-id scanning.
153
+ **Why this order?** Direct API lookup is the strongest Level 2 signal, so it runs before local ancestry heuristics. Verified ancestry is still cheaper than patch-id scanning and resolves most merge-based workflows without diff streaming.
140
154
 
141
155
  **Patch-ID explained**: `git patch-id --stable` generates a content-based hash from a commit's diff, ignoring all metadata (author, date, message). When a commit is rebased, its SHA changes but the patch-id stays the same — enabling deterministic matching of rebased commits.
142
156
 
@@ -170,7 +184,7 @@ interface TraceNode {
170
184
 
171
185
  | Type | Symbol | Meaning | When it appears |
172
186
  |------|--------|---------|-----------------|
173
- | `original_commit` | `●` | The commit that introduced or last modified this line | Always (at least one) |
187
+ | `original_commit` | `●` | The commit selected by the active trace mode | Always (at least one) |
174
188
  | `cosmetic_commit` | `○` | A formatting-only change (whitespace, imports) | When AST detects no logic change |
175
189
  | `merge_commit` | `◆` | The merge commit on the base branch | Merge-based workflows |
176
190
  | `rebased_commit` | `◇` | A rebased version of the original commit | Rebase workflows with patch-id match |
@@ -206,6 +220,13 @@ interface TraceNode {
206
220
  └─ https://github.com/org/repo/pull/42
207
221
  ```
208
222
 
223
+ **Last meaningful change mode (`--mode change`):**
224
+ ```
225
+ ● Commit e4f5a6b [exact] via blame
226
+ ▸ PR #55 refactor: update validation logic
227
+ └─ https://github.com/org/repo/pull/55
228
+ ```
229
+
209
230
  **Squash merge (Level 2):**
210
231
  ```
211
232
  ● Commit e4f5a6b [exact] via blame-CMw
@@ -334,7 +355,7 @@ import { trace, graph, health, clearCache, LineLoreError } from '@lumy-pack/line
334
355
 
335
356
  ### `trace(options): Promise<TraceFullResult>`
336
357
 
337
- Trace a code line to its originating PR.
358
+ Trace a code line to its originating or last-change PR, depending on the selected mode.
338
359
 
339
360
  **Options (`TraceOptions`):**
340
361
 
@@ -344,6 +365,7 @@ Trace a code line to its originating PR.
344
365
  | `line` | `number` | yes | — | Starting line number (1-indexed) |
345
366
  | `endLine` | `number` | no | — | Ending line for range queries |
346
367
  | `remote` | `string` | no | `'origin'` | Git remote name |
368
+ | `mode` | `'origin' \| 'change'` | no | `'origin'` | `origin` uses `git blame -w -C -C -M` (follows copy/move history across renames), `change` uses `git blame -w` (finds the last meaningful local change, ignoring whitespace but not copy/move) |
347
369
  | `deep` | `boolean` | no | `false` | Expand patch-id scan range (500→2000), continue search after merge commit match |
348
370
  | `noAst` | `boolean` | no | `false` | Disable AST analysis |
349
371
  | `noCache` | `boolean` | no | `false` | Disable cache reads and writes |
@@ -389,6 +411,7 @@ const result = await trace({
389
411
  file: 'src/config.ts',
390
412
  line: 10,
391
413
  endLine: 50,
414
+ mode: 'origin',
392
415
  deep: true, // search harder for squash merges
393
416
  noCache: true, // skip cache for fresh results
394
417
  });
@@ -631,8 +654,9 @@ import type {
631
654
 
632
655
  | Command | Purpose |
633
656
  |---------|---------|
634
- | `npx @lumy-pack/line-lore trace <file>` | Trace a line to its PR |
657
+ | `npx @lumy-pack/line-lore trace <file>` | Trace a line to its origin or last-change PR |
635
658
  | `-L, --line <num>` | Starting line (required) |
659
+ | `--mode <origin\|change>` | Choose between content origin and last meaningful change |
636
660
  | `--end-line <num>` | Ending line for range |
637
661
  | `--deep` | Deep trace (squash merges) |
638
662
  | `--output <format>` | Output as json, llm, or human |
package/dist/cli.mjs CHANGED
@@ -367,25 +367,6 @@ var init_executor = __esm({
367
367
 
368
368
  // src/core/ancestry/ancestry.ts
369
369
  import { filter as filter4, isTruthy as isTruthy4 } from "@winglet/common-utils";
370
- async function findMergeCommit(commitSha, options) {
371
- const ref = options?.ref ?? "HEAD";
372
- const budget = options?.timeout ?? DEFAULT_ANCESTRY_TIMEOUT;
373
- const startTime = Date.now();
374
- const firstParentResult = await findMergeCommitWithArgs(
375
- commitSha,
376
- ref,
377
- ["--first-parent"],
378
- { ...options, timeout: budget }
379
- );
380
- if (firstParentResult) return firstParentResult;
381
- const elapsed = Date.now() - startTime;
382
- const remaining = budget - elapsed;
383
- if (remaining <= 0) return null;
384
- return findMergeCommitWithArgs(commitSha, ref, [], {
385
- ...options,
386
- timeout: remaining
387
- });
388
- }
389
370
  async function verifyMergeIntroducesCommit(targetSha, mergeResult, options) {
390
371
  if (mergeResult.parentShas.length < 2) return true;
391
372
  const firstParent = mergeResult.parentShas[0];
@@ -415,7 +396,7 @@ async function isAncestor(commitA, commitB, options) {
415
396
  return null;
416
397
  }
417
398
  }
418
- async function findMergeCommitWithArgs(commitSha, ref, extraArgs, options) {
399
+ async function findMergeCommitsWithArgs(commitSha, ref, extraArgs, options) {
419
400
  try {
420
401
  const result = await gitExec(
421
402
  [
@@ -431,28 +412,29 @@ async function findMergeCommitWithArgs(commitSha, ref, extraArgs, options) {
431
412
  { cwd: options?.cwd, timeout: options?.timeout }
432
413
  );
433
414
  const lines = filter4(result.stdout.trim().split("\n"), isTruthy4);
434
- if (lines.length === 0) return null;
415
+ if (lines.length === 0) return [];
416
+ const verifiedCandidates = [];
435
417
  const candidateCount = Math.min(lines.length, MAX_CANDIDATES);
436
- let verifiedCount = 0;
418
+ let attemptedCount = 0;
437
419
  for (let i = 0; i < candidateCount; i++) {
438
420
  const candidate = parseMergeLogLine(lines[i]);
439
421
  if (!candidate) continue;
440
- verifiedCount++;
422
+ attemptedCount++;
441
423
  const verified = await verifyMergeIntroducesCommit(
442
424
  commitSha,
443
425
  candidate,
444
426
  options
445
427
  );
446
- if (verified) return candidate;
428
+ if (verified) verifiedCandidates.push(candidate);
447
429
  }
448
- if (verifiedCount > 0 && options?.warnings) {
430
+ if (attemptedCount > 0 && verifiedCandidates.length === 0 && options?.warnings) {
449
431
  options.warnings.push(
450
- `ancestry: all ${verifiedCount} merge candidate(s) failed verification for ${commitSha.slice(0, 8)}`
432
+ `ancestry: all ${attemptedCount} merge candidate(s) failed verification for ${commitSha.slice(0, 8)}`
451
433
  );
452
434
  }
453
- return null;
435
+ return verifiedCandidates;
454
436
  } catch {
455
- return null;
437
+ return [];
456
438
  }
457
439
  }
458
440
  function parseMergeLogLine(line) {
@@ -472,6 +454,38 @@ function parseMergeLogLine(line) {
472
454
  const subject = parts.slice(subjectStart).join(" ");
473
455
  return { mergeCommitSha, parentShas, subject };
474
456
  }
457
+ async function findMergeCommits(commitSha, options) {
458
+ const ref = options?.ref ?? "HEAD";
459
+ const budget = options?.timeout ?? DEFAULT_ANCESTRY_TIMEOUT;
460
+ const startTime = Date.now();
461
+ const results = [];
462
+ const seen = /* @__PURE__ */ new Set();
463
+ const pushUnique = (candidates) => {
464
+ for (const candidate of candidates) {
465
+ if (seen.has(candidate.mergeCommitSha)) continue;
466
+ seen.add(candidate.mergeCommitSha);
467
+ results.push(candidate);
468
+ if (results.length >= MAX_CANDIDATES) break;
469
+ }
470
+ };
471
+ const firstParent = await findMergeCommitsWithArgs(
472
+ commitSha,
473
+ ref,
474
+ ["--first-parent"],
475
+ { ...options, timeout: budget }
476
+ );
477
+ pushUnique(firstParent);
478
+ const elapsed = Date.now() - startTime;
479
+ const remaining = budget - elapsed;
480
+ if (remaining > 0 && results.length < MAX_CANDIDATES) {
481
+ const full = await findMergeCommitsWithArgs(commitSha, ref, [], {
482
+ ...options,
483
+ timeout: remaining
484
+ });
485
+ pushUnique(full);
486
+ }
487
+ return results;
488
+ }
475
489
  async function getCommitSubject(sha, options) {
476
490
  try {
477
491
  const result = await gitExec(["log", "-1", "--format=%s", sha], {
@@ -670,16 +684,17 @@ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
670
684
  }
671
685
  }
672
686
  let mergeBasedPR = null;
673
- const mergeResult = await findMergeCommit(commitSha, options);
674
- if (mergeResult) {
687
+ const mergeCandidates = await findMergeCommits(commitSha, options);
688
+ const hasAncestryMerges = mergeCandidates.length > 0;
689
+ for (const candidate of mergeCandidates) {
675
690
  const prNumber = extractPRFromMergeMessage(
676
- mergeResult.subject,
691
+ candidate.subject,
677
692
  options?.platform
678
693
  );
679
694
  if (prNumber) {
680
695
  if (adapter) {
681
696
  const prInfo = await adapter.getPRForCommit(
682
- mergeResult.mergeCommitSha,
697
+ candidate.mergeCommitSha,
683
698
  prSelectOptions
684
699
  );
685
700
  if (prInfo?.mergedAt) {
@@ -689,23 +704,32 @@ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
689
704
  if (!mergeBasedPR) {
690
705
  mergeBasedPR = {
691
706
  number: prNumber,
692
- title: mergeResult.subject,
707
+ title: candidate.subject,
693
708
  author: "",
694
709
  url: "",
695
- mergeCommit: mergeResult.mergeCommitSha,
710
+ mergeCommit: candidate.mergeCommitSha,
696
711
  baseBranch: "",
697
712
  resolvedVia: "ancestry"
698
713
  };
699
714
  }
700
- if (!options?.deep || mergeBasedPR.mergedAt) {
701
- await cache.set(commitSha, toCachedPR(mergeBasedPR));
702
- return mergeBasedPR;
715
+ break;
716
+ }
717
+ if (adapter) {
718
+ const mergeCommitPR = await adapter.getPRForCommit(
719
+ candidate.mergeCommitSha,
720
+ prSelectOptions
721
+ );
722
+ if (mergeCommitPR?.mergedAt) {
723
+ mergeBasedPR = { ...mergeCommitPR, resolvedVia: "ancestry" };
724
+ break;
703
725
  }
704
726
  }
705
727
  }
706
728
  if (mergeBasedPR) {
707
- await cache.set(commitSha, toCachedPR(mergeBasedPR));
708
- return mergeBasedPR;
729
+ if (!options?.deep || mergeBasedPR.mergedAt) {
730
+ await cache.set(commitSha, toCachedPR(mergeBasedPR));
731
+ return mergeBasedPR;
732
+ }
709
733
  }
710
734
  const commitSubject = await getCommitSubject(commitSha, options);
711
735
  if (commitSubject) {
@@ -727,7 +751,7 @@ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
727
751
  return subjectPR;
728
752
  }
729
753
  }
730
- if (!options?.skipPatchIdScan && _recursionDepth < MAX_RECURSION_DEPTH) {
754
+ if (!options?.skipPatchIdScan && _recursionDepth < MAX_RECURSION_DEPTH && (!hasAncestryMerges || options?.deep)) {
731
755
  const patchIdMatch = await findPatchIdMatch(commitSha, {
732
756
  ...options,
733
757
  scanDepth: options?.deep ? DEEP_SCAN_DEPTH : void 0
@@ -745,6 +769,10 @@ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
745
769
  }
746
770
  }
747
771
  }
772
+ if (mergeBasedPR) {
773
+ await cache.set(commitSha, toCachedPR(mergeBasedPR));
774
+ return mergeBasedPR;
775
+ }
748
776
  return null;
749
777
  }
750
778
  function resetPRCache() {
@@ -781,7 +809,7 @@ var VERSION;
781
809
  var init_version = __esm({
782
810
  "src/version.ts"() {
783
811
  "use strict";
784
- VERSION = "0.0.7";
812
+ VERSION = "0.0.9";
785
813
  }
786
814
  });
787
815
 
@@ -2094,6 +2122,7 @@ function parsePorcelainOutput(output) {
2094
2122
  }
2095
2123
  let commitHash = headerMatch[1];
2096
2124
  const originalLine = parseInt(headerMatch[2], 10);
2125
+ const finalLine = parseInt(headerMatch[3], 10) || 0;
2097
2126
  const isBoundary = commitHash.startsWith("^");
2098
2127
  if (isBoundary) {
2099
2128
  commitHash = commitHash.slice(1).padStart(40, "0");
@@ -2137,6 +2166,7 @@ function parsePorcelainOutput(output) {
2137
2166
  authorEmail: cleanEmail,
2138
2167
  date,
2139
2168
  lineContent,
2169
+ finalLine,
2140
2170
  originalFile,
2141
2171
  originalLine: originalFile ? originalLine : void 0
2142
2172
  });
@@ -2147,14 +2177,112 @@ function parsePorcelainOutput(output) {
2147
2177
  // src/core/blame/blame.ts
2148
2178
  async function executeBlame(file, lineRange, options) {
2149
2179
  const lineSpec = `${lineRange.start},${lineRange.end}`;
2150
- const result = await gitExec(
2151
- ["blame", "-w", "-C", "-C", "-M", "--porcelain", "-L", lineSpec, file],
2152
- options
2153
- );
2180
+ const args = options?.mode === "change" ? ["blame", "-w", "--porcelain", "-L", lineSpec, file] : ["blame", "-w", "-C", "-C", "-M", "--porcelain", "-L", lineSpec, file];
2181
+ const result = await gitExec(args, options);
2154
2182
  return parsePorcelainOutput(result.stdout);
2155
2183
  }
2156
- async function analyzeBlameResults(results, filePath, options) {
2157
- const uniqueShas = [...new Set(map6(results, (r) => r.commitHash))];
2184
+ async function executeDualBlame(file, lineRange, options) {
2185
+ if (options?.mode === "change") {
2186
+ const results = await executeBlame(file, lineRange, options);
2187
+ return { blame: results, changeBlame: [] };
2188
+ }
2189
+ const [originResult, changeResult] = await Promise.allSettled([
2190
+ executeBlame(file, lineRange, options),
2191
+ executeBlame(file, lineRange, { ...options, mode: "change" })
2192
+ ]);
2193
+ const blame = originResult.status === "fulfilled" ? originResult.value : [];
2194
+ const changeBlame = changeResult.status === "fulfilled" ? changeResult.value : [];
2195
+ if (originResult.status === "rejected") {
2196
+ throw originResult.reason;
2197
+ }
2198
+ return { blame, changeBlame };
2199
+ }
2200
+ async function verifyRename(originalFile, currentFile, options) {
2201
+ try {
2202
+ const result = await gitExec(
2203
+ [
2204
+ "log",
2205
+ "--diff-filter=R",
2206
+ "--find-renames",
2207
+ "--format=%H",
2208
+ "--",
2209
+ originalFile,
2210
+ currentFile
2211
+ ],
2212
+ options
2213
+ );
2214
+ return result.stdout.trim().length > 0;
2215
+ } catch {
2216
+ return false;
2217
+ }
2218
+ }
2219
+ function crossValidateBlame(originResults, changeResults) {
2220
+ const changeMap = /* @__PURE__ */ new Map();
2221
+ for (const r of changeResults) {
2222
+ changeMap.set(r.finalLine, r);
2223
+ }
2224
+ const validated = [];
2225
+ const crossValidatedFlags = [];
2226
+ const changeFallbackFlags = [];
2227
+ const renameChecks = [];
2228
+ for (let i = 0; i < originResults.length; i++) {
2229
+ const origin = originResults[i];
2230
+ const change = changeMap.get(origin.finalLine);
2231
+ if (!change) {
2232
+ validated.push(origin);
2233
+ crossValidatedFlags.push(false);
2234
+ changeFallbackFlags.push(false);
2235
+ continue;
2236
+ }
2237
+ if (origin.commitHash === change.commitHash) {
2238
+ validated.push(origin);
2239
+ crossValidatedFlags.push(true);
2240
+ changeFallbackFlags.push(false);
2241
+ continue;
2242
+ }
2243
+ if (origin.originalFile) {
2244
+ renameChecks.push({
2245
+ originalFile: origin.originalFile,
2246
+ lineIndex: i
2247
+ });
2248
+ }
2249
+ validated.push(change);
2250
+ crossValidatedFlags.push(true);
2251
+ changeFallbackFlags.push(true);
2252
+ }
2253
+ return { validated, renameChecks, crossValidatedFlags, changeFallbackFlags };
2254
+ }
2255
+ async function analyzeBlameResults(results, filePath, options, changeResults) {
2256
+ let effectiveResults = results;
2257
+ let crossValidatedFlags;
2258
+ let changeFallbackFlagsResult;
2259
+ if (changeResults && changeResults.length > 0) {
2260
+ const { validated, renameChecks, crossValidatedFlags: cvFlags, changeFallbackFlags } = crossValidateBlame(results, changeResults);
2261
+ if (renameChecks.length > 0) {
2262
+ const pendingChecks = /* @__PURE__ */ new Map();
2263
+ for (const check of renameChecks) {
2264
+ const cacheKey = `${check.originalFile}:${filePath}`;
2265
+ if (!pendingChecks.has(cacheKey)) {
2266
+ pendingChecks.set(cacheKey, verifyRename(check.originalFile, filePath, options));
2267
+ }
2268
+ }
2269
+ const renameResults = /* @__PURE__ */ new Map();
2270
+ for (const [key, promise] of pendingChecks) {
2271
+ renameResults.set(key, await promise);
2272
+ }
2273
+ for (const check of renameChecks) {
2274
+ const cacheKey = `${check.originalFile}:${filePath}`;
2275
+ if (renameResults.get(cacheKey)) {
2276
+ validated[check.lineIndex] = results[check.lineIndex];
2277
+ changeFallbackFlags[check.lineIndex] = false;
2278
+ }
2279
+ }
2280
+ }
2281
+ effectiveResults = validated;
2282
+ crossValidatedFlags = cvFlags;
2283
+ changeFallbackFlagsResult = changeFallbackFlags;
2284
+ }
2285
+ const uniqueShas = [...new Set(map6(effectiveResults, (r) => r.commitHash))];
2158
2286
  const cosmeticMap = /* @__PURE__ */ new Map();
2159
2287
  const zeroSha = "0".repeat(40);
2160
2288
  const tasks = [];
@@ -2163,7 +2291,7 @@ async function analyzeBlameResults(results, filePath, options) {
2163
2291
  tasks.push(
2164
2292
  (async () => {
2165
2293
  try {
2166
- const blameResult = results.find((r) => r.commitHash === sha);
2294
+ const blameResult = effectiveResults.find((r) => r.commitHash === sha);
2167
2295
  if (!blameResult) return;
2168
2296
  const file = blameResult.originalFile ?? filePath;
2169
2297
  const diff = await getCosmeticDiff(sha, file, options);
@@ -2175,12 +2303,14 @@ async function analyzeBlameResults(results, filePath, options) {
2175
2303
  );
2176
2304
  });
2177
2305
  await Promise.all(tasks);
2178
- return map6(results, (blame) => {
2306
+ return map6(effectiveResults, (blame, i) => {
2179
2307
  const cosmetic = cosmeticMap.get(blame.commitHash);
2180
2308
  return {
2181
2309
  blame,
2182
2310
  isCosmetic: cosmetic?.isCosmetic ?? false,
2183
- cosmeticReason: cosmetic?.reason
2311
+ cosmeticReason: cosmetic?.reason,
2312
+ crossValidated: crossValidatedFlags?.[i],
2313
+ usedChangeFallback: changeFallbackFlagsResult?.[i]
2184
2314
  };
2185
2315
  });
2186
2316
  }
@@ -2357,8 +2487,11 @@ async function runBlameAndAuth(adapter, options, execOptions) {
2357
2487
  const lineRange = parseLineRange(
2358
2488
  options.endLine ? `${options.line},${options.endLine}` : `${options.line}`
2359
2489
  );
2360
- const blameChain = executeBlame(options.file, lineRange, execOptions).then(
2361
- (results) => analyzeBlameResults(results, options.file, execOptions)
2490
+ const blameChain = executeDualBlame(options.file, lineRange, {
2491
+ ...execOptions,
2492
+ mode: options.mode
2493
+ }).then(
2494
+ ({ blame, changeBlame }) => analyzeBlameResults(blame, options.file, execOptions, changeBlame.length > 0 ? changeBlame : void 0)
2362
2495
  );
2363
2496
  const [authResult, blameResult] = await Promise.allSettled([
2364
2497
  adapter ? adapter.checkAuth() : Promise.resolve({ authenticated: false }),
@@ -2380,12 +2513,24 @@ async function runBlameAndAuth(adapter, options, execOptions) {
2380
2513
  }
2381
2514
  return { analyzed: blameResult.value, operatingLevel, warnings };
2382
2515
  }
2383
- async function processEntry(entry, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan, preferredBase) {
2516
+ function resolveTraceMode(mode) {
2517
+ return mode ?? "origin";
2518
+ }
2519
+ function deduplicatedLookupPR(sha, adapter, options, inflight) {
2520
+ const existing = inflight.get(sha);
2521
+ if (existing) return existing;
2522
+ const promise = lookupPR(sha, adapter, options);
2523
+ inflight.set(sha, promise);
2524
+ promise.finally(() => inflight.delete(sha));
2525
+ return promise;
2526
+ }
2527
+ async function processEntry(entry, featureFlags, adapter, options, execOptions, repoId, inflightPR, skipPatchIdScan, preferredBase) {
2384
2528
  const nodes = [];
2529
+ const traceMode = resolveTraceMode(options.mode);
2385
2530
  const commitNode = {
2386
2531
  type: entry.isCosmetic ? "cosmetic_commit" : "original_commit",
2387
2532
  sha: entry.blame.commitHash,
2388
- trackingMethod: "blame-CMw",
2533
+ trackingMethod: traceMode === "change" || entry.usedChangeFallback ? "blame" : "blame-CMw",
2389
2534
  confidence: "exact",
2390
2535
  note: entry.cosmeticReason ? `Cosmetic change: ${entry.cosmeticReason}` : void 0
2391
2536
  };
@@ -2407,17 +2552,18 @@ async function processEntry(entry, featureFlags, adapter, options, execOptions,
2407
2552
  }
2408
2553
  }
2409
2554
  const targetSha = nodes[nodes.length - 1].sha;
2555
+ const prLookupOptions = {
2556
+ ...execOptions,
2557
+ noCache: options.noCache,
2558
+ cacheOnly: options.cacheOnly,
2559
+ deep: featureFlags.deepTrace,
2560
+ repoId,
2561
+ skipPatchIdScan,
2562
+ preferredBase,
2563
+ platform: adapter?.platform
2564
+ };
2410
2565
  if (targetSha) {
2411
- const prInfo = await lookupPR(targetSha, adapter, {
2412
- ...execOptions,
2413
- noCache: options.noCache,
2414
- cacheOnly: options.cacheOnly,
2415
- deep: featureFlags.deepTrace,
2416
- repoId,
2417
- skipPatchIdScan,
2418
- preferredBase,
2419
- platform: adapter?.platform
2420
- });
2566
+ const prInfo = await deduplicatedLookupPR(targetSha, adapter, prLookupOptions, inflightPR);
2421
2567
  if (prInfo) {
2422
2568
  nodes.push({
2423
2569
  type: "pull_request",
@@ -2434,6 +2580,7 @@ async function processEntry(entry, featureFlags, adapter, options, execOptions,
2434
2580
  return nodes;
2435
2581
  }
2436
2582
  async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan, preferredBase) {
2583
+ const inflightPR = /* @__PURE__ */ new Map();
2437
2584
  const results = await Promise.allSettled(
2438
2585
  map8(
2439
2586
  analyzed,
@@ -2444,6 +2591,7 @@ async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOpt
2444
2591
  options,
2445
2592
  execOptions,
2446
2593
  repoId,
2594
+ inflightPR,
2447
2595
  skipPatchIdScan,
2448
2596
  preferredBase
2449
2597
  )
@@ -2453,6 +2601,7 @@ async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOpt
2453
2601
  }
2454
2602
  var legacyCacheCleaned = false;
2455
2603
  async function trace(options) {
2604
+ const mode = resolveTraceMode(options.mode);
2456
2605
  const { file, cwd } = await resolveFileContext(options.file, options.cwd);
2457
2606
  const warnings = [];
2458
2607
  const execOptions = { cwd, warnings };
@@ -2474,7 +2623,7 @@ async function trace(options) {
2474
2623
  }
2475
2624
  const blameAuth = await runBlameAndAuth(
2476
2625
  platform.adapter,
2477
- { ...options, file, cwd },
2626
+ { ...options, mode, file, cwd },
2478
2627
  execOptions
2479
2628
  );
2480
2629
  const operatingLevel = blameAuth.operatingLevel || platform.operatingLevel;
@@ -2517,7 +2666,7 @@ async function trace(options) {
2517
2666
  blameAuth.analyzed,
2518
2667
  featureFlags,
2519
2668
  platform.adapter,
2520
- { ...options, file, cwd },
2669
+ { ...options, mode, file, cwd },
2521
2670
  execOptions,
2522
2671
  repoId,
2523
2672
  cloneStatus.partialClone || void 0,
@@ -2728,7 +2877,11 @@ function formatNodeHuman(node) {
2728
2877
  init_normalizer();
2729
2878
  init_errors();
2730
2879
  function registerTraceCommand(program2) {
2731
- program2.command("trace <file>").description("Trace a file line to its originating PR").requiredOption("-L, --line <range>", 'Line number or range (e.g., "42" or "10,50")').option("--deep", "Enable deep trace for squash PRs").option("--no-ast", "Disable AST diff analysis").option("--no-cache", "Disable cache").option("--cache-only", "Return cached results only (no API calls)").option("--json", "Output in JSON format").option("-q, --quiet", "Output PR number only").option("--output <format>", "Output format: human, json, llm", "human").option("--no-color", "Disable colored output").action(async (file, opts) => {
2880
+ program2.command("trace <file>").description("Trace a file line to its originating or last-change PR").requiredOption("-L, --line <range>", 'Line number or range (e.g., "42" or "10,50")').option("--mode <mode>", "Trace mode: change (default) or origin (includes copy/move source)", "change").option("--deep", "Enable deep trace for squash PRs").option("--no-ast", "Disable AST diff analysis").option("--no-cache", "Disable cache").option("--cache-only", "Return cached results only (no API calls)").option("--json", "Output in JSON format").option("-q, --quiet", "Output PR number only").option("--output <format>", "Output format: human, json, llm", "human").option("--no-color", "Disable colored output").action(async (file, opts) => {
2881
+ const mode = opts.mode;
2882
+ if (mode !== "origin" && mode !== "change") {
2883
+ throw new Error(`Invalid trace mode: ${String(mode)}`);
2884
+ }
2732
2885
  const lineStr = opts.line;
2733
2886
  const parts = lineStr.split(",");
2734
2887
  const line = parseInt(parts[0], 10);
@@ -2737,6 +2890,7 @@ function registerTraceCommand(program2) {
2737
2890
  file,
2738
2891
  line,
2739
2892
  endLine,
2893
+ mode,
2740
2894
  deep: opts.deep,
2741
2895
  noAst: opts.ast === false,
2742
2896
  noCache: opts.cache === false,
@@ -2777,7 +2931,7 @@ init_errors();
2777
2931
  // src/utils/command-registry.ts
2778
2932
  var TRACE_COMMAND = {
2779
2933
  name: "trace",
2780
- description: "Trace a file line to its originating PR",
2934
+ description: "Trace a file line to its originating or last-change PR",
2781
2935
  usage: "line-lore trace <file> [options]",
2782
2936
  arguments: [
2783
2937
  {
@@ -2792,6 +2946,12 @@ var TRACE_COMMAND = {
2792
2946
  description: 'Line number or range (e.g., "42" or "10,50")',
2793
2947
  type: "string"
2794
2948
  },
2949
+ {
2950
+ flag: "--mode <mode>",
2951
+ description: "Trace mode: origin or change",
2952
+ type: "string",
2953
+ default: "origin"
2954
+ },
2795
2955
  {
2796
2956
  flag: "--deep",
2797
2957
  description: "Enable deep trace for squash PRs",