Skip to content

feat(bugfix-detect): broaden classification beyond Conventional Commit prefix - #18

Merged
avifenesh merged 2 commits into
mainfrom
feat/broader-bugfix-detect
Apr 23, 2026
Merged

avifenesh merged 2 commits into
mainfrom
feat/broader-bugfix-detect

Conversation

@avifenesh

@avifenesh avifenesh commented Apr 23, 2026 •

Copy link
Copy Markdown
Contributor

Why

bug_fix_changes was previously incremented only when a commit subject started with fix:. That misses every project that does not enforce Conventional Commits, plus several repos that do use it but file regressions under different verbs - "Fix race", "Hotfix for prod outage", "Resolves #42", "revert sketchy commit", etc. On agnix this under-counted bug-fix activity even though the commit history is full of plain English fix subjects.

What changes

New analyzer-core::bug_fix_detect module with one public function, is_bug_fix(subject), wired into the aggregator in place of the prefix-only check.

Heuristic returns true when any of:

  1. Conventional Commit prefix is one of fix, bugfix, hotfix, patch, revert (so patch:, revert:, and hotfix: now also count).
  2. Subject contains a fix-related whole-word keyword (case-insensitive): fix, fixed, fixes, fixing, bug, bugfix, hotfix, patch, patched, revert, reverts, reverted, regression, race, deadlock, leak, crash, crashed, oops, typo, mistake, broken.
  3. Subject contains an issue-closure phrase: fixes #123, closes #42, resolves GH-7, resolves owner/repo#900.

False-positive guard

Matching is whole-word (case-insensitive), so prefix, suffix, affix, postfix, unfixable do not register. close without an issue ref also does not register, so Close the modal on ESC stays out.

Tests

  • 16 new unit tests in bug_fix_detect.rs cover every branch (positives, negatives, mixed case, issue-closure variants, the false-positive set).
  • 1 new aggregator-level test confirms the wired path counts plain-English fix subjects against bug_fix_changes.

What this affects downstream

  • bugspots query density rises for repos that don't use fix:
  • file_activity.bug_fix_changes and last_bug_fix become more accurate
  • diff-risk's bug_fix_rate term gets a more honest denominator

Test plan

  • cargo test --workspace - 178 tests pass (was 162)
  • cargo clippy --workspace --all-targets -- -D warnings - clean
  • cargo fmt --all - clean
  • CI green on PR

Independence

Stacks cleanly with #17 (drop-AI-detection) - the two PRs touch different code paths and either order works. They can land in any order.


Note

Medium Risk
Changes the definition of what counts as a bug-fix commit, which will alter downstream metrics (bug_fix_changes, last_bug_fix, and any derived scores) and could introduce false positives/negatives despite added tests.

Overview
Improves bug-fix attribution by replacing the aggregator’s strict prefix == "fix" check with a new heuristic is_bug_fix() matcher.

