From f01bb4361614abb522308791b6fde0dbf074b4f8 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:45:07 +0000 Subject: [PATCH 1/2] fix(word-boundary-generalization): shared word-boundary matcher across detector and all rule modules Raw substring containment (detector.py:57-58) read "fixture" as the debug signal "fix" (probe P1) and flagged innocent input at 35/needs_clarification. All term lookups now route through inputguard/matching.py, which matches terms as standalone words using the boundary class the coding matcher already used, extended with inflectional endings so suffix-shaped hits (bugs, errors, debugged, refactoring, profiling, stack traces) keep firing. Coding rules keep their v0.2 multiword token fallback via token_fallback=True; other callers get strict adjacent-phrase matching. No-alnum terms (/, =>, ->) keep substring semantics. Finders compile once per term-set (lru_cache). Co-authored-by: Kalisetti Nihanth Naidu --- inputguard/detector.py | 6 +- inputguard/matching.py | 197 +++++++++++++++++++++++++++++++ inputguard/rules/coding.py | 23 +--- inputguard/rules/debug.py | 4 +- inputguard/rules/explanation.py | 4 +- inputguard/rules/feature.py | 4 +- inputguard/rules/optimization.py | 4 +- 7 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 inputguard/matching.py diff --git a/inputguard/detector.py b/inputguard/detector.py index 31db70e..a86fe96 100644 --- a/inputguard/detector.py +++ b/inputguard/detector.py @@ -4,6 +4,8 @@ from typing import Iterable, Mapping, Optional, Tuple +from inputguard.matching import contains_any + DEBUG_SIGNALS = { "error", "exception", "traceback", "not working", "isn't working", "doesn't work", "won't work", "broken", "failing", "fails", "failed", @@ -69,7 +71,9 @@ def normalize(text: str) -> str: def _contains_any(text: str, terms) -> bool: - return any(term in text for term in terms) + # v0.3: word-boundary matching shared with the rule modules — "fixture" + # is no longer read as the debug signal "fix" (probe P1). + return contains_any(text, terms) def detect_intent( diff --git a/inputguard/matching.py b/inputguard/matching.py new file mode 100644 index 0000000..36d3ef8 --- /dev/null +++ b/inputguard/matching.py @@ -0,0 +1,197 @@ +"""Shared word-boundary matching for every signal and rule wordlist. + +Before v0.3 the detector and four rule modules matched terms with raw +substring containment (``term in text``), so the debug signal ``"fix"`` read +``"fixture"`` as a debug request and innocent inputs were flagged (probe P1: +``"I love this fixture in the test suite..."`` scored 35 as debug). All term +lookups now go through this module, which matches terms as standalone words +under three guarantees: + +- **Boundaries:** a term matches only at a word boundary on each side, so + ``"fix"`` matches ``"fix this"`` but not ``"fixture"``, ``"refix"``, or + ``"praised"`` (from ``"raised"``). The boundary class is the one the v0.2 + coding matcher already used (``(?``, ``->``, ``::``) have +no word to bound and keep plain substring semantics. + +Text is expected pipeline-normalized (lowercased, whitespace-collapsed); +case is folded defensively so ad-hoc callers get identical verdicts. + +Matching is deterministic and thread-safe: finders are compiled once per +term-set through ``functools.lru_cache`` (compiled finders are immutable and +the cached values are idempotent, so concurrent first calls are safe). +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from typing import Callable, FrozenSet, Iterable, List, Tuple + +__all__ = ["contains_any", "contains_term"] + +# The v0.2 coding matcher's boundary class: a neighboring identifier +# character ("my_error") still counts as a mention; a longer word does not. +_LEFT_BOUND = r"(? "debugged"). +_DOUBLABLE = frozenset("bgmnpt") + + +def _fold(text: str) -> str: + # Internal callers pass normalized text; fold defensively for ad-hoc calls. + return text if text.islower() else text.lower() + + +def _stem_variants(term: str) -> Tuple[str, ...]: + """Stems whose suffixed forms belong to ``term`` ("debug" -> "debugged").""" + variants = {term} + if term.endswith("e"): + variants.add(term[:-1]) # "store" -> "storing" + if term[-1] in _DOUBLABLE: + variants.add(term + term[-1]) # "debug" -> "debugged" + return tuple(sorted(variants)) + + +def _single_word_body(term: str) -> str: + return "|".join(re.escape(stem) + _ENDINGS for stem in _stem_variants(term)) + + +def _single_word_pattern(term: str) -> str: + return _LEFT_BOUND + "(?:" + _single_word_body(term) + ")" + _RIGHT_BOUND + + +def _phrase_body(tokens: Tuple[str, ...]) -> str: + # Adjacent words (v0.2 substring parity); the final word carries the + # inflections so plural phrases ("stack traces") still match the term. + head = " ".join(re.escape(token) for token in tokens[:-1]) + tail = re.escape(tokens[-1]) + _ENDINGS + return head + " " + tail if head else tail + + +def _phrase_pattern(tokens: Tuple[str, ...]) -> str: + return _LEFT_BOUND + "(?:" + _phrase_body(tokens) + ")" + _RIGHT_BOUND + + +def _exact_word_pattern(term: str) -> str: + return _LEFT_BOUND + re.escape(term) + _RIGHT_BOUND + + +def contains_term(text: str, term: str) -> bool: + """Whether ``term`` occurs in ``text`` as a standalone word or phrase.""" + return _term_in(_fold(text), term.strip()) + + +def _term_in(text: str, term: str) -> bool: + if not term: + return False + if not any(ch.isalnum() for ch in term): + # "/", "=>", "->", "::" — no word to bound; v0.2 substring semantics. + return term in text + if " " in term: + return re.search(_phrase_pattern(tuple(term.split())), text) is not None + return re.search(_single_word_pattern(term), text) is not None + + +@lru_cache(maxsize=None) +def _build_finder( + terms: FrozenSet[str], token_fallback: bool +) -> Callable[[str], bool]: + singles: List[str] = [] + phrases: List[Tuple[str, ...]] = [] + plain: List[str] = [] + for term in terms: + stripped = term.strip() + if not stripped: + continue + if not any(ch.isalnum() for ch in stripped): + plain.append(stripped) + elif " " in stripped: + phrases.append(tuple(stripped.split())) + else: + singles.append(stripped) + + # One alternation scan covers every single-word term in the set. + single_re = ( + re.compile( + _LEFT_BOUND + + "(?:" + + "|".join(sorted(_single_word_body(s) for s in singles)) + + ")" + + _RIGHT_BOUND + ) + if singles + else None + ) + phrase_re = ( + re.compile( + _LEFT_BOUND + + "(?:" + + "|".join(sorted(_phrase_body(tokens) for tokens in phrases)) + + ")" + + _RIGHT_BOUND + ) + if phrases + else None + ) + # v0.2 coding parity: fallback tokens match exactly, without inflections. + fallback_res = ( + [ + tuple(re.compile(_exact_word_pattern(token)) for token in tokens) + for tokens in phrases + ] + if token_fallback + else [] + ) + plain_tuple = tuple(plain) + + def find(text: str) -> bool: + if single_re is not None and single_re.search(text): + return True + if any(term in text for term in plain_tuple): + return True + if phrase_re is not None and phrase_re.search(text): + return True + if token_fallback: + for tokens_re in fallback_res: + if all(token_re.search(text) for token_re in tokens_re): + return True + return False + + return find + + +def contains_any( + text: str, terms: Iterable[str], token_fallback: bool = False +) -> bool: + """Whether any of ``terms`` occurs in ``text`` as a standalone word or phrase. + + ``token_fallback=True`` restores the v0.2 coding-rule behavior of matching + a multiword term whose words are all present but not adjacent. + """ + if isinstance(terms, str): + raise TypeError( + "contains_any expects an iterable of terms, not a single string — " + f"got {terms!r}." + ) + return _build_finder(frozenset(terms), token_fallback)(_fold(text)) diff --git a/inputguard/rules/coding.py b/inputguard/rules/coding.py index 85f8fc1..2b3b9ff 100644 --- a/inputguard/rules/coding.py +++ b/inputguard/rules/coding.py @@ -3,6 +3,7 @@ import re from typing import List, Optional, Set +from inputguard.matching import contains_any from inputguard.registry import register_rule from inputguard.types import RuleFinding @@ -110,23 +111,11 @@ def _normalize(text: str) -> str: return re.sub(r"\s+", " ", text.lower()).strip() -def _contains_term(text: str, term: str) -> bool: - if term == "/": - return "/" in text - pattern = r"(? bool: - return any(_contains_term(text, t) for t in terms) +def _contains_any(text: str, terms) -> bool: + # v0.3: word-boundary matching via the shared matcher. token_fallback + # keeps the v0.2 coding-rule behavior for multiword terms ("def ", + # "sign in with"): their words may appear non-adjacent. + return contains_any(text, terms, token_fallback=True) def check_missing_language(text: str) -> Optional[RuleFinding]: diff --git a/inputguard/rules/debug.py b/inputguard/rules/debug.py index f474dbc..7431f15 100644 --- a/inputguard/rules/debug.py +++ b/inputguard/rules/debug.py @@ -4,6 +4,7 @@ from typing import List, Optional from inputguard.detector import DEBUG_SIGNALS +from inputguard.matching import contains_any from inputguard.registry import register_rule from inputguard.types import RuleFinding @@ -43,7 +44,8 @@ def _normalize(text: str) -> str: def _contains_any(text: str, terms) -> bool: - return any(term in text for term in terms) + # v0.3: word-boundary matching via the shared matcher (probe P1 fix). + return contains_any(text, terms) def check_missing_error_message(text: str) -> Optional[RuleFinding]: diff --git a/inputguard/rules/explanation.py b/inputguard/rules/explanation.py index bc933e6..69498de 100644 --- a/inputguard/rules/explanation.py +++ b/inputguard/rules/explanation.py @@ -4,6 +4,7 @@ from typing import List, Optional from inputguard.detector import EXPLANATION_SIGNALS +from inputguard.matching import contains_any from inputguard.registry import register_rule from inputguard.types import RuleFinding @@ -37,7 +38,8 @@ def _normalize(text: str) -> str: def _contains_any(text: str, terms) -> bool: - return any(term in text for term in terms) + # v0.3: word-boundary matching via the shared matcher (probe P1 fix). + return contains_any(text, terms) def check_missing_code_reference(text: str) -> Optional[RuleFinding]: diff --git a/inputguard/rules/feature.py b/inputguard/rules/feature.py index 642ab18..51b6948 100644 --- a/inputguard/rules/feature.py +++ b/inputguard/rules/feature.py @@ -4,6 +4,7 @@ from typing import List, Optional from inputguard.detector import FEATURE_SIGNALS +from inputguard.matching import contains_any from inputguard.registry import register_rule from inputguard.types import RuleFinding @@ -44,7 +45,8 @@ def _normalize(text: str) -> str: def _contains_any(text: str, terms) -> bool: - return any(term in text for term in terms) + # v0.3: word-boundary matching via the shared matcher (probe P1 fix). + return contains_any(text, terms) def check_missing_existing_stack(text: str) -> Optional[RuleFinding]: diff --git a/inputguard/rules/optimization.py b/inputguard/rules/optimization.py index b1d82ed..fdfc587 100644 --- a/inputguard/rules/optimization.py +++ b/inputguard/rules/optimization.py @@ -4,6 +4,7 @@ from typing import List, Optional from inputguard.detector import OPTIMIZATION_SIGNALS +from inputguard.matching import contains_any from inputguard.registry import register_rule from inputguard.types import RuleFinding @@ -43,7 +44,8 @@ def _normalize(text: str) -> str: def _contains_any(text: str, terms) -> bool: - return any(term in text for term in terms) + # v0.3: word-boundary matching via the shared matcher (probe P1 fix). + return contains_any(text, terms) def check_missing_optimization_target(text: str) -> Optional[RuleFinding]: From 6970f68b4040b5cdfa468a8b67187c295472c496 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:45:54 +0000 Subject: [PATCH 2/2] test(fp-regressions): pin word-boundary matcher contract and probe-P1 fix Regression coverage for the shared matcher: the P1 "fixture" input no longer reads as debug and is not hard-flagged; embedded words (fixture, refix, prefix, praised) stop matching; inflected forms (bugs, errors, debugged, refactoring, profiling, stack traces) keep firing so the boundary tightening adds no false negatives; phrases match adjacently with final-word inflections; the coding token fallback stays opt-in; symbol terms keep substring semantics. Co-authored-by: Kalisetti Nihanth Naidu --- tests/test_word_boundary.py | 176 ++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/test_word_boundary.py diff --git a/tests/test_word_boundary.py b/tests/test_word_boundary.py new file mode 100644 index 0000000..8130c0a --- /dev/null +++ b/tests/test_word_boundary.py @@ -0,0 +1,176 @@ +"""Word-boundary matching regressions (the probe P1 false-positive class). + +The v0.2 matcher read ``"fixture"`` as the debug signal ``"fix"`` (probe P1: +``"I love this fixture in the test suite, what does it do"`` scored 35 as a +debug request). These tests pin the shared matcher's contract +(``inputguard.matching``): terms match as standalone words, inflected and +suffixed forms still match (no new false negatives at term boundaries), and +unrelated embeddings (``"fixture"``, ``"refix"``, ``"prefix"``) do not. +""" + +from __future__ import annotations + +import pytest + +from inputguard import InputGuard +from inputguard.detector import DEBUG_SIGNALS +from inputguard.matching import contains_any, contains_term + + +P1_INPUT = "I love this fixture in the test suite, what does it do" +DEBUG_CODES = {"missing_error_message", "missing_expected_vs_actual", "missing_debug_code_context"} + + +class TestProbeP1Regression: + """The false positive that motivated the matcher: fixture reads as fix.""" + + def test_p1_input_no_longer_reads_as_debug(self): + result = InputGuard().analyze(P1_INPUT) + assert result.detected_intent != "debug" + assert not any(f.code in DEBUG_CODES for f in result.findings) + + def test_p1_input_not_hard_flagged(self): + # The input is honestly an (underspecified) explanation request, so + # advisory findings are correct — but it must not land in a hard + # flag state in the default warning mode (v0.2: score 35, + # needs_clarification). + result = InputGuard().analyze(P1_INPUT) + assert result.clarity_score >= 60 + assert result.status in ("ready", "usable_with_warnings") + + def test_p1_input_does_not_match_any_debug_signal(self): + assert contains_any(P1_INPUT.lower(), DEBUG_SIGNALS) is False + + +class TestBoundaryPolarity: + """Embeddings that must stop matching (the killed false-positive class).""" + + @pytest.mark.parametrize( + "text", + [ + "the fixture loads slowly", + "my fixtures are missing", + "refix the layout", + "prefix the variable", + ], + ) + def test_embedded_words_do_not_match(self, text): + assert contains_term(text, "fix") is False + + def test_left_embedding_does_not_match(self): + # "raised" embedded in "praised" (the survey's second substring FP). + assert contains_term("praised the release", "raised") is False + + def test_fixture_does_not_match_fix(self): + assert contains_term("the fixture loads slowly", "fix") is False + + def test_suffix_embeddings_are_required(self): + # "fixture" is not a suffix of "fix" — only inflectional endings count. + assert contains_term("fixture", "fix") is False + assert contains_term("fixtures", "fix") is False + + +class TestGenuineHitsStillMatch: + """No new false negatives: real term occurrences keep firing.""" + + @pytest.mark.parametrize( + ("text", "term"), + [ + ("fix the login bug", "fix"), + ("fix, this breaks", "fix"), + ("(fix) applied", "fix"), + ("FIX THIS NOW", "fix"), + ("my_error is misleading", "error"), + ("route /users", "/"), + ("x=>y mapping", "=>"), + ("a->b pointer", "->"), + ], + ) + def test_genuine_hits(self, text, term): + assert contains_term(text, term) is True + + +class TestInflectionsStillMatch: + """Inflectional forms the substring matcher caught must keep matching.""" + + @pytest.mark.parametrize( + ("text", "term"), + [ + ("my code has bugs", "bug"), + ("it raises errors", "error"), + ("i debugged the loop", "debug"), + ("still debugging it", "debug"), + ("the bugfix was debugged twice", "debug"), + ("it crashed twice", "crash"), + ("refactoring the hot loop", "refactor"), + ("we refactored it", "refactor"), + ("profile this code", "profil"), + ("profiling shows the bottleneck", "profil"), + ("the profiler output", "profil"), + ("stack traces attached", "stack trace"), + ("error messages below", "error message"), + ], + ) + def test_inflected_forms(self, text, term): + assert contains_term(text, term) is True + + +class TestMultiwordPhrases: + """Phrases match adjacently (with final-word inflections); the coding + rules' v0.2 token fallback stays opt-in.""" + + def test_adjacent_phrase_matches(self): + assert contains_any("it is not working today", {"not working"}) is True + + def test_non_adjacent_words_do_not_match_by_default(self): + # v0.2 substring never matched these either — adjacency is required + # unless the caller opts into the coding token fallback. + assert contains_any("add authentication to the app", {"add to the"}) is False + + def test_token_fallback_is_opt_in(self): + terms = {"error message"} + text = "the error and the message" + assert contains_any(text, terms) is False + assert contains_any(text, terms, token_fallback=True) is True + + def test_token_fallback_matches_v02_coding_rules(self): + # The v0.2 coding matcher's fallback: multiword term words may appear + # non-adjacent (e.g. "def " token prefixes). + assert contains_any("in def process_order we loop", {"def "}) is True + + +class TestAnalyzeSpotChecks: + """End-to-end: intents and findings survive the matcher change.""" + + @pytest.mark.parametrize( + ("text", "expected_intent"), + [ + ("fix the login bug, it crashes with an error", "debug"), + ("my code has bugs that raise errors", "debug"), + ("the parser failed after I debugged it, stack traces show typeerror", "debug"), + ("we are refactoring the hot loop", "optimization"), + ("profiling shows the bottleneck", "optimization"), + ("Build a REST API using FastAPI. Store users in PostgreSQL with JWT auth and Stripe integration.", "build"), + ], + ) + def test_intent_preserved(self, text, expected_intent): + assert InputGuard().analyze(text).detected_intent == expected_intent + + def test_feature_phrase_does_not_shadow_build(self): + # "add to the" must not fire on non-adjacent words and steal the + # intent from build (regression: coding auth rule stopped running). + result = InputGuard().analyze("add authentication to the app") + assert result.detected_intent == "build" + assert "missing_auth_type" in [f.code for f in result.findings] + + +class TestMatcherContract: + def test_string_terms_rejected(self): + with pytest.raises(TypeError): + contains_any("text", "fix") + + def test_empty_term_never_matches(self): + assert contains_term("anything", "") is False + + def test_case_folded_for_ad_hoc_callers(self): + assert contains_any("FIX the bug", {"fix"}) is True