mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-17 18:14:37 +00:00
8 files changed
+487
-129
No files matched your search
@@ -97,7 +97,7 @@ class Language(str, Enum):
|
||||
match self:
|
||||
# Existing cases...
|
||||
case self.NEW_LANGUAGE:
|
||||
return FilenameMatcher("*.newlang", "*.nl") # File extensions
|
||||
return FilenameMatcher(".newlang", ".nl") # File extensions
|
||||
```
|
||||
|
||||
### 2.2 Update Language Server Factory
|
||||
|
||||
+16
-6
@@ -87,12 +87,13 @@ encoding: utf-8
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# Set this to [] to not use the default modes defined in the global config for this project.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
@@ -137,9 +138,18 @@ ignored_memory_patterns: []
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of mode names to be activated additionally for this project.
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
+8
-6
@@ -2,23 +2,19 @@
|
||||
|
||||
Status of the `main` branch. Changes prior to the next official version change will appear here.
|
||||
|
||||
* Hooks:
|
||||
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
|
||||
`permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386
|
||||
|
||||
* General:
|
||||
- Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`.
|
||||
Instead, they can define `added_modes` to add modes on top of base and default modes.
|
||||
See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes).
|
||||
- Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`.
|
||||
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
|
||||
|
||||
* JetBrains:
|
||||
- Add new tools:
|
||||
- `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group
|
||||
- `jet_brains_run_inspections`: Runs IDE inspections on a file and returns the results
|
||||
|
||||
* LSP Tools:
|
||||
* LSP Backend:
|
||||
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
|
||||
- Add new tools:
|
||||
- `find_declaration`: Finds the declaration/definition of a symbol
|
||||
- `find_implementations`: Finds the implementations of an interface or abstract method
|
||||
@@ -33,6 +29,12 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
- Add **HTML** (experimental) using `vscode-html-language-server` from the `vscode-langservers-extracted` npm package. Provides in-file element/id symbols via documentSymbol; cross-file references are not meaningful for HTML. Also used as a companion server by the Angular LS for plain HTML documentSymbol support.
|
||||
- Add **SCSS / Sass / CSS** (experimental) using [some-sass-language-server](https://github.com/wkillerud/some-sass). Handles `.scss`, `.sass`, and `.css` through one server, with full `@use`/`@forward` workspace-wide go-to-definition and find-references for variables, mixins, and functions across Sass files. The `.css` path uses the same `vscode-css-languageservice` engine that powers the standalone CSS LS; CSS feature toggles default off upstream and are flipped on at startup so symbols, hover, completion, and syntax-level diagnostics work for plain CSS as well.
|
||||
|
||||
* Hooks:
|
||||
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
|
||||
`permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386
|
||||
- Extension: heuristics for parsing commands and firing a hook on too many greps or reads. Important for clients that, unlike claude code, don't have dedicated grep/read tools.
|
||||
- Read hook now only fires on reads of code files (using heuristics to parse the read command string)
|
||||
|
||||
# v1.2.0 (2026-04-27)
|
||||
|
||||
* General:
|
||||
|
||||
@@ -145,11 +145,14 @@ without reading entire files.
|
||||
| find referencing symbols | yes | yes |
|
||||
| search in project dependencies | -- | yes |
|
||||
| type hierarchy | -- | yes |
|
||||
| find declaration | yes | yes |
|
||||
| find implementations | yes | yes |
|
||||
| find declaration | yes* | yes |
|
||||
| find implementations | yes** | yes |
|
||||
| query external projects | yes | yes |
|
||||
| diagnostics/inspections | yes | yes |
|
||||
|
||||
*: Will generally not work for declarations in external dependencies. <br>
|
||||
**: Only available for some languages, limited by the language server functionality.
|
||||
|
||||
### Refactoring
|
||||
|
||||
Without precise refactoring tools, agents are forced to resort to unreliable and expensive search and replace operations.
|
||||
|
||||
@@ -318,9 +318,67 @@ The Codex app does not start a session in the project's directory, so when using
|
||||
asking Codex to "Activate the current dir as project using serena" at the start of each session (though Codex might
|
||||
do this automatically).
|
||||
|
||||
**Hooks.** The Codex hook system is less refined than of VSCode or Claude Code, but Codex also displays less drift
|
||||
and is better in using Serena's tools out of the box. If you use the Codex app, you can consider setting up the
|
||||
SessionStart hook (see above) to remind the agent to activate the project directory.
|
||||
**Hooks.**
|
||||
Codex supports lifecycle hooks; see the
|
||||
[Codex hooks documentation](https://developers.openai.com/codex/hooks) for details. To enable
|
||||
Serena's hooks for Codex, add this feature flag to `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
codex_hooks = true
|
||||
```
|
||||
|
||||
Then create `~/.codex/hooks.json` with the following content:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "serena-hooks remind --client=codex"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "serena-hooks activate --client=codex"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "serena-hooks cleanup --client=codex"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The hooks will:
|
||||
|
||||
- **`activate`**: Prompt the agent to activate the current project and read Serena's instructions
|
||||
when a Codex session starts or resumes.
|
||||
- **`remind`**: Nudge the agent to use Serena's symbolic tools when it makes too many consecutive
|
||||
code-search or code-file-read calls without using Serena tools in between.
|
||||
- **`cleanup`**: Clean up hook session data when the session ends.
|
||||
|
||||
The `PreToolUse` matcher is intentionally restricted to `Bash`. The Serena reminder hook for Codex
|
||||
tracks shell-based grep and code-file reads, so running it for every tool call is unnecessary.
|
||||
|
||||
## Claude Desktop
|
||||
|
||||
|
||||
+148
-18
@@ -66,15 +66,16 @@ class PreToolUseHook(Hook, ABC):
|
||||
permission_decision_reason: str
|
||||
additional_context: str = ""
|
||||
|
||||
def to_json_string(self) -> str:
|
||||
def to_json_string(self, client: HookClient) -> str:
|
||||
hook_output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": self.permission_decision,
|
||||
"permissionDecisionReason": self.permission_decision_reason,
|
||||
"additionalContext": self.additional_context,
|
||||
}
|
||||
}
|
||||
if client != HookClient.CODEX:
|
||||
hook_output["hookSpecificOutput"]["additionalContext"] = self.additional_context
|
||||
return json.dumps(hook_output)
|
||||
|
||||
def is_serena_tool(self) -> bool:
|
||||
@@ -190,8 +191,8 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
return
|
||||
|
||||
now = hook.triggered_at_timestamp
|
||||
is_grep = hook.is_grep_tool()
|
||||
is_read = hook.is_read_file_tool()
|
||||
is_grep = hook.is_grep_call()
|
||||
is_code_file_read = hook.is_read_code_file_call()
|
||||
|
||||
# update grep counter
|
||||
if is_grep:
|
||||
@@ -203,7 +204,7 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
self.last_grep_use_timestamp = now
|
||||
|
||||
# update read file counter
|
||||
if is_read:
|
||||
if is_code_file_read:
|
||||
read_period = self._READ_FILE_RESET_PERIOD_SECONDS
|
||||
if (
|
||||
self.last_read_file_use_timestamp is not None
|
||||
@@ -216,7 +217,7 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
|
||||
# update combined non-symbolic counter — catches mixed bursts (e.g.
|
||||
# alternating grep/read) that neither per-tool counter would trip
|
||||
if is_grep or is_read:
|
||||
if is_grep or is_code_file_read:
|
||||
combined_period = self._NON_SYMBOLIC_RESET_PERIOD_SECONDS
|
||||
if (
|
||||
self.last_non_symbolic_use_timestamp is not None
|
||||
@@ -241,25 +242,154 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
#: ingest. Conservative on purpose: only verbs that strongly imply *reading*
|
||||
#: a file, never *modifying* one — so ``view_file``/``open_file``/``show_file``
|
||||
#: are caught alongside ``read_file``, while ``write_file``/``edit_file`` are not.
|
||||
_READ_FILE_VERB_SUBSTRINGS: tuple[str, ...] = ("read", "view", "open", "show")
|
||||
_READ_FILE_VERB_SUBSTRINGS: frozenset[str] = frozenset(("read", "view", "open", "show"))
|
||||
|
||||
#: Shell commands that perform grep-like search; used to classify Codex
|
||||
#: shell-command tool calls whose ``cmd`` or ``command`` field starts with one of these.
|
||||
_GREP_SHELL_COMMANDS: frozenset[str] = frozenset(("grep", "rg", "ag", "ack", "fgrep", "egrep"))
|
||||
|
||||
#: Shell commands that perform file-read operations; used to classify Codex
|
||||
#: shell-command tool calls whose ``cmd`` or ``command`` field starts with one of these.
|
||||
_READ_SHELL_COMMANDS: frozenset[str] = frozenset(("cat", "head", "tail", "sed", "less", "more", "bat", "get-content", "gc"))
|
||||
|
||||
#: file suffixes for source-like files where symbolic tools are usually more
|
||||
#: appropriate than repeated raw reads. Lowercase and extension-only.
|
||||
_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
||||
(
|
||||
".al",
|
||||
".bash",
|
||||
".c",
|
||||
".clj",
|
||||
".cljs",
|
||||
".cpp",
|
||||
".cs",
|
||||
".css",
|
||||
".dart",
|
||||
".elm",
|
||||
".ex",
|
||||
".exs",
|
||||
".fs",
|
||||
".fsx",
|
||||
".go",
|
||||
".groovy",
|
||||
".h",
|
||||
".hpp",
|
||||
".hs",
|
||||
".html",
|
||||
".java",
|
||||
".jl",
|
||||
".js",
|
||||
".jsx",
|
||||
".kt",
|
||||
".kts",
|
||||
".lean",
|
||||
".lua",
|
||||
".m",
|
||||
".matlab",
|
||||
".php",
|
||||
".ps1",
|
||||
".py",
|
||||
".r",
|
||||
".rb",
|
||||
".rs",
|
||||
".scala",
|
||||
".sh",
|
||||
".sol",
|
||||
".svelte",
|
||||
".swift",
|
||||
".ts",
|
||||
".tsx",
|
||||
".vue",
|
||||
".zig",
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, client: HookClient):
|
||||
super().__init__(client)
|
||||
self._tool_call_counter = self.ToolUseCounter.load(self)
|
||||
|
||||
def is_grep_tool(self) -> bool:
|
||||
# extract a shell ``cmd``/``command`` field (Codex shell-command-style payloads):
|
||||
# split into command name (basename, lowercased) and the remaining argument string
|
||||
# so both bare names (``rg``) and path-prefixed invocations (``/usr/bin/grep``) are
|
||||
# normalised the same way. Stays ``None`` when no shell command is present.
|
||||
self._command: str | None = None
|
||||
self._command_name: str | None = None
|
||||
self._command_args_str: str | None = None
|
||||
# extract a direct file-path field (Claude Code's ``Read``/``Edit``/``Write`` tool
|
||||
# payloads pass the target as ``file_path`` rather than via a shell command).
|
||||
self._file_path: str | None = None
|
||||
if self._tool_input is not None:
|
||||
self._command = self._tool_input.get("cmd", self._tool_input.get("command", "")).strip()
|
||||
if self._command:
|
||||
cmd_split = self._command.split(maxsplit=1)
|
||||
if len(cmd_split) > 1:
|
||||
self._command_args_str = cmd_split[1]
|
||||
self._command_name = os.path.basename(cmd_split[0]).lower()
|
||||
file_path = self._tool_input.get("file_path") or self._tool_input.get("filePath") or ""
|
||||
self._file_path = str(file_path).strip() or None
|
||||
|
||||
def is_grep_call(self) -> bool:
|
||||
if self._client == HookClient.CLAUDE_CODE:
|
||||
return self._tool_name == "grep"
|
||||
if self._client == HookClient.CODEX and self._is_shell_command_call():
|
||||
return self._command_name in self._GREP_SHELL_COMMANDS
|
||||
# heuristic for other clients
|
||||
return "grep" in self._tool_name
|
||||
|
||||
def is_read_file_tool(self) -> bool:
|
||||
def is_read_call(self) -> bool:
|
||||
if self._client == HookClient.CLAUDE_CODE:
|
||||
return self._tool_name == "read"
|
||||
if self._client == HookClient.CODEX and self._is_shell_command_call():
|
||||
return self._command_name in self._READ_SHELL_COMMANDS
|
||||
# heuristic for other clients
|
||||
name = self._tool_name
|
||||
if "file" not in name:
|
||||
return False
|
||||
return any(verb in name for verb in self._READ_FILE_VERB_SUBSTRINGS)
|
||||
|
||||
def _is_shell_command_call(self) -> bool:
|
||||
""":return: whether the hook payload represents a shell-command tool call."""
|
||||
return self._command_name is not None
|
||||
|
||||
def is_read_file_call(self) -> bool:
|
||||
""":return: whether the tool call reads a file-like target."""
|
||||
return self.is_read_call()
|
||||
|
||||
def is_read_code_file_call(self) -> bool:
|
||||
""":return: whether the tool call reads a source-like file target."""
|
||||
if not self.is_read_file_call():
|
||||
return False
|
||||
|
||||
if self._file_path is not None:
|
||||
return self._is_code_file_path(self._file_path)
|
||||
|
||||
if self._client == HookClient.CODEX and self._command_args_str is not None:
|
||||
return any(self._is_code_file_path(argument) for argument in self._iter_shell_path_arguments())
|
||||
|
||||
return True
|
||||
|
||||
def _iter_shell_path_arguments(self) -> list[str]:
|
||||
""":return: path-like shell arguments with quoting and common options removed."""
|
||||
arguments: list[str] = []
|
||||
if self._command_args_str is None:
|
||||
return arguments
|
||||
|
||||
for raw_argument in self._command_args_str.split():
|
||||
argument = raw_argument.strip().strip("'\"")
|
||||
if not argument or argument.startswith("-"):
|
||||
continue
|
||||
arguments.append(argument)
|
||||
|
||||
return arguments
|
||||
|
||||
@classmethod
|
||||
def _is_code_file_path(cls, file_path: str) -> bool:
|
||||
""":return: whether ``file_path`` has a source-like suffix."""
|
||||
cleaned_path = file_path.strip().strip("'\"")
|
||||
if not cleaned_path:
|
||||
return False
|
||||
return Path(cleaned_path).suffix.lower() in cls._CODE_FILE_EXTENSIONS
|
||||
|
||||
def execute(self) -> None:
|
||||
# gate the entire hook on the rate-limit window: while we are within
|
||||
# _MIN_DENY_INTERVAL_SECONDS of the last emitted deny, no counter
|
||||
@@ -280,14 +410,14 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
too_many_non_symbolic = self._tool_call_counter.too_many_recent_non_symbolic()
|
||||
|
||||
output_data: PreToolUseHook.OutputData | None = None
|
||||
if self.is_grep_tool() and too_many_greps:
|
||||
if self.is_grep_call() and too_many_greps:
|
||||
output_data = self._build_grep_deny()
|
||||
elif self.is_read_file_tool() and too_many_reads:
|
||||
output_data = self._build_read_deny()
|
||||
elif self.is_read_code_file_call() and too_many_reads:
|
||||
output_data = self._build_code_read_deny()
|
||||
elif too_many_greps:
|
||||
output_data = self._build_grep_deny()
|
||||
elif too_many_reads:
|
||||
output_data = self._build_read_deny()
|
||||
output_data = self._build_code_read_deny()
|
||||
elif too_many_non_symbolic:
|
||||
output_data = self._build_non_symbolic_deny()
|
||||
|
||||
@@ -297,7 +427,7 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
# ensures we never reach this branch within the rate-limit window.
|
||||
self._tool_call_counter.reset()
|
||||
self._tool_call_counter.last_deny_timestamp = self.triggered_at_timestamp
|
||||
click.echo(output_data.to_json_string())
|
||||
click.echo(output_data.to_json_string(self._client))
|
||||
self._tool_call_counter.save(self)
|
||||
|
||||
def _build_grep_deny(self) -> "PreToolUseHook.OutputData":
|
||||
@@ -311,13 +441,13 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook):
|
||||
),
|
||||
)
|
||||
|
||||
def _build_read_deny(self) -> "PreToolUseHook.OutputData":
|
||||
def _build_code_read_deny(self) -> "PreToolUseHook.OutputData":
|
||||
return self.OutputData(
|
||||
permission_decision="deny",
|
||||
permission_decision_reason="Too many consecutive read calls without using symbolic tools. "
|
||||
permission_decision_reason="Too many consecutive read calls of files without using symbolic tools. "
|
||||
"You can continue using read now if needed, the counter was reset.",
|
||||
additional_context=(
|
||||
"You were using many read file calls recently. Consider using Serena's symbolic "
|
||||
"You were using many read calls on files recently. Consider using Serena's symbolic "
|
||||
"mcp tools instead for more targeted reads. You can continue using read now if needed, the counter was reset."
|
||||
),
|
||||
)
|
||||
@@ -395,7 +525,7 @@ class PreToolUseAutoApproveSerenaHook(PreToolUseHook):
|
||||
permission_decision="allow",
|
||||
permission_decision_reason=f"Auto-approved: Serena tool call while client is in {self._permission_mode} mode.",
|
||||
)
|
||||
click.echo(output_data.to_json_string())
|
||||
click.echo(output_data.to_json_string(self._client))
|
||||
|
||||
|
||||
_client_option = click.option(
|
||||
|
||||
+95
-78
@@ -2,7 +2,7 @@
|
||||
Configuration objects for language servers
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -13,15 +13,25 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class FilenameMatcher:
|
||||
def __init__(self, *patterns: str) -> None:
|
||||
def __init__(self, *file_extensions: str) -> None:
|
||||
"""
|
||||
:param patterns: fnmatch-compatible patterns
|
||||
:param file_extensions: file extensions, e.g., `.py, .yml`
|
||||
"""
|
||||
self.patterns = patterns
|
||||
self._file_extensions = file_extensions
|
||||
|
||||
def is_relevant_filename(self, fn: str) -> bool:
|
||||
for pattern in self.patterns:
|
||||
if fnmatch.fnmatch(fn, pattern):
|
||||
for ext in self._file_extensions:
|
||||
if fn.endswith(ext):
|
||||
return True
|
||||
return False
|
||||
|
||||
def string_contains_relevant_filename(self, string: str) -> bool:
|
||||
""":return: whether ``string`` contains an occurrence of any registered extension as
|
||||
a *complete* extension — i.e. the extension must either end the string or be followed
|
||||
by a non-extension-character (anything other than a letter, digit, or underscore).
|
||||
"""
|
||||
for ext in self._file_extensions:
|
||||
if re.search(rf"{re.escape(ext)}(?:\W|$)", string):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -151,7 +161,7 @@ class Language(str, Enum):
|
||||
"""
|
||||
ANSIBLE = "ansible"
|
||||
"""Ansible language server (experimental) using @ansible/ansible-language-server.
|
||||
Supports *.yaml and *.yml files (same extensions as YAML, hence experimental).
|
||||
Supports .yaml and .yml files (same extensions as YAML, hence experimental).
|
||||
Must be explicitly specified in project.yml. Requires Node.js and npm.
|
||||
Requires ``ansible`` in PATH for full functionality.
|
||||
"""
|
||||
@@ -184,10 +194,11 @@ class Language(str, Enum):
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]:
|
||||
def iter_all(cls, include_experimental: bool = False, include_non_programming_languages: bool = True) -> Iterable[Self]:
|
||||
for lang in cls:
|
||||
if include_experimental or not lang.is_experimental():
|
||||
yield lang
|
||||
if include_non_programming_languages or lang.is_programming_language():
|
||||
yield lang
|
||||
|
||||
def is_experimental(self) -> bool:
|
||||
"""
|
||||
@@ -217,6 +228,12 @@ class Language(str, Enum):
|
||||
self.ANGULAR,
|
||||
}
|
||||
|
||||
def is_programming_language(self) -> bool:
|
||||
"""Whether the supported language should be considered a programming language.
|
||||
Solidlsp supports languages like markdown or json, this method returns False for them.
|
||||
"""
|
||||
return self not in frozenset((self.MARKDOWN, self.JSON, self.TOML, self.YAML, self.ANSIBLE))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@@ -246,149 +263,149 @@ class Language(str, Enum):
|
||||
def get_source_fn_matcher(self) -> FilenameMatcher:
|
||||
match self:
|
||||
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY:
|
||||
return FilenameMatcher("*.py", "*.pyi")
|
||||
return FilenameMatcher(".py", ".pyi")
|
||||
case self.JAVA:
|
||||
return FilenameMatcher("*.java")
|
||||
return FilenameMatcher(".java")
|
||||
case self.TYPESCRIPT | self.TYPESCRIPT_VTS:
|
||||
# see https://github.com/oraios/serena/issues/204
|
||||
path_patterns = []
|
||||
for prefix in ["c", "m", ""]:
|
||||
for postfix in ["x", ""]:
|
||||
for base_pattern in ["ts", "js"]:
|
||||
path_patterns.append(f"*.{prefix}{base_pattern}{postfix}")
|
||||
path_patterns.append(f".{prefix}{base_pattern}{postfix}")
|
||||
return FilenameMatcher(*path_patterns)
|
||||
case self.CSHARP | self.CSHARP_OMNISHARP:
|
||||
return FilenameMatcher("*.cs")
|
||||
return FilenameMatcher(".cs")
|
||||
case self.RUST:
|
||||
return FilenameMatcher("*.rs")
|
||||
return FilenameMatcher(".rs")
|
||||
case self.GO:
|
||||
return FilenameMatcher("*.go")
|
||||
return FilenameMatcher(".go")
|
||||
case self.RUBY:
|
||||
return FilenameMatcher("*.rb", "*.erb")
|
||||
return FilenameMatcher(".rb", ".erb")
|
||||
case self.RUBY_SOLARGRAPH:
|
||||
return FilenameMatcher("*.rb")
|
||||
return FilenameMatcher(".rb")
|
||||
case self.CPP | self.CPP_CCLS:
|
||||
return FilenameMatcher("*.cpp", "*.h", "*.hpp", "*.c", "*.hxx", "*.cc", "*.cxx")
|
||||
return FilenameMatcher(".cpp", ".h", ".hpp", ".c", ".hxx", ".cc", ".cxx")
|
||||
case self.KOTLIN:
|
||||
return FilenameMatcher("*.kt", "*.kts")
|
||||
return FilenameMatcher(".kt", ".kts")
|
||||
case self.DART:
|
||||
return FilenameMatcher("*.dart")
|
||||
return FilenameMatcher(".dart")
|
||||
case self.PHP | self.PHP_PHPACTOR:
|
||||
return FilenameMatcher("*.php")
|
||||
return FilenameMatcher(".php")
|
||||
case self.R:
|
||||
return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw")
|
||||
return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw")
|
||||
case self.PERL:
|
||||
return FilenameMatcher("*.pl", "*.pm", "*.t")
|
||||
return FilenameMatcher(".pl", ".pm", ".t")
|
||||
case self.CLOJURE:
|
||||
return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn
|
||||
return FilenameMatcher(".clj", ".cljs", ".cljc", ".edn") # codespell:ignore edn
|
||||
case self.ELIXIR:
|
||||
return FilenameMatcher("*.ex", "*.exs")
|
||||
return FilenameMatcher(".ex", ".exs")
|
||||
case self.ELM:
|
||||
return FilenameMatcher("*.elm")
|
||||
return FilenameMatcher(".elm")
|
||||
case self.TERRAFORM:
|
||||
return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate")
|
||||
return FilenameMatcher(".tf", ".tfvars", ".tfstate")
|
||||
case self.SWIFT:
|
||||
return FilenameMatcher("*.swift")
|
||||
return FilenameMatcher(".swift")
|
||||
case self.BASH:
|
||||
return FilenameMatcher("*.sh", "*.bash")
|
||||
return FilenameMatcher(".sh", ".bash")
|
||||
case self.CRYSTAL:
|
||||
return FilenameMatcher("*.cr")
|
||||
return FilenameMatcher(".cr")
|
||||
case self.YAML:
|
||||
return FilenameMatcher("*.yaml", "*.yml")
|
||||
return FilenameMatcher(".yaml", ".yml")
|
||||
case self.JSON:
|
||||
return FilenameMatcher("*.json", "*.jsonc")
|
||||
return FilenameMatcher(".json", ".jsonc")
|
||||
case self.TOML:
|
||||
return FilenameMatcher("*.toml")
|
||||
return FilenameMatcher(".toml")
|
||||
case self.ZIG:
|
||||
return FilenameMatcher("*.zig", "*.zon")
|
||||
return FilenameMatcher(".zig", ".zon")
|
||||
case self.LUA:
|
||||
return FilenameMatcher("*.lua")
|
||||
return FilenameMatcher(".lua")
|
||||
case self.LUAU:
|
||||
return FilenameMatcher("*.luau")
|
||||
return FilenameMatcher(".luau")
|
||||
case self.NIX:
|
||||
return FilenameMatcher("*.nix")
|
||||
return FilenameMatcher(".nix")
|
||||
case self.ERLANG:
|
||||
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
|
||||
return FilenameMatcher(".erl", ".hrl", ".escript", ".config", ".app", ".app.src")
|
||||
case self.OCAML:
|
||||
return FilenameMatcher("*.ml", "*.mli", "*.re", "*.rei")
|
||||
return FilenameMatcher(".ml", ".mli", ".re", ".rei")
|
||||
case self.AL:
|
||||
return FilenameMatcher("*.al", "*.dal")
|
||||
return FilenameMatcher(".al", ".dal")
|
||||
case self.FSHARP:
|
||||
return FilenameMatcher("*.fs", "*.fsx", "*.fsi")
|
||||
return FilenameMatcher(".fs", ".fsx", ".fsi")
|
||||
case self.REGO:
|
||||
return FilenameMatcher("*.rego")
|
||||
return FilenameMatcher(".rego")
|
||||
case self.MARKDOWN:
|
||||
return FilenameMatcher("*.md", "*.markdown")
|
||||
return FilenameMatcher(".md", ".markdown")
|
||||
case self.SCALA:
|
||||
return FilenameMatcher("*.scala", "*.sbt")
|
||||
return FilenameMatcher(".scala", ".sbt")
|
||||
case self.JULIA:
|
||||
return FilenameMatcher("*.jl")
|
||||
return FilenameMatcher(".jl")
|
||||
case self.FORTRAN:
|
||||
return FilenameMatcher(
|
||||
"*.f90", "*.F90", "*.f95", "*.F95", "*.f03", "*.F03", "*.f08", "*.F08", "*.f", "*.F", "*.for", "*.FOR", "*.fpp", "*.FPP"
|
||||
".f90", ".F90", ".f95", ".F95", ".f03", ".F03", ".f08", ".F08", ".f", ".F", ".for", ".FOR", ".fpp", ".FPP"
|
||||
)
|
||||
case self.HASKELL:
|
||||
return FilenameMatcher("*.hs", "*.lhs")
|
||||
return FilenameMatcher(".hs", ".lhs")
|
||||
case self.HAXE:
|
||||
return FilenameMatcher("*.hx")
|
||||
return FilenameMatcher(".hx")
|
||||
case self.LEAN4:
|
||||
return FilenameMatcher("*.lean")
|
||||
return FilenameMatcher(".lean")
|
||||
case self.VUE:
|
||||
path_patterns = ["*.vue"]
|
||||
path_patterns = [".vue"]
|
||||
for prefix in ["c", "m", ""]:
|
||||
for postfix in ["x", ""]:
|
||||
for base_pattern in ["ts", "js"]:
|
||||
path_patterns.append(f"*.{prefix}{base_pattern}{postfix}")
|
||||
path_patterns.append(f".{prefix}{base_pattern}{postfix}")
|
||||
return FilenameMatcher(*path_patterns)
|
||||
case self.POWERSHELL:
|
||||
return FilenameMatcher("*.ps1", "*.psm1", "*.psd1")
|
||||
return FilenameMatcher(".ps1", ".psm1", ".psd1")
|
||||
case self.PASCAL:
|
||||
return FilenameMatcher("*.pas", "*.pp", "*.lpr", "*.dpr", "*.dpk", "*.inc")
|
||||
return FilenameMatcher(".pas", ".pp", ".lpr", ".dpr", ".dpk", ".inc")
|
||||
case self.GROOVY:
|
||||
return FilenameMatcher("*.groovy", "*.gvy")
|
||||
return FilenameMatcher(".groovy", ".gvy")
|
||||
case self.MATLAB:
|
||||
return FilenameMatcher("*.m", "*.mlx", "*.mlapp")
|
||||
return FilenameMatcher(".m", ".mlx", ".mlapp")
|
||||
case self.HLSL:
|
||||
return FilenameMatcher(
|
||||
"*.hlsl",
|
||||
"*.hlsli",
|
||||
"*.fx",
|
||||
"*.fxh",
|
||||
"*.cginc",
|
||||
"*.compute",
|
||||
"*.shader",
|
||||
"*.glsl",
|
||||
"*.vert",
|
||||
"*.frag",
|
||||
"*.geom",
|
||||
"*.tesc",
|
||||
"*.tese",
|
||||
"*.comp",
|
||||
"*.wgsl",
|
||||
".hlsl",
|
||||
".hlsli",
|
||||
".fx",
|
||||
".fxh",
|
||||
".cginc",
|
||||
".compute",
|
||||
".shader",
|
||||
".glsl",
|
||||
".vert",
|
||||
".frag",
|
||||
".geom",
|
||||
".tesc",
|
||||
".tese",
|
||||
".comp",
|
||||
".wgsl",
|
||||
)
|
||||
case self.SYSTEMVERILOG:
|
||||
return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh")
|
||||
return FilenameMatcher(".sv", ".svh", ".v", ".vh")
|
||||
case self.SOLIDITY:
|
||||
return FilenameMatcher("*.sol")
|
||||
return FilenameMatcher(".sol")
|
||||
case self.ANSIBLE:
|
||||
return FilenameMatcher("*.yaml", "*.yml")
|
||||
return FilenameMatcher(".yaml", ".yml")
|
||||
case self.MSL:
|
||||
return FilenameMatcher("*.mrc")
|
||||
return FilenameMatcher(".mrc")
|
||||
case self.HTML:
|
||||
return FilenameMatcher("*.html", "*.htm")
|
||||
return FilenameMatcher(".html", ".htm")
|
||||
case self.SCSS:
|
||||
# *.css is handled by the same engine (vscode-css-languageservice) that powers
|
||||
# Microsoft's CSS LS, so we route plain CSS through Some Sass too. The CSS feature
|
||||
# toggles default off upstream and are flipped on at initialization time.
|
||||
return FilenameMatcher("*.scss", "*.sass", "*.css")
|
||||
return FilenameMatcher(".scss", ".sass", ".css")
|
||||
case self.ANGULAR:
|
||||
# Angular templates can be standalone .html files or inline templates
|
||||
# within .ts component files; the dual-server architecture handles both.
|
||||
# SCSS / styles are deliberately NOT subsumed — use Language.SCSS for those.
|
||||
path_patterns = ["*.html", "*.htm"]
|
||||
path_patterns = [".html", ".htm"]
|
||||
for prefix in ["c", "m", ""]:
|
||||
for postfix in ["x", ""]:
|
||||
path_patterns.append(f"*.{prefix}ts{postfix}")
|
||||
path_patterns.append(f".{prefix}ts{postfix}")
|
||||
return FilenameMatcher(*path_patterns)
|
||||
case _:
|
||||
raise ValueError(f"Unhandled language: {self}")
|
||||
|
||||
+153
-15
@@ -23,11 +23,24 @@ def _make_stdin(data: dict) -> StringIO:
|
||||
return StringIO(json.dumps(data))
|
||||
|
||||
|
||||
def _base_input(tool_name: str = "grep_search", session_id: str = "test-session-123") -> dict:
|
||||
def _base_input(
|
||||
tool_name: str = "grep_search",
|
||||
session_id: str = "test-session-123",
|
||||
tool_input: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"tool_name": tool_name,
|
||||
"tool_input": {"query": "foo"},
|
||||
"tool_input": tool_input if tool_input is not None else {"query": "foo"},
|
||||
}
|
||||
|
||||
|
||||
def _read_input(tool_name: str = "read", session_id: str = "test-session-123", file_path: str = "src/foo.py") -> dict:
|
||||
"""Build a hook payload for a read-style tool call against a file."""
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"tool_name": tool_name,
|
||||
"tool_input": {"file_path": file_path},
|
||||
}
|
||||
|
||||
|
||||
@@ -67,24 +80,44 @@ class TestPreToolUseRemindAboutSerenaHook:
|
||||
for name, expected in [("grep", True), ("grep_search", False), ("mcp_grep", False), ("read", False)]:
|
||||
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE)
|
||||
assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name} (claude-code)"
|
||||
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (claude-code)"
|
||||
|
||||
def test_grep_tool_detection_non_claude_code(self, tmp_path: Path):
|
||||
"""Non-Claude-Code clients fall back to substring matching to cover verbose tool names."""
|
||||
for name, expected in [("grep_search", True), ("mcp_grep", True), ("read_file", False), ("serena_find", False)]:
|
||||
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.VSCODE)
|
||||
assert hook.is_grep_tool() == expected, f"is_grep_tool() wrong for {name} (vscode)"
|
||||
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {name} (vscode)"
|
||||
|
||||
def test_grep_tool_detection_codex_shell_commands(self, tmp_path: Path):
|
||||
"""Codex shell-command tools are classified by the command embedded in their payload."""
|
||||
cases = [
|
||||
("exec_command", {"cmd": "rg -n foo README.md"}, True),
|
||||
("functions.shell_command", {"command": "rg -n foo README.md"}, True),
|
||||
("functions.shell_command", {"command": "Get-Content README.md"}, False),
|
||||
]
|
||||
for tool_name, tool_input, expected in cases:
|
||||
with (
|
||||
patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))),
|
||||
patch("serena.hooks.serena_home_dir", str(tmp_path)),
|
||||
):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.CODEX)
|
||||
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {tool_name} / {tool_input}"
|
||||
|
||||
def test_read_file_tool_detection_claude_code(self, tmp_path: Path):
|
||||
"""Claude Code uses the exact tool name ``Read`` (lowercased to ``read``)."""
|
||||
for name, expected in [("read", True), ("read_file", False), ("readFile", False), ("grep", False)]:
|
||||
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
with (
|
||||
patch("sys.stdin", _make_stdin(_read_input(tool_name=name))),
|
||||
patch("serena.hooks.serena_home_dir", str(tmp_path)),
|
||||
):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE)
|
||||
assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name} (claude-code)"
|
||||
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (claude-code)"
|
||||
|
||||
def test_read_file_tool_detection_non_claude_code(self, tmp_path: Path):
|
||||
"""Non-Claude-Code clients accept any read-style verb (``read``/``view``/``open``/``show``) combined with ``file``."""
|
||||
"""Non-Claude-Code clients accept any read-style verb (``read``/``view``/``open``/``show``)
|
||||
combined with ``file``.
|
||||
"""
|
||||
cases = [
|
||||
# canonical names
|
||||
("read_file", True),
|
||||
@@ -100,9 +133,49 @@ class TestPreToolUseRemindAboutSerenaHook:
|
||||
("edit_file", False),
|
||||
]
|
||||
for name, expected in cases:
|
||||
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
with (
|
||||
patch("sys.stdin", _make_stdin(_read_input(tool_name=name))),
|
||||
patch("serena.hooks.serena_home_dir", str(tmp_path)),
|
||||
):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.VSCODE)
|
||||
assert hook.is_read_file_tool() == expected, f"is_read_file_tool() wrong for {name} (vscode)"
|
||||
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {name} (vscode)"
|
||||
|
||||
def test_read_non_code_file_counts_as_file_read(self, tmp_path: Path):
|
||||
"""A ``Read`` call against a non-source file still counts as a file read."""
|
||||
payload = _read_input(tool_name="read", file_path="notes/todo.txt")
|
||||
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE)
|
||||
assert hook.is_read_call() is True
|
||||
assert hook.is_read_file_call() is True
|
||||
assert hook.is_read_code_file_call() is False
|
||||
|
||||
def test_read_code_file_detection(self, tmp_path: Path):
|
||||
"""Only source-like read targets count for the code-read reminder."""
|
||||
cases = [
|
||||
(HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="README.md"), False),
|
||||
(HookClient.CLAUDE_CODE, _read_input(tool_name="read", file_path="src/foo.py"), True),
|
||||
(HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"}), False),
|
||||
(HookClient.CODEX, _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"}), True),
|
||||
]
|
||||
for client, payload, expected in cases:
|
||||
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
hook = PreToolUseRemindAboutSerenaHook(client)
|
||||
assert hook.is_read_code_file_call() == expected, f"is_read_code_file_call() wrong for {client} / {payload}"
|
||||
|
||||
def test_read_file_tool_detection_codex_shell_commands(self, tmp_path: Path):
|
||||
"""Codex shell-command tools count PowerShell file reads, including non-code files."""
|
||||
cases = [
|
||||
("exec_command", {"cmd": "cat README.md"}, True),
|
||||
("functions.shell_command", {"command": "Get-Content README.md"}, True),
|
||||
("functions.shell_command", {"command": "rg -n foo README.md"}, False),
|
||||
]
|
||||
for tool_name, tool_input, expected in cases:
|
||||
with (
|
||||
patch("sys.stdin", _make_stdin(_base_input(tool_name=tool_name, tool_input=tool_input))),
|
||||
patch("serena.hooks.serena_home_dir", str(tmp_path)),
|
||||
):
|
||||
hook = PreToolUseRemindAboutSerenaHook(HookClient.CODEX)
|
||||
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {tool_name} / {tool_input}"
|
||||
|
||||
def test_serena_tool_detection(self, tmp_path: Path):
|
||||
for name, expected in [("mcp_serena_find_symbol", True), ("serena_overview", True), ("grep_search", False)]:
|
||||
@@ -141,17 +214,57 @@ class TestPreToolUseRemindAboutSerenaHook:
|
||||
assert hook_output["permissionDecision"] == "deny"
|
||||
assert "grep" in hook_output["additionalContext"].lower()
|
||||
|
||||
def test_deny_output_after_threshold_greps_codex_shell_command(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
||||
"""After reaching the grep threshold, Codex ``functions.shell_command`` emits a deny."""
|
||||
payload = _base_input("functions.shell_command", tool_input={"command": "rg -n foo README.md"})
|
||||
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
|
||||
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
PreToolUseRemindAboutSerenaHook(HookClient.CODEX).execute()
|
||||
|
||||
output = capsys.readouterr().out.strip()
|
||||
result = json.loads(output)
|
||||
hook_output = result["hookSpecificOutput"]
|
||||
assert hook_output["permissionDecision"] == "deny"
|
||||
assert "additionalContext" not in hook_output
|
||||
assert "grep" in hook_output["permissionDecisionReason"].lower()
|
||||
|
||||
def test_deny_output_after_threshold_reads(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
||||
"""After reaching the read file threshold, the hook should output a deny."""
|
||||
"""After reaching the read file threshold, the hook should output a deny.
|
||||
|
||||
``_read_input`` supplies the direct ``file_path`` field emitted by file-read tools.
|
||||
"""
|
||||
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
|
||||
with patch("sys.stdin", _make_stdin(_base_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
with patch("sys.stdin", _make_stdin(_read_input("read"))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
PreToolUseRemindAboutSerenaHook(HookClient.CLAUDE_CODE).execute()
|
||||
|
||||
output = capsys.readouterr().out.strip()
|
||||
result = json.loads(output)
|
||||
hook_output = result["hookSpecificOutput"]
|
||||
assert hook_output["permissionDecision"] == "deny"
|
||||
assert "read file" in hook_output["additionalContext"].lower()
|
||||
assert "read" in hook_output["additionalContext"].lower()
|
||||
assert "files" in hook_output["additionalContext"].lower()
|
||||
|
||||
def test_no_deny_after_threshold_get_content_markdown_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
||||
"""PowerShell ``Get-Content`` calls on markdown files do not count for the code-read reminder."""
|
||||
payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content README.md"})
|
||||
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
|
||||
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
PreToolUseRemindAboutSerenaHook(HookClient.CODEX).execute()
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_deny_output_after_threshold_get_content_code_file_codex(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
||||
"""PowerShell ``Get-Content`` calls on source files count for the code-read reminder."""
|
||||
payload = _base_input("functions.shell_command", tool_input={"command": "Get-Content src/foo.py"})
|
||||
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
|
||||
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
|
||||
PreToolUseRemindAboutSerenaHook(HookClient.CODEX).execute()
|
||||
|
||||
result = json.loads(capsys.readouterr().out.strip())
|
||||
hook_output = result["hookSpecificOutput"]
|
||||
assert hook_output["permissionDecision"] == "deny"
|
||||
assert "additionalContext" not in hook_output
|
||||
assert "read" in hook_output["permissionDecisionReason"].lower()
|
||||
|
||||
def test_serena_tool_resets_counters(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
||||
"""Using a Serena tool should reset counters, so the threshold is not reached."""
|
||||
@@ -297,7 +410,7 @@ class TestToolUseCounter:
|
||||
counter.last_read_file_use_timestamp = now - timedelta(seconds=1)
|
||||
counter.n_recent_read_file_uses = 1
|
||||
|
||||
hook = self._make_hook_stub("read_file", now)
|
||||
hook = self._make_hook_stub("read_file", now, file_path="src/foo.py")
|
||||
counter.update(hook)
|
||||
|
||||
assert counter.n_recent_read_file_uses == 2
|
||||
@@ -308,11 +421,22 @@ class TestToolUseCounter:
|
||||
counter.last_read_file_use_timestamp = now - timedelta(seconds=ToolUseCounter._READ_FILE_RESET_PERIOD_SECONDS + 1)
|
||||
counter.n_recent_read_file_uses = 2
|
||||
|
||||
hook = self._make_hook_stub("read_file", now)
|
||||
hook = self._make_hook_stub("read_file", now, file_path="src/foo.py")
|
||||
counter.update(hook)
|
||||
|
||||
assert counter.n_recent_read_file_uses == 1
|
||||
|
||||
def test_update_ignores_read_of_non_code_file(self):
|
||||
"""A read call whose payload points at a non-source file does not increment the code-read counter."""
|
||||
counter = ToolUseCounter()
|
||||
now = datetime.now()
|
||||
|
||||
hook = self._make_hook_stub("read_file", now, file_path="notes/todo.txt")
|
||||
counter.update(hook)
|
||||
|
||||
assert counter.n_recent_read_file_uses == 0
|
||||
assert counter.last_read_file_use_timestamp is None
|
||||
|
||||
def test_serena_tool_resets_all_counters(self):
|
||||
counter = ToolUseCounter(
|
||||
n_recent_grep_uses=2,
|
||||
@@ -393,18 +517,32 @@ class TestToolUseCounter:
|
||||
assert counter.last_deny_timestamp == base
|
||||
|
||||
@staticmethod
|
||||
def _make_hook_stub(tool_name: str, timestamp: datetime) -> PreToolUseRemindAboutSerenaHook:
|
||||
def _make_hook_stub(
|
||||
tool_name: str,
|
||||
timestamp: datetime,
|
||||
file_path: str | None = None,
|
||||
command_args_str: str | None = None,
|
||||
) -> PreToolUseRemindAboutSerenaHook:
|
||||
"""Create a minimal stub that satisfies ToolUseCounter.update without reading stdin.
|
||||
|
||||
Uses ``HookClient.VSCODE`` so that ``is_grep_tool`` / ``is_read_file_tool`` apply the
|
||||
substring-matching branch — the counter tests below feed verbose tool names like
|
||||
``grep_search`` / ``read_file`` which are only recognized under the non-Claude-Code
|
||||
branch (Claude Code uses exact names ``grep`` / ``read``).
|
||||
|
||||
:param file_path: optional payload path; the hook classifies read-style tools as
|
||||
file reads regardless of the path extension.
|
||||
:param command_args_str: optional shell-argument tail; equivalent to ``file_path`` but
|
||||
populates the ``cmd``-derived field instead of ``file_path``.
|
||||
"""
|
||||
stub = object.__new__(PreToolUseRemindAboutSerenaHook)
|
||||
stub._tool_name = tool_name.lower()
|
||||
stub._client = HookClient.VSCODE
|
||||
stub.triggered_at_timestamp = timestamp
|
||||
stub._command = None
|
||||
stub._command_name = None
|
||||
stub._command_args_str = command_args_str
|
||||
stub._file_path = file_path
|
||||
return stub
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user