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
35 changes: 34 additions & 1 deletion src/aelfrice/directive_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Escape the verbs when building the prefix pattern to future‑proof against special regex characters.

Currently this is safe because all verbs are simple alphabetic tokens, but if a future verb includes regex metacharacters (e.g. C++, .net, or a hyphen), the pattern could behave incorrectly. To make this robust, build the alternation with "|".join(re.escape(v) for v in _CODING_TASK_PREFIX_VERBS) as you did for _RULE_MARKER_CONNECTIVES.

Suggested change
_CODING_TASK_PREFIX_PATTERN = re.compile(
r"^\s*(?:" + "|".join(_CODING_TASK_PREFIX_VERBS) + r")\b",
re.IGNORECASE,
)
_CODING_TASK_PREFIX_PATTERN = re.compile(
r"^\s*(?:" + "|".join(re.escape(v) for v in _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.
Expand All @@ -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
Expand All @@ -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
47 changes: 47 additions & 0 deletions tests/test_directive_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +55 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Consider adding cases with leading whitespace/punctuation before the coding-task verb to lock in the intended anchoring behavior.

The _CODING_TASK_PREFIX_PATTERN already supports leading whitespace, but the current parametrization only covers verbs at column 0. Adding a couple of cases like " refactor X so it never blocks" or "\tAdd a test that ensures …" would validate that the prefix filter still behaves correctly with common leading-whitespace formats and guard against regressions if the regex is changed later.

"Make the worker shutdown ensure no half-flushed batches",
"Build a fixture that requires the v0.1 corpus path",
Comment on lines +57 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add negative controls where coding-task verbs appear non-initially so we prove the filter is strictly head-position based.

Since detect_directive is explicitly head-verb positional, add a few cases where a non-coding verb appears first and the coding-task verb appears later (e.g. "before merging, add a test that ensures …"). These should still be True to validate that the filter only keys off the head verb and ignores non-head occurrences of coding-task verbs.

"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
Loading