The new detection counts fixes not only via Conventional Commit prefixes (e.g. fix, hotfix, revert, patch) but also via whole-word fix keywords and issue-closure phrases (e.g. closes #42, resolves GH-7), with tests added for boundary/false-positive cases and an aggregator-level test to ensure freeform subjects increment bug_fix_changes.

Reviewed by Cursor Bugbot for commit 7056eee. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a heuristic bug-fix classification system for commit subjects, expanding detection to include keywords and issue closure references. Feedback identifies logic errors in the cross-repo issue reference parser, specifically regarding repository name character sets and parsing boundaries. It also suggests improving test coverage for cross-repo references that lack explicit keywords.

Comment on lines +107 to +140
fn followed_by_issue_ref(rest: &str) -> bool {
// Allow optional `:` and whitespace, then either `#NNN`, `gh-NNN`, or
// `<owner>/<repo>#NNN`. We only need a quick check, not a full parser.
let trimmed = rest.trim_start_matches([':', ' ', '\t']);
let bytes = trimmed.as_bytes();

// Skip an optional `<word>/<word>` prefix (cross-repo references).
let mut idx = 0;
while idx < bytes.len()
&& (bytes[idx].is_ascii_alphanumeric()
|| bytes[idx] == b'-'
|| bytes[idx] == b'_'
|| bytes[idx] == b'/')
{
idx += 1;
}
let after_org = if idx > 0 && idx < bytes.len() && bytes[idx - 1] == b'/' {
&trimmed[idx..]
} else {
trimmed
};
let bytes = after_org.as_bytes();

if bytes.starts_with(b"#") && bytes.len() > 1 && bytes[1].is_ascii_digit() {
return true;
}
if (bytes.starts_with(b"gh-") || bytes.starts_with(b"GH-"))
&& bytes.len() > 3
&& bytes[3].is_ascii_digit()
{
return true;
}
false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for detecting cross-repo issue references (e.g., owner/repo#123) is currently broken.

  1. The loop at line 116 correctly consumes the repository prefix, but the condition at line 123 bytes[idx - 1] == b'/' is incorrect for standard owner/repo#123 syntax (where the character before # is part of the repo name, not a slash).
  2. Repository names can also contain dots (.), which are currently not included in the allowed character set in the loop.
  3. The GH- check is redundant because the input string has already been converted to lowercase.

I suggest updating the logic to check if the loop stopped at a # character and adding . to the allowed set.

fn followed_by_issue_ref(rest: &str) -> bool {
    // Allow optional : and whitespace, then either #NNN, gh-NNN, or
    // <owner>/<repo>#NNN. We only need a quick check, not a full parser.
    let trimmed = rest.trim_start_matches([':', ' ', '\t']);
    let bytes = trimmed.as_bytes();

    // Skip an optional <owner>/<repo> prefix (cross-repo references).
    let mut idx = 0;
    while idx < bytes.len()
        && (bytes[idx].is_ascii_alphanumeric()
            || bytes[idx] == b'-'
            || bytes[idx] == b'_'
            || bytes[idx] == b'/'
            || bytes[idx] == b'.')
    {
        idx += 1;
    }

    // If we stopped at a '#', we likely skipped a repository prefix.
    let after_org = if idx < bytes.len() && bytes[idx] == b'#' {
        &trimmed[idx..]
    } else {
        trimmed
    };
    let bytes = after_org.as_bytes();

    if bytes.starts_with(b"#") && bytes.len() > 1 && bytes[1].is_ascii_digit() {
        return true;
    }
    if bytes.starts_with(b"gh-")
        && bytes.len() > 3
        && bytes[3].is_ascii_digit()
    {
        return true;
    }
    false
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c3d902c. Rewrote followed_by_issue_ref(): find # directly, then validate the prefix splits on / into a non-empty owner and repo with only legal repo-name characters (alphanumeric, dots, dashes, underscores). Also dropped the redundant uppercase GH- check (input is already lowercased) and added cross-repo cases to the test suite (agent-sh/agnix#900, repos with dots and underscores, malformed refs).

Comment on lines +421 to +436
let delta = make_delta(vec![
make_commit("alice", "fix: explicit conventional fix", file()),
make_commit("alice", "Fix race condition in worker pool", file()),
make_commit("alice", "Resolves #42", file()),
make_commit("alice", "hotfix for prod outage", file()),
make_commit("alice", "feat: add login flow", file()),
make_commit("alice", "Add prefix support to parser", file()),
]);

merge_delta(&mut map, &delta);

let activity = &map.file_activity["src/lib.rs"];
assert_eq!(
activity.bug_fix_changes, 4,
"expected 4 bug-fix commits (conventional + race + resolves + hotfix)"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current test suite for bug-fix detection does not verify cross-repo issue references that lack a separate keyword (like Fix). Adding such a case would ensure the has_issue_closure logic is working correctly independently of the keyword heuristic.

        let delta = make_delta(vec![
            make_commit("alice", "fix: explicit conventional fix", file()),
            make_commit("alice", "Fix race condition in worker pool", file()),
            make_commit("alice", "Resolves #42", file()),
            make_commit("alice", "Resolves agent-sh/agnix#900", file()),
            make_commit("alice", "hotfix for prod outage", file()),
            make_commit("alice", "feat: add login flow", file()),
            make_commit("alice", "Add prefix support to parser", file()),
        ]);

        merge_delta(&mut map, &delta);

        let activity = &map.file_activity["src/lib.rs"];
        assert_eq!(
            activity.bug_fix_changes, 5,
            "expected 5 bug-fix commits (conventional + race + resolves + cross-repo + hotfix)"
        );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c3d902c — added Closes agent-sh/agnix#900 (no fix keyword) to the aggregator integration test, bumping the expected count to 5 and exercising the cross-repo branch independently of the keyword heuristic.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7056eee. Configure here.

&trimmed[idx..]
} else {
trimmed
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-repo issue reference parsing never matches

Medium Severity

followed_by_issue_ref fails to detect cross-repo references like owner/repo#NNN. The while loop consumes all alphanumeric, -, _, and / characters, so for "agent-sh/agnix#900" it eats agent-sh/agnix (stopping at # with idx=14). Then bytes[idx - 1] == b'/' checks the last consumed char, which is x (not /), so after_org falls back to the full string — which doesn't start with #. A subject like "Closes org/repo#42" (no fix keyword) silently returns false. The test on line 221 is masked because "Fix" triggers the keyword path, not the closure path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7056eee. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c3d902c — replaced the broken character-eating loop with: find # directly, then validate prefix contains / and both segments use only repo-name-legal characters. Closes agent-sh/agnix#900 and Closes org/repo#42 (no fix keyword) now register correctly.

…t prefix

Background: bug_fix_changes was previously incremented only when a commit
subject started with `fix:`. That misses every project that does not
enforce Conventional Commits, plus several repos that *do* use it but
file regressions under different verbs ("Fix race", "Hotfix for prod
outage", "Resolves #42", "revert sketchy commit", etc.). On agnix this
under-counted bug-fix activity even though the commit history is full
of plain English fix subjects.

This adds a new analyzer-core::bug_fix_detect module with a single
public function, is_bug_fix(subject), and wires it into the aggregator
in place of the prefix-only check.

Heuristic returns true when any of:
  1. Conventional Commit prefix is one of: fix, bugfix, hotfix, patch,
     revert (so "patch:", "revert:", and "hotfix:" now also count).
  2. Subject contains a fix-related whole-word keyword:
     fix/fixed/fixes/fixing, bug/bugfix/hotfix, patch/patched,
     revert/reverts/reverted, regression, race, deadlock, leak, crash,
     oops, typo, mistake, broken.
  3. Subject contains an issue-closure phrase: "fixes #123",
     "closes #42", "resolves GH-7", "resolves owner/repo#900".

False-positive guard: matching is whole-word (case-insensitive) so
"prefix", "suffix", "affix", "postfix", "unfixable" do not register.
"Close" without an issue ref also does not register, so
"Close the modal on ESC" stays out.

16 new unit tests in bug_fix_detect.rs cover every branch (positives,
negatives, mixed case, issue-closure variants, the false-positive set).
One aggregator-level test confirms the wired path counts plain-English
fix subjects against bug_fix_changes.

What this affects downstream:
- bugspots query density should rise for repos that don't use `fix:`
- file_activity.bug_fix_changes and last_bug_fix become more accurate
- diff-risk's bug_fix_rate term gets a more honest denominator

Tests: 178 passing across the workspace (was 162), clippy clean.
Reviewer (gemini, cursor) caught that followed_by_issue_ref() never
hit the <owner>/<repo>#NNN form. The character-eating loop consumed
"agent-sh/agnix" but then checked bytes[idx-1] against b'/' - which
matched the last char of "agnix", not the slash inside, so the
slice-back logic always fell through to "doesn't start with #".

Rewritten to find '#' directly, then validate that what came before
contains a slash and otherwise looks like owner/repo (alphanumeric +
. _ -). This also fixes:
- repo names with dots (e.g. "my.repo.name") were rejected
- redundant uppercase "GH-" check (input is already lowercased)
- "Closes /#1" no longer matches (owner segment must be non-empty)

Tests: add cross_repo_issue_refs_hit (4 cases including
agent-sh/agnix#900, repos with dots/underscores), add
malformed_cross_repo_refs_do_not_hit (3 cases), and add a cross-repo
case to the aggregator integration test.

Reviewer: gemini-code-assist#18 (high), cursor#18 (medium)
@avifenesh
avifenesh force-pushed the feat/broader-bugfix-detect branch from c3d902c to 25f89c3 Compare April 23, 2026 22:43
@avifenesh
avifenesh merged commit 0fed790 into main Apr 23, 2026
4 checks passed
avifenesh added a commit that referenced this pull request Apr 24, 2026
Bump workspace version to 0.5.0. Includes 6 merged PRs since v0.4.0:
drop AI attribution detection (#17), broaden bug-fix classification
(#18), suppress generated-file bugspot pollution (#19), entry-points
query (#23), find query (#24), and LLM-augmented descriptors/summary
subcommands (#25).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant