Skip to content

fix(rules): allow flag-only negation to pass with empty command tokens#179

Merged
fohte merged 4 commits intomainfrom
fohte/fix-flag-negation-empty-tokens
Mar 10, 2026
Merged

fix(rules): allow flag-only negation to pass with empty command tokens#179
fohte merged 4 commits intomainfrom
fohte/fix-flag-negation-empty-tokens

Conversation

@fohte
Copy link
Owner

@fohte fohte commented Mar 10, 2026

Why

  • Flag-only negation patterns (e.g. !-o|--output|--compress-program) incorrectly returned false when the command had zero tokens after the program name
    • sort !-o|--output|--compress-program * did not match sort (no arguments), resulting in ask instead of allow
    • cat * matched cat (no arguments), so the behavior was inconsistent

What

  • Allow flag-only negation to treat empty tokens as "flag absent" and pass through
    • The cmd_tokens.is_empty() guard returned false for all negations without distinguishing flag-only from positional
    • Flag-only negation consumed one positional token on success, but it performs a whole-list scan and should not consume any token

Open with Devin

…emain

Flag-only negation patterns (e.g. `!-o|--output`) scan all command
tokens for forbidden flags using order-independent matching. When the
command had zero tokens after the program name (e.g. bare `sort`), the
early `cmd_tokens.is_empty()` guard returned false for all negations
indiscriminately, even though an absent flag trivially satisfies a
flag-only negation.

Additionally, flag-only negations were consuming one positional token
on success (advancing to `cmd_tokens[1..]`), which is incorrect since
they perform a whole-list scan rather than matching a specific position.

Split the negation handling into two branches:
- Flag-only: allow empty tokens (flag absent = pass), do not consume
- Positional: preserve existing behavior (empty = fail, consume one)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@codecov
Copy link

codecov bot commented Mar 10, 2026

Codecov Report

