From fe7cfdcdc7a451e8511049033ef5cc4bca249e09 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:44:49 +0000 Subject: [PATCH 1/4] fix(registry-contract-spec-b1): revert Rule.check to the spec's check(self, text) signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged foundation (PR #3) dispatches rule.check(text, intent), but the pinned spec (art_bTvdPdJS section 1) defines check(self, text). The intent parameter is redundant by construction: analyze() dispatches REGISTRY.rules_for_intent(detected_intent), which filters on rule.domain == intent, so every rule invoked already knows the intent as its own domain member — and all 19 built-in adapters ignore it. The mismatch is not cosmetic: registration only checks callable(rule.check), so a rule written per the spec registers cleanly and then crashes analyze() mid-run with "TypeError: check() takes 2 positional arguments but 3 were given" (review probe P1). Spec and code must agree before wave-2/3 rule authors copy either version. - inputguard/registry.py: Rule protocol back to check(self, text); module and member docstrings updated to match. - inputguard/analyzer.py: dispatch rule.check(normalized). - inputguard/rules/: all 19 built-in adapters drop the unused intent parameter; InsufficientContextRule docstring no longer cites (text, intent). - tests/test_registry.py: helper and decorator-form test rules updated. Part of release PR #2. Co-authored-by: Kalisetti Nihanth Naidu --- inputguard/analyzer.py | 2 +- inputguard/registry.py | 13 +++++++------ inputguard/rules/coding.py | 26 +++++++++++++------------- inputguard/rules/debug.py | 6 +++--- inputguard/rules/explanation.py | 4 ++-- inputguard/rules/feature.py | 6 +++--- inputguard/rules/optimization.py | 6 +++--- tests/test_registry.py | 2 +- 8 files changed, 33 insertions(+), 32 deletions(-) diff --git a/inputguard/analyzer.py b/inputguard/analyzer.py index 94074ca..5711cec 100644 --- a/inputguard/analyzer.py +++ b/inputguard/analyzer.py @@ -75,7 +75,7 @@ def analyze(self, user_input: str, domain: str = "coding") -> AnalysisResult: findings: List[RuleFinding] = [] seen_codes: Set[str] = set() for rule in REGISTRY.rules_for_intent(detected_intent): - finding = rule.check(normalized, detected_intent) + finding = rule.check(normalized) if finding is None or finding.code in seen_codes: continue seen_codes.add(finding.code) diff --git a/inputguard/registry.py b/inputguard/registry.py index 3762bcc..ddb5776 100644 --- a/inputguard/registry.py +++ b/inputguard/registry.py @@ -4,7 +4,7 @@ engine: rules and domains are registered data, not code paths. - A rule is any object with the four members (``id``, ``domain``, - ``severity``, ``gap``) and a ``check(text, intent)`` method — the + ``severity``, ``gap``) and a ``check(text)`` method — the :class:`Rule` protocol. - A domain is a named analysis scope (``"coding"`` ships built in) that declares its intent signals in priority order via @@ -57,8 +57,8 @@ class Rule(Protocol): ``id`` must be unique across the registry, ``severity`` must be one of ``'low' | 'medium' | 'high'`` (validated at registration), and ``gap`` groups the rule's findings for scoring dedup (``None`` dedupes by code - instead). ``check`` receives lowercased, whitespace-collapsed text plus - the intent detected for this ``analyze()`` call, and returns at most one + instead). ``check`` receives lowercased, whitespace-collapsed text, and + returns at most one :class:`~inputguard.types.RuleFinding` — ``None`` when the rule does not fire. """ @@ -68,7 +68,7 @@ class Rule(Protocol): severity: str gap: Optional[str] - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: ... # pragma: no cover — protocol body @@ -266,7 +266,7 @@ def _validate(self, rule: Rule) -> None: raise TypeError( f"Rule {rule!r} is missing required member(s): " f"{', '.join(repr(m) for m in missing)}. A rule needs id, domain, " - "severity, gap, and a check(text, intent) method." + "severity, gap, and a check(text) method." ) if not isinstance(rule.id, str) or not rule.id: raise ValueError(f"Rule id must be a non-empty string, got {rule.id!r}.") @@ -286,7 +286,7 @@ def _validate(self, rule: Rule) -> None: if not callable(rule.check): raise TypeError( f"Rule {rule.id!r}: check must be callable — " - "check(text, intent) -> Optional[RuleFinding]." + "check(text) -> Optional[RuleFinding]." ) def _add(self, rule: Rule) -> None: @@ -307,6 +307,7 @@ def _declared_intents(self) -> Set[str]: declared: Set[str] = set() for signal_spec in self._domains.values(): declared.update(intent for intent, _ in signal_spec) + return declared diff --git a/inputguard/rules/coding.py b/inputguard/rules/coding.py index 60ca560..85f8fc1 100644 --- a/inputguard/rules/coding.py +++ b/inputguard/rules/coding.py @@ -316,7 +316,7 @@ class MissingLanguageRule: severity = "high" gap = "programming language" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_language(text) @@ -329,7 +329,7 @@ class MissingApiStructureRule: severity = "high" gap = "api structure" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_api_structure(text) @@ -342,7 +342,7 @@ class MissingDataModelRule: severity = "high" gap = "data model" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_data_model(text) @@ -355,7 +355,7 @@ class MissingIntegrationSpecificsRule: severity = "medium" gap = "integration specifics" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_integration_specifics(text) @@ -368,7 +368,7 @@ class MissingAuthTypeRule: severity = "high" gap = "authentication type" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_auth_type(text) @@ -381,7 +381,7 @@ class MissingOutputFormatRule: severity = "medium" gap = "output format" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_output_format(text) @@ -394,7 +394,7 @@ class IntentWithoutDetailRule: severity = "high" gap = "programming language" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return _check_intent_without_detail(text) @@ -403,11 +403,11 @@ class InsufficientContextRule: """Catch-all safety net for build-intent input (registry adapter). v0.2's run_coding_rules passed this rule the findings collected so far and - it fired only when that list was empty. A Rule sees only (text, intent), - so the adapter re-runs the other built-in build rules — they are pure - functions, so the verdict is identical. Findings from user-registered - rules are not visible here: the catch-all suppresses on the built-in - build rules only. + it fired only when that list was empty. A Rule sees only the normalized + text, so the adapter re-runs the other built-in build rules — they are + pure functions, so the verdict is identical. Findings from + user-registered rules are not visible here: the catch-all suppresses on + the built-in build rules only. """ id = "insufficient_context" @@ -415,7 +415,7 @@ class InsufficientContextRule: severity = "high" gap = "task context" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: normalized = _normalize(text) seen_codes: Set[str] = set() prior: List[RuleFinding] = [] diff --git a/inputguard/rules/debug.py b/inputguard/rules/debug.py index 3067ae7..f474dbc 100644 --- a/inputguard/rules/debug.py +++ b/inputguard/rules/debug.py @@ -120,7 +120,7 @@ class MissingErrorMessageRule: severity = "high" gap = "error description" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_error_message(text) @@ -133,7 +133,7 @@ class MissingExpectedVsActualRule: severity = "high" gap = "expected vs actual behavior" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_expected_vs_actual(text) @@ -146,7 +146,7 @@ class MissingDebugCodeContextRule: severity = "medium" gap = "code context" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_debug_code_context(text) diff --git a/inputguard/rules/explanation.py b/inputguard/rules/explanation.py index 8a5a5cf..bc933e6 100644 --- a/inputguard/rules/explanation.py +++ b/inputguard/rules/explanation.py @@ -101,7 +101,7 @@ class MissingCodeReferenceRule: severity = "high" gap = "code reference" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_code_reference(text) @@ -114,7 +114,7 @@ class MissingExplanationDepthRule: severity = "low" gap = "explanation depth" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_explanation_depth(text) diff --git a/inputguard/rules/feature.py b/inputguard/rules/feature.py index 7afa551..642ab18 100644 --- a/inputguard/rules/feature.py +++ b/inputguard/rules/feature.py @@ -121,7 +121,7 @@ class MissingExistingStackRule: severity = "high" gap = "existing stack" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_existing_stack(text) @@ -134,7 +134,7 @@ class MissingFeatureScopeRule: severity = "high" gap = "feature scope" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_feature_scope(text) @@ -147,7 +147,7 @@ class MissingCompletionCriteriaRule: severity = "low" gap = "completion criteria" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_completion_criteria(text) diff --git a/inputguard/rules/optimization.py b/inputguard/rules/optimization.py index 031346c..b1d82ed 100644 --- a/inputguard/rules/optimization.py +++ b/inputguard/rules/optimization.py @@ -120,7 +120,7 @@ class MissingOptimizationTargetRule: severity = "high" gap = "optimization target" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_optimization_target(text) @@ -133,7 +133,7 @@ class MissingPerformanceBaselineRule: severity = "medium" gap = "performance baseline" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_performance_baseline(text) @@ -146,7 +146,7 @@ class MissingOptimizationConstraintRule: severity = "low" gap = "optimization constraint" - def check(self, text: str, intent: str) -> Optional[RuleFinding]: + def check(self, text: str) -> Optional[RuleFinding]: return check_missing_optimization_constraint(text) diff --git a/tests/test_registry.py b/tests/test_registry.py index 7e0da55..dc61341 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -66,7 +66,7 @@ class TestRule: TestRule.domain = domain TestRule.severity = severity TestRule.gap = gap - TestRule.check = lambda self, text, intent: finding + TestRule.check = lambda self, text: finding return TestRule() From 751992cee59ce586dfd6fea699fdcdb4dc012166 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:53:33 +0000 Subject: [PATCH 2/4] fix(registry-intent-scoping-b2): globally-unique intents, registration guards, loud rule attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cross-domain leak and the registration holes the adversarial review proved against the merged foundation (art_hC18m78C). All guards fire at registration time, before any mutation. - B2: register_domain raises ValueError when an intent name is already declared by another registered domain, naming both domains. Rules dispatch on intent name alone (rules_for_intent filters on rule.domain), so a shared intent name ran one domain's rules inside the other's analysis (review probe P6b: a legal rule fired inside a coding analysis). Duplicate intent names within one signals argument raise too — the ownership map must be unambiguous. - C4: the self-contradictory error ("declares domain 'coding', which no registered domain declares as an intent. Registered domains: 'coding'") now states the actual constraint and enumerates the valid intent names; the Rule protocol documents that domain holds the intent name the rule is registered under, never a domain name. - C3: duplicate rule ids within one register_domain call raise — the old code silently dropped the second rule (review probe P2). - N3: _validate checks the check() signature via inspect.signature binding; a rule whose check cannot accept the single positional text argument is a registration error, not a mid-analyze TypeError (review probe P1's crash shape). - C2-lite: analyze() wraps rule dispatch and re-raises RuntimeError with the rule id and its registration origin ("at :", captured at _add time and exposed via RuleRegistry.rule_origin), chaining the original exception. Blast-radius contract documented on the Rule protocol and RuleRegistry: a rule exception aborts analyze() by design, registration is permanent, REGISTRY is process-global. - C6: the extension API (register_rule, register_domain, REGISTRY, Rule) is exported at package top level, additive on the untouched v0.2 four-export surface. Part of release PR #2. Co-authored-by: Kalisetti Nihanth Naidu --- inputguard/__init__.py | 15 +++- inputguard/analyzer.py | 15 +++- inputguard/registry.py | 171 ++++++++++++++++++++++++++++++++++++++--- tests/test_registry.py | 5 +- 4 files changed, 191 insertions(+), 15 deletions(-) diff --git a/inputguard/__init__.py b/inputguard/__init__.py index f90a4ef..f62271f 100644 --- a/inputguard/__init__.py +++ b/inputguard/__init__.py @@ -1,6 +1,19 @@ from inputguard.analyzer import InputGuard +from inputguard.registry import REGISTRY, Rule, register_domain, register_rule from inputguard.types import AnalysisResult, RuleFinding __version__ = "0.2.0" -__all__ = ["InputGuard", "AnalysisResult", "RuleFinding", "__version__"] +__all__ = [ + # v0.2 public API — unchanged compat contract. + "InputGuard", + "AnalysisResult", + "RuleFinding", + "__version__", + # v0.3 extension API (additive): register custom rules and domains + # through the same path the built-ins take. + "REGISTRY", + "Rule", + "register_rule", + "register_domain", +] diff --git a/inputguard/analyzer.py b/inputguard/analyzer.py index 5711cec..d518443 100644 --- a/inputguard/analyzer.py +++ b/inputguard/analyzer.py @@ -75,7 +75,20 @@ def analyze(self, user_input: str, domain: str = "coding") -> AnalysisResult: findings: List[RuleFinding] = [] seen_codes: Set[str] = set() for rule in REGISTRY.rules_for_intent(detected_intent): - finding = rule.check(normalized) + try: + finding = rule.check(normalized) + except Exception as exc: + # Loud failure with attribution (review C2-lite): a rule + # exception is never swallowed or silently degraded around — + # it aborts analyze(), naming the rule and where it was + # registered, with the original traceback chained. + raise RuntimeError( + f"inputguard rule {rule.id!r} " + f"(registered {REGISTRY.rule_origin(rule.id)}) raised " + f"{type(exc).__name__}: {exc}. A rule exception aborts " + f"analyze() by design — rules are the author's " + f"responsibility after registration; fix or remove the rule." + ) from exc if finding is None or finding.code in seen_codes: continue seen_codes.add(finding.code) diff --git a/inputguard/registry.py b/inputguard/registry.py index ddb5776..6261bf2 100644 --- a/inputguard/registry.py +++ b/inputguard/registry.py @@ -8,7 +8,9 @@ :class:`Rule` protocol. - A domain is a named analysis scope (``"coding"`` ships built in) that declares its intent signals in priority order via - :meth:`RuleRegistry.register_domain`. + :meth:`RuleRegistry.register_domain`. Intent names are globally unique + across domains (enforced at registration) because rules dispatch on + intent name alone. Registration happens at import/startup time; ``analyze()`` only reads the registry, preserving the thread-safe, dependency-free pipeline v0.2 @@ -17,6 +19,7 @@ from __future__ import annotations +import inspect from typing import ( Dict, Iterable, @@ -49,6 +52,29 @@ _REQUIRED_MEMBERS = ("id", "domain", "severity", "gap", "check") +# Placeholder passed to check's signature at registration to prove the +# analyzer's one-positional-argument dispatch can bind. +_PROBE_TEXT = "probe text" + + +def _registration_origin() -> str: + """Call site of the registration, for loud rule attribution. + + The first stack frame outside this module — where ``register_rule`` / + ``register_domain`` was actually invoked. Registration is startup-time, + so the walk costs nothing during ``analyze()``. + """ + frame = inspect.currentframe() + try: + caller = frame + while caller is not None and caller.f_code.co_filename == __file__: + caller = caller.f_back + if caller is None: + return "unknown origin" + return f"at {caller.f_code.co_filename}:{caller.f_lineno}" + finally: + del frame # break reference cycles through frame objects + @runtime_checkable class Rule(Protocol): @@ -57,10 +83,31 @@ class Rule(Protocol): ``id`` must be unique across the registry, ``severity`` must be one of ``'low' | 'medium' | 'high'`` (validated at registration), and ``gap`` groups the rule's findings for scoring dedup (``None`` dedupes by code - instead). ``check`` receives lowercased, whitespace-collapsed text, and + instead). + + ``domain`` holds the **intent name** the rule is registered under — + never a domain name. ``analyze()`` dispatches + ``rules_for_intent(detected_intent)``, so a rule fires only when its + intent is the one detected; for the built-in coding domain the valid + values are ``'build'``, ``'debug'``, ``'optimization'``, + ``'explanation'``, and ``'feature'``. Intent names are globally unique + across domains (``register_domain`` raises on a collision), so an + intent name unambiguously identifies the rules that run for it. + + ``check`` receives lowercased, whitespace-collapsed text, and returns at most one :class:`~inputguard.types.RuleFinding` — ``None`` when the rule does not fire. + + Contracts a rule author accepts at registration: + + - A ``check`` exception is not contained: it aborts the ``analyze()`` + call in flight, re-raised with the rule id and its registration + origin. Shape is validated at registration; behaviour is not — a + rule is the author's responsibility after registration. + - Registration is permanent for the process lifetime (there is no + unregister), and the module-level ``REGISTRY`` is a process-global + singleton shared by everything that imports inputguard. """ id: str @@ -92,12 +139,19 @@ def _normalize_signals( raise ValueError(f"Domain {name!r}: signals must declare at least one intent.") normalized: List[Tuple[str, Tuple[str, ...]]] = [] + seen_intents: Set[str] = set() fallbacks = 0 for intent, terms in items: if not isinstance(intent, str) or not intent: raise ValueError( f"Domain {name!r}: intent names must be non-empty strings, got {intent!r}." ) + if intent in seen_intents: + raise ValueError( + f"Domain {name!r}: duplicate intent name {intent!r} in signals — " + f"declare each intent exactly once." + ) + seen_intents.add(intent) if isinstance(terms, str) or not isinstance(terms, Iterable): raise TypeError( f"Domain {name!r}: terms for intent {intent!r} must be an iterable " @@ -129,11 +183,23 @@ class RuleRegistry: lookups and iteration over registered rules, so parallel ``analyze()`` calls stay consistent (the v0.2 thread-safety contract). Registering while another thread is mid-``analyze()`` is not supported. + + Blast radius: registration is permanent for the process lifetime — + there is no unregister — and the module-level ``REGISTRY`` is a + process-global singleton shared by everything that imports inputguard. + The registry validates a rule's shape at registration (members, + severity, ``check`` arity); it cannot guard a rule's behaviour. A + ``check`` that raises aborts the ``analyze()`` call in flight with + loud attribution (rule id + registration origin) — a broken rule is + its author's registration responsibility. """ def __init__(self) -> None: self._rules: Dict[str, Rule] = {} self._domains: Dict[str, SignalSpec] = {} + # rule id -> human-readable registration call site, for loud + # attribution when a rule raises inside analyze(). + self._origins: Dict[str, str] = {} # -- registration ---------------------------------------------------- @@ -147,8 +213,11 @@ def register_rule(self, rule: Union[type, Rule]) -> Union[type, Rule]: Raises ``ValueError`` on a duplicate rule id, an unknown severity, or — once any domain is registered — a domain no registered domain declares as an intent. Raises ``TypeError`` when the object is not - shaped like a :class:`Rule` or the class cannot be constructed with - no arguments. + shaped like a :class:`Rule`, the class cannot be constructed with + no arguments, or ``check`` cannot be called with the single + positional argument the analyzer passes (``rule.check(text)``) — + a wrong-signature rule is a registration error, never a mid-``analyze()`` + crash. """ if isinstance(rule, type): instance = _instantiate(rule) @@ -174,9 +243,15 @@ def register_domain( example via the ``@register_rule`` decorator) are reused, and the rest are registered through the same path. + Intent names are **globally unique across domains**: rules dispatch on + intent name alone, so an intent name declared by two domains would run + one domain's rules inside the other's analysis. + Raises ``ValueError`` on a duplicate domain name, malformed signals - (no fallback intent), or a rule whose domain the domain does not - declare. + (no fallback intent, duplicate intent names within ``signals``), an + intent name already declared by another registered domain, duplicate + rule ids within the ``rules`` argument, or a rule whose domain the + domain does not declare. Nothing mutates unless every check passes. """ if not isinstance(name, str) or not name: raise ValueError(f"Domain name must be a non-empty string, got {name!r}.") @@ -187,6 +262,21 @@ def register_domain( normalized_signals = _normalize_signals(name, signals) declared = {intent for intent, _ in normalized_signals} + # Globally-unique intent names: a collision would silently leak rules + # across domains in both directions (rules_for_intent filters on the + # intent name alone), so it is a registration-time error. + for intent in sorted(declared): + owner = self._intent_owner(intent) + if owner is not None: + raise ValueError( + f"Domain {name!r} cannot declare intent {intent!r}: it is " + f"already declared by domain {owner!r}. Intent names must be " + f"globally unique across domains — rules dispatch on intent " + f"name alone, so a shared intent name would run one domain's " + f"rules inside the other domain's analysis. Rename the intent " + f"in one of the two domains." + ) + coerced = [self._coerce(entry) for entry in rules] for rule in coerced: self._validate(rule) @@ -194,8 +284,17 @@ def register_domain( if rule.domain not in declared: raise ValueError( f"Rule {rule.id!r} declares domain {rule.domain!r}, which is not " - f"an intent of domain {name!r}. Declared intents: {sorted(declared)}." + f"an intent of domain {name!r}. Rule.domain must hold one of the " + f"domain's intent names — never the domain name itself. " + f"Declared intents: {sorted(declared)}." ) + coerced_ids = [rule.id for rule in coerced] + if len(set(coerced_ids)) != len(coerced_ids): + duplicated = sorted({rid for rid in coerced_ids if coerced_ids.count(rid) > 1}) + raise ValueError( + f"Duplicate rule id within one register_domain call: " + f"{', '.join(repr(d) for d in duplicated)}. Rule ids must be unique." + ) for rule in coerced: existing = self._rules.get(rule.id) if existing is not None and existing is not rule: @@ -231,7 +330,12 @@ def domain_names(self) -> Tuple[str, ...]: return tuple(self._domains) def rules_for_intent(self, intent: str) -> List[Rule]: - """Rules whose domain is this intent scope, in registration order.""" + """Rules whose domain is this intent scope, in registration order. + + Intent names are globally unique across domains (enforced at + registration), so an intent name dispatches exactly one domain's + rules — never a mix from several domains. + """ return [rule for rule in self._rules.values() if rule.domain == intent] def rule_ids(self) -> Tuple[str, ...]: @@ -246,6 +350,15 @@ def get_rule(self, rule_id: str) -> Optional[Rule]: """Return the rule registered under ``rule_id``, or ``None``.""" return self._rules.get(rule_id) + def rule_origin(self, rule_id: str) -> str: + """Human-readable registration call site for ``rule_id``. + + ``"at :"`` — the first frame outside this module when + the rule was registered — or ``"unknown origin"`` if the id is not + registered. Used for loud rule attribution in analyzer errors. + """ + return self._origins.get(rule_id, "unknown origin") + # -- internals --------------------------------------------------------- def _coerce(self, entry: Union[type, Rule]) -> Rule: @@ -288,6 +401,26 @@ def _validate(self, rule: Rule) -> None: f"Rule {rule.id!r}: check must be callable — " "check(text) -> Optional[RuleFinding]." ) + # Arity validation (review N3): the analyzer dispatches exactly one + # positional argument, rule.check(normalized_text). A signature that + # cannot accept it must fail here, at registration — not later, + # mid-analyze, on arbitrary user input. + try: + signature = inspect.signature(rule.check) + signature.bind(_PROBE_TEXT) + except TypeError as exc: + raise TypeError( + f"Rule {rule.id!r}: check{signature} cannot be called as " + f"rule.check(text) — the analyzer passes exactly one positional " + f"argument, the normalized text. Define check(self, text) per the " + f"Rule protocol. ({exc})" + ) from exc + except ValueError: + # Signature introspection unavailable (e.g. some C callables): + # skip the arity check rather than reject an inspectable-in-practice + # rule. A genuinely wrong signature still fails loudly at + # analyze() with rule attribution. + pass def _add(self, rule: Rule) -> None: if rule.id in self._rules: @@ -296,20 +429,34 @@ def _add(self, rule: Rule) -> None: # rules register during package import, before any domain exists), then # enforced for everything registered afterwards. if self._domains and rule.domain not in self._declared_intents(): + valid_intents = ", ".join(repr(i) for i in sorted(self._declared_intents())) raise ValueError( - f"Rule {rule.id!r} declares domain {rule.domain!r}, which no " - f"registered domain declares as an intent. Registered domains: " - f"{', '.join(repr(d) for d in self._domains)}." + f"Rule {rule.id!r} declares domain {rule.domain!r}, but Rule.domain " + f"must hold the intent name the rule is registered under — never a " + f"domain name. Valid intents: {valid_intents or 'none'}. " + f"Registered domains: {', '.join(repr(d) for d in self._domains)}." ) self._rules[rule.id] = rule + self._origins[rule.id] = _registration_origin() def _declared_intents(self) -> Set[str]: declared: Set[str] = set() for signal_spec in self._domains.values(): declared.update(intent for intent, _ in signal_spec) - + return declared + def _intent_owner(self, intent: str) -> Optional[str]: + """The domain that declared ``intent``, or ``None``. + + Globally-unique intent names (enforced at registration) make this + unambiguous: an intent belongs to exactly one domain. + """ + for domain, signal_spec in self._domains.items(): + if any(existing == intent for existing, _ in signal_spec): + return domain + return None + REGISTRY = RuleRegistry() diff --git a/tests/test_registry.py b/tests/test_registry.py index dc61341..f08d5a0 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -75,11 +75,14 @@ def registry_isolation(): """Snapshot the registry around tests that mutate it.""" rules_before = dict(REGISTRY._rules) domains_before = dict(REGISTRY._domains) + origins_before = dict(REGISTRY._origins) yield REGISTRY REGISTRY._rules.clear() REGISTRY._rules.update(rules_before) REGISTRY._domains.clear() REGISTRY._domains.update(domains_before) + REGISTRY._origins.clear() + REGISTRY._origins.update(origins_before) # --- the 19 built-ins dogfood the registry path --------------------------- @@ -147,7 +150,7 @@ class AlwaysAskForDeadlineRule: severity = "medium" gap = "deadline" - def check(self, text, intent): + def check(self, text): return RuleFinding( code=self.id, message="Test rule fired.", From 970bab2ee1df3ea733f5b7f7120cf73cda414825 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:55:00 +0000 Subject: [PATCH 3/4] test(registry-contract-guards): pin every remediated registry behavior in the suite Permanent tests for the review's findings so the gates enforce the chosen contract (the review found none of these covered): - spec-compliant check(text) rule registers and fires end to end (P1) - wrong-arity and zero-argument check rejected at registration, nothing enters the registry (N3) - cross-domain intent collision raises naming both domains (B2/P6b), leakage probe cannot recur - duplicate intent within one signals argument raises (N1 registry half) - same-call duplicate rule ids raise and leave no partial registration (C3/P2) - rule exception aborts analyze() with rule id + registration origin and the original cause chained (C2-lite/P4) - extension API importable at top level; v0.2 four-export surface intact (C6) Suite: 123 passed (114 baseline + 9 new). Part of release PR #2. Co-authored-by: Kalisetti Nihanth Naidu --- tests/test_registry.py | 149 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/tests/test_registry.py b/tests/test_registry.py index f08d5a0..e60b294 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -307,3 +307,152 @@ def test_parallel_analyze_stays_consistent(): assert len(parallel) == 65 for offset in range(0, len(parallel), len(texts)): assert parallel[offset : offset + len(texts)] == sequential + + +# --- registry contract remediations (adversarial review art_hC18m78C) ------ + + +def test_spec_compliant_check_text_rule_registers_and_fires(registry_isolation): + """Review P1: a rule written per the pinned spec used to crash analyze().""" + + class SpecRule: + id = "test_spec_signature_rule" + domain = "debug" + severity = "medium" + gap = "deadline" + + def check(self, text): + if "fix the bug" in text: + return RuleFinding( + code=self.id, + message="Spec-signature rule fired.", + severity=self.severity, + gap=self.gap, + ) + return None + + register_rule(SpecRule()) + result = InputGuard().analyze("fix the bug in my app") + assert "test_spec_signature_rule" in [f.code for f in result.findings] + + +def test_wrong_arity_check_rejected_at_registration(registry_isolation): + """Review N3: a wrong-signature rule is a registration error, not a + mid-analyze TypeError. The retired foundation signature is the case.""" + + class OldSignatureRule: + id = "test_old_signature" + domain = "debug" + severity = "low" + gap = None + + def check(self, text, intent): + return None + + with pytest.raises(TypeError, match="test_old_signature"): + register_rule(OldSignatureRule()) + assert "test_old_signature" not in REGISTRY.rule_ids() + + +def test_zero_argument_check_rejected_at_registration(registry_isolation): + + class NoArgRule: + id = "test_noarg_check" + domain = "debug" + severity = "low" + gap = None + + def check(self): + return None + + with pytest.raises(TypeError, match="test_noarg_check"): + register_rule(NoArgRule()) + assert "test_noarg_check" not in REGISTRY.rule_ids() + + +def test_intent_collision_across_domains_raises(registry_isolation): + """Review B2/P6b: 'legal' reusing coding's 'debug' intent used to register + silently and leak rules across domains. The error names both domains.""" + legal_signals = {"debug": ("lawsuit", "court filing"), "plead": ()} + with pytest.raises(ValueError, match="globally unique") as exc_info: + register_domain("legal", legal_signals) + assert "legal" in str(exc_info.value) + assert "coding" in str(exc_info.value) + + +def test_cross_domain_rule_leakage_is_now_impossible(registry_isolation): + """Review P6b: a legal rule firing inside a coding analysis must not recur.""" + legal_signals = {"debug": ("lawsuit", "court filing"), "plead": ()} + with pytest.raises(ValueError, match="globally unique"): + register_domain("legal", legal_signals) + + result = InputGuard().analyze("fix the bug in my app") + assert "legal_fires" not in [f.code for f in result.findings] + assert "legal" not in REGISTRY.domain_names() + + +def test_duplicate_intent_within_one_domain_raises(registry_isolation): + """Review N1 (registry half): one call cannot declare an intent twice.""" + duplicated = [("build", ("make",)), ("build", ()), ("review", ("check",))] + with pytest.raises(ValueError, match="duplicate intent"): + register_domain("testdupint", duplicated) + + +def test_same_call_duplicate_rule_ids_raise(registry_isolation): + """Review C3/P2: duplicate ids in one register_domain call used to be + silently dropped (the high-severity second rule vanished).""" + + class HighSeverity: + severity = "high" + gap = "probe gap" + domain = "probe thing" + + def __init__(self, rule_id): + self.id = rule_id + + def check(self, text): + return None + + with pytest.raises(ValueError, match="register_domain call.*'dup_x'"): + register_domain( + "testdupdom", + {"probe thing": ("widget",), "probe review": ()}, + rules=[HighSeverity("dup_x"), HighSeverity("dup_x")], + ) + assert "testdupdom" not in REGISTRY.domain_names() + + +def test_rule_exception_gets_loud_attribution(registry_isolation): + """Review C2-lite/P4: a rule exception aborts analyze() naming the rule + and its registration origin, with the original exception chained.""" + register_domain("testboom", {"boom thing": ("widget",), "boom review": ()}) + + class ExplodingRule: + id = "test_exploding_rule" + domain = "boom thing" + severity = "low" + gap = None + + def check(self, text): + raise RuntimeError("boom from user rule") + + register_rule(ExplodingRule()) + with pytest.raises(RuntimeError, match="test_exploding_rule.*registered at") as exc_info: + InputGuard().analyze("widget please", domain="testboom") + assert "boom from user rule" in str(exc_info.value.__cause__) + + +def test_extension_api_exported_at_top_level(): + """Review C6: the extension API is importable from the package root.""" + import inputguard + + for name in ("register_rule", "register_domain", "REGISTRY", "Rule"): + assert hasattr(inputguard, name) + assert name in inputguard.__all__ + # the v0.2 compat surface is untouched and leads __all__ + assert inputguard.__all__[:4] == [ + "InputGuard", + "AnalysisResult", + "RuleFinding", + "__version__", + ] From c9cb9b327e0e3c7d1371baac3dec584efe5a41c4 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:59:14 +0000 Subject: [PATCH 4/4] test: adapt PR #4 follow-ups test rule to the spec check(text) signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase adaptation: PR #4's unknown-gap test registered a rule with the retired check(text, intent) signature; the N3 registration guard now correctly rejects it. The test's subject (unknown-gap fallback follow-up) is orthogonal to the signature — the rule moves to the spec's check(self, text). Suite: 202 passed. Part of release PR #2. Co-authored-by: Kalisetti Nihanth Naidu --- tests/test_followups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_followups.py b/tests/test_followups.py index 6e21a5d..2f93711 100644 --- a/tests/test_followups.py +++ b/tests/test_followups.py @@ -179,7 +179,7 @@ class DeadlineRule: severity = "medium" gap = "deadline" - def check(self, text: str, intent: str): + def check(self, text: str): return finding rules_before = {rule.id: rule for rule in REGISTRY.rules()}