|
| 1 | +"""v1.108.114 — route() gains mutate + stateful intent buckets. |
| 2 | +
|
| 3 | +The leakage finding: route() had no bucket for edit/execute commands or for |
| 4 | +session/recent-change queries, so those intents fell through to search_symbols |
| 5 | +or NO_MATCH. This adds both, on-charter: mutate is recognize-and-redirect to a |
| 6 | +READ-ONLY prep tool (jcm never performs the edit), stateful maps to the |
| 7 | +session/delta tools. |
| 8 | +""" |
| 9 | +import re |
| 10 | + |
| 11 | +from jcodemunch_mcp import counter |
| 12 | + |
| 13 | +# A catalog superset covering the new bucket targets + the pre-existing ones. |
| 14 | +NAMES = { |
| 15 | + "search_symbols", "search_text", "get_file_outline", "get_repo_health", |
| 16 | + "get_call_hierarchy", "find_references", "get_blast_radius", |
| 17 | + "get_class_hierarchy", "get_dependency_graph", "find_dead_code", |
| 18 | + "assemble_task_context", |
| 19 | + # new bucket targets |
| 20 | + "get_changed_symbols", "get_session_context", |
| 21 | + "check_rename_safe", "check_delete_safe", "plan_refactoring", "check_edit_safe", |
| 22 | +} |
| 23 | + |
| 24 | +MUTATE_PREP_TOOLS = {"check_rename_safe", "check_delete_safe", |
| 25 | + "plan_refactoring", "check_edit_safe"} |
| 26 | +STATEFUL_TOOLS = {"get_changed_symbols", "get_session_context"} |
| 27 | + |
| 28 | + |
| 29 | +def _top(task): |
| 30 | + recs = counter.classify_intent(task, NAMES) |
| 31 | + return recs[0]["action"] if recs else None |
| 32 | + |
| 33 | + |
| 34 | +# --- mutate bucket --------------------------------------------------------- # |
| 35 | + |
| 36 | +def test_mutate_commands_route_to_readonly_prep(): |
| 37 | + cases = { |
| 38 | + "rename UserRepository to AccountRepository everywhere": "check_rename_safe", |
| 39 | + "delete the unused helper functions": "check_delete_safe", |
| 40 | + "remove the dead config loader": "check_delete_safe", |
| 41 | + "refactor the auth module": "plan_refactoring", |
| 42 | + "extract this into a separate method": "plan_refactoring", |
| 43 | + "move the config loading into its own module": "plan_refactoring", |
| 44 | + "inline the temporary variable": "plan_refactoring", |
| 45 | + "add a retry to the http client": "check_edit_safe", |
| 46 | + "fix the bug in parse_config": "check_edit_safe", |
| 47 | + "implement pagination for the results endpoint": "check_edit_safe", |
| 48 | + "update the docstring on createSession": "check_edit_safe", |
| 49 | + "change the timeout to 60 seconds": "check_edit_safe", |
| 50 | + "write a function that validates emails": "check_edit_safe", |
| 51 | + "generate a migration for the new column": "check_edit_safe", |
| 52 | + "apply the fix and run the tests": "check_edit_safe", |
| 53 | + } |
| 54 | + for task, expected in cases.items(): |
| 55 | + assert _top(task) == expected, f"{task!r} -> {_top(task)} (want {expected})" |
| 56 | + |
| 57 | + |
| 58 | +def test_mutate_recommendations_are_readonly_charter_safe(): |
| 59 | + """The mutate bucket must only ever recommend read-only prep tools — |
| 60 | + never a state-changing or exec/write action. This is the charter guard.""" |
| 61 | + for tool in MUTATE_PREP_TOOLS: |
| 62 | + assert not counter.is_state_changing(tool), f"{tool} is state-changing" |
| 63 | + assert counter.forbidden_reason(tool) is None, f"{tool} trips the exec/write tripwire" |
| 64 | + |
| 65 | + |
| 66 | +def test_mutate_why_declares_readonly(): |
| 67 | + recs = counter.classify_intent("rename Foo to Bar", NAMES) |
| 68 | + assert recs and "read-only" in recs[0]["why"].lower() |
| 69 | + |
| 70 | + |
| 71 | +def test_edit_question_is_not_captured_as_mutate(): |
| 72 | + """Leading-verb anchor: a QUESTION about an edit must fall through to the |
| 73 | + impact rules, not the mutate bucket (which is for imperative commands).""" |
| 74 | + assert _top("what would be affected if I rename UserRepository") not in MUTATE_PREP_TOOLS |
| 75 | + assert _top("what breaks if I change the signature of createSession") not in MUTATE_PREP_TOOLS |
| 76 | + |
| 77 | + |
| 78 | +# --- stateful bucket ------------------------------------------------------- # |
| 79 | + |
| 80 | +def test_stateful_change_queries_route_to_delta_tool(): |
| 81 | + for task in [ |
| 82 | + "what did I just change", |
| 83 | + "show me my uncommitted edits", |
| 84 | + "what symbols changed since the last commit", |
| 85 | + "what's different from main", |
| 86 | + "which functions did my last edit affect", |
| 87 | + "list files changed today", |
| 88 | + "what's in my working tree that's new", |
| 89 | + "show recently modified symbols", |
| 90 | + "the classes I renamed just now", |
| 91 | + "what edits are pending", |
| 92 | + ]: |
| 93 | + assert _top(task) in STATEFUL_TOOLS, f"{task!r} -> {_top(task)}" |
| 94 | + |
| 95 | + |
| 96 | +def test_stateful_session_queries_route_to_session_tool(): |
| 97 | + for task in [ |
| 98 | + "what have we touched in this session", |
| 99 | + "pick up where we left off", |
| 100 | + "recap the changes from this branch", |
| 101 | + "what did we work on earlier", |
| 102 | + "the file I was editing a minute ago", |
| 103 | + ]: |
| 104 | + assert _top(task) in STATEFUL_TOOLS, f"{task!r} -> {_top(task)}" |
| 105 | + |
| 106 | + |
| 107 | +def test_stateful_does_not_steal_semantic_session_word(): |
| 108 | + """'where does session state get persisted' is a semantic code query, not a |
| 109 | + stateful session recap — the bare word 'session' must not trigger the bucket.""" |
| 110 | + assert _top("where does session state get persisted") not in STATEFUL_TOOLS |
| 111 | + |
| 112 | + |
| 113 | +# --- no regression to existing routes -------------------------------------- # |
| 114 | + |
| 115 | +def test_existing_routes_unchanged(): |
| 116 | + assert _top("who calls this function") == "get_call_hierarchy" |
| 117 | + assert _top("find dead code in the project") == "find_dead_code" |
| 118 | + assert _top("search for the string TODO in the code") == "search_text" |
| 119 | + assert _top("find the UserRepository class") == "search_symbols" |
| 120 | + |
| 121 | + |
| 122 | +def test_mutate_prep_not_auto_executed(): |
| 123 | + """Mutate prep tools have no _QUERY_ARG entry -> route recommends, never |
| 124 | + auto-executes a mutation flow from a free-form task.""" |
| 125 | + for tool in MUTATE_PREP_TOOLS: |
| 126 | + assert counter.shape_execute_args(tool, "some/repo", "rename Foo to Bar") is None |
0 commit comments