@lumy-pack/line-lore 0.0.6 → 0.0.8

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/dist/cli.mjs CHANGED
@@ -367,26 +367,36 @@ 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
- });
370
+ async function verifyMergeIntroducesCommit(targetSha, mergeResult, options) {
371
+ if (mergeResult.parentShas.length < 2) return true;
372
+ const firstParent = mergeResult.parentShas[0];
373
+ const branchParents = mergeResult.parentShas.slice(1);
374
+ const onMainline = await isAncestor(targetSha, firstParent, options);
375
+ if (onMainline === null) return false;
376
+ if (onMainline) return false;
377
+ for (const branchParent of branchParents) {
378
+ const onBranch = await isAncestor(targetSha, branchParent, options);
379
+ if (onBranch === null) return false;
380
+ if (onBranch) return true;
381
+ }
382
+ return false;
383
+ }
384
+ async function isAncestor(commitA, commitB, options) {
385
+ try {
386
+ const result = await gitExec(
387
+ ["merge-base", "--is-ancestor", commitA, commitB],
388
+ {
389
+ cwd: options?.cwd,
390
+ timeout: options?.timeout ?? 5e3,
391
+ allowExitCodes: [1]
392
+ }
393
+ );
394
+ return result.exitCode === 0;
395
+ } catch {
396
+ return null;
397
+ }
388
398
  }