❌ Patch coverage is 95.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.92%. Comparing base (377f83d) to head (c29ca3b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/rules/pattern_matcher.rs 95.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #179      +/-   ##
==========================================
+ Coverage   89.86%   89.92%   +0.05%     
==========================================
  Files          38       38              
  Lines        7212     7225      +13     
==========================================
+ Hits         6481     6497      +16     
+ Misses        731      728       -3     
Flag Coverage Δ
Linux 89.78% <95.00%> (+0.06%) ⬆️
macOS 91.65% <95.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

The patch coverage was low because the positional negation branch and
the flag-only negation empty-tokens branch inside extract_placeholder_all
were not exercised by existing tests.

Add three extract_placeholder_cases: positional negation with tokens,
positional negation with empty tokens, and flag-only negation with
empty tokens.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
devin-ai-integration[bot]

This comment was marked as resolved.

fohte and others added 2 commits March 10, 2026 20:11
…tests

The flag-only negation rejection branch (line 519) and the positional
negation rejection branch (line 534) were not covered. Add test cases
where the forbidden flag/token is present so the negation fails.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The Negation descriptions in the architecture and pattern-syntax docs
did not reflect that flag-only negation does not consume a positional
token and passes when no command tokens remain.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@fohte fohte merged commit 1d6c757 into main Mar 10, 2026
10 checks passed
@fohte fohte deleted the fohte/fix-flag-negation-empty-tokens branch March 10, 2026 11:25
@fohte-bot fohte-bot bot mentioned this pull request Mar 10, 2026
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves a bug in the rule engine's pattern matching for flag-only negations. Previously, rules using flag-only negations would incorrectly reject commands that had no arguments, even if the forbidden flag was absent. The update modifies the matching logic to correctly interpret the absence of command tokens as the absence of the negated flag, allowing such commands to pass. This change improves the accuracy and consistency of rule evaluation, particularly for commands with minimal arguments.

Highlights

  • Corrected Flag-Only Negation: Fixed an issue where flag-only negation patterns (e.g., !-o) incorrectly failed when the command had no arguments after the program name.
  • Consistent Rule Evaluation: Ensured that flag-only negations now correctly treat empty command tokens as "flag absent," aligning behavior with expectations and resolving inconsistencies.
  • Refined Token Consumption: Modified the pattern matching logic to prevent flag-only negations from consuming a positional token, as they perform a whole-list scan.
Changelog
  • docs/src/content/docs/architecture/pattern-matching.md
    • Updated the documentation for Negation pattern tokens to distinguish between positional and flag-only negation behavior, including how empty tokens are handled.
  • docs/src/content/docs/pattern-syntax/alternation.md
    • Clarified the behavior of flag-only negation regarding token consumption and its passing condition when no tokens remain.
  • docs/src/content/docs/pattern-syntax/matching-behavior.md
    • Enhanced the explanation of flag-only negation, detailing that it does not consume a positional token and passes when command tokens are absent, and added a new example.
  • src/rules/pattern_matcher.rs
    • Reworked the PatternToken::Negation matching logic to correctly handle flag-only negations by not consuming tokens and allowing them to pass when cmd_tokens are empty.
  • tests/integration/compound_command_evaluation.rs
    • Introduced new integration tests to verify the correct behavior of flag-only negation with empty tokens within compound commands.
  • tests/integration/config_to_rule_evaluation.rs
    • Added new integration tests to confirm the proper evaluation of flag-only negation with empty tokens for single commands.
Activity
  • The pull request was authored by fohte.
  • The description includes a Devin review badge, suggesting an automated review was initiated.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

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

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 correctly fixes a bug where flag-only negations failed on commands with no arguments and ensures they don't consume a command token. This improvement enhances the accuracy of the pattern matching engine, and no new security vulnerabilities were introduced. I've also suggested some refactorings to reduce code duplication in the negation handling logic, which would improve maintainability.

Comment on lines 224 to 249
PatternToken::Negation(inner) => {
if cmd_tokens.is_empty() {
return false;
}
// Flag-only negations (all inner elements start with `-`, excluding
// bare `--`) use order-independent matching: scan the entire command
// token list. Other negations check only the positional token.
let negation_passed = if is_flag_only_negation(inner) {
!cmd_tokens
if is_flag_only_negation(inner) {
// Flag-only negations scan the entire command token list for
// the forbidden flag (order-independent). When no tokens remain
// the flag is trivially absent, so the negation passes.
let negation_passed = !cmd_tokens
.iter()
.any(|t| match_flag_token_with_equals(inner, t, definitions))
} else {
!match_single_token(inner, cmd_tokens[0], definitions)
};
if negation_passed {
match_tokens_core(rest, &cmd_tokens[1..], definitions, steps, captures)
.any(|t| match_flag_token_with_equals(inner, t, definitions));
if negation_passed {
// Flag-only negations do not consume a positional token.
match_tokens_core(rest, cmd_tokens, definitions, steps, captures)
} else {
false
}
} else {
false
if cmd_tokens.is_empty() {
return false;
}
let negation_passed = !match_single_token(inner, cmd_tokens[0], definitions);
if negation_passed {
match_tokens_core(rest, &cmd_tokens[1..], definitions, steps, captures)
} else {
false
}
}
}

Choose a reason for hiding this comment

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

medium

The logic for handling flag-only and positional negations has some duplication. You can refactor this to reduce redundancy and make the distinction between the two cases (the number of tokens consumed) more explicit. This will improve maintainability.

        PatternToken::Negation(inner) => {
            let (negation_passed, consumed) = if is_flag_only_negation(inner) {
                // Flag-only negations scan the entire command token list.
                // If no tokens remain, the flag is trivially absent.
                // They do not consume a positional token.
                let passed = !cmd_tokens
                    .iter()
                    .any(|t| match_flag_token_with_equals(inner, t, definitions));
                (passed, 0)
            } else {
                // Positional negations check only the next token.
                if cmd_tokens.is_empty() {
                    return false;
                }
                let passed = !match_single_token(inner, cmd_tokens[0], definitions);
                (passed, 1)
            };

            if negation_passed {
                match_tokens_core(rest, &cmd_tokens[consumed..], definitions, steps, captures)
            } else {
                false
            }
        }

Comment on lines 505 to 537
PatternToken::Negation(inner) => {
if cmd_tokens.is_empty() {
return Ok(());
}
let negation_passed = if is_flag_only_negation(inner) {
!cmd_tokens
if is_flag_only_negation(inner) {
let negation_passed = !cmd_tokens
.iter()
.any(|t| match_flag_token_with_equals(inner, t, definitions))
.any(|t| match_flag_token_with_equals(inner, t, definitions));
if negation_passed {
extract_placeholder_all(
rest,
cmd_tokens,
definitions,
steps,
captured,
all_candidates,
)?;
}
} else {
!match_single_token(inner, cmd_tokens[0], definitions)
};
if negation_passed {
extract_placeholder_all(
rest,
&cmd_tokens[1..],
definitions,
steps,
captured,
all_candidates,
)?;
if cmd_tokens.is_empty() {
return Ok(());
}
let negation_passed = !match_single_token(inner, cmd_tokens[0], definitions);
if negation_passed {
extract_placeholder_all(
rest,
&cmd_tokens[1..],
definitions,
steps,
captured,
all_candidates,
)?;
}
}
Ok(())
}

Choose a reason for hiding this comment

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

medium

Similar to match_tokens_core, the logic for PatternToken::Negation here has some duplicated code. Refactoring this would improve clarity and maintainability by making the difference in token consumption explicit.

        PatternToken::Negation(inner) => {
            let (negation_passed, consumed) = if is_flag_only_negation(inner) {
                let passed = !cmd_tokens
                    .iter()
                    .any(|t| match_flag_token_with_equals(inner, t, definitions));
                (passed, 0)
            } else {
                if cmd_tokens.is_empty() {
                    return Ok(());
                }
                let passed = !match_single_token(inner, cmd_tokens[0], definitions);
                (passed, 1)
            };

            if negation_passed {
                extract_placeholder_all(
                    rest,
                    &cmd_tokens[consumed..],
                    definitions,
                    steps,
                    captured,
                    all_candidates,
                )?;
            }
            Ok(())
        }

fohte added a commit that referenced this pull request Mar 10, 2026
…into single match_engine

Two nearly-identical recursive matching functions (match_tokens_core for
boolean matching, extract_placeholder_all for placeholder extraction)
duplicated all PatternToken variant handling. Bug fixes applied to one
function were frequently missed in the other (PR #175, #179, #180).

Merge both into a single `match_engine` function parameterized by an
optional `extract` tuple. Each PatternToken variant's logic now exists
in exactly one place.

Also introduce a flag normalization layer (`split_flag_equals`,
`flag_aliases_match_token`) to centralize `--flag=value` splitting,
replacing the ad-hoc `=`-joined handling scattered across
match_flag_token_with_equals, FlagWithValue, and optional_flags_absent.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
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