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
49 changes: 49 additions & 0 deletions emrg/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,55 @@ def boolean_argument(
)


def brace_alternation(pattern: str) -> bool:
"""True when a glob carries `{a,b}` alternation — a comma inside braces.

The file-finding tools walk with `Path.glob`/`Path.rglob`, whose pattern
language has no brace expansion: `*.{py,rs}` is not two patterns, it is one
literal string that matches no file whose name contains a brace. The failure
is silent and it is a **false negative** - `No matches` / `No files matched`,
the same sentence a real absence produces - so the tool answers "nothing
here" about a question it never asked.

Measured 2026-10-10 (`cyc20261010-220909`) in this checkout, `grep` over
`emrg/tools/`:

glob='*.py' -> Found 9 matches ... (searched 15 files)
glob='*.{py,rs}' -> No matches ... (searched 0 files)

**The comma is the whole predicate, and a bare brace is not one.** This
function first answered `"{" in pattern or "}" in pattern`, and that refused
patterns the walk reads correctly: `Path.glob` gives `{` no special meaning,
so `a{b}.py` is the exact name of a real file. Measured 2026-10-10
(`cyc20261011-001130`) on a tree holding a file literally named `a{b}.py`:
`glob` and `grep` both answered `Found 1 matches … a{b}.py` on master and both
**refused** it on the branch, with a message asserting `{a,b}` alternation the
input does not carry. That is the over-block this repository named once
already (`emrg/tools/command_scan.py`, the #1513 lesson: `echo sh "patch …"`
was a bug, not a safe over-block). Alternation needs two alternatives, so the
subject is a brace group holding a comma - `{b}` has nothing to alternate
between.

What a caller does with this is the caller's half: both tools **also** require
that the pattern selected nothing before they refuse, so a comma-bearing
pattern that selects files (`a{,b}.py`, a literal name) is searched normally.
:func:`emrg.tools.glob_tool.GlobTool.execute` and
:meth:`emrg.tools.grep_tool.GrepTool.execute` each say so where they check.

:param pattern: the glob pattern as the caller passed it.
:returns: True when the pattern is alternation-shaped.
"""
depth = 0
for char in pattern:
if char == "{":
depth += 1
elif char == "}":
depth = max(0, depth - 1)
elif char == "," and depth:
return True
return False


def special_file_kind(mode: int) -> str | None:
"""Name a file subject's kind from its ``st_mode`` — ``None`` when it is a regular file.

Expand Down
36 changes: 34 additions & 2 deletions emrg/tools/glob_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path

from emrg.server.tool_types import ToolDefinition, ToolResult
from emrg.tools.base import ToolExecutor
from emrg.tools.base import ToolExecutor, brace_alternation

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -44,7 +44,12 @@ def definition(self) -> ToolDefinition:
"description": (
"Glob pattern relative to the project root. "
"Examples: '**/*.py', 'src/**/*.rs', '**/*test*.py', "
"'*.md', 'emrg/tools/*.py'"
"'*.md', 'emrg/tools/*.py'. The walk does not expand "
"`{a,b}` brace alternation, and a pattern that carries one "
"and selects nothing is refused with that reason rather "
"than answered `No files matched` - pass one pattern per "
"call. A name that really contains a brace is a literal "
"name here and is still searched."
),
},
"workdir": {
Expand Down Expand Up @@ -100,6 +105,33 @@ async def execute(self, arguments: dict) -> ToolResult:
name="glob", content=f"Error: invalid pattern: {e}", error=True
)

# A pattern this walk cannot expand is refused rather than searched: it matches
# nothing, and `No files matched` is the same sentence a real absence produces - a
# false negative about a question never asked. See `brace_alternation` in
# `emrg/tools/base.py` for the measurement.
#
# The check sits *after* the walk, on `matched`, because the refusal has to be a
# measurement and not a prediction. `brace_alternation` alone fires on the shape of
# the pattern, and `Path.glob` gives `{` no special meaning, so a literal name like
# `a{b}.py` selects its file and must not be refused (measured 2026-10-10,
# `cyc20261011-001130`: refusing it was this branch's own over-block). A pattern
# that selected nothing is the case the refusal is about, and there the message's
# two claims - alternation is not expanded, and nothing was selected - are both
# readings rather than guesses.
if not matched and brace_alternation(pattern):
return ToolResult(
name="glob",
content=(
f"Error: the pattern {pattern!r} uses `{{a,b}}` brace alternation, "
"which this tool does not expand - it walks with `Path.glob`, so the "
"whole pattern is one literal string. It selected nothing, and `No "
"files matched` is the sentence a real absence produces, so this "
"answer would not be distinguishable from one. Pass one pattern per "
"call ('**/*.py', then '**/*.rs')."
),
error=True,
)

matches = [p for p in matched if not self._is_hidden_or_ignored(p, cwd)]
#: Paths that *matched the pattern* and were then dropped by the skip policy.
#: Both messages below name this number, because without it "0" reads as "the
Expand Down
149 changes: 144 additions & 5 deletions emrg/tools/grep_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,100 @@

from __future__ import annotations

import fnmatch
import logging
import re
from pathlib import Path

from emrg.server.tool_types import ToolDefinition, ToolResult
from emrg.tools.base import ToolExecutor, count_argument
from emrg.tools.base import ToolExecutor, brace_alternation, count_argument

logger = logging.getLogger(__name__)

MAX_RESULTS = 200 # Cap matches to prevent excessive result volume
MAX_FILE_SIZE = 512 * 1024 # 512KB — skip files larger than this


def _selects(path: Path, file_glob: str | None) -> bool:
"""Does the glob filter select this one named file?

