diff --git a/src/aelfrice/directive_detector.py b/src/aelfrice/directive_detector.py index 305915f14..09cce1194 100644 --- a/src/aelfrice/directive_detector.py +++ b/src/aelfrice/directive_detector.py @@ -89,6 +89,35 @@ re.IGNORECASE, ) +# Path A intent-prefix filter (issue #374, ratified 2026-05-07). Imperative +# coding-task verbs in head position read as one-shot session tasks +# ("Refactor X so it never blocks", "Add a test that ensures …"), not durable +# rules. They short-circuit to False even when a downstream imperative-verb +# match would otherwise fire. +_CODING_TASK_PREFIX_VERBS: tuple[str, ...] = ( + "refactor", "add", "implement", "write", "create", "update", + "fix", "make", "build", "remove", "rename", "extract", + "merge", "split", "move", "delete", +) +_CODING_TASK_PREFIX_PATTERN = re.compile( + r"^\s*(?:" + "|".join(_CODING_TASK_PREFIX_VERBS) + r")\b", + re.IGNORECASE, +) + +# Rule-marker connectives — re-enable directive classification when a +# coding-task prefix is present but the sentence still encodes a durable rule +# ("Refactor X so it never blocks as a rule"). Empty per the #374 ratification: +# the prefix always wins on first iteration; expand only with corpus evidence. +_RULE_MARKER_CONNECTIVES: tuple[str, ...] = () +_RULE_MARKER_PATTERN: re.Pattern[str] | None = ( + re.compile( + r"\b(?:" + "|".join(re.escape(c) for c in _RULE_MARKER_CONNECTIVES) + r")\b", + re.IGNORECASE, + ) + if _RULE_MARKER_CONNECTIVES + else None +) + def detect_directive(text: str) -> bool: """Return True if `text` reads as a durable imperative directive. @@ -98,7 +127,8 @@ def detect_directive(text: str) -> bool: 2. Questions (leading interrogative or trailing '?') → False. 3. Reported-speech / habitual narration ("I never X when Y") → False. 4. Hedged statements ("maybe", "I think", …) → False. - 5. Otherwise: True iff any imperative verb appears. + 5. Coding-task imperative prefix without a rule-marker connective → False. + 6. Otherwise: True iff any imperative verb appears. """ if not text or not text.strip(): return False @@ -111,4 +141,7 @@ def detect_directive(text: str) -> bool: return False if _HEDGE_PATTERN.search(stripped): return False + if _CODING_TASK_PREFIX_PATTERN.match(stripped): + if _RULE_MARKER_PATTERN is None or not _RULE_MARKER_PATTERN.search(stripped): + return False return _VERB_PATTERN.search(stripped) is not None diff --git a/tests/test_directive_detector.py b/tests/test_directive_detector.py index ec21c1527..14c7045e0 100644 --- a/tests/test_directive_detector.py +++ b/tests/test_directive_detector.py @@ -45,3 +45,50 @@ def test_directive_positives(text: str) -> None: ) def test_directive_negatives(text: str) -> None: assert detect_directive(text) is False + + +# Path A: head-position coding-task prefix short-circuits to False even when +# a downstream imperative verb would otherwise fire. Each row embeds a verb +# from the imperative bank ("never", "ensure", "must", "only", "avoid", …) +# so the test would pass under the old detector if Path A weren't applied — +# making the test load-bearing for the new branch. +@pytest.mark.parametrize( + "text", + [ + "Refactor X so it never blocks", + "Add a test that ensures the gate fires", + "Implement the parser so it must reject empty input", + "Write a guard that always returns False on the empty case", + "Create a wrapper that should not propagate exceptions", + "Update the README so it only mentions the public API", + "Fix the pre-push hook to avoid bypassing on rebase", + "Make the worker shutdown ensure no half-flushed batches", + "Build a fixture that requires the v0.1 corpus path", + "Remove the dead branch before merging", + "Rename _emit so it cannot collide with _emit_core", + "Extract the helper unless the call site needs inlining", + "Merge the two threads after the gate passes", + "Split the test so each case must check exactly one signal", + "Move the docstring before the type annotations", + "Delete the cache whenever the schema bumps", + ], +) +def test_directive_coding_task_prefix_short_circuits(text: str) -> None: + assert detect_directive(text) is False + + +# Regression: leading deontic anchors and durable rules where the head verb +# is NOT in the coding-task bank still classify True. The prefix filter is +# case-insensitive but positional, and only fires on the head verb. +@pytest.mark.parametrize( + "text", + [ + "always update the changelog before tagging", + "never delete a worktree without releasing the claim first", + "must rename the temp file before commit", + "only merge after the gate passes", + "before merging, ensure CI is green", + ], +) +def test_directive_prefix_filter_does_not_swallow_rules(text: str) -> None: + assert detect_directive(text) is True