@@ -86,6 +86,8 @@ public TrackingSummary trackPrIteration(
8686 int matched = 0 ;
8787 int newOnly = 0 ;
8888 int resolved = 0 ;
89+ int unanchoredResolved = 0 ;
90+ int unanchoredPersisting = 0 ;
8991
9092 for (String filePath : allFiles ) {
9193 List <CodeAnalysisIssue > fileNewIssues = newByFile .getOrDefault (filePath , List .of ());
@@ -102,14 +104,118 @@ public TrackingSummary trackPrIteration(
102104 continue ;
103105 }
104106
107+ // ── Separate unanchored previous issues (line <= 1, no lineHash, no codeSnippet) ──
108+ // These cannot be reliably tracked by content hashing. Instead, match them
109+ // by the original issue ID that the AI preserves in its response.
110+ // If the AI omitted the issue entirely → resolved (AI didn't find it).
111+ // If the AI re-reported it with isResolved=true → resolved.
112+ // If the AI re-reported it with isResolved=false → mark for AI reconciliation.
113+ List <CodeAnalysisIssue > anchoredPrevIssues = new ArrayList <>();
114+ List <CodeAnalysisIssue > unanchoredPrevIssues = new ArrayList <>();
115+ for (CodeAnalysisIssue prev : filePrevIssues ) {
116+ if (isUnanchored (prev )) {
117+ unanchoredPrevIssues .add (prev );
118+ } else {
119+ anchoredPrevIssues .add (prev );
120+ }
121+ }
122+
123+ // Handle unanchored previous issues via fingerprint matching.
124+ // Unlike IssueTracker (which would make these "immortal" via Pass 3/4),
125+ // we match by fingerprint but ALSO respect the new issue's isResolved flag
126+ // from the AI's Stage 1 review.
127+ if (!unanchoredPrevIssues .isEmpty ()) {
128+ // Build a lookup: fingerprint → list of new issues with that fingerprint
129+ Map <String , List <CodeAnalysisIssue >> newByFingerprint = new LinkedHashMap <>();
130+ for (CodeAnalysisIssue newIssue : fileNewIssues ) {
131+ String fp = newIssue .getIssueFingerprint ();
132+ if (fp != null ) {
133+ newByFingerprint .computeIfAbsent (fp , k -> new ArrayList <>()).add (newIssue );
134+ }
135+ }
136+
137+ Set <CodeAnalysisIssue > unanchoredMatchedNewIssues = new HashSet <>();
138+ for (CodeAnalysisIssue prevIssue : unanchoredPrevIssues ) {
139+ String prevFp = prevIssue .getIssueFingerprint ();
140+ List <CodeAnalysisIssue > candidates = prevFp != null
141+ ? newByFingerprint .getOrDefault (prevFp , List .of ())
142+ : List .of ();
143+
144+ // Pick the first unmatched candidate (unanchored issues at line 1 are
145+ // identical in terms of fingerprint, so order doesn't matter)
146+ CodeAnalysisIssue matchedNew = null ;
147+ for (CodeAnalysisIssue c : candidates ) {
148+ if (!unanchoredMatchedNewIssues .contains (c )) {
149+ matchedNew = c ;
150+ break ;
151+ }
152+ }
153+
154+ if (matchedNew != null ) {
155+ // AI re-reported this issue — link them
156+ matchedNew .setTrackedFromIssueId (prevIssue .getId ());
157+ matchedNew .setTrackingConfidence ("UNANCHORED_FP_MATCH" );
158+ unanchoredMatchedNewIssues .add (matchedNew );
159+
160+ if (matchedNew .isResolved ()) {
161+ // AI marked it resolved in Stage 1 → trust it
162+ unanchoredResolved ++;
163+ log .info ("Unanchored issue {} resolved by AI (new issue {}, file={})" ,
164+ prevIssue .getId (), matchedNew .getId (), filePath );
165+ } else if (prevIssue .isResolved ()) {
166+ // Previous was resolved (user dismissed) → carry forward
167+ matchedNew .setResolved (true );
168+ matchedNew .setResolvedDescription (prevIssue .getResolvedDescription ());
169+ matchedNew .setResolvedByPr (prevIssue .getResolvedByPr ());
170+ matchedNew .setResolvedCommitHash (prevIssue .getResolvedCommitHash ());
171+ matchedNew .setResolvedAnalysisId (prevIssue .getResolvedAnalysisId ());
172+ matchedNew .setResolvedAt (prevIssue .getResolvedAt ());
173+ matchedNew .setResolvedBy (prevIssue .getResolvedBy ());
174+ unanchoredResolved ++;
175+ } else {
176+ // AI says still present — persists (but can be overridden by
177+ // dedicated AI reconciliation if caller implements it)
178+ unanchoredPersisting ++;
179+ }
180+ issueRepository .save (matchedNew );
181+ matched ++;
182+ } else {
183+ // AI did NOT re-report this issue → it's resolved
184+ unanchoredResolved ++;
185+ resolved ++;
186+ log .info ("Unanchored issue {} resolved — AI omitted it in new review (file={})" ,
187+ prevIssue .getId (), filePath );
188+ }
189+ }
190+
191+ // Remove unanchored-matched new issues from the pool before IssueTracker
192+ // so they don't get double-counted or double-matched
193+ fileNewIssues = fileNewIssues .stream ()
194+ .filter (ni -> !unanchoredMatchedNewIssues .contains (ni ))
195+ .collect (Collectors .toList ());
196+ }
197+
198+ // ── Run IssueTracker on anchored issues only ──
199+ if (anchoredPrevIssues .isEmpty () && fileNewIssues .isEmpty ()) {
200+ continue ;
201+ }
202+ if (anchoredPrevIssues .isEmpty ()) {
203+ newOnly += fileNewIssues .size ();
204+ continue ;
205+ }
206+ if (fileNewIssues .isEmpty ()) {
207+ resolved += anchoredPrevIssues .size ();
208+ continue ;
209+ }
210+
105211 // Wrap issues as Trackables
106212 // RAW = new issues (recomputed against new file content)
107213 List <TrackableIssue > rawTrackables = fileNewIssues .stream ()
108214 .map (issue -> recomputeTrackable (issue , newFileContents ))
109215 .collect (Collectors .toList ());
110216
111217 // BASE = previous issues (with original detection-time hashes)
112- List <TrackableIssue > baseTrackables = filePrevIssues .stream ()
218+ List <TrackableIssue > baseTrackables = anchoredPrevIssues .stream ()
113219 .map (TrackableIssue ::fromOriginal )
114220 .collect (Collectors .toList ());
115221
@@ -125,9 +231,12 @@ public TrackingSummary trackPrIteration(
125231 newIssue .setTrackedFromIssueId (prevIssue .getId ());
126232 newIssue .setTrackingConfidence (pair .confidence ().name ());
127233
128- // If the previous issue was resolved (e.g. user dismissed it), carry that
129- // status forward so the same issue doesn't reappear as an annotation.
234+ // Check resolved status from BOTH directions:
235+ // 1. Previous issue was resolved (user dismissed, or previous reconciliation)
236+ // 2. New issue was marked resolved by the AI in Stage 1 review
237+ // (createIssueFromData sets isResolved=true when AI says isResolved: true)
130238 if (prevIssue .isResolved ()) {
239+ // Carry forward previous resolution
131240 newIssue .setResolved (true );
132241 newIssue .setResolvedDescription (prevIssue .getResolvedDescription ());
133242 newIssue .setResolvedByPr (prevIssue .getResolvedByPr ());
@@ -137,6 +246,10 @@ public TrackingSummary trackPrIteration(
137246 newIssue .setResolvedBy (prevIssue .getResolvedBy ());
138247 log .info ("Carried forward resolved status from issue {} to new issue {} (confidence={})" ,
139248 prevIssue .getId (), newIssue .getId (), pair .confidence ().name ());
249+ } else if (newIssue .isResolved ()) {
250+ // AI in Stage 1 marked this re-emitted issue as resolved — trust it
251+ log .info ("AI marked matched issue as resolved: prev={} → new={} (confidence={})" ,
252+ prevIssue .getId (), newIssue .getId (), pair .confidence ().name ());
140253 }
141254
142255 issueRepository .save (newIssue );
@@ -150,11 +263,14 @@ public TrackingSummary trackPrIteration(
150263 resolved += tracking .getUnmatchedBases ().size ();
151264 }
152265
153- log .info ("PR tracking for analysis {}: {} matched, {} new, {} resolved (previous analysis={})" ,
154- newAnalysis .getId (), matched , newOnly , resolved , previousAnalysis .getId ());
266+ log .info ("PR tracking for analysis {}: {} matched, {} new, {} resolved " +
267+ "(unanchored: {} resolved, {} persisting) (previous analysis={})" ,
268+ newAnalysis .getId (), matched , newOnly , resolved ,
269+ unanchoredResolved , unanchoredPersisting , previousAnalysis .getId ());
155270
156271 return new TrackingSummary (matched , resolved , newOnly ,
157- prevIssues .stream ().filter (CodeAnalysisIssue ::isResolved ).count ());
272+ prevIssues .stream ().filter (CodeAnalysisIssue ::isResolved ).count (),
273+ unanchoredResolved , unanchoredPersisting );
158274 }
159275
160276 // ── Trackable adapter ────────────────────────────────────────────────
@@ -217,6 +333,18 @@ private TrackableIssue recomputeTrackable(CodeAnalysisIssue issue, Map<String, S
217333 );
218334 }
219335
336+ /**
337+ * An issue is "unanchored" when it has no meaningful code location —
338+ * line is absent or 1, no line hash, and no code snippet.
339+ * These cannot be reliably tracked by content hashing (IssueTracker Pass 3/4
340+ * would match them forever on fingerprint+line alone).
341+ */
342+ private boolean isUnanchored (CodeAnalysisIssue issue ) {
343+ return (issue .getLineNumber () == null || issue .getLineNumber () <= 1 )
344+ && issue .getLineHash () == null
345+ && (issue .getCodeSnippet () == null || issue .getCodeSnippet ().isBlank ());
346+ }
347+
220348 private Map <String , List <CodeAnalysisIssue >> groupByFile (List <CodeAnalysisIssue > issues ) {
221349 return issues .stream ()
222350 .filter (i -> i .getFilePath () != null )
@@ -232,8 +360,15 @@ public record TrackingSummary(
232360 int matchedCount ,
233361 int resolvedCount ,
234362 int newIssueCount ,
235- long previouslyResolvedCount
363+ long previouslyResolvedCount ,
364+ int unanchoredResolvedCount ,
365+ int unanchoredPersistingCount
236366 ) {
367+ /** Convenience constructor for first iteration (no tracking). */
368+ public TrackingSummary (int matchedCount , int resolvedCount , int newIssueCount , long previouslyResolvedCount ) {
369+ this (matchedCount , resolvedCount , newIssueCount , previouslyResolvedCount , 0 , 0 );
370+ }
371+
237372 public boolean isFirstIteration () {
238373 return matchedCount == 0 && resolvedCount == 0 && previouslyResolvedCount == 0 ;
239374 }
0 commit comments