@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 +44 -20
- package/dist/cli.mjs +228 -68
- package/dist/core/ancestry/ancestry.d.ts +18 -0
- package/dist/core/ancestry/index.d.ts +1 -1
- package/dist/core/blame/blame.d.ts +11 -3
- package/dist/core/blame/index.d.ts +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/index.cjs +214 -65
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +214 -65
- package/dist/types/blame.d.ts +12 -0
- package/dist/types/git.d.ts +5 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/stage.d.ts +4 -0
- package/dist/types/trace.d.ts +3 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
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
|
|
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 =
|
|
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
|
|
102
|
+
@lumy-pack/line-lore executes a deterministic pipeline with two trace modes:
|
|
93
103
|
|
|
94
|
-
1. **Line → Commit (Blame)**:
|
|
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 —
|
|
115
|
-
│
|
|
116
|
-
│
|
|
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
|
-
│ •
|
|
121
|
-
│ If
|
|
122
|
-
│
|
|
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?**
|
|
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
|
|
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
|
|
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
|
|
415
|
+
if (lines.length === 0) return [];
|
|
416
|
+
const verifiedCandidates = [];
|
|
435
417
|
const candidateCount = Math.min(lines.length, MAX_CANDIDATES);
|
|
436
|
-
let
|
|
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
|
-
|
|
422
|
+
attemptedCount++;
|
|
441
423
|
const verified = await verifyMergeIntroducesCommit(
|
|
442
424
|
commitSha,
|
|
443
425
|
candidate,
|
|
444
426
|
options
|
|
445
427
|
);
|
|
446
|
-
if (verified)
|
|
428
|
+
if (verified) verifiedCandidates.push(candidate);
|
|
447
429
|
}
|
|
448
|
-
if (
|
|
430
|
+
if (attemptedCount > 0 && verifiedCandidates.length === 0 && options?.warnings) {
|
|
449
431
|
options.warnings.push(
|
|
450
|
-
`ancestry: all ${
|
|
432
|
+
`ancestry: all ${attemptedCount} merge candidate(s) failed verification for ${commitSha.slice(0, 8)}`
|
|
451
433
|
);
|
|
452
434
|
}
|
|
453
|
-
return
|
|
435
|
+
return verifiedCandidates;
|
|
454
436
|
} catch {
|
|
455
|
-
return
|
|
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
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
707
|
+
title: candidate.subject,
|
|
693
708
|
author: "",
|
|
694
709
|
url: "",
|
|
695
|
-
mergeCommit:
|
|
710
|
+
mergeCommit: candidate.mergeCommitSha,
|
|
696
711
|
baseBranch: "",
|
|
697
712
|
resolvedVia: "ancestry"
|
|
698
713
|
};
|
|
699
714
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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.
|
|
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
|
|
2151
|
-
|
|
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
|
|
2157
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
2361
|
-
|
|
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
|
-
|
|
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
|
|
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",
|