The caller named a *file*, so there is no tree to walk and the filter is
applied here instead. Without this, `glob` was read by the directory branch
and ignored by the file branch - and the summary still printed
`matching '<glob>'`, so the output claimed a filter nothing had applied
(measured 2026-10-10: `path=read_tool.py, glob='*.nomatch'` returned the same
1 match as no filter at all).

**The domain is the name, and it is stated here rather than assumed.** What a
named file offers the matcher is one relative path - its own name, the path
it would have relative to itself - so a pattern that still carries a
directory component *after* its leading `**` segments are dropped cannot
select one. That is the walk's own answer for the root such a file implies,
its own directory: `<dir>/src`.rglob('src/**/*.ts') selects nothing. It is
**not** the directory branch's answer, whose root is the directory the caller
passed, so the two branches answer the same question only for name-shaped
patterns. Claiming otherwise was this function's own unmeasured sentence
(vetoed in `cyc20261011-022427`: `path=<dir>/main.py, glob='**/*.py'`
excluded a file the directory branch had just found).

`**` matches zero directories, so dropping the leading ones is the walk's
behaviour, not a shortcut: `root.rglob(p)` is `root.glob("**/" + p)`, and
`**/*.py` selects a top-level `main.py` exactly as `*.py` does.
"""
if not file_glob:
return True
return _walk_selects_name(path.name, file_glob)


def _walk_selects_name(name: str, file_glob: str) -> bool:
"""Would the walk select the file whose relative path is `name`?

``name`` is one path component: a file name carries no separator, so after
the leading `**` segments are dropped a pattern with anything left over
names directories this path does not have.
"""
segments = file_glob.split("/")
while segments and segments[0] == "**":
segments.pop(0)
if not segments:
return True # `**` alone: the walk selects every file
if len(segments) != 1:
return False # a directory component a bare name cannot carry
return fnmatch.fnmatch(name, segments[0])


def _names_a_directory(file_glob: str) -> bool:
"""Does the filter carry a directory component, once `**` is discounted?"""
segments = file_glob.split("/")
while segments and segments[0] == "**":
segments.pop(0)
return len(segments) != 1


def _named_file_exclusion_note(name: str, file_glob: str) -> str:
"""Why the filter excluded `name` — the sentence, and the remedy it implies.

Two shapes, two remedies, so they are not one sentence. A pattern carrying a
directory component names a path a bare file name cannot be (`src/**/*.ts`
against `deep.ts`), and the remedy is to pass the directory instead of the
file; a name-shaped pattern simply did not match (`*.nomatch`), and there the
remedy is the pattern. Before this, both printed the second sentence — and for
the first shape it was a claim the tool's own walk contradicted when the
caller passed the directory, which is the veto `cyc20261011-022427` names.
"""
if _names_a_directory(file_glob):
return (
f" - the named file {name!r} offers only its own name to the filter, "
f"and {file_glob!r} names a directory, so a named file cannot match "
f"it; pass the containing directory with this pattern to search the "
f"tree, or a name-shaped pattern to filter this file"
)
return (
f" - the named file {name!r} does not match the glob filter, "
"so nothing was searched"
)


class GrepTool(ToolExecutor):
"""Search file contents using regex patterns with optional context lines.

