Finding Evolution Across Pushes
How findings are fingerprinted, deduped and classified as new, persisting, modified or resolved when the PR head changes.
The engine in son-of-anton-review (feat/cloudflare-native) compares findings with the prior-review snapshots supplied to the run, not an unlimited history of every review. It dedupes findings and records findingEvolution on the run. The model finding schema has a file path but no line numbers; these identities do not represent inline GitHub comments.
Pipeline
Stable fingerprint
stableFindingFingerprint(finding) returns finding:<sha256 prefix>.
- If the finding carries an explicit id (
fingerprint,findingFingerprint,ruleId,rule_idorcheckName), that id is hashed. - Otherwise the hash covers normalized severity, category, path and title.
Normalization strips code spans, URLs, quotes and accents, lowercases, and drops :line:col suffixes from paths. critical maps to blocker, warning to medium.
findingFallbackKey(finding) is the looser identity: severity, path, and up to 18 sorted semantic tokens from title, body and the last two path segments. Tokens are stemmed and aliased (authorization, permission, access all become auth; lacks, skips, without become missing).
Similarity match
findingsAreSimilar(left, right) is true when any of these hold:
| Test | Rule |
|---|---|
| Fingerprint | stableFindingFingerprint equal |
| Fallback key | findingFallbackKey equal |
| Fuzzy | same normalized path, severities within one rank of each other, and Jaccard token overlap of titles >= 0.35 or of title+body >= 0.28 |
Severity order
Ranking and severity compatibility use this order, most severe first:
| Rank | Severity |
|---|---|
| 0 | blocker (also critical) |
| 1 | high |
| 2 | medium (also warning) |
| 3 | low |
| 4 | nit |
| 5 | info |
Unknown severities rank last. The review schema only accepts blocker, high, medium, low from the model (src/codex-review-runner.js, REVIEW_SCHEMA).
Dedupe within one review
dedupeFindings(findings) walks the list once. A finding that is similar to one already kept replaces it only if it is more severe, or equally severe with a longer body. contextIds from both are merged so memory provenance is never lost.
Classification against the prior head
classifyFindingEvolution(previous, next, { unresolvedFindings, unresolvedSources }) dedupes both sides, then for each new finding looks for an exact fingerprint match first and a similarity match second.
| Status | Meaning |
|---|---|
new | No prior finding matched |
persisting | Matched and canonical text (severity, category, path, title, body) identical |
modified | Matched but text changed |
resolved | Prior finding not matched by anything in the new run |
unresolved | Prior finding not matched, but flagged by same-head reconciliation as still open |
Each entry records finding, previousFinding, fingerprint and fallbackKey. unresolved entries also carry sourceFindings with the reviewKey and fingerprint of every original report.
The service builds previous from the latest supplied prior review plus same-head concerns recovered by reconciliation. Its outcome recorder separately selects the latest supplied review from a different head and writes one outcome per prior finding. A missing evolution status defaults to resolved, but an explicitly incomplete semantic analysis records unknown rather than fixed. That distinction matters: evolution status alone is not proof of a code fix.
Model-reported dispositions
For same-head reconciliation, the model can explicitly disposition an old finding through priorFindingDispositions, a required array in the review schema. Across different heads, the classifier also calls unmatched prior findings resolved; it does not require a disposition for every cross-head omission.
{
"priorReviewKey": "<exact source review key>",
"fingerprint": "finding:...",
"disposition": "resolved | false_positive | no_longer_applicable",
"explanation": "why",
"evidence": [{ "headSha": "<current head>", "path": "src/x.js", "quote": "verbatim code" }]
}The prompt tells the model that a missing finding on an unchanged head is not a fix, that omission is not a disposition, and to return [] when nothing is justified.
Two gates apply before a disposition counts:
- The runner rejects any disposition whose evidence is not on the current
headSha, has an absolute or..path, or whosequoteis not found ingit show <headSha>:<path>. reconcileSameHeadFindingsaccepts it only ifpriorReviewKeyandfingerprintname a known source occurrence, the disposition is one of the three allowed values, and the fingerprint is not re-reported in the current findings.
Accepted dispositions are stored as priorFindingDispositions on the review.
validateMemoryFindingProvenance(findings, supplied) rejects any contextIds entry not actually supplied to the model. See Learning, memory and priors.
Same-head reconciliation
reconcileSameHeadFindings(run, findings, dispositions) covers re-runs on the same commit. It collects findings and carried unresolved occurrences from supplied same-head snapshots, applies supported historical dispositions and tracks anything not covered by a current finding or accepted disposition. Each unresolved concern adds a limitation. The runner then forces verdict: needs-attention, caps confidenceScore at 3, sets mergeStatus to caution unless it is already block, and appends limitations to missingValidations. The service performs another continuity pass before publication.
Feedback guard
guardFeedbackResolutions reports omitted prior blocker, high, critical, security/auth/vulnerability-category concerns when supplied feedback has reaction: down linked by context ID or title. It does not reinsert findings or inspect code to decide whether the feedback was the only new signal. The runner turns held concerns into missingValidations and incomplete-context status.
This guard consumes the memory feedback snapshot, not a live reaction attached to every finding. The current publisher creates review bodies rather than inline comments, so do not infer a complete per-finding reaction-learning loop from the guard.
Publishing across heads
GitHubPublisher creates a check run with external_id = reviewKey and updates that check when its ID is known. PR reviews are POSTed with an <!-- anton-review <reviewKey> --> marker and commit_id = headSha. There is no per-finding inline-comment creation or update.
Publication claims are scoped to a review key and action, not to a finding across all heads. A newer head or deliberate variant can publish another review containing a persisting concern. Native uncertain publication requires remote-receipt reconciliation before resending; the local SQLite ledger instead supports lease-based reclamation. Neither establishes a blanket “no duplicate comments across heads” guarantee. See Publish modes.
Known remaining work
The publisher does not edit individual prior findings according to their evolution status. Evolution and outcomes support run history and subsequent review context; omission, similarity matching and feedback are not line-level addressed detection.
Source evidence
son-of-anton-review/src/finding-evolution.js:3-4,64-100,143-251,257-400.son-of-anton-review/src/codex-review-runner.js:210-248,2719-2783;src/review-service.js:397-462,478-515.son-of-anton-review/src/github-publisher.js:22-66;src/sqlite-store.js:415-462;cloudflare-native/src/state-transport.js:490-524.