fix(check): scope signature-change detection to exported symbols#1792
fix(check): scope signature-change detection to exported symbols#1792carlos-alm wants to merge 3 commits into
Conversation
…x hunk-context false positive checkNoSignatureChanges flagged any function/method/class whose declaration line fell inside a diff hunk's line range, without distinguishing: (1) unchanged declarations swept in purely because unified diffs include 3 context lines around a real change elsewhere in the same hunk, and (2) private, file-local helpers whose deletion/rename can never break an external caller (every call site lives in the same file, in the same diff). parseDiffOutput now walks each hunk body and tracks only the lines actually marked '-', producing precise oldRanges instead of trusting the raw hunk header span. checkNoSignatureChanges is now scoped to exported=1 symbols, matching the check's actual purpose (protect callers outside the diff). Discovered while adopting the leiden typed-array helpers extracted in a prior refactor: removing a private duplicate fget/iget pair from two sibling files (never exported, zero external callers) was blocked by both issues. docs check acknowledged: internal bug fix to codegraph's own check.ts gate predicate, no user-facing feature/language/architecture-table changes. Closes #1760 Impact: 6 functions changed, 7 affected
| const hunkMatch = line.match(HUNK_RE); | ||
| if (hunkMatch) { | ||
| removedTracker.flush(currentFile, oldRanges); | ||
| removedTracker.startHunk(parseInt(hunkMatch[1]!, 10)); | ||
| const newStart = parseInt(hunkMatch[3]!, 10); | ||
| const newCount = parseInt(hunkMatch[4] || '1', 10); | ||
| if (newCount > 0) { | ||
| changedRanges.get(currentFile)!.push({ start: newStart, end: newStart + newCount - 1 }); | ||
| } |
There was a problem hiding this comment.
Asymmetric precision:
changedRanges still uses raw hunk header span
oldRanges is now precisely line-tracked via RemovedLineTracker, but changedRanges is still derived from the raw +start,+count hunk header (lines 123-127). With getGitDiff's --unified=0 this is harmless today — the header span and the actual + lines are identical. But parseDiffOutput is a public export and, if ever called with a context-carrying diff (e.g., a stored diff from a CI artifact or a direct test fixture), changedRanges would silently include context lines on the new side, potentially causing checkMaxBlastRadius to flag functions whose bodies were untouched. A symmetric new-side tracker (or at minimum a code comment acknowledging this invariant) would remove the latent inconsistency.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed — extended the line tracker to walk the new side of each hunk body symmetrically with the old side, so changedRanges now only includes lines actually marked + instead of the raw +start,+count header span. Added a symmetric regression test (new-side ranges exclude unchanged context lines around an addition) mirroring the existing old-side test. Behavior for the current --unified=0 caller is unchanged; this closes the latent gap for any future caller that passes a context-carrying diff.
Codegraph Impact Analysis8 functions changed → 8 callers affected across 4 files
|
) changedRanges was still derived from the raw +start,+count hunk header while oldRanges was precisely line-tracked, so a context-carrying diff passed to the public parseDiffOutput export could silently sweep up untouched lines on the new side. Extend the line tracker to walk both sides of the hunk body symmetrically. docs check acknowledged: internal parsing fix only, no README/CLAUDE.md/ROADMAP.md changes needed. Impact: 7 functions changed, 7 affected
| consume( | ||
| line: string, | ||
| file: string, | ||
| oldRanges: Map<string, DiffRange[]>, | ||
| changedRanges: Map<string, DiffRange[]>, | ||
| ): void { | ||
| if (line.startsWith('-')) { | ||
| this.flushAdded(file, changedRanges); | ||
| if (this.removedRunStart === null) this.removedRunStart = this.oldLineCursor; | ||
| this.removedRunEnd = this.oldLineCursor; | ||
| this.oldLineCursor++; | ||
| return; | ||
| } | ||
| if (line.startsWith('+')) { | ||
| this.flushRemoved(file, oldRanges); | ||
| if (this.addedRunStart === null) this.addedRunStart = this.newLineCursor; | ||
| this.addedRunEnd = this.newLineCursor; | ||
| this.newLineCursor++; | ||
| return; | ||
| } | ||
| // A context line or a "\ No newline" marker ends both runs. | ||
| this.flushRemoved(file, oldRanges); | ||
| this.flushAdded(file, changedRanges); | ||
| if (line.startsWith(' ')) { | ||
| this.oldLineCursor++; | ||
| this.newLineCursor++; | ||
| } |
There was a problem hiding this comment.
+++ /dev/null bypasses all filters and corrupts the previous file's ranges
When a deleted file follows a modified file in the diff, the +++ /dev/null header is not caught by any guard: isDevNullSourceLine only matches --- /dev/null, and extractNewFileName requires a b/ prefix. The line falls through to tracker.consume, where line.startsWith('+') evaluates to true, so it is recorded as an added source line in changedRanges for the previous file. The subsequent @@ -N,M +0,0 @@ hunk header from the deleted file then flushes and restarts the tracker under the wrong file, causing the deleted file's removed lines to land in oldRanges of the preceding file.
Before this PR, pushHunkRanges received the same line but the HUNK_RE match failed, so it silently returned — effectively a safe no-op. The new consume path actively processes any +-prefixed line, introducing the regression. The doc comment on consume states that `+++` headers are "already filtered out by the caller" — that holds for +++ b/… but not for +++ /dev/null. The fix is to add a symmetric guard in parseDiffOutput before tracker.consume is reached:
if (line.startsWith('+++ ')) continue; // +++ /dev/null (deleted-file header) not caught by extractNewFileNameThe test suite covers new-file creation (--- /dev/null → +++ b/…) but has no test for file deletion (--- a/… → +++ /dev/null), so this path is currently uncovered.
Summary
checkNoSignatureChanges(src/features/check.ts) to scope signature-change detection to exported symbols only, matching the check's actual purpose of protecting external callers.parseDiffOutputto walk each diff hunk body and track only lines actually marked-, instead of trusting the raw hunk-header span (which always includes 3 lines of unchanged context and was sweeping up untouched declarations as false positives).Titan Audit Context
Changes
src/cli/commands/check.tssrc/features/check.tstests/integration/check.test.tsBackground
Discovered while adopting the leiden typed-array helpers extracted earlier in this Titan run: removing a private, unexported, zero-caller duplicate
fget/igetpair was incorrectly blocked bycodegraph check --staged's signatures predicate, for two independent reasons (both fixed here).Metrics Impact
codegraph check --staged's signatures predicate now only fires on symbols with real external callers, eliminating a source of false-positive gate failures for internal refactors.Test plan
Closes #1760