Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion inputguard/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
197 changes: 197 additions & 0 deletions inputguard/matching.py
Original file line number Diff line number Diff line change
@@ -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 (``(?<![a-z0-9])...(?![a-z0-9])`` on
lowercased text), so identifier mentions like ``my_error`` still count.
- **Inflections stay genuine:** common endings (plural, ``-ed``, ``-ing``,
``-er``, ``-ly``, ``-e``) still match — ``"bugs"``, ``"errors"``,
``"debugged"``, ``"refactoring"``, ``"stack traces"``, ``"profiling"``
all keep firing, so tightening the boundaries introduces no new false
negatives. Formally: every v0.2 substring hit whose embedding was
suffix-shaped at a word boundary still matches; the killed hits are
exactly the embedded-inside-a-longer-word class that produced probe P1.
- **Multiword terms:** a phrase matches when each of its words does (each
word accepting inflections, so ``"error messages"`` matches the term
``"error message"``). With ``token_fallback=True`` a phrase also matches
when its words are all present but not adjacent — the v0.2 coding-rule
behavior, kept for the coding rules only so their verdicts are unchanged.

Terms with no alphanumeric characters (``/``, ``=>``, ``->``, ``::``) 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"(?<![a-z0-9])"
_RIGHT_BOUND = r"(?![a-z0-9])"

# Inflectional endings a term may absorb while still counting as a hit.
# Deliberately suffix-only — a wildcard right side would make "fix" match
# "fixture" again.
_ENDINGS = r"(?:s|es|ed|d|ing|ers|er|ly|e)?"

# Final consonants that double before -ed/-ing ("debug" -> "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))
23 changes: 6 additions & 17 deletions inputguard/rules/coding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"(?<![a-z0-9])" + re.escape(term) + r"(?![a-z0-9])"
if re.search(pattern, text):
return True
if " " in term:
tokens = term.split()
return all(
re.search(r"(?<![a-z0-9])" + re.escape(tok) + r"(?![a-z0-9])", text)
for tok in tokens
)
return False


def _contains_any(text: str, terms: Set[str]) -> 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]:
Expand Down
4 changes: 3 additions & 1 deletion inputguard/rules/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down
4 changes: 3 additions & 1 deletion inputguard/rules/explanation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down
4 changes: 3 additions & 1 deletion inputguard/rules/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down
4 changes: 3 additions & 1 deletion inputguard/rules/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down
Loading