@lumy-pack/line-lore 0.0.5 → 0.0.7

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
@@ -369,14 +369,51 @@ var init_executor = __esm({
369
369
  import { filter as filter4, isTruthy as isTruthy4 } from "@winglet/common-utils";
370
370
  async function findMergeCommit(commitSha, options) {
371
371
  const ref = options?.ref ?? "HEAD";
372
+ const budget = options?.timeout ?? DEFAULT_ANCESTRY_TIMEOUT;
373
+ const startTime = Date.now();
372
374
  const firstParentResult = await findMergeCommitWithArgs(
373
375
  commitSha,
374
376
  ref,
375
377
  ["--first-parent"],
376
- options
378
+ { ...options, timeout: budget }
377
379
  );
378
380
  if (firstParentResult) return firstParentResult;
379
- return findMergeCommitWithArgs(commitSha, ref, [], options);
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
+ async function verifyMergeIntroducesCommit(targetSha, mergeResult, options) {
390
+ if (mergeResult.parentShas.length < 2) return true;
391
+ const firstParent = mergeResult.parentShas[0];
392
+ const branchParents = mergeResult.parentShas.slice(1);
393
+ const onMainline = await isAncestor(targetSha, firstParent, options);
394
+ if (onMainline === null) return false;
395
+ if (onMainline) return false;
396
+ for (const branchParent of branchParents) {
397
+ const onBranch = await isAncestor(targetSha, branchParent, options);
398
+ if (onBranch === null) return false;
399
+ if (onBranch) return true;
400
+ }
401
+ return false;
402
+ }
403
+ async function isAncestor(commitA, commitB, options) {
404
+ try {
405
+ const result = await gitExec(
406
+ ["merge-base", "--is-ancestor", commitA, commitB],
407
+ {
408
+ cwd: options?.cwd,
409
+ timeout: options?.timeout ?? 5e3,
410
+ allowExitCodes: [1]
411
+ }
412
+ );
413
+ return result.exitCode === 0;
414
+ } catch {
415
+ return null;
416
+ }
380
417
  }
381
418
  async function findMergeCommitWithArgs(commitSha, ref, extraArgs, options) {
382
419
  try {
@@ -395,7 +432,25 @@ async function findMergeCommitWithArgs(commitSha, ref, extraArgs, options) {
395
432
  );
396
433
  const lines = filter4(result.stdout.trim().split("\n"), isTruthy4);
397
434
  if (lines.length === 0) return null;
398
- return parseMergeLogLine(lines[0]);
435
+ const candidateCount = Math.min(lines.length, MAX_CANDIDATES);
436
+ let verifiedCount = 0;
437
+ for (let i = 0; i < candidateCount; i++) {
438
+ const candidate = parseMergeLogLine(lines[i]);
439
+ if (!candidate) continue;
440
+ verifiedCount++;
441
+ const verified = await verifyMergeIntroducesCommit(
442
+ commitSha,
443
+ candidate,
444
+ options
445
+ );
446
+ if (verified) return candidate;
447
+ }
448
+ if (verifiedCount > 0 && options?.warnings) {
449
+ options.warnings.push(
450
+ `ancestry: all ${verifiedCount} merge candidate(s) failed verification for ${commitSha.slice(0, 8)}`
451
+ );
452
+ }
453
+ return null;
399
454
  } catch {
400
455
  return null;
401
456
  }
@@ -417,19 +472,38 @@ function parseMergeLogLine(line) {
417
472
  const subject = parts.slice(subjectStart).join(" ");
418
473
  return { mergeCommitSha, parentShas, subject };
419
474
  }
420
- function extractPRFromMergeMessage(subject) {
475
+ async function getCommitSubject(sha, options) {
476
+ try {
477
+ const result = await gitExec(["log", "-1", "--format=%s", sha], {
478
+ cwd: options?.cwd,
479
+ timeout: options?.timeout ?? 5e3
480
+ });
481
+ const subject = result.stdout.trim();
482
+ return subject || null;
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+ function extractPRFromMergeMessage(subject, platform) {
421
488
  const ghMatch = /Merge pull request #(\d+)/.exec(subject);
422
489
  if (ghMatch) return parseInt(ghMatch[1], 10);
423
490
  const squashMatch = /\(#(\d+)\)\s*$/.exec(subject);
424
491
  if (squashMatch) return parseInt(squashMatch[1], 10);
425
- const glMatch = /!(\d+)\s*$/.exec(subject);
426
- if (glMatch) return parseInt(glMatch[1], 10);
492
+ if (!platform || platform === "gitlab" || platform === "gitlab-self-hosted") {
493
+ const glMatch = /See merge request\s+\S*!(\d+)\s*$/.exec(subject);
494
+ if (glMatch) return parseInt(glMatch[1], 10);
495
+ }
496
+ const adoMatch = /Merged PR (\d+):/.exec(subject);
497
+ if (adoMatch) return parseInt(adoMatch[1], 10);
427
498
  return null;
428
499
  }
500
+ var DEFAULT_ANCESTRY_TIMEOUT, MAX_CANDIDATES;
429
501
  var init_ancestry = __esm({
430
502
  "src/core/ancestry/ancestry.ts"() {
431
503
  "use strict";
432
504
  init_executor();
505
+ DEFAULT_ANCESTRY_TIMEOUT = 3e4;
506
+ MAX_CANDIDATES = 10;
433
507
  }
434
508
  });
435
509
 
@@ -549,19 +623,67 @@ function getCache2(repoId, noCache) {
549
623
  }
550
624
  return cache;
551
625
  }
552
- async function lookupPR(commitSha, adapter, options) {
553
- const cache = getCache2(options?.repoId, options?.noCache);
626
+ function toCachedPR(pr) {
627
+ return {
628
+ number: pr.number,
629
+ title: pr.title,
630
+ author: pr.author,
631
+ url: pr.url,
632
+ mergeCommit: pr.mergeCommit,
633
+ baseBranch: pr.baseBranch,
634
+ mergedAt: pr.mergedAt ? new Date(pr.mergedAt).getTime() : void 0,
635
+ resolvedVia: pr.resolvedVia
636
+ };
637
+ }
638
+ function fromCachedPR(cached) {
639
+ let mergedAt;
640
+ if (cached.mergedAt != null) {
641
+ mergedAt = typeof cached.mergedAt === "number" ? new Date(cached.mergedAt).toISOString() : String(cached.mergedAt);
642
+ }
643
+ return {
644
+ number: cached.number,
645
+ title: cached.title,
646
+ author: cached.author,
647
+ url: cached.url,
648
+ mergeCommit: cached.mergeCommit,
649
+ baseBranch: cached.baseBranch,
650
+ mergedAt,
651
+ // Preserve original resolvedVia; fallback to url heuristic for legacy cache entries
652
+ resolvedVia: cached.resolvedVia ?? (cached.url ? "api" : "message")
653
+ };
654
+ }
655
+ async function lookupPR(commitSha, adapter, options, _recursionDepth = 0) {
656
+ const cache = getCache2(
657
+ options?.repoId,
658
+ options?.cacheOnly ? false : options?.noCache
659
+ );
554
660
  const cached = await cache.get(commitSha);
555
- if (cached) return cached;
661
+ if (cached) return fromCachedPR(cached);
662
+ if (options?.cacheOnly) return null;
663
+ const prSelectOptions = options?.preferredBase ? { preferredBase: options.preferredBase } : void 0;
664
+ if (adapter) {
665
+ const directPR = await adapter.getPRForCommit(commitSha, prSelectOptions);
666
+ if (directPR?.mergedAt) {
667
+ const result = { ...directPR, resolvedVia: "api" };
668
+ await cache.set(commitSha, toCachedPR(result));
669
+ return result;
670
+ }
671
+ }
556
672
  let mergeBasedPR = null;
557
673
  const mergeResult = await findMergeCommit(commitSha, options);
558
674
  if (mergeResult) {
559
- const prNumber = extractPRFromMergeMessage(mergeResult.subject);
675
+ const prNumber = extractPRFromMergeMessage(
676
+ mergeResult.subject,
677
+ options?.platform
678
+ );
560
679
  if (prNumber) {
561
680
  if (adapter) {
562
- const prInfo = await adapter.getPRForCommit(mergeResult.mergeCommitSha);
681
+ const prInfo = await adapter.getPRForCommit(
682
+ mergeResult.mergeCommitSha,
683
+ prSelectOptions
684
+ );
563
685
  if (prInfo?.mergedAt) {
564
- mergeBasedPR = prInfo;
686
+ mergeBasedPR = { ...prInfo, resolvedVia: "ancestry" };
565
687
  }
566
688
  }
567
689
  if (!mergeBasedPR) {
@@ -571,35 +693,56 @@ async function lookupPR(commitSha, adapter, options) {
571
693
  author: "",
572
694
  url: "",
573
695
  mergeCommit: mergeResult.mergeCommitSha,
574
- baseBranch: ""
696
+ baseBranch: "",
697
+ resolvedVia: "ancestry"
575
698
  };
576
699
  }
577
700
  if (!options?.deep || mergeBasedPR.mergedAt) {
578
- await cache.set(commitSha, mergeBasedPR);
701
+ await cache.set(commitSha, toCachedPR(mergeBasedPR));
579
702
  return mergeBasedPR;
580
703
  }
581
704
  }
582
705
  }
583
706
  if (mergeBasedPR) {
584
- await cache.set(commitSha, mergeBasedPR);
707
+ await cache.set(commitSha, toCachedPR(mergeBasedPR));
585
708
  return mergeBasedPR;
586
709
  }
587
- if (adapter) {
588
- const prInfo = await adapter.getPRForCommit(commitSha);
589
- if (prInfo?.mergedAt) {
590
- await cache.set(commitSha, prInfo);
591
- return prInfo;
710
+ const commitSubject = await getCommitSubject(commitSha, options);
711
+ if (commitSubject) {
712
+ const directPrNumber = extractPRFromMergeMessage(
713
+ commitSubject,
714
+ options?.platform
715
+ );
716
+ if (directPrNumber) {
717
+ const subjectPR = {
718
+ number: directPrNumber,
719
+ title: commitSubject,
720
+ author: "",
721
+ url: "",
722
+ mergeCommit: commitSha,
723
+ baseBranch: "",
724
+ resolvedVia: "message"
725
+ };
726
+ await cache.set(commitSha, toCachedPR(subjectPR));
727
+ return subjectPR;
592
728
  }
593
729
  }
594
- const patchIdMatch = await findPatchIdMatch(commitSha, {
595
- ...options,
596
- scanDepth: options?.deep ? DEEP_SCAN_DEPTH : void 0
597
- });
598
- if (patchIdMatch) {
599
- const result = await lookupPR(patchIdMatch.matchedSha, adapter, options);
600
- if (result) {
601
- await cache.set(commitSha, result);
602
- return result;
730
+ if (!options?.skipPatchIdScan && _recursionDepth < MAX_RECURSION_DEPTH) {
731
+ const patchIdMatch = await findPatchIdMatch(commitSha, {
732
+ ...options,
733
+ scanDepth: options?.deep ? DEEP_SCAN_DEPTH : void 0
734
+ });
735
+ if (patchIdMatch) {
736
+ const result = await lookupPR(
737
+ patchIdMatch.matchedSha,
738
+ adapter,
739
+ options,
740
+ _recursionDepth + 1
741
+ );
742
+ if (result) {
743
+ await cache.set(commitSha, toCachedPR(result));
744
+ return result;
745
+ }
603
746
  }
604
747
  }
605
748
  return null;
@@ -607,7 +750,7 @@ async function lookupPR(commitSha, adapter, options) {
607
750
  function resetPRCache() {
608
751
  cacheRegistry2.clear();
609
752
  }
610
- var cacheRegistry2, DEEP_SCAN_DEPTH;
753
+ var cacheRegistry2, DEEP_SCAN_DEPTH, MAX_RECURSION_DEPTH;
611
754
  var init_pr_lookup = __esm({
612
755
  "src/core/pr-lookup/pr-lookup.ts"() {
613
756
  "use strict";
@@ -616,6 +759,7 @@ var init_pr_lookup = __esm({
616
759
  init_patch_id2();
617
760
  cacheRegistry2 = /* @__PURE__ */ new Map();
618
761
  DEEP_SCAN_DEPTH = 2e3;
762
+ MAX_RECURSION_DEPTH = 2;
619
763
  }
620
764
  });
621
765
 
@@ -637,7 +781,7 @@ var VERSION;
637
781
  var init_version = __esm({
638
782
  "src/version.ts"() {
639
783
  "use strict";
640
- VERSION = "0.0.5";
784
+ VERSION = "0.0.7";
641
785
  }
642
786
  });
643
787
 
@@ -734,6 +878,7 @@ import { Command } from "commander";
734
878
 
735
879
  // src/core/core.ts
736
880
  import { createHash as createHash2 } from "crypto";
881
+ import { dirname, isAbsolute, relative } from "path";
737
882
  import { map as map8 } from "@winglet/common-utils";
738
883
 
739
884
  // src/ast/parser.ts
@@ -962,6 +1107,27 @@ function isVersionAtLeast(version2, minVersion) {
962
1107
  }
963
1108
  return true;
964
1109
  }
1110
+ async function checkCloneStatus(options) {
1111
+ let partialClone = false;
1112
+ let shallow = false;
1113
+ try {
1114
+ const shallowResult = await gitExec(
1115
+ ["rev-parse", "--is-shallow-repository"],
1116
+ { cwd: options?.cwd }
1117
+ );
1118
+ shallow = shallowResult.stdout.trim() === "true";
1119
+ } catch {
1120
+ }
1121
+ try {
1122
+ const partialResult = await gitExec(
1123
+ ["config", "--get", "extensions.partialclone"],
1124
+ { cwd: options?.cwd }
1125
+ );
1126
+ partialClone = partialResult.stdout.trim().length > 0;
1127
+ } catch {
1128
+ }
1129
+ return { partialClone, shallow };
1130
+ }
965
1131
  async function checkGitHealth(options) {
966
1132
  const hints = [];
967
1133
  let gitVersion = "0.0.0";
@@ -988,7 +1154,18 @@ async function checkGitHealth(options) {
988
1154
  `Upgrade git to ${BLOOM_FILTER_MIN_VERSION.join(".")}+ for bloom filter support (current: ${gitVersion}).`
989
1155
  );
990
1156
  }
991
- return { commitGraph, bloomFilter, gitVersion, hints };
1157
+ const cloneStatus = await checkCloneStatus({ cwd: options?.cwd });
1158
+ if (cloneStatus.partialClone) {
1159
+ hints.push(
1160
+ "Partial clone detected. Patch-ID scan (Strategy 5) will be skipped to avoid blob downloads."
1161
+ );
1162
+ }
1163
+ if (cloneStatus.shallow) {
1164
+ hints.push(
1165
+ "Shallow repository detected. Ancestry-path results may be incomplete."
1166
+ );
1167
+ }
1168
+ return { commitGraph, bloomFilter, gitVersion, hints, ...cloneStatus };
992
1169
  }
993
1170
 
994
1171
  // src/git/remote.ts
@@ -1081,7 +1258,7 @@ var GitHubAdapter = class {
1081
1258
  return { authenticated: false, hostname: this.hostname };
1082
1259
  }
1083
1260
  }
1084
- async getPRForCommit(sha) {
1261
+ async getPRForCommit(sha, options) {
1085
1262
  if (this.scheduler.isRateLimited()) return null;
1086
1263
  try {
1087
1264
  const result = await shellExec(
@@ -1098,18 +1275,20 @@ var GitHubAdapter = class {
1098
1275
  );
1099
1276
  const prs = JSON.parse(result.stdout);
1100
1277
  if (!isArray(prs) || prs.length === 0) return null;
1101
- const defaultBranch = await this.detectDefaultBranch();
1102
- const defaultBranchPR = prs.find(
1103
- (pr) => pr.base === defaultBranch
1104
- );
1105
- const data = defaultBranchPR ?? prs[0];
1278
+ let data = prs[0];
1279
+ if (options?.preferredBase) {
1280
+ const preferred = prs.find(
1281
+ (pr) => pr.base === options.preferredBase
1282
+ );
1283
+ if (preferred) data = preferred;
1284
+ }
1106
1285
  return {
1107
1286
  number: data.number,
1108
1287
  title: data.title ?? "",
1109
1288
  author: data.user ?? "",
1110
1289
  url: data.html_url ?? "",
1111
1290
  mergeCommit: data.merge_commit_sha ?? sha,
1112
- baseBranch: data.base ?? defaultBranch,
1291
+ baseBranch: data.base ?? "",
1113
1292
  mergedAt: data.merged_at
1114
1293
  };
1115
1294
  } catch {
@@ -1286,7 +1465,7 @@ var GitLabAdapter = class {
1286
1465
  return { authenticated: false, hostname: this.hostname };
1287
1466
  }
1288
1467
  }
1289
- async getPRForCommit(sha) {
1468
+ async getPRForCommit(sha, options) {
1290
1469
  if (this.scheduler.isRateLimited()) return null;
1291
1470
  try {
1292
1471
  const result = await shellExec(
@@ -1310,18 +1489,20 @@ var GitLabAdapter = class {
1310
1489
  return aTime - bTime;
1311
1490
  });
1312
1491
  if (mergedMRs.length === 0) return null;
1313
- const defaultBranch = await this.detectDefaultBranch();
1314
- const defaultBranchMR = mergedMRs.find(
1315
- (mr2) => mr2.target_branch === defaultBranch
1316
- );
1317
- const mr = defaultBranchMR ?? mergedMRs[0];
1492
+ let mr = mergedMRs[0];
1493
+ if (options?.preferredBase) {
1494
+ const preferred = mergedMRs.find(
1495
+ (m) => m.target_branch === options.preferredBase
1496
+ );
1497
+ if (preferred) mr = preferred;
1498
+ }
1318
1499
  return {
1319
1500
  number: mr.iid,
1320
1501
  title: mr.title ?? "",
1321
1502
  author: mr.author?.username ?? "",
1322
1503
  url: mr.web_url ?? "",
1323
1504
  mergeCommit: mr.merge_commit_sha ?? sha,
1324
- baseBranch: mr.target_branch ?? defaultBranch,
1505
+ baseBranch: mr.target_branch ?? "",
1325
1506
  mergedAt: mr.merged_at
1326
1507
  };
1327
1508
  } catch {
@@ -2098,6 +2279,28 @@ async function traverse(adapter, type, number, depth, maxDepth, nodes, edges, vi
2098
2279
 
2099
2280
  // src/core/core.ts
2100
2281
  init_pr_lookup2();
2282
+ function resolvedViaToTrackingMethod(resolvedVia) {
2283
+ switch (resolvedVia) {
2284
+ case "api":
2285
+ return "api";
2286
+ case "ancestry":
2287
+ return "ancestry-path";
2288
+ case "message":
2289
+ return "message-parse";
2290
+ case "patch-id":
2291
+ return "patch-id";
2292
+ }
2293
+ }
2294
+ function resolvedViaToConfidence(resolvedVia) {
2295
+ switch (resolvedVia) {
2296
+ case "api":
2297
+ case "ancestry":
2298
+ return "exact";
2299
+ case "message":
2300
+ case "patch-id":
2301
+ return "heuristic";
2302
+ }
2303
+ }
2101
2304
  function computeFeatureFlags(operatingLevel, options) {
2102
2305
  return {
2103
2306
  astDiff: isAstAvailable() && !options.noAst,
@@ -2115,6 +2318,22 @@ async function resolveRepoIdentity(cwd) {
2115
2318
  return { host: "_local", owner: "_", repo: "_unknown" };
2116
2319
  }
2117
2320
  }
2321
+ async function resolveFileContext(file, cwd) {
2322
+ if (cwd || !isAbsolute(file)) return { file, cwd };
2323
+ const fileDir = dirname(file);
2324
+ try {
2325
+ const result = await gitExec(["rev-parse", "--show-toplevel"], {
2326
+ cwd: fileDir
2327
+ });
2328
+ const repoRoot = result.stdout.trim();
2329
+ return {
2330
+ file: relative(repoRoot, file),
2331
+ cwd: repoRoot
2332
+ };
2333
+ } catch {
2334
+ return { file, cwd };
2335
+ }
2336
+ }
2118
2337
  async function detectPlatform2(options) {
2119
2338
  const warnings = [];
2120
2339
  let adapter = null;
@@ -2161,7 +2380,7 @@ async function runBlameAndAuth(adapter, options, execOptions) {
2161
2380
  }
2162
2381
  return { analyzed: blameResult.value, operatingLevel, warnings };
2163
2382
  }
2164
- async function processEntry(entry, featureFlags, adapter, options, execOptions, repoId) {
2383
+ async function processEntry(entry, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan, preferredBase) {
2165
2384
  const nodes = [];
2166
2385
  const commitNode = {
2167
2386
  type: entry.isCosmetic ? "cosmetic_commit" : "original_commit",
@@ -2192,15 +2411,19 @@ async function processEntry(entry, featureFlags, adapter, options, execOptions,
2192
2411
  const prInfo = await lookupPR(targetSha, adapter, {
2193
2412
  ...execOptions,
2194
2413
  noCache: options.noCache,
2414
+ cacheOnly: options.cacheOnly,
2195
2415
  deep: featureFlags.deepTrace,
2196
- repoId
2416
+ repoId,
2417
+ skipPatchIdScan,
2418
+ preferredBase,
2419
+ platform: adapter?.platform
2197
2420
  });
2198
2421
  if (prInfo) {
2199
2422
  nodes.push({
2200
2423
  type: "pull_request",
2201
2424
  sha: prInfo.mergeCommit,
2202
- trackingMethod: prInfo.url ? "api" : "message-parse",
2203
- confidence: prInfo.url ? "exact" : "heuristic",
2425
+ trackingMethod: resolvedViaToTrackingMethod(prInfo.resolvedVia),
2426
+ confidence: resolvedViaToConfidence(prInfo.resolvedVia),
2204
2427
  prNumber: prInfo.number,
2205
2428
  prUrl: prInfo.url || void 0,
2206
2429
  prTitle: prInfo.title || void 0,
@@ -2210,24 +2433,35 @@ async function processEntry(entry, featureFlags, adapter, options, execOptions,
2210
2433
  }
2211
2434
  return nodes;
2212
2435
  }
2213
- async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOptions, repoId) {
2436
+ async function buildTraceNodes(analyzed, featureFlags, adapter, options, execOptions, repoId, skipPatchIdScan, preferredBase) {
2214
2437
  const results = await Promise.allSettled(
2215
2438
  map8(
2216
2439
  analyzed,
2217
- (entry) => processEntry(entry, featureFlags, adapter, options, execOptions, repoId)
2440
+ (entry) => processEntry(
2441
+ entry,
2442
+ featureFlags,
2443
+ adapter,
2444
+ options,
2445
+ execOptions,
2446
+ repoId,
2447
+ skipPatchIdScan,
2448
+ preferredBase
2449
+ )
2218
2450
  )
2219
2451
  );
2220
2452
  return results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
2221
2453
  }
2222
2454
  var legacyCacheCleaned = false;
2223
2455
  async function trace(options) {
2224
- const execOptions = { cwd: options.cwd };
2456
+ const { file, cwd } = await resolveFileContext(options.file, options.cwd);
2457
+ const warnings = [];
2458
+ const execOptions = { cwd, warnings };
2225
2459
  if (!legacyCacheCleaned) {
2226
2460
  legacyCacheCleaned = true;
2227
2461
  cleanupLegacyCache().catch(() => {
2228
2462
  });
2229
2463
  }
2230
- const platform = await detectPlatform2(options);
2464
+ const platform = await detectPlatform2({ ...options, cwd });
2231
2465
  let repoId;
2232
2466
  if (platform.remote) {
2233
2467
  repoId = {
@@ -2236,23 +2470,58 @@ async function trace(options) {
2236
2470
  repo: platform.remote.repo
2237
2471
  };
2238
2472
  } else {
2239
- repoId = await resolveRepoIdentity(options.cwd);
2473
+ repoId = await resolveRepoIdentity(cwd);
2240
2474
  }
2241
2475
  const blameAuth = await runBlameAndAuth(
2242
2476
  platform.adapter,
2243
- options,
2477
+ { ...options, file, cwd },
2244
2478
  execOptions
2245
2479
  );
2246
2480
  const operatingLevel = blameAuth.operatingLevel || platform.operatingLevel;
2247
- const warnings = [...platform.warnings, ...blameAuth.warnings];
2481
+ warnings.push(...platform.warnings, ...blameAuth.warnings);
2482
+ if (options.cacheOnly && options.noCache) {
2483
+ warnings.push(
2484
+ "Both cacheOnly and noCache are set. cacheOnly takes precedence \u2014 cache reads are enabled."
2485
+ );
2486
+ }
2248
2487
  const featureFlags = computeFeatureFlags(operatingLevel, options);
2488
+ let cloneStatus = { partialClone: false, shallow: false };
2489
+ try {
2490
+ const result = await checkCloneStatus({ cwd });
2491
+ if (result) cloneStatus = result;
2492
+ } catch {
2493
+ }
2494
+ if (cloneStatus.partialClone) {
2495
+ warnings.push(
2496
+ "Partial clone detected. Patch-ID scan (Strategy 5) will be skipped to avoid blob downloads."
2497
+ );
2498
+ }
2499
+ if (cloneStatus.shallow) {
2500
+ warnings.push(
2501
+ "Shallow repository detected. Ancestry-path results may be incomplete."
2502
+ );
2503
+ }
2504
+ let preferredBase;
2505
+ try {
2506
+ const branchResult = await gitExec(
2507
+ ["rev-parse", "--abbrev-ref", "HEAD"],
2508
+ execOptions
2509
+ );
2510
+ const branch = branchResult.stdout.trim();
2511
+ if (branch && branch !== "HEAD") {
2512
+ preferredBase = branch;
2513
+ }
2514
+ } catch {
2515
+ }
2249
2516
  const nodes = await buildTraceNodes(
2250
2517
  blameAuth.analyzed,
2251
2518
  featureFlags,
2252
2519
  platform.adapter,
2253
- options,
2520
+ { ...options, file, cwd },
2254
2521
  execOptions,
2255
- repoId
2522
+ repoId,
2523
+ cloneStatus.partialClone || void 0,
2524
+ preferredBase
2256
2525
  );
2257
2526
  return { nodes, operatingLevel, featureFlags, warnings };
2258
2527
  }
@@ -2459,7 +2728,7 @@ function formatNodeHuman(node) {
2459
2728
  init_normalizer();
2460
2729
  init_errors();
2461
2730
  function registerTraceCommand(program2) {
2462
- 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("--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) => {
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) => {
2463
2732
  const lineStr = opts.line;
2464
2733
  const parts = lineStr.split(",");
2465
2734
  const line = parseInt(parts[0], 10);
@@ -2470,7 +2739,8 @@ function registerTraceCommand(program2) {
2470
2739
  endLine,
2471
2740
  deep: opts.deep,
2472
2741
  noAst: opts.ast === false,
2473
- noCache: opts.cache === false
2742
+ noCache: opts.cache === false,
2743
+ cacheOnly: opts.cacheOnly
2474
2744
  };
2475
2745
  const cliOptions = {
2476
2746
  json: opts.json,
@@ -4,7 +4,21 @@ export interface AncestryResult {
4
4
  parentShas: string[];
5
5
  subject: string;
6
6
  }
7
+ export declare const DEFAULT_ANCESTRY_TIMEOUT = 30000;
7
8
  export declare function findMergeCommit(commitSha: string, options?: GitExecOptions & {
8
9
  ref?: string;
9
10
  }): Promise<AncestryResult | null>;
10
- export declare function extractPRFromMergeMessage(subject: string): number | null;
11
+ /**
12
+ * Verify that a merge commit actually introduced the target commit
13
+ * through its branch side (non-first parent), not from the mainline.
14
+ *
15
+ * Dual condition:
16
+ * 1. Target IS an ancestor of at least one non-first parent (branch side)
17
+ * 2. Target is NOT an ancestor of the first parent (mainline side)
18
+ *
19
+ * Returns false on git command failure (fail-skip policy).
20
+ */
21
+ export declare function verifyMergeIntroducesCommit(targetSha: string, mergeResult: AncestryResult, options?: GitExecOptions): Promise<boolean>;
22
+ /** Retrieve the subject line of a single commit. Returns null on git failure. */
23
+ export declare function getCommitSubject(sha: string, options?: GitExecOptions): Promise<string | null>;
24
+ export declare function extractPRFromMergeMessage(subject: string, platform?: string): number | null;
@@ -1,2 +1,2 @@
1
- export { extractPRFromMergeMessage, findMergeCommit } from './ancestry.js';
1
+ export { extractPRFromMergeMessage, findMergeCommit, getCommitSubject, verifyMergeIntroducesCommit, } from './ancestry.js';
2
2
  export type { AncestryResult } from './ancestry.js';
@@ -1 +1,2 @@
1
1
  export { lookupPR, resetPRCache } from './pr-lookup.js';
2
+ export type { PRLookupResult, ResolvedVia } from './pr-lookup.js';
@@ -1,9 +1,31 @@
1
1
  import type { RepoIdentity } from '../../cache/index.js';
2
2
  import type { GitExecOptions, PRInfo, PlatformAdapter } from '../../types/index.js';
3
+ export type ResolvedVia = 'api' | 'ancestry' | 'message' | 'patch-id';
4
+ export interface PRLookupResult extends PRInfo {
5
+ resolvedVia: ResolvedVia;
6
+ }
3
7
  export interface PRLookupOptions extends GitExecOptions {
4
8
  noCache?: boolean;
9
+ /** Return cached results only — skip all fallback strategies */
10
+ cacheOnly?: boolean;
5
11
  deep?: boolean;
6
12
  repoId?: RepoIdentity;
13
+ /** Skip Strategy 5 (patch-id scan) — set automatically for partial clone environments */
14
+ skipPatchIdScan?: boolean;
15
+ /** Preferred base branch for PR selection — when multiple PRs match, prefer the one targeting this branch */
16
+ preferredBase?: string;
17
+ /** Platform type for platform-aware merge message parsing */
18
+ platform?: string;
7
19
  }
8
- export declare function lookupPR(commitSha: string, adapter: PlatformAdapter | null, options?: PRLookupOptions): Promise<PRInfo | null>;
20
+ /**
21
+ * Multi-strategy PR lookup pipeline:
22
+ * Strategy 1: Cache
23
+ * Strategy 2: API direct (ground truth — Level 2)
24
+ * Strategy 3: Ancestry-path + merge commit verification (structural proof)
25
+ * Strategy 4: Blame commit message parsing (heuristic — squash merge detection)
26
+ * Strategy 5: Patch-ID matching + recursion (last resort)
27
+ */
28
+ export declare function lookupPR(commitSha: string, adapter: PlatformAdapter | null, options?: PRLookupOptions,
29
+ /** @internal recursion depth tracker — do not set from external callers */
30
+ _recursionDepth?: number): Promise<PRLookupResult | null>;
9
31
  export declare function resetPRCache(): void;