Skip to content

Commit a5b4e78

Browse files
committed
Add comprehensive test suite and GitHub Actions CI
- Extract pure algorithms into HunkMatcher (no IntelliJ deps) for easy testing - HunkMatcherTest: 22 tests covering exactMatch, parseDiffAndRemap, fuzzySearch, lcsScore - ReviewCommentTest: 18 tests covering serialization, status, edge cases (unicode, multiline, long threads) - CommentStoreFileIOTest: 13 tests covering file I/O, atomic writes, filtering, malformed files, schema versions - Remove MockProject-based AnchorResolverTest (replaced by HunkMatcherTest) - Add .github/workflows/ci.yml: build, test, plugin verification on JDK 17 https://claude.ai/code/session_015LpmjAt17XD582hZ9TPLCv
1 parent c0edba1 commit a5b4e78

File tree

7 files changed

+1038
-320
lines changed

7 files changed

+1038
-320
lines changed

.github/workflows/ci.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main, 'claude/**' ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
build-and-test:
14+
name: Build & Test
15+
runs-on: ubuntu-latest
16+
defaults:
17+
run:
18+
working-directory: review-plugin
19+
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
24+
- name: Set up JDK 17
25+
uses: actions/setup-java@v4
26+
with:
27+
distribution: temurin
28+
java-version: 17
29+
30+
- name: Setup Gradle
31+
uses: gradle/actions/setup-gradle@v4
32+
33+
- name: Build plugin
34+
run: ./gradlew buildPlugin
35+
36+
- name: Run tests
37+
run: ./gradlew test
38+
39+
- name: Upload test results
40+
if: always()
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: test-results
44+
path: review-plugin/build/reports/tests/
45+
retention-days: 14
46+
47+
verify-plugin:
48+
name: Verify Plugin Compatibility
49+
runs-on: ubuntu-latest
50+
needs: build-and-test
51+
defaults:
52+
run:
53+
working-directory: review-plugin
54+
55+
steps:
56+
- name: Checkout
57+
uses: actions/checkout@v4
58+
59+
- name: Set up JDK 17
60+
uses: actions/setup-java@v4
61+
with:
62+
distribution: temurin
63+
java-version: 17
64+
65+
- name: Setup Gradle
66+
uses: gradle/actions/setup-gradle@v4
67+
68+
- name: Verify plugin descriptor
69+
run: ./gradlew verifyPluginConfiguration
70+
71+
- name: Build plugin distribution
72+
run: ./gradlew buildPlugin
73+
74+
- name: Upload plugin artifact
75+
uses: actions/upload-artifact@v4
76+
with:
77+
name: review-plugin
78+
path: review-plugin/build/distributions/*.zip
79+
retention-days: 30
Lines changed: 4 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.reviewplugin.anchor
22

33
import com.intellij.openapi.project.Project
4-
import com.reviewplugin.model.Hunk
54
import com.reviewplugin.model.ReviewComment
65
import java.util.concurrent.ConcurrentHashMap
76

@@ -33,7 +32,7 @@ class AnchorResolver(private val project: Project) {
3332
val target = anchor.hunk.target
3433

3534
// Step 1: Exact match at hint
36-
exactMatch(fileLines, anchor.line_hint, target)?.let {
35+
HunkMatcher.exactMatch(fileLines, anchor.line_hint, target)?.let {
3736
return AnchorResult.Found(it)
3837
}
3938

@@ -47,130 +46,19 @@ class AnchorResolver(private val project: Project) {
4746
}
4847

4948
// Step 3: Fuzzy search
50-
fuzzySearch(fileLines, anchor.hunk)?.let {
49+
HunkMatcher.fuzzySearch(fileLines, anchor.hunk)?.let {
5150
return AnchorResult.Found(it)
5251
}
5352

5453
return AnchorResult.Drifted
5554
}
5655

57-
/**
58-
* Step 1: Check if lines at line_hint match the target exactly (trimmed comparison).
59-
* Returns 0-based line index or null.
60-
*/
61-
internal fun exactMatch(lines: List<String>, lineHint: Int, target: List<String>): Int? {
62-
// line_hint is 1-based in the schema
63-
val startIdx = lineHint - 1
64-
if (startIdx < 0 || startIdx + target.size > lines.size) return null
65-
val matches = target.indices.all { i ->
66-
lines[startIdx + i].trim() == target[i].trim()
67-
}
68-
return if (matches) startIdx else null
69-
}
70-
71-
/**
72-
* Step 2: Parse unified diff to remap line_hint from old commit to current.
73-
* Returns 0-based line index or null.
74-
*/
75-
internal fun gitDiffRemap(commit: String, file: String, hintLine: Int): Int? {
56+
private fun gitDiffRemap(commit: String, file: String, hintLine: Int): Int? {
7657
val gitRunner = GitRunner(project.basePath ?: return null)
7758
val diffOutput = gitRunner.diffUnified(commit, file)
7859
if (diffOutput.isBlank()) {
79-
// No diff means file hasn't changed, return original hint (0-based)
8060
return hintLine - 1
8161
}
82-
return parseDiffAndRemap(diffOutput, hintLine)
83-
}
84-
85-
/**
86-
* Parse unified diff output and remap a 1-based old line number to a 0-based new line number.
87-
*/
88-
internal fun parseDiffAndRemap(diffOutput: String, oldLine: Int): Int? {
89-
val hunkHeaderRegex = Regex("""^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@""")
90-
var offset = 0
91-
var lastOldEnd = 0
92-
93-
for (line in diffOutput.lines()) {
94-
val match = hunkHeaderRegex.find(line) ?: continue
95-
val oldStart = match.groupValues[1].toInt()
96-
val oldCount = match.groupValues[2].ifEmpty { "1" }.toInt()
97-
val newStart = match.groupValues[3].toInt()
98-
val newCount = match.groupValues[4].ifEmpty { "1" }.toInt()
99-
100-
// If our line is before this hunk, the accumulated offset applies
101-
if (oldLine < oldStart) {
102-
return oldLine - 1 + offset
103-
}
104-
105-
// If our line falls within a deleted range in this hunk, it's gone
106-
val oldEnd = oldStart + oldCount - 1
107-
if (oldLine in oldStart..oldEnd) {
108-
// Line is in the modified hunk - could be deleted or changed
109-
// We can't precisely determine without parsing line-by-line, so return null
110-
// to fall through to fuzzy search
111-
return null
112-
}
113-
114-
offset = (newStart + newCount) - (oldStart + oldCount)
115-
lastOldEnd = oldEnd
116-
}
117-
118-
// Line is after all hunks
119-
return oldLine - 1 + offset
120-
}
121-
122-
/**
123-
* Step 3: Sliding window fuzzy search using LCS similarity.
124-
* Returns 0-based line index or null.
125-
*/
126-
internal fun fuzzySearch(lines: List<String>, hunk: Hunk): Int? {
127-
val searchBlock = hunk.context_before + hunk.target + hunk.context_after
128-
if (searchBlock.isEmpty()) return null
129-
130-
val windowSize = searchBlock.size
131-
if (lines.size < windowSize) return null
132-
133-
var bestScore = 0.0
134-
var bestLine = -1
135-
val threshold = 0.75
136-
137-
for (i in 0..lines.size - windowSize) {
138-
val window = lines.subList(i, i + windowSize)
139-
val score = lcsScore(
140-
window.map { it.trim() },
141-
searchBlock.map { it.trim() }
142-
)
143-
if (score > bestScore) {
144-
bestScore = score
145-
bestLine = i
146-
}
147-
}
148-
149-
if (bestScore >= threshold && bestLine >= 0) {
150-
// Return the line corresponding to the start of the target within the block
151-
return bestLine + hunk.context_before.size
152-
}
153-
return null
154-
}
155-
156-
/**
157-
* Compute LCS-based similarity between two lists of strings.
158-
*/
159-
private fun lcsScore(a: List<String>, b: List<String>): Double {
160-
val m = a.size
161-
val n = b.size
162-
if (m == 0 || n == 0) return 0.0
163-
164-
val dp = Array(m + 1) { IntArray(n + 1) }
165-
for (i in 1..m) {
166-
for (j in 1..n) {
167-
dp[i][j] = if (a[i - 1] == b[j - 1]) {
168-
dp[i - 1][j - 1] + 1
169-
} else {
170-
maxOf(dp[i - 1][j], dp[i][j - 1])
171-
}
172-
}
173-
}
174-
return dp[m][n].toDouble() / maxOf(m, n)
62+
return HunkMatcher.parseDiffAndRemap(diffOutput, hintLine)
17563
}
17664
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.reviewplugin.anchor
2+
3+
import com.reviewplugin.model.Hunk
4+
5+
/**
6+
* Pure algorithms for hunk matching and diff remapping.
7+
* No IntelliJ dependencies — fully unit-testable.
8+
*/
9+
object HunkMatcher {
10+
11+
/**
12+
* Check if lines at lineHint (1-based) match the target exactly (trimmed comparison).
13+
* Returns 0-based line index or null.
14+
*/
15+
fun exactMatch(lines: List<String>, lineHint: Int, target: List<String>): Int? {
16+
val startIdx = lineHint - 1
17+
if (startIdx < 0 || startIdx + target.size > lines.size) return null
18+
val matches = target.indices.all { i ->
19+
lines[startIdx + i].trim() == target[i].trim()
20+
}
21+
return if (matches) startIdx else null
22+
}
23+
24+
/**
25+
* Parse unified diff output and remap a 1-based old line number to a 0-based new line number.
26+
* Returns null if the line falls within a modified hunk (can't reliably remap).
27+
*/
28+
fun parseDiffAndRemap(diffOutput: String, oldLine: Int): Int? {
29+
val hunkHeaderRegex = Regex("""^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@""")
30+
var offset = 0
31+
32+
for (line in diffOutput.lines()) {
33+
val match = hunkHeaderRegex.find(line) ?: continue
34+
val oldStart = match.groupValues[1].toInt()
35+
val oldCount = match.groupValues[2].ifEmpty { "1" }.toInt()
36+
val newStart = match.groupValues[3].toInt()
37+
val newCount = match.groupValues[4].ifEmpty { "1" }.toInt()
38+
39+
if (oldLine < oldStart) {
40+
return oldLine - 1 + offset
41+
}
42+
43+
val oldEnd = oldStart + oldCount - 1
44+
if (oldLine in oldStart..oldEnd) {
45+
return null
46+
}
47+
48+
offset = (newStart + newCount) - (oldStart + oldCount)
49+
}
50+
51+
return oldLine - 1 + offset
52+
}
53+
54+
/**
55+
* Sliding window fuzzy search using LCS similarity.
56+
* Returns 0-based line index of the target start, or null if no match above threshold.
57+
*/
58+
fun fuzzySearch(lines: List<String>, hunk: Hunk, threshold: Double = 0.75): Int? {
59+
val searchBlock = hunk.context_before + hunk.target + hunk.context_after
60+
if (searchBlock.isEmpty()) return null
61+
62+
val windowSize = searchBlock.size
63+
if (lines.size < windowSize) return null
64+
65+
var bestScore = 0.0
66+
var bestLine = -1
67+
68+
for (i in 0..lines.size - windowSize) {
69+
val window = lines.subList(i, i + windowSize)
70+
val score = lcsScore(
71+
window.map { it.trim() },
72+
searchBlock.map { it.trim() }
73+
)
74+
if (score > bestScore) {
75+
bestScore = score
76+
bestLine = i
77+
}
78+
}
79+
80+
if (bestScore >= threshold && bestLine >= 0) {
81+
return bestLine + hunk.context_before.size
82+
}
83+
return null
84+
}
85+
86+
/**
87+
* Compute LCS-based similarity between two lists of strings.
88+
* Returns a value between 0.0 and 1.0.
89+
*/
90+
fun lcsScore(a: List<String>, b: List<String>): Double {
91+
val m = a.size
92+
val n = b.size
93+
if (m == 0 || n == 0) return 0.0
94+
95+
val dp = Array(m + 1) { IntArray(n + 1) }
96+
for (i in 1..m) {
97+
for (j in 1..n) {
98+
dp[i][j] = if (a[i - 1] == b[j - 1]) {
99+
dp[i - 1][j - 1] + 1
100+
} else {
101+
maxOf(dp[i - 1][j], dp[i][j - 1])
102+
}
103+
}
104+
}
105+
return dp[m][n].toDouble() / maxOf(m, n)
106+
}
107+
}

0 commit comments

Comments
 (0)