389
- async function findMergeCommitWithArgs(commitSha, ref, extraArgs, options) {
399
+ async function findMergeCommitsWithArgs(commitSha, ref, extraArgs, options) {
390
400
  try {
391
401
  const result = await gitExec(
392
402
  [
@@ -402,10 +412,29 @@ async function findMergeCommitWithArgs(commitSha, ref, extraArgs, options) {
402
412
  { cwd: options?.cwd, timeout: options?.timeout }
403
413
  );
404
414
  const lines = filter4(result.stdout.trim().split("\n"), isTruthy4);
405
- if (lines.length === 0) return null;
406
- return parseMergeLogLine(lines[0]);
415
+ if (lines.length === 0) return [];
416
+ const verifiedCandidates = [];
417
+ const candidateCount = Math.min(lines.length, MAX_CANDIDATES);
418
+ let attemptedCount = 0;
419
+ for (let i = 0; i < candidateCount; i++) {
420
+ const candidate = parseMergeLogLine(lines[i]);
421
+ if (!candidate) continue;
422
+ attemptedCount++;
423
+ const verified = await verifyMergeIntroducesCommit(
424
+ commitSha,
425
+ candidate,
426
+ options
427
+ );
428
+ if (verified) verifiedCandidates.push(candidate);
429
+ }
430
+ if (attemptedCount > 0 && verifiedCandidates.length === 0 && options?.warnings) {
431
+ options.warnings.push(
432
+ `ancestry: all ${attemptedCount} merge candidate(s) failed verification for ${commitSha.slice(0, 8)}`
433
+ );
434
+ }
435
+ return verifiedCandidates;
407
436
  } catch {
408
- return null;
437
+ return [];
409
438
  }
410
439
  }
411
440
  function parseMergeLogLine(line) {
@@ -425,23 +454,70 @@ function parseMergeLogLine(line) {
425
454
  const subject = parts.slice(subjectStart).join(" ");
426
455
  return { mergeCommitSha, parentShas, subject };
427
456
  }
428
- function extractPRFromMergeMessage(subject) {
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
+ }
489
+ async function getCommitSubject(sha, options) {
490
+ try {
491
+ const result = await gitExec(["log", "-1", "--format=%s", sha], {
492
+ cwd: options?.cwd,
493
+ timeout: options?.timeout ?? 5e3
494
+ });
495
+ const subject = result.stdout.trim();
496
+ return subject || null;
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+ function extractPRFromMergeMessage(subject, platform) {
429
502
  const ghMatch = /Merge pull request #(\d+)/.exec(subject);
430
503
  if (ghMatch) return parseInt(ghMatch[1], 10);
431
504
  const squashMatch = /\(#(\d+)\)\s*$/.exec(subject);
432
505
  if (squashMatch) return parseInt(squashMatch[1], 10);
433
- const glMatch = /!(\d+)\s*$/.exec(subject);
434
- if (glMatch) return parseInt(glMatch[1], 10);
506
+ if (!platform || platform === "gitlab" || platform === "gitlab-self-hosted") {
507
+ const glMatch = /See merge request\s+\S*!(\d+)\s*$/.exec(subject);
508
+ if (glMatch) return parseInt(glMatch[1], 10);
509
+ }
435
510
  const adoMatch = /Merged PR (\d+):/.exec(subject);
436
511
  if (adoMatch) return parseInt(adoMatch[1], 10);
437
512
  return null;
438
513
  }
439
- var DEFAULT_ANCESTRY_TIMEOUT;
514
+ var DEFAULT_ANCESTRY_TIMEOUT, MAX_CANDIDATES;
440
515
  var init_ancestry = __esm({
441
516
  "src/core/ancestry/ancestry.ts"() {
442
517
  "use strict";
443
518
  init_executor();
444
519
  DEFAULT_ANCESTRY_TIMEOUT = 3e4;
520
+ MAX_CANDIDATES = 10;
445
521
  }
446
522
  });
447
523
 
@@ -569,7 +645,8 @@ function toCachedPR(pr) {
569
645
  url: pr.url,
570
646
  mergeCommit: pr.mergeCommit,
571
647
  baseBranch: pr.baseBranch,
572
- mergedAt: pr.mergedAt ? new Date(pr.mergedAt).getTime() : void 0
648
+ mergedAt: pr.mergedAt ? new Date(pr.mergedAt).getTime() : void 0,
649
+ resolvedVia: pr.resolvedVia
573
650
  };
574
651
  }
575
652
  function fromCachedPR(cached) {
@@ -584,7 +661,9 @@ function fromCachedPR(cached) {
584
661
  url: cached.url,
585
662
  mergeCommit: cached.mergeCommit,
586
663
  baseBranch: cached.baseBranch,
587
- mergedAt
664
+ mergedAt,
665
+ // Preserve original resolvedVia; fallback to url heuristic for legacy cache entries
666
+ resolvedVia: cached.resolvedVia ?? (cached.url ? "api" : "message")
588
667
  };
589
668
  }
590
669
  async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
@@ -595,45 +674,84 @@ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
595
674
  const cached = await cache.get(commitSha);
596
675
  if (cached) return fromCachedPR(cached);
597
676
  if (options?.cacheOnly) return null;
677
+ const prSelectOptions = options?.preferredBase ? { preferredBase: options.preferredBase } : void 0;
678
+ if (adapter) {
679
+ const directPR = await adapter.getPRForCommit(commitSha, prSelectOptions);
680
+ if (directPR?.mergedAt) {
681
+ const result = { ...directPR, resolvedVia: "api" };
682
+ await cache.set(commitSha, toCachedPR(result));
683
+ return result;
684
+ }
685
+ }
598
686
  let mergeBasedPR = null;
599
- const mergeResult = await findMergeCommit(commitSha, options);
600
- if (mergeResult) {
601
- const prNumber = extractPRFromMergeMessage(mergeResult.subject);
687
+ const mergeCandidates = await findMergeCommits(commitSha, options);
688
+ const hasAncestryMerges = mergeCandidates.length > 0;
689
+ for (const candidate of mergeCandidates) {
690
+ const prNumber = extractPRFromMergeMessage(
691
+ candidate.subject,
692
+ options?.platform
693
+ );
602
694
  if (prNumber) {
603
695
  if (adapter) {
604
- const prInfo = await adapter.getPRForCommit(mergeResult.mergeCommitSha);
696
+ const prInfo = await adapter.getPRForCommit(
697
+ candidate.mergeCommitSha,
698
+ prSelectOptions
699
+ );
605
700
  if (prInfo?.mergedAt) {
606
- mergeBasedPR = prInfo;
701
+ mergeBasedPR = { ...prInfo, resolvedVia: "ancestry" };
607
702
  }
608
703
  }
609
704
  if (!mergeBasedPR) {
610
705
  mergeBasedPR = {
611
706
  number: prNumber,
612
- title: mergeResult.subject,
707
+ title: candidate.subject,
613
708
  author: "",
614
709
  url: "",
615
- mergeCommit: mergeResult.mergeCommitSha,
616
- baseBranch: ""
710
+ mergeCommit: candidate.mergeCommitSha,
711
+ baseBranch: "",
712
+ resolvedVia: "ancestry"
617
713
  };
618
714
  }
619
- if (!options?.deep || mergeBasedPR.mergedAt) {
620
- await cache.set(commitSha, toCachedPR(mergeBasedPR));
621
- 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;
622
725
  }
623
726
  }
624
727
  }
625
728
  if (mergeBasedPR) {
626
- await cache.set(commitSha, toCachedPR(mergeBasedPR));
627
- return mergeBasedPR;
729
+ if (!options?.deep || mergeBasedPR.mergedAt) {
730
+ await cache.set(commitSha, toCachedPR(mergeBasedPR));
731
+ return mergeBasedPR;
732
+ }
628
733
  }
629
- if (adapter) {
630
- const prInfo = await adapter.getPRForCommit(commitSha);
631
- if (prInfo?.mergedAt) {
632
- await cache.set(commitSha, toCachedPR(prInfo));
633
- return prInfo;
734
+ const commitSubject = await getCommitSubject(commitSha, options);
735
+ if (commitSubject) {
736
+ const directPrNumber = extractPRFromMergeMessage(
737
+ commitSubject,
738
+ options?.platform
739
+ );
740
+ if (directPrNumber) {
741
+ const subjectPR = {
742
+ number: directPrNumber,
743
+ title: commitSubject,
744
+ author: "",
745
+ url: "",
746
+ mergeCommit: commitSha,
747
+ baseBranch: "",
748
+ resolvedVia: "message"
749
+ };
750
+ await cache.set(commitSha, toCachedPR(subjectPR));
751
+ return subjectPR;
634
752
  }
635
753
  }
636
- if (!options?.skipPatchIdScan && _recursionDepth < MAX_RECURSION_DEPTH) {
754
+ if (!options?.skipPatchIdScan && _recursionDepth < MAX_RECURSION_DEPTH && (!hasAncestryMerges || options?.deep)) {
637
755
  const patchIdMatch = await findPatchIdMatch(commitSha, {
638
756
  ...options,
639
757
  scanDepth: options?.deep ? DEEP_SCAN_DEPTH : void 0
@@ -651,6 +769,10 @@ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
651
769
  }
652
770
  }
653
771
  }
772
+ if (mergeBasedPR) {
773
+ await cache.set(commitSha, toCachedPR(mergeBasedPR));
774
+ return mergeBasedPR;
775
+ }
654
776
  return null;
655
777
  }
656
778
  function resetPRCache() {
@@ -687,7 +809,7 @@ var VERSION;
687
809
  var init_version = __esm({
688
810
  "src/version.ts"() {
689
811
  "use strict";
690
- VERSION = "0.0.6";
812
+ VERSION = "0.0.8";
691
813
  }
692
814
  });
693
815
 
@@ -784,6 +906,7 @@ import { Command } from "commander";
784
906
 
785
907
  // src/core/core.ts
786
908
  import { createHash as createHash2 } from "crypto";
909
+ import { dirname, isAbsolute, relative } from "path";
787
910
  import { map as map8 } from "@winglet/common-utils";
788
911
 
789
912
  // src/ast/parser.ts
@@ -1062,7 +1185,7 @@ async function checkGitHealth(options) {
1062
1185
  const cloneStatus = await checkCloneStatus({ cwd: options?.cwd });
1063
1186
  if (cloneStatus.partialClone) {
1064
1187
  hints.push(
1065
- "Partial clone detected. Patch-ID scan (Strategy 4) will be skipped to avoid blob downloads."
1188
+ "Partial clone detected. Patch-ID scan (Strategy 5) will be skipped to avoid blob downloads."
1066
1189
  );
1067
1190
  }
1068
1191
  if (cloneStatus.shallow) {
@@ -1163,7 +1286,7 @@ var GitHubAdapter = class {
1163
1286
  return { authenticated: false, hostname: this.hostname };
1164
1287
  }
1165
1288
  }
1166
- async getPRForCommit(sha) {
1289
+ async getPRForCommit(sha, options) {
1167
1290
  if (this.scheduler.isRateLimited()) return null;
1168
1291
  try {
1169
1292
  const result = await shellExec(
@@ -1180,18 +1303,20 @@ var GitHubAdapter = class {
1180
1303
  );
1181
1304
  const prs = JSON.parse(result.stdout);
1182
1305
  if (!isArray(prs) || prs.length === 0) return null;
1183
- const defaultBranch = await this.detectDefaultBranch();
1184
- const defaultBranchPR = prs.find(
1185
- (pr) => pr.base === defaultBranch
1186
- );
1187
- const data = defaultBranchPR ?? prs[0];
1306
+ let data = prs[0];
1307
+ if (options?.preferredBase) {
1308
+ const preferred = prs.find(
1309
+ (pr) => pr.base === options.preferredBase
1310
+ );
1311
+ if (preferred) data = preferred;
1312
+ }
1188
1313
  return {
1189
1314
  number: data.number,
1190
1315
  title: data.title ?? "",
1191
1316
  author: data.user ?? "",
1192
1317
  url: data.html_url ?? "",
1193
1318
  mergeCommit: data.merge_commit_sha ?? sha,
1194
- baseBranch: data.base ?? defaultBranch,
1319
+ baseBranch: data.base ?? "",
1195
1320
  mergedAt: data.merged_at
1196
1321
  };
1197
1322
  } catch {
@@ -1368,7 +1493,7 @@ var GitLabAdapter = class {
1368
1493
  return { authenticated: false, hostname: this.hostname };
1369
1494
  }
1370
1495
  }
1371
- async getPRForCommit(sha) {
1496
+ async getPRForCommit(sha, options) {
1372
1497
  if (this.scheduler.isRateLimited()) return null;
1373
1498
  try {
1374
1499
  const result = await shellExec(
@@ -1392,18 +1517,20 @@ var GitLabAdapter = class {
1392
1517
  return aTime - bTime;
1393
1518
  });
1394
1519
  if (mergedMRs.length === 0) return null;
1395
- const defaultBranch = await this.detectDefaultBranch();
1396
- const defaultBranchMR = mergedMRs.find(
1397
- (mr2) => mr2.target_branch === defaultBranch
1398
- );
1399
- const mr = defaultBranchMR ?? mergedMRs[0];
1520
+ let mr = mergedMRs[0];
1521
+ if (options?.preferredBase) {
1522
+ const preferred = mergedMRs.find(
1523
+ (m) => m.target_branch === options.preferredBase
1524
+ );
1525
+ if (preferred) mr = preferred;
1526
+ }
1400
1527
  return {
1401
1528
  number: mr.iid,
1402
1529
  title: mr.title ?? "",
1403
1530
  author: mr.author?.username ?? "",
1404
1531
  url: mr.web_url ?? "",
1405
1532
  mergeCommit: mr.merge_commit_sha ?? sha,
1406
- baseBranch: mr.target_branch ?? defaultBranch,
1533
+ baseBranch: mr.target_branch ?? "",
1407
1534
  mergedAt: mr.merged_at
1408
1535
  };
1409
1536
  } catch {
@@ -1995,6 +2122,7 @@ function parsePorcelainOutput(output) {
1995
2122
  }
1996
2123
  let commitHash = headerMatch[1];
1997
2124
  const originalLine = parseInt(headerMatch[2], 10);
2125
+ const finalLine = parseInt(headerMatch[3], 10) || 0;
1998
2126
  const isBoundary = commitHash.startsWith("^");
1999
2127
  if (isBoundary) {
2000
2128
  commitHash = commitHash.slice(1).padStart(40, "0");
@@ -2038,6 +2166,7 @@ function parsePorcelainOutput(output) {
2038
2166
  authorEmail: cleanEmail,
2039
2167
  date,
2040
2168
  lineContent,
2169
+ finalLine,
2041
2170
  originalFile,
2042
2171
  originalLine: originalFile ? originalLine : void 0
2043
2172
  });
@@ -2048,10 +2177,8 @@ function parsePorcelainOutput(output) {
2048
2177
  // src/core/blame/blame.ts
2049
2178
  async function executeBlame(file, lineRange, options) {
2050
2179
  const lineSpec = `${lineRange.start},${lineRange.end}`;
2051
- const result = await gitExec(
2052
- ["blame", "-w", "-C", "-C", "-M", "--porcelain", "-L", lineSpec, file],
2053
- options
2054
- );
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);
2055
2182
  return parsePorcelainOutput(result.stdout);
2056
2183
  }
2057
2184
  async function analyzeBlameResults(results, filePath, options) {
@@ -2180,6 +2307,28 @@ async function traverse(adapter, type, number, depth, maxDepth, nodes, edges, vi
2180
2307
 
2181
2308
  // src/core/core.ts
2182
2309
  init_pr_lookup2();
2310
+ function resolvedViaToTrackingMethod(resolvedVia) {
2311
+ switch (resolvedVia) {
2312
+ case "api":
2313
+ return "api";
2314
+ case "ancestry":
2315
+ return "ancestry-path";
2316
+ case "message":
2317
+ return "message-parse";
2318
+ case "patch-id":
2319
+ return "patch-id";
2320
+ }
2321
+ }
2322
+ function resolvedViaToConfidence(resolvedVia) {
2323
+ switch (resolvedVia) {
2324
+ case "api":
2325
+ case "ancestry":
2326
+ return "exact";
2327
+ case "message":
2328
+ case "patch-id":
2329
+ return "heuristic";
2330
+ }
2331
+ }
2183
2332
  function computeFeatureFlags(operatingLevel, options) {
2184
2333
  return {
2185
2334
  astDiff: isAstAvailable() && !options.noAst,
@@ -2197,6 +2346,22 @@ async function resolveRepoIdentity(cwd) {
2197
2346
  return { host: "_local", owner: "_", repo: "_unknown" };
2198
2347
  }
2199
2348
  }
2349
+ async function resolveFileContext(file, cwd) {
2350
+ if (cwd || !isAbsolute(file)) return { file, cwd };
2351
+ const fileDir = dirname(file);
2352
+ try {
2353
+ const result = await gitExec(["rev-parse", "--show-toplevel"], {
2354
+ cwd: fileDir
2355
+ });
2356
+ const repoRoot = result.stdout.trim();
2357
+ return {
2358
+ file: relative(repoRoot, file),
2359
+ cwd: repoRoot
2360
+ };
2361
+ } catch {
2362
+ return { file, cwd };
2363
+ }
2364
+ }
2200
2365
  async function detectPlatform2(options) {
2201
2366
  const warnings = [];
2202
2367
  let adapter = null;
@@ -2220,9 +2385,10 @@ async function runBlameAndAuth(adapter, options, execOptions) {
2220
2385
  const lineRange = parseLineRange(
2221
2386
  options.endLine ? `${options.line},${options.endLine}` : `${options.line}`
2222
2387
  );
2223
- const blameChain = executeBlame(options.file, lineRange, execOptions).then(
2224
- (results) => analyzeBlameResults(results, options.file, execOptions)
2225
- );
2388
+ const blameChain = executeBlame(options.file, lineRange, {
2389
+ ...execOptions,
2390
+ mode: options.mode
2391
+ }).then((results) => analyzeBlameResults(results, options.file, execOptions));
2226
2392
  const [authResult, blameResult] = await Promise.allSettled([
2227
2393
  adapter ? adapter.checkAuth() : Promise.resolve({ authenticated: false }),
2228
2394
  blameChain
@@ -2243,12 +2409,24 @@ async function runBlameAndAuth(adapter, options, execOptions) {
2243
2409
  }
2244
2410
  return { analyzed: blameResult.value, operatingLevel, warnings };
2245
2411
  }
2246
- async function processEntry(entry, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan) {
2412
+ function resolveTraceMode(mode) {
2413
+ return mode ?? "origin";
2414
+ }
2415
+ function deduplicatedLookupPR(sha, adapter, options, inflight) {
2416
+ const existing = inflight.get(sha);
2417
+ if (existing) return existing;
2418
+ const promise = lookupPR(sha, adapter, options);
2419
+ inflight.set(sha, promise);
2420
+ promise.finally(() => inflight.delete(sha));
2421
+ return promise;
2422
+ }
2423
+ async function processEntry(entry, featureFlags, adapter, options, execOptions, repoId, inflightPR, skipPatchIdScan, preferredBase) {
2247
2424
  const nodes = [];
2425
+ const traceMode = resolveTraceMode(options.mode);
2248
2426
  const commitNode = {
2249
2427
  type: entry.isCosmetic ? "cosmetic_commit" : "original_commit",
2250
2428
  sha: entry.blame.commitHash,
2251
- trackingMethod: "blame-CMw",
2429
+ trackingMethod: traceMode === "change" ? "blame" : "blame-CMw",
2252
2430
  confidence: "exact",
2253
2431
  note: entry.cosmeticReason ? `Cosmetic change: ${entry.cosmeticReason}` : void 0
2254
2432
  };
@@ -2270,21 +2448,24 @@ async function processEntry(entry, featureFlags, adapter, options, execOptions,
2270
2448
  }
2271
2449
  }
2272
2450
  const targetSha = nodes[nodes.length - 1].sha;
2451
+ const prLookupOptions = {
2452
+ ...execOptions,
2453
+ noCache: options.noCache,
2454
+ cacheOnly: options.cacheOnly,
2455
+ deep: featureFlags.deepTrace,
2456
+ repoId,
2457
+ skipPatchIdScan,
2458
+ preferredBase,
2459
+ platform: adapter?.platform
2460
+ };
2273
2461
  if (targetSha) {
2274
- const prInfo = await lookupPR(targetSha, adapter, {
2275
- ...execOptions,
2276
- noCache: options.noCache,
2277
- cacheOnly: options.cacheOnly,
2278
- deep: featureFlags.deepTrace,
2279
- repoId,
2280
- skipPatchIdScan
2281
- });
2462
+ const prInfo = await deduplicatedLookupPR(targetSha, adapter, prLookupOptions, inflightPR);
2282
2463
  if (prInfo) {
2283
2464
  nodes.push({
2284
2465
  type: "pull_request",
2285
2466
  sha: prInfo.mergeCommit,
2286
- trackingMethod: prInfo.url ? "api" : "message-parse",
2287
- confidence: prInfo.url ? "exact" : "heuristic",
2467
+ trackingMethod: resolvedViaToTrackingMethod(prInfo.resolvedVia),
2468
+ confidence: resolvedViaToConfidence(prInfo.resolvedVia),
2288
2469
  prNumber: prInfo.number,
2289
2470
  prUrl: prInfo.url || void 0,
2290
2471
  prTitle: prInfo.title || void 0,
@@ -2294,7 +2475,8 @@ async function processEntry(entry, featureFlags, adapter, options, execOptions,
2294
2475
  }
2295
2476
  return nodes;
2296
2477
  }
2297
- async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan) {
2478
+ async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan, preferredBase) {
2479
+ const inflightPR = /* @__PURE__ */ new Map();
2298
2480
  const results = await Promise.allSettled(
2299
2481
  map8(
2300
2482
  analyzed,
@@ -2305,7 +2487,9 @@ async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOpt
2305
2487
  options,
2306
2488
  execOptions,
2307
2489
  repoId,
2308
- skipPatchIdScan
2490
+ inflightPR,
2491
+ skipPatchIdScan,
2492
+ preferredBase
2309
2493
  )
2310
2494
  )
2311
2495
  );
@@ -2313,13 +2497,16 @@ async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOpt
2313
2497
  }
2314
2498
  var legacyCacheCleaned = false;
2315
2499
  async function trace(options) {
2316
- const execOptions = { cwd: options.cwd };
2500
+ const mode = resolveTraceMode(options.mode);
2501
+ const { file, cwd } = await resolveFileContext(options.file, options.cwd);
2502
+ const warnings = [];
2503
+ const execOptions = { cwd, warnings };
2317
2504
  if (!legacyCacheCleaned) {
2318
2505
  legacyCacheCleaned = true;
2319
2506
  cleanupLegacyCache().catch(() => {
2320
2507
  });
2321
2508
  }
2322
- const platform = await detectPlatform2(options);
2509
+ const platform = await detectPlatform2({ ...options, cwd });
2323
2510
  let repoId;
2324
2511
  if (platform.remote) {
2325
2512
  repoId = {
@@ -2328,15 +2515,15 @@ async function trace(options) {
2328
2515
  repo: platform.remote.repo
2329
2516
  };
2330
2517
  } else {
2331
- repoId = await resolveRepoIdentity(options.cwd);
2518
+ repoId = await resolveRepoIdentity(cwd);
2332
2519
  }
2333
2520
  const blameAuth = await runBlameAndAuth(
2334
2521
  platform.adapter,
2335
- options,
2522
+ { ...options, mode, file, cwd },
2336
2523
  execOptions
2337
2524
  );
2338
2525
  const operatingLevel = blameAuth.operatingLevel || platform.operatingLevel;
2339
- const warnings = [...platform.warnings, ...blameAuth.warnings];
2526
+ warnings.push(...platform.warnings, ...blameAuth.warnings);
2340
2527
  if (options.cacheOnly && options.noCache) {
2341
2528
  warnings.push(
2342
2529
  "Both cacheOnly and noCache are set. cacheOnly takes precedence \u2014 cache reads are enabled."
@@ -2345,13 +2532,13 @@ async function trace(options) {
2345
2532
  const featureFlags = computeFeatureFlags(operatingLevel, options);
2346
2533
  let cloneStatus = { partialClone: false, shallow: false };
2347
2534
  try {
2348
- const result = await checkCloneStatus({ cwd: options.cwd });
2535
+ const result = await checkCloneStatus({ cwd });
2349
2536
  if (result) cloneStatus = result;
2350
2537
  } catch {
2351
2538
  }
2352
2539
  if (cloneStatus.partialClone) {
2353
2540
  warnings.push(
2354
- "Partial clone detected. Patch-ID scan (Strategy 4) will be skipped to avoid blob downloads."
2541
+ "Partial clone detected. Patch-ID scan (Strategy 5) will be skipped to avoid blob downloads."
2355
2542
  );
2356
2543
  }
2357
2544
  if (cloneStatus.shallow) {
@@ -2359,14 +2546,27 @@ async function trace(options) {
2359
2546
  "Shallow repository detected. Ancestry-path results may be incomplete."
2360
2547
  );
2361
2548
  }
2549
+ let preferredBase;
2550
+ try {
2551
+ const branchResult = await gitExec(
2552
+ ["rev-parse", "--abbrev-ref", "HEAD"],
2553
+ execOptions
2554
+ );
2555
+ const branch = branchResult.stdout.trim();
2556
+ if (branch && branch !== "HEAD") {
2557
+ preferredBase = branch;
2558
+ }
2559
+ } catch {
2560
+ }
2362
2561
  const nodes = await buildTraceNodes(
2363
2562
  blameAuth.analyzed,
2364
2563
  featureFlags,
2365
2564
  platform.adapter,
2366
- options,
2565
+ { ...options, mode, file, cwd },
2367
2566
  execOptions,
2368
2567
  repoId,
2369
- cloneStatus.partialClone || void 0
2568
+ cloneStatus.partialClone || void 0,
2569
+ preferredBase
2370
2570
  );
2371
2571
  return { nodes, operatingLevel, featureFlags, warnings };
2372
2572
  }
@@ -2573,7 +2773,11 @@ function formatNodeHuman(node) {
2573
2773
  init_normalizer();
2574
2774
  init_errors();
2575
2775
  function registerTraceCommand(program2) {
2576
- 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) => {
2776
+ 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) => {
2777
+ const mode = opts.mode;
2778
+ if (mode !== "origin" && mode !== "change") {
2779
+ throw new Error(`Invalid trace mode: ${String(mode)}`);
2780
+ }
2577
2781
  const lineStr = opts.line;
2578
2782
  const parts = lineStr.split(",");
2579
2783
  const line = parseInt(parts[0], 10);
@@ -2582,6 +2786,7 @@ function registerTraceCommand(program2) {
2582
2786
  file,
2583
2787
  line,
2584
2788
  endLine,
2789
+ mode,
2585
2790
  deep: opts.deep,
2586
2791
  noAst: opts.ast === false,
2587
2792
  noCache: opts.cache === false,
@@ -2622,7 +2827,7 @@ init_errors();
2622
2827
  // src/utils/command-registry.ts
2623
2828
  var TRACE_COMMAND = {
2624
2829
  name: "trace",
2625
- description: "Trace a file line to its originating PR",
2830
+ description: "Trace a file line to its originating or last-change PR",
2626
2831
  usage: "line-lore trace <file> [options]",
2627
2832
  arguments: [
2628
2833
  {
@@ -2637,6 +2842,12 @@ var TRACE_COMMAND = {
2637
2842
  description: 'Line number or range (e.g., "42" or "10,50")',
2638
2843
  type: "string"
2639
2844
  },
2845
+ {
2846
+ flag: "--mode <mode>",
2847
+ description: "Trace mode: origin or change",
2848
+ type: "string",
2849
+ default: "origin"
2850
+ },
2640
2851
  {
2641
2852
  flag: "--deep",
2642
2853
  description: "Enable deep trace for squash PRs",