diff --git a/emrg/tools/base.py b/emrg/tools/base.py index ed8342ac..dec4c209 100644 --- a/emrg/tools/base.py +++ b/emrg/tools/base.py @@ -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. diff --git a/emrg/tools/glob_tool.py b/emrg/tools/glob_tool.py index 3afaa81a..893022db 100644 --- a/emrg/tools/glob_tool.py +++ b/emrg/tools/glob_tool.py @@ -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__) @@ -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": { @@ -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 diff --git a/emrg/tools/grep_tool.py b/emrg/tools/grep_tool.py index e92a2b1a..2b699932 100644 --- a/emrg/tools/grep_tool.py +++ b/emrg/tools/grep_tool.py @@ -6,12 +6,13 @@ 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__) @@ -19,6 +20,86 @@ 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 ''`, 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: `/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=/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. @@ -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": { @@ -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 @@ -285,12 +418,18 @@ async def execute(self, arguments: dict) -> ToolResult: skipped = f"; {oversize + undecodable} skipped: " + ", ".join(parts) if not results: + # `matching ''` 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 "") ), ) diff --git a/tests/test_glob_tool.py b/tests/test_glob_tool.py index ab7e8407..b2a69fa5 100644 --- a/tests/test_glob_tool.py +++ b/tests/test_glob_tool.py @@ -207,6 +207,140 @@ def test_glob_invalid_workdir(): assert "not found" in result.content.lower() +def test_a_brace_pattern_is_refused_rather_than_answered_with_no_files_matched(temp_cwd): + """`**/*.{py,md}` is not a pattern this walk can read, and it did not say so. + + `Path.glob` has no brace expansion, so the whole pattern is one literal string that + matches nothing — and the answer was `No files matched pattern '**/*.{py,md}'`, the + same sentence a genuinely empty tree produces. Measured on this checkout 2026-10-10 + (`cyc20261010-220909`): `**/*.py` -> `Found N matches`, `**/*.{py,md}` -> `No files + matched`, and the tree plainly holds both kinds of file. + + `glob`'s own description does not advertise braces, so this is not a broken promise — + it is the other half: an answer that cannot be told apart from a true one. Refused + with the reason instead, through the same rule `grep` fails to apply + (`emrg/tools/base.py::brace_alternation`), so the two tools give one answer. + """ + tool = GlobTool() + + brace = _run(tool.execute({"pattern": "**/*.{py,md}", "workdir": str(temp_cwd)})) + assert brace.error, brace.content + assert brace.content.startswith("Error:"), brace.content + assert "brace" in brace.content, brace.content + assert "does not expand" in brace.content, brace.content + + # The control: the two patterns the brace form meant, each still answered. + for pattern, expected in (("**/*.py", "main.py"), ("**/*.md", "README.md")): + control = _run(tool.execute({"pattern": pattern, "workdir": str(temp_cwd)})) + assert not control.error, control.content + assert expected in control.content, ( + f"{pattern} has to keep matching {expected}, or 'refused' is " + f"indistinguishable from 'handled': {control.content}" + ) + + +def test_a_literal_brace_name_is_searched_not_refused(temp_cwd): + """A brace is a literal character to this walk, so a name holding one is a real name. + + The refusal added for `**/*.{py,md}` first fired on `"{" in pattern or "}" in pattern`, + and that refused patterns `Path.glob` reads correctly: on a tree holding a file named + `a{b}.py`, master answered `Found 1 matches … a{b}.py` and the branch refused it, with a + message asserting `{a,b}` alternation the input does not carry. Measured 2026-10-10 + (`cyc20261011-001130`) in this checkout. This repository named that class 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 trigger is a comma inside the braces — and + the tool additionally requires that the pattern selected nothing, which is what makes + the refusal a reading rather than a prediction. Both halves are asserted here, because + each alone is satisfied by a wrong rule: dropping the refusal entirely passes the first + half, and the over-block passed the second. + """ + tool = GlobTool() + literal = temp_cwd / "a{b}.py" + literal.write_text("") + comma = temp_cwd / "a{b,c}.py" + comma.write_text("") + + named = _run(tool.execute({"pattern": "a{b}.py", "workdir": str(temp_cwd)})) + assert not named.error, ( + "`Path.glob` gives `{` no special meaning, so this pattern is the exact name of a " + f"file that exists — refusing it blocks a pattern that works: {named.content}" + ) + assert "a{b}.py" in named.content, named.content + assert "Found 1 matches" in named.content, named.content + + # The literal name reached through a wildcard is the same reading. + wildcard = _run(tool.execute({"pattern": "a{b}*", "workdir": str(temp_cwd)})) + assert not wildcard.error, wildcard.content + assert "a{b}.py" in wildcard.content, wildcard.content + + # The other half of the discriminator: a comma between the braces is alternation, it + # selected nothing, and it is still refused rather than answered `No files matched`. + alternation = _run(tool.execute({"pattern": "*.{py,rs}", "workdir": str(temp_cwd)})) + assert alternation.error, ( + "`*.{py,rs}` is alternation this walk cannot expand, and it selected nothing — the " + f"answer has to be the refusal, not an empty reading: {alternation.content}" + ) + assert "brace" in alternation.content, alternation.content + + # And a comma-bearing pattern that **selects** the file whose name carries the comma is + # answered: `Path.glob` reads that name literally, so this is the reading that pins the + # "selected nothing" half of the rule — without it, gating on the pattern's shape alone + # passes every call above. + comma_named = _run(tool.execute({"pattern": "a{b,c}.py", "workdir": str(temp_cwd)})) + assert not comma_named.error, ( + "a file is named `a{b,c}.py` and `Path.glob` matches that literal name, so the " + f"pattern selects it and must be answered: {comma_named.content}" + ) + assert "Found 1 matches" in comma_named.content, comma_named.content + + # And a literal brace name that is simply *absent* gets the empty answer, not the + # refusal: `{b}` alternates between nothing, so refusing it asserts alternation the + # pattern does not carry. This is the reading that tells the comma predicate apart from + # "any brace" — with the wide predicate the gate alone still refuses this call whenever + # the file is missing, and the file's presence above cannot separate the two. + missing = _run(tool.execute({"pattern": "zz{b}.py", "workdir": str(temp_cwd)})) + assert not missing.error, ( + "no file is named `zz{b}.py`, and that is the true answer — `{b}` alternates " + f"between nothing, so this is not alternation: {missing.content}" + ) + assert "No files matched" in missing.content, missing.content + + +def test_the_pattern_description_states_what_a_brace_pattern_gets(temp_cwd): + """`glob`'s description now describes two behaviors, so both are read from it. + + A description is the behaviour's second home, and the pair drifts in both directions: a + description that promises a refusal an unread pattern never gets, and a refusal whose + description does not mention it, are one defect seen from two sides. Both clauses are + asserted here — the refusal, and the literal name that keeps being searched — because + each alone is satisfied by a description that is wrong about the other. + """ + param = GlobTool().definition().parameters["properties"]["pattern"]["description"] + assert "refused with that reason" in param, ( + f"the description no longer says what an unreadable pattern gets: {param}" + ) + assert "still searched" in param, ( + f"the description no longer says a literal brace name is a real name: {param}" + ) + + refused = _run(GlobTool().execute({"pattern": "*.{py,rs}", "workdir": str(temp_cwd)})) + assert refused.error, refused.content + assert refused.content.startswith("Error:"), refused.content + assert not refused.content.startswith("No files matched"), refused.content + # "with that reason" is a clause of the description, so the reason is read too: a + # refusal that does not name the pattern would leave the caller with an error and no + # remedy, which is the promise the rest of the sentence makes. + assert "brace" in refused.content, refused.content + + literal = temp_cwd / "a{b}.py" + literal.write_text("") + answered = _run(GlobTool().execute({"pattern": "a{b}.py", "workdir": str(temp_cwd)})) + assert not answered.error, ( + f"the description says this is a literal name and is searched: {answered.content}" + ) + assert "a{b}.py" in answered.content, answered.content def test_the_siblings_spelling_of_workdir_selects_the_same_tree(temp_cwd): """`grep` names this parameter `path`; `glob` reads that spelling too (issue #2071). diff --git a/tests/test_grep_tool.py b/tests/test_grep_tool.py index 5e641386..8ec7d6ab 100644 --- a/tests/test_grep_tool.py +++ b/tests/test_grep_tool.py @@ -592,6 +592,362 @@ def test_grep_context_inside_its_domain_still_renders_the_block(tmp_path): assert "result budget" not in result.content, "a complete search is not a cut one" +# --- the glob filter: applied to every subject, and never to a pattern it cannot read --- + + +def test_the_glob_filter_applies_to_a_named_file_too(tmp_path): + """`glob` was read by the directory branch and **ignored** by the file branch. + + Measured 2026-10-10 (`cyc20261010-220909`) on `emrg/tools/read_tool.py`: with + `glob='*.nomatch'` the call returned the same one match as no filter at all, and the + summary still printed `matching '*.nomatch'` — the output claimed a filter that had + never run. The description says "Only search files matching this glob pattern", and a + named file is still a file the filter can exclude. + + Both directions, because a filter that excludes everything would pass the + exclusion half alone. + """ + tool = GrepTool() + named = tmp_path / "main.py" + named.write_text("import os\n\ndef main():\n return 1\n") + + unfiltered = _run(tool.execute({"pattern": "def main", "path": str(named)})) + assert not unfiltered.error + assert "Found 1 matches" in unfiltered.content, unfiltered.content + assert "searched 1 files" in unfiltered.content + + matching = _run(tool.execute({ + "pattern": "def main", "path": str(named), "glob": "*.py", + })) + assert not matching.error + assert "Found 1 matches" in matching.content, matching.content + assert "searched 1 files" in matching.content + + excluded = _run(tool.execute({ + "pattern": "def main", "path": str(named), "glob": "*.nomatch", + })) + assert not excluded.error + # The discriminating assertion first: `Found` below is also satisfied by a *wrong* + # match, so an arm that drops the filter has to die on the line that says nothing was + # searched (the arm runner reports a kill on a later line as UNJUDGEABLE). + assert "searched 0 files" in excluded.content, ( + "a file path plus a non-matching glob must search nothing — the filter was " + f"ignored here before this cycle: {excluded.content}" + ) + assert "Found" not in excluded.content, excluded.content + # The reading has to say *why*, or `searched 0 files matching '*.nomatch'` reads as + # "that tree holds none" rather than "the filter you passed excluded the one file you + # pointed at". The two have different remedies. + assert "does not match the glob filter" in excluded.content, excluded.content + assert "main.py" in excluded.content, excluded.content + + +def test_a_named_file_is_matched_the_way_the_walk_matches_it(tmp_path): + """`**/` matches zero directories, so `**/*.py` selects a top-level file. + + Vetoed in `cyc20261011-022427`. Measured 2026-10-10 on the then head `6819efa2`: + one directory holding `main.py` and `sub/deep.py`, one filter, two call shapes — + `path=, glob='**/*.py'` found both files, `path=/main.py` answered + `No matches ... (searched 0 files) ... does not match the glob filter`. The filter + selected that file in one branch and excluded it in the other, and the reason printed + was one the tool's own walk contradicted: `root.rglob(p)` is `root.glob("**/" + p)`, + so `**/*.py` and `*.py` select the same top-level `main.py`. `**/*.py` is *glob*'s + first advertised example, so a caller following the description reaches this. + + The predicate is now the walk's own answer for the one relative path a named file + offers — its own name — which is the whole of what this test pins. + """ + tool = GrepTool() + named = tmp_path / "main.py" + named.write_text("import os\n\ndef main():\n return 1\n") + nested = tmp_path / "sub" + nested.mkdir() + (nested / "deep.py").write_text("def other():\n return 2\n") + + by_directory = _run(tool.execute({ + "pattern": "def ", "path": str(tmp_path), "glob": "**/*.py", + })) + assert "Found 2 matches" in by_directory.content, by_directory.content + + # The discriminating assertion first: `Found` below is also satisfied by a *wrong* + # reading that searched the file anyway, so an arm that reverts the predicate to + # the name alone has to die on the file the walk selects and `fnmatch` refuses. + assert "main.py" in by_directory.content, by_directory.content + + by_file = _run(tool.execute({ + "pattern": "def ", "path": str(named), "glob": "**/*.py", + })) + assert "searched 1 files" in by_file.content, ( + "`**/` matches zero directories, so the walk selects a top-level file and the " + f"named-file branch has to agree: {by_file.content}" + ) + assert "Found 1 matches" in by_file.content, by_file.content + + # The exclusion half, so this test discriminates against master as well as against a + # name-only predicate: master ignored the filter for a named file outright, and would + # pass everything above for the wrong reason (it searches the file whatever the filter + # says). One name-shaped miss on the same file separates "the walk's answer" from + # "no filter at all". + excluded = _run(tool.execute({ + "pattern": "def ", "path": str(named), "glob": "**/*.nomatch", + })) + assert "searched 0 files" in excluded.content, ( + f"a filter the walk would not select must still exclude the file: {excluded.content}" + ) + assert "Found" not in excluded.content, excluded.content + + +def test_a_filter_naming_a_directory_states_that_domain_not_the_file(tmp_path): + """`src/**/*.ts` cannot select a named file, and the reading says why. + + What a named file offers the matcher is one relative path — its name — so a pattern + that still names a directory after its leading `**` segments are dropped has no path + to match: `deep.ts` is not under a `src` relative to itself, and the walk rooted at + the file's own directory selects nothing either. The directory branch's root is + somewhere else — the directory the caller passed — so the two branches answer the + same question only for name-shaped patterns, and the veto `cyc20261011-022427` asked + for that to be stated rather than asserted (`_selects` claimed "the two branches + answer the same question", which nothing measured). + + Both halves are pinned: the sentence names the domain, and it is **not** the + `does not match the glob filter` sentence, which for this shape is a cause the tool's + own walk contradicts once the caller passes the directory. + """ + tool = GrepTool() + src = tmp_path / "src" + src.mkdir() + named = src / "deep.ts" + named.write_text("const x = 1;\n") + + by_directory = _run(tool.execute({ + "pattern": "const", "path": str(tmp_path), "glob": "src/**/*.ts", + })) + assert "Found 1 matches" in by_directory.content, by_directory.content + assert "src/deep.ts" in by_directory.content, by_directory.content + + by_file = _run(tool.execute({ + "pattern": "const", "path": str(named), "glob": "src/**/*.ts", + })) + assert "searched 0 files" in by_file.content, by_file.content + assert "offers only its own name" in by_file.content, by_file.content + assert "does not match the glob filter" not in by_file.content, ( + "the exclusion reason has to name *this* cause — a directory component — because " + f"the walk from the directory the caller could have passed does select it: {by_file.content}" + ) + # A name-shaped pattern keeps the other sentence: there the walk agrees with the + # exclusion, so `does not match` is a reading and not a contradiction. + name_shaped = _run(tool.execute({ + "pattern": "const", "path": str(named), "glob": "*.nomatch", + })) + assert "does not match the glob filter" in name_shaped.content, name_shaped.content + + +def test_a_brace_glob_is_refused_rather_than_searched_to_an_empty_answer(tmp_path): + """`*.{py,rs}` is the pattern the description advertised, and this walk cannot read it. + + `Path.rglob` has no brace expansion, so the whole pattern is one literal string that + matches no file whose name contains a brace. Measured 2026-10-10 (`cyc20261010-220909`) + over `emrg/tools/`: `glob='*.py'` -> `Found 9 matches ... (searched 15 files)`, + `glob='*.{py,rs}'` -> `No matches ... (searched 0 files)`. The second sentence is the + one a real absence produces, so a model following the description's own example was + told "nothing here" about a question the tool never asked. + + Refusing is the reading this repository uses for a request it cannot answer (the + `count_argument` family): the alternative is an empty answer that is indistinguishable + from a true one. + """ + tool = GrepTool() + f = tmp_path / "main.py" + f.write_text("import os\n") + + brace = _run(tool.execute({ + "pattern": "import", "path": str(tmp_path), "glob": "*.{py,rs}", + })) + assert brace.error, brace.content + assert brace.content.startswith("Error:"), ( + "the refusal has to be an error, not an empty reading with an explanation " + f"beside it: {brace.content}" + ) + assert "brace" in brace.content, brace.content + assert "does not expand" in brace.content, brace.content + + # The control: the same search, spelled the way the filter can read. + control = _run(tool.execute({ + "pattern": "import", "path": str(tmp_path), "glob": "*.py", + })) + assert not control.error, control.content + assert "Found 1 matches" in control.content, ( + "the pattern the brace example meant has to keep working, or 'refused' is " + f"indistinguishable from 'handled': {control.content}" + ) + + +def _advertised_globs() -> list[str]: + """Every glob example the `glob` parameter's description holds. + + Read from the schema rather than restated: the description is the contract the model + reads, so an example in it is a promise, and a promise nothing measures is how + `*.{py,rs}` survived in that list while selecting nothing. + """ + param = GrepTool().definition().parameters["properties"]["glob"]["description"] + assert "Examples:" in param, ( + f"the glob parameter states no examples, so this reader has no subject: {param}" + ) + # Split on the two markers rather than on a period: the examples themselves contain + # periods (`'*.py'`), and a non-greedy match up to the first one parses an unterminated + # quote and returns nothing — the shape this reader had before it was fixed to read the + # same description its author wrote. + section = param.split("Examples:", 1)[1].split("Default:", 1)[0] + return re.findall(r"'([^']+)'", section) + + +def test_every_glob_example_the_description_advertises_selects_files(tmp_path): + """Each advertised example is run, against a tree holding the file it names. + + An example that selects nothing is worse than a missing one: the caller reads + `No matches` and concludes the pattern is absent from the tree. That is the defect + this cycle fixed for one entry of this very list — so the list is held to it, and a + later edit that reintroduces an unreadable form goes red here rather than in a + model's answer. + """ + globs = _advertised_globs() + assert globs, "no examples parsed out of the description" + + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.py").write_text("import os\n") + (tmp_path / "src" / "app.ts").write_text("import x\n") + (tmp_path / "README.md").write_text("# Project\n") + + for glob_example in globs: + result = _run(GrepTool().execute({ + "pattern": ".", "path": str(tmp_path), "glob": glob_example, + })) + assert not result.error, ( + f"the description advertises {glob_example!r}, but the tool refuses it: " + f"{result.content}" + ) + assert "searched 0 files" not in result.content, ( + f"the description advertises {glob_example!r}, which selects no file at all — " + f"the caller would read 'No matches' as a fact about the tree: {result.content}" + ) + + +def test_a_literal_brace_name_in_the_filter_selects_its_file(tmp_path): + """The filter's brace refusal must not fire on a name the walk can read literally. + + The refusal added for `*.{py,rs}` first fired on `"{" in filter or "}" in filter`, and + that refused filters `Path.rglob` reads correctly: on a tree holding a file named + `a{b}.py`, master's directory branch selected it and the branch refused the call, with a + message asserting `{a,b}` alternation the input does not carry. Measured 2026-10-10 + (`cyc20261011-001130`) in this checkout. This repository named that class 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 trigger is a comma inside the braces — and + the tool additionally requires that the filter selected nothing, which is what makes the + refusal a reading rather than a prediction. Both halves are asserted, because each alone + is satisfied by a wrong rule: dropping the refusal passes the literal half, and the + over-block passed the alternation half. + """ + tool = GrepTool() + literal = tmp_path / "a{b}.py" + literal.write_text("needle here\n") + comma = tmp_path / "a{b,c}.py" + comma.write_text("needle here\n") + + through_the_directory = _run(tool.execute({ + "pattern": "needle", "path": str(tmp_path), "glob": "a{b}.py", + })) + assert not through_the_directory.error, ( + "the directory branch reads `a{b}.py` literally and selects the file that holds " + f"it; refusing it blocks a filter that works: {through_the_directory.content}" + ) + assert "Found 1 matches" in through_the_directory.content, through_the_directory.content + assert "searched 1 files" in through_the_directory.content, through_the_directory.content + + through_the_named_file = _run(tool.execute({ + "pattern": "needle", "path": str(literal), "glob": "a{b}.py", + })) + assert not through_the_named_file.error, through_the_named_file.content + assert "Found 1 matches" in through_the_named_file.content, through_the_named_file.content + + # The other half of the discriminator: a comma between the braces is alternation, it + # selected nothing, and it is still refused rather than searched to `No matches`. + alternation = _run(tool.execute({ + "pattern": "needle", "path": str(tmp_path), "glob": "*.{py,rs}", + })) + assert alternation.error, ( + "`*.{py,rs}` is the pattern the description used to advertise, and it selects " + f"nothing — the answer has to be the refusal: {alternation.content}" + ) + assert "brace" in alternation.content, alternation.content + + comma_but_selecting = _run(tool.execute({ + "pattern": "needle", "path": str(tmp_path), "glob": "a{b,c}.py", + })) + assert not comma_but_selecting.error, ( + "a comma-bearing filter that selects the named file is answered, not refused: the " + f"refusal is gated on the filter having selected nothing: {comma_but_selecting.content}" + ) + assert "Found 1 matches" in comma_but_selecting.content, comma_but_selecting.content + + # And a literal brace name that is simply *absent* gets the empty answer, not the + # refusal: `{b}` alternates between nothing, so refusing it asserts alternation the + # pattern does not carry. This is the reading that tells the comma predicate apart from + # "any brace" — with the wide predicate the gate alone still refuses this call whenever + # no file matches, and the files above cannot separate the two. + missing = _run(tool.execute({ + "pattern": "needle", "path": str(tmp_path), "glob": "zz{b}.py", + })) + assert not missing.error, ( + "no file matches `zz{b}.py`, and that is the true answer — `{b}` alternates " + f"between nothing, so this is not alternation: {missing.content}" + ) + assert "No matches" in missing.content, missing.content + + # A named file with an unreadable filter is refused for the filter's reason, not told + # that it merely failed to match: `_selects` matches with `fnmatch`, which does not + # expand braces either, so the exclusion and the empty walk have one cause — the pattern + # — and the remedy is to change it, not to look at the file. + named_unreadable = _run(tool.execute({ + "pattern": "needle", "path": str(literal), "glob": "*.{py,rs}", + })) + assert named_unreadable.error, ( + "the filter cannot be read at all, so this is the refusal and not a match " + f"failure the caller would fix by looking at the file: {named_unreadable.content}" + ) + assert "brace" in named_unreadable.content, named_unreadable.content + + +def test_the_glob_description_states_the_refusal_the_tool_gives(tmp_path): + """The description is the behaviour's second home, so a refusal it does not mention + is a surprise the caller could have been spared. + + Both halves in one test, because either alone is satisfied by a wrong rule: a + description that promises a refusal an unread filter never gives, and a refusal whose + description says nothing about it, are the two ways this pair drifts apart. The + repository's own shape for that is `test_read_never_cuts_a_line` — a promise in a tool + description that no test holds is how a claim survives review. + """ + param = GrepTool().definition().parameters["properties"]["glob"]["description"] + assert "brace-alternation" in param and "refused with that reason" in param, ( + f"the description no longer says what an unreadable filter gets: {param}" + ) + + (tmp_path / "main.py").write_text("needle\n") + refused = _run(GrepTool().execute({ + "pattern": "needle", "path": str(tmp_path), "glob": "*.{py,rs}", + })) + assert refused.error, ( + f"the description says this is refused, and it was searched instead: {refused.content}" + ) + # The description's second clause — "rather than searched to `No matches`" — is the + # half that matters on its own: an empty reading beside an explanation is what this + # whole PR exists to remove. The refusal *quotes* that sentence while refusing it, so + # the reading is the answer's own opening, not a substring. + assert refused.content.startswith("Error:"), refused.content + assert not refused.content.startswith("No matches"), refused.content + assert "brace" in refused.content, refused.content def test_the_siblings_spelling_of_path_selects_the_same_tree(temp_cwd): """`glob` names this parameter `workdir`; `grep` reads that spelling too (issue #2071).