Expand Down Expand Up @@ -64,8 +145,17 @@ def definition(self) -> ToolDefinition:
"type": "string",
"description": (
"Only search files matching this glob pattern. "
"Examples: '*.py', '*.{py,rs}', 'src/**/*.ts'. "
"Default: all text files."
"Examples: '*.py', '*.md', 'src/**/*.ts'. "
"Default: all text files. The pattern is matched by "
"`Path.rglob`, which does not expand `{a,b}` brace "
"alternation (`*.{py,rs}` selects nothing) - pass one "
"pattern per call, or a regex-ish alternation is not "
"available here. A named file is filtered too: if the "
"path is one file and it does not match, nothing is "
"searched and the summary says so. A brace-alternation "
"filter that selects nothing is refused with that reason "
"rather than searched to `No matches` - which is why the "
"refusal names the pattern, not the tree."
),
},
"ignore_case": {
Expand Down Expand Up @@ -175,12 +265,55 @@ async def execute(self, arguments: dict) -> ToolResult:
pattern, root, file_glob, ignore_case,
)

# Collect files
# Collect files. The named-file branch is filtered too: `glob` means
# "only search files matching this pattern", and a file the caller named
# is still a file the filter can exclude.
#: Why the filter excluded the file the caller named, when it did. The
#: reason is chosen by the pattern's shape, because the two shapes have
#: different remedies — see `_named_file_exclusion_note`.
excluded_named_file: str | None = None
if root.is_file():
files = [root]
if _selects(root, file_glob):
files = [root]
else:
files = []
excluded_named_file = _named_file_exclusion_note(root.name, file_glob)
else:
files = self._collect_files(root, file_glob)

# A filter this walk cannot expand is refused rather than searched: it selects
# nothing and the answer reads `No matches` - a false negative indistinguishable
# from a real absence. See `brace_alternation` for the measurement and the example
# that advertised it.
#
# The check sits *after* the collection, on `files`, because the refusal has to be
# a measurement rather than a prediction. `brace_alternation` alone fires on the
# shape of the pattern, and `Path.rglob` gives `{` no special meaning, so a literal
# name like `a{b}.py` selects its file and must not be refused (measured 2026-10-10,
# `cyc20261011-001130`: refusing it was this branch's own over-block). A filter that
# selected nothing is the case the refusal is about, and there its two claims -
# alternation is not expanded, and nothing was selected - are both readings rather
# than guesses.
#
# The named-file branch is inside this, not carved out of it: `_selects` matches the
# file's name with `fnmatch`, which does not expand braces either, so `glob='*.{py,rs}'`
# pointed at one file excludes it for the same reason the walk selects nothing - and the
# remedy is the pattern, not the file. Naming that cause is worth more than naming the
# exclusion it produced.
if not files and file_glob and brace_alternation(file_glob):
return ToolResult(
name="grep",
content=(
f"Error: the glob filter {file_glob!r} uses `{{a,b}}` brace "
"alternation, which this filter does not expand - it walks with "
"`Path.rglob`, so the whole pattern is one literal string. It selected "
"nothing, and `No matches` is the sentence a real absence produces, so "
"this answer would not be distinguishable from one. Pass one pattern "
"per call ('*.py', then '*.rs'), or omit the filter and search the tree."
),
error=True,
)

# Search
results: list[str] = []
#: Where each match block's header line landed in ``results``. The block count
Expand Down Expand Up @@ -285,12 +418,18 @@ async def execute(self, arguments: dict) -> ToolResult:
skipped = f"; {oversize + undecodable} skipped: " + ", ".join(parts)

if not results:
# `matching '<glob>'` is appended only when the filter really ran, and
# `excluded_named_file` names the case where it ran and excluded the
# one file the caller pointed at - the two readings have different
# remedies (drop the filter / fix the pattern), so they are not the
# same sentence.
return ToolResult(
name="grep",
content=(
f"No matches for '{pattern}' in {root} "
f"(searched {files_read} files{skipped})"
+ (f" matching '{file_glob}'" if file_glob else "")
+ (excluded_named_file or "")
),
)

Expand Down
Loading
Loading