Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ private static AppliedChange fromPending(PendingAppliedChange pac) {
return AppliedChange.builder()
.originalLineFrom(pac.lineFrom())
.originalLineTo(pac.lineTo())
.declaredLineFrom(pac.declaredLineFrom())
.declaredLineTo(pac.declaredLineTo())
.deltaLines(pac.deltaLines())
.comparisonCode(pac.comparisonCode())
.lineNormalizedCode(pac.lineNormalizedCode())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ public List<HunkOutcome> classifyRemediationHunks(Remediation remediation, Path
*/
private HunkOutcome classifyRange(int lineFrom, int lineTo, List<AppliedChange> applied, String candidateComparisonCode) {
for (AppliedChange ac : applied) {
if (ac.coversFully(lineFrom, lineTo)) {
if (ac.contentCovers(candidateComparisonCode, lineFrom, lineTo)) {
if (ac.coversDeclaredRange(lineFrom, lineTo)){
if (ac.contentCovers(candidateComparisonCode, lineFrom, lineTo)) {
return HunkOutcome.SUPERSEDED;
}
return HunkOutcome.POSSIBLY_REMEDIATED;
}
if (ac.overlapsPartially(lineFrom, lineTo)) {
if (ac.overlapsDeclaredRangePartially(lineFrom, lineTo)) {
return HunkOutcome.CONFLICTS;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@

import java.nio.file.Path;

public record PendingAppliedChange(Path filePath, int lineFrom, int lineTo, int deltaLines,
String comparisonCode, String lineNormalizedCode) {
import lombok.Builder;

@Builder
public record PendingAppliedChange(
Path filePath,
int lineFrom,
int lineTo,
int declaredLineFrom,
int declaredLineTo,
int deltaLines,
String comparisonCode,
String lineNormalizedCode) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,24 @@
public final class AppliedChange {
private final int originalLineFrom;
private final int originalLineTo;
private final int declaredLineFrom;
private final int declaredLineTo;
private final int deltaLines;
private final String comparisonCode;
private final String[] lineNormalizedContent;

public AppliedChange(int originalLineFrom, int originalLineTo, int deltaLines, String comparisonCode) {
this(originalLineFrom, originalLineTo, deltaLines, comparisonCode, null);
this(originalLineFrom, originalLineTo, originalLineFrom, originalLineTo, deltaLines, comparisonCode, null);

}

@Builder
public AppliedChange(int originalLineFrom, int originalLineTo, int deltaLines, String comparisonCode, String lineNormalizedCode) {
public AppliedChange(int originalLineFrom, int originalLineTo, int declaredLineFrom, int declaredLineTo, int deltaLines, String comparisonCode, String lineNormalizedCode)
{
this.originalLineFrom = originalLineFrom;
this.originalLineTo = originalLineTo;
this.declaredLineFrom = declaredLineFrom;
this.declaredLineTo = declaredLineTo;
this.deltaLines = deltaLines;
this.comparisonCode = comparisonCode;
// Store line-by-line normalized content for offset-anchored comparison (newlines preserved, each line normalized)
Expand All @@ -63,10 +69,25 @@ public boolean coversFully(int lineFrom, int lineTo) {

/** True if [lineFrom, lineTo] overlaps this change's original range without either side fully containing the other. */
public boolean overlapsPartially(int lineFrom, int lineTo) {
boolean disjoint = lineTo < originalLineFrom || lineFrom > originalLineTo;
return !disjoint;
boolean overlaps = lineFrom <= originalLineTo && originalLineFrom <= lineTo;
boolean candidateContainsThis = lineFrom <= originalLineFrom && originalLineTo <= lineTo;
return overlaps && !coversFully(lineFrom, lineTo) && !candidateContainsThis;
}


/** True if this change's declared range fully contains [lineFrom, lineTo]. */
public boolean coversDeclaredRange(int lineFrom, int lineTo) {
return declaredLineFrom <= lineFrom && lineTo <= declaredLineTo;
}

/** True if [lineFrom, lineTo] overlaps this change's declared range without either side fully containing the other. */
public boolean overlapsDeclaredRangePartially(int lineFrom, int lineTo) {
boolean overlaps = lineFrom <= declaredLineTo && declaredLineFrom <= lineTo;
boolean candidateContainsThis = lineFrom <= declaredLineFrom && declaredLineTo <= lineTo;
return overlaps && !coversDeclaredRange(lineFrom, lineTo) && !candidateContainsThis;
}


/**
* Below this length a normalized comparison code (e.g. {@code "return;"}) is too short and
* generic to prove that a substring hit inside a broader fix's content is the same fix,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,16 @@ private void processFileChanges(Remediation remediation, FileChange fileChange,
int linesAfterChange = updatedContent.split("\n", -1).length;
int delta = linesAfterChange - linesBeforeChange;
String lineNormalizedCode = hunk.lineNormalizedCode(filename);
ledger.stage(new PendingAppliedChange(filePath, actualLineFrom, actualLineTo, delta, comparisonCode, lineNormalizedCode));
ledger.stage(PendingAppliedChange.builder()
.filePath(filePath)
.lineFrom(actualLineFrom)
.lineTo(actualLineTo)
.declaredLineFrom(declaredLineFrom)
.declaredLineTo(declaredLineTo)
.deltaLines(delta)
.comparisonCode(comparisonCode)
.lineNormalizedCode(lineNormalizedCode)
.build());
appliedKeysOut.add(key);
appliedInThisFile++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,42 @@ void secondFprDoesNotApplyOverAFixTheFirstFprAlreadyRewrote() throws Exception {
assertEquals(1, second.skippedRemediations());
assertEquals(afterFirst, Files.readString(sourceFile), "the second FPR must leave the file untouched");
}
/**
* Regression test for coordinate-space mismatch bug: HunkClassifier must compare declared
* (pristine-file) line numbers against declared ranges, not actual (post-shift) ranges, when
* pre-classifying narrower hunks nested inside a broader fix that shifted them. The broader
* fix (wide-deletes) declares lines 1-5 and deletes them, replacing with 2 lines (delta -3).
* The narrower fix (narrow-inside) declares line 3, which sits inside 1-5's declared range.
* Before the fix, classifyRange would compare declared-3 against the broader hunk's ACTUAL
* range (which is now at a shifted position), missing the nesting; after the fix, it compares
* declared-against-declared and correctly classifies as POSSIBLY_REMEDIATED.
*/
@Test
void narrowerRemediationInsideBroaderDeleteIsClassifiedAsPossiblyRemediated() throws Exception {
String originalSource = "line1\nline2\nline3\nline4\nline5\nline6\n";
writeSourceFile("Example.java", originalSource);
Path fprPath = buildFpr(List.of(
new MultiHunkRemediationSpec("wide-deletes", List.of(new FileSpec("Example.java", List.of(
new HunkSpec(1, 5, 0, 0,
"line1\nline2\nline3\nline4\nline5",
"line1\nline2\nline3\nline4\nline5",
"replacement1\nreplacement2"))))),
new MultiHunkRemediationSpec("narrow-inside", List.of(new FileSpec("Example.java", List.of(
new HunkSpec(3, 3, 1, 1, "line2\nline3\nline4",
"line3", "line3-modified")))))));

RemediationMetric metric = apply(fprPath);

assertEquals(2, metric.totalRemediations());
assertEquals(1, metric.appliedRemediations(), "the broader fix must be applied");
assertEquals(1, metric.possiblyRemediatedRemediations(),
"the narrower fix's declared line 3 sits inside the broader fix's declared 1-5, so it must be "
+ "pre-classified as POSSIBLY_REMEDIATED even though its actual position shifted");
assertEquals(0, metric.skippedRemediations(),
"no remediation should be skipped; the narrower one must not fail with ANCHOR_DOES_NOT_MATCH");
}



private record HunkSpec(int lineFrom, int lineTo, int contextBefore, int contextAfter,
String context, String originalCode, String newCode) {}
Expand Down Expand Up @@ -1295,12 +1331,12 @@ void previewModePopulatesPreviewDetailsWithChanges() throws Exception {
assertEquals("available", detail.status());
assertNotNull(detail.files());
assertEquals(1, detail.files().size());

var filePreview = detail.files().get("Example.java");
assertNotNull(filePreview);
assertEquals("UTF-8", filePreview.encoding());
assertEquals(1, filePreview.changes().size());

var change = filePreview.changes().get(0);
assertEquals(1, change.changeIndex());
assertEquals(3, change.lineFrom());
Expand Down Expand Up @@ -1344,7 +1380,7 @@ void previewModeCapturesSkipReasonsInPreviewDetails() throws Exception {

String validHash = TestHashUtil.sha256Base64Unix("class Valid { void run() { old(); } }");
String missingHash = "dGVzdGhhc2g="; // arbitrary hash for missing file

String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<Remediations xmlns="xmlns://www.fortify.com/schema/remediations">
Expand Down Expand Up @@ -1414,7 +1450,7 @@ void previewModeCapturesSkipReasonsInPreviewDetails() throws Exception {
.findFirst().orElseThrow();
assertEquals("ISSUE-VALID", available.issueId());
assertNotNull(available.files().get("Valid.java"));

var skipped = metric.previewDetails().stream()
.filter(d -> "skipped".equals(d.status()))
.findFirst().orElseThrow();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,24 @@ class AppliedChangeTest {
*/
@Test
void unavailableComparisonCodeIsNotTreatedAsProvenCoverage() {
assertFalse(new AppliedChange(1, 3, 0, "W1W2W3").contentCovers(null, 2, 2),
assertFalse(
AppliedChange.builder()
.originalLineFrom(1)
.originalLineTo(3)
.deltaLines(0)
.comparisonCode("W1W2W3")
.build()
.contentCovers(null, 2, 2),
"a candidate whose content could not be computed has not been proven covered");
assertFalse(new AppliedChange(1, 3, 0, null).contentCovers("M2", 2, 2),

assertFalse(
AppliedChange.builder()
.originalLineFrom(1)
.originalLineTo(3)
.deltaLines(0)
.comparisonCode(null)
.build()
.contentCovers("M2", 2, 2),
"an applied change whose content is unknown cannot prove it covers anything");
}
}
Loading