symgrep-codesearch 0.6.4__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {symgrep_codesearch-0.6.4/src/symgrep_codesearch.egg-info → symgrep_codesearch-0.7.0}/PKG-INFO +20 -3
  2. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/README.md +19 -2
  3. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/pyproject.toml +1 -1
  4. symgrep_codesearch-0.7.0/src/symgrep/archives.py +86 -0
  5. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/cli.py +39 -5
  6. symgrep_codesearch-0.7.0/src/symgrep/glob_recall.py +54 -0
  7. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/mcp_server.py +36 -4
  8. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/mcp_structured.py +52 -13
  9. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/report.py +19 -0
  10. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/semantic.py +57 -3
  11. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0/src/symgrep_codesearch.egg-info}/PKG-INFO +20 -3
  12. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/SOURCES.txt +3 -0
  13. symgrep_codesearch-0.7.0/tests/test_semantic_glob_lang.py +239 -0
  14. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/LICENSE +0 -0
  15. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/setup.cfg +0 -0
  16. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/__init__.py +0 -0
  17. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/blocks.py +0 -0
  18. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/classfile.py +0 -0
  19. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/data_symbols.py +0 -0
  20. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/docsymbols.py +0 -0
  21. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/enclosing.py +0 -0
  22. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/resolve.py +0 -0
  23. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/resolve_js.py +0 -0
  24. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/resolve_python.py +0 -0
  25. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/ripgrep.py +0 -0
  26. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/structural.py +0 -0
  27. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/structure.py +0 -0
  28. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/dependency_links.txt +0 -0
  29. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/entry_points.txt +0 -0
  30. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/requires.txt +0 -0
  31. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/top_level.txt +0 -0
  32. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_blocks_extraction.py +0 -0
  33. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_blocks_retrieval.py +0 -0
  34. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_bulk_describe.py +0 -0
  35. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_classfile.py +0 -0
  36. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_data_symbols.py +0 -0
  37. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_describe_lane_confinement.py +0 -0
  38. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_doc_symbols.py +0 -0
  39. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_file_structure.py +0 -0
  40. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_freshness.py +0 -0
  41. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_honest_recall.py +0 -0
  42. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_http_ensure.py +0 -0
  43. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_index_reconstruct.py +0 -0
  44. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_index_scope.py +0 -0
  45. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_index_worker.py +0 -0
  46. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_mcp_structured.py +0 -0
  47. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_name_uniqueness.py +0 -0
  48. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_nudge_classify.py +0 -0
  49. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_nudge_runaway.py +0 -0
  50. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_report_filters.py +0 -0
  51. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve.py +0 -0
  52. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve_js.py +0 -0
  53. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve_python.py +0 -0
  54. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve_r2.py +0 -0
  55. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_review_regressions.py +0 -0
  56. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_semantic_freshness_signal.py +0 -0
  57. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_sites_and_symbols.py +0 -0
  58. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_structural.py +0 -0
  59. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_symbol_vocabulary.py +0 -0
  60. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_ts_fallback_loud.py +0 -0
  61. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_ts_symbol_coverage.py +0 -0
  62. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_usage.py +0 -0
  63. {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_veccore_adoption.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symgrep-codesearch
3
- Version: 0.6.4
3
+ Version: 0.7.0
4
4
  Summary: Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/ashdamle/symgrep
@@ -140,11 +140,20 @@ that needed "every function with its scope-qualified name and span" wrote its ow
140
140
  ## Use
141
141
 
142
142
  ```bash
143
- symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
143
+ symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--lang L] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
144
144
  symgrep --index src/ # build the semantic index (incremental)
145
145
  symgrep --semantic "what the function would do" src/ # search by meaning
146
+ symgrep --semantic "…" --glob '*.py' --lang python src/ # meaning, scoped to a file set (ranked WITHIN it)
146
147
  ```
147
148
 
149
+ `--glob` and `--lang` scope **every** mode, `--semantic` included: they are literal file bounds, and under
150
+ `--semantic` meaning is ranked only *within* the matching files — never used to *select* files (selecting by
151
+ meaning would silently drop relevant files, the exact blindness symgrep exists to remove). Because a glob is
152
+ a guess about *where* code lives, a **regex** search also reports what the glob **hid** — files that match the
153
+ pattern but fall outside the glob — so a too-narrow `--glob` is visible, not a silent false-negative. And
154
+ archives (`.zip`/`.tar.gz`/`.jar`/…) are **opaque**: never indexed or searched, but **named** in the output
155
+ when they sit under the scope, so their contents can't be mistaken for *not there*.
156
+
148
157
  The index stores one sentence per function describing its **job**, keyed by body hash, so a rebuild only
149
158
  costs the functions that changed. Embeddings are local (`fastembed` / bge-small): no API key, no network,
150
159
  nothing about the code leaves the machine.
@@ -384,7 +393,9 @@ wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionS
384
393
  (fastmcp requires both media types in Accept even though the reply is plain JSON).
385
394
  - Call: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{…}}}`
386
395
  with the same arguments as the stdio tool: `pattern`, `roots` (required), `case_insensitive`,
387
- `fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`.
396
+ `fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`, `lang`.
397
+ `globs` and `lang` scope **every** mode including `semantic` (literal file bounds; under `semantic`,
398
+ meaning is ranked only within them, never used to select files).
388
399
  - Reply: `result.content[0].text` = the same rendered report the stdio tool returns (honest zero-hits,
389
400
  FRESHNESS line, scope line included). Tool errors arrive as JSON-RPC errors, not empty results.
390
401
  - **Structured output**: pass `"structured": true` (or the alias `"json_output": true`) and
@@ -394,6 +405,12 @@ wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionS
394
405
  basis, score?, desc?, match_lines?}], …}` — name hits carry `match_lines`; semantic hits carry
395
406
  `score` + `desc` and resolve the span from the file's AST. `scope` (semantic) carries the
396
407
  staleness/coverage counters.
408
+ - Two result-level **honesty** keys expose a silent false-negative to a program, not just a human:
409
+ `glob_excluded` (name mode) lists files that match the pattern but `--glob` excluded — the dropped
410
+ matches of a too-narrow glob, empty without a glob; `archives` lists opaque containers
411
+ (`.zip`/`.tar.gz`/…) under the scope whose contents are never indexed/searched — present on a semantic
412
+ search always, and on a name search only at zero hits (mirroring the prose; no walk on the hit path),
413
+ with `archives_capped` flagging a truncated walk rather than a silent undercount.
397
414
  - `{status: "no_index", …}` when the semantic index isn't built (fixable by `symgrep --index`) vs
398
415
  `{status: "unavailable", reason: …, message: …}` when it cannot run (no embedder / corrupt). These
399
416
  are distinguishable **without** reading the prose — the honesty feature a program needs.
@@ -109,11 +109,20 @@ that needed "every function with its scope-qualified name and span" wrote its ow
109
109
  ## Use
110
110
 
111
111
  ```bash
112
- symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
112
+ symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--lang L] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
113
113
  symgrep --index src/ # build the semantic index (incremental)
114
114
  symgrep --semantic "what the function would do" src/ # search by meaning
115
+ symgrep --semantic "…" --glob '*.py' --lang python src/ # meaning, scoped to a file set (ranked WITHIN it)
115
116
  ```
116
117
 
118
+ `--glob` and `--lang` scope **every** mode, `--semantic` included: they are literal file bounds, and under
119
+ `--semantic` meaning is ranked only *within* the matching files — never used to *select* files (selecting by
120
+ meaning would silently drop relevant files, the exact blindness symgrep exists to remove). Because a glob is
121
+ a guess about *where* code lives, a **regex** search also reports what the glob **hid** — files that match the
122
+ pattern but fall outside the glob — so a too-narrow `--glob` is visible, not a silent false-negative. And
123
+ archives (`.zip`/`.tar.gz`/`.jar`/…) are **opaque**: never indexed or searched, but **named** in the output
124
+ when they sit under the scope, so their contents can't be mistaken for *not there*.
125
+
117
126
  The index stores one sentence per function describing its **job**, keyed by body hash, so a rebuild only
118
127
  costs the functions that changed. Embeddings are local (`fastembed` / bge-small): no API key, no network,
119
128
  nothing about the code leaves the machine.
@@ -353,7 +362,9 @@ wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionS
353
362
  (fastmcp requires both media types in Accept even though the reply is plain JSON).
354
363
  - Call: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{…}}}`
355
364
  with the same arguments as the stdio tool: `pattern`, `roots` (required), `case_insensitive`,
356
- `fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`.
365
+ `fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`, `lang`.
366
+ `globs` and `lang` scope **every** mode including `semantic` (literal file bounds; under `semantic`,
367
+ meaning is ranked only within them, never used to select files).
357
368
  - Reply: `result.content[0].text` = the same rendered report the stdio tool returns (honest zero-hits,
358
369
  FRESHNESS line, scope line included). Tool errors arrive as JSON-RPC errors, not empty results.
359
370
  - **Structured output**: pass `"structured": true` (or the alias `"json_output": true`) and
@@ -363,6 +374,12 @@ wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionS
363
374
  basis, score?, desc?, match_lines?}], …}` — name hits carry `match_lines`; semantic hits carry
364
375
  `score` + `desc` and resolve the span from the file's AST. `scope` (semantic) carries the
365
376
  staleness/coverage counters.
377
+ - Two result-level **honesty** keys expose a silent false-negative to a program, not just a human:
378
+ `glob_excluded` (name mode) lists files that match the pattern but `--glob` excluded — the dropped
379
+ matches of a too-narrow glob, empty without a glob; `archives` lists opaque containers
380
+ (`.zip`/`.tar.gz`/…) under the scope whose contents are never indexed/searched — present on a semantic
381
+ search always, and on a name search only at zero hits (mirroring the prose; no walk on the hit path),
382
+ with `archives_capped` flagging a truncated walk rather than a silent undercount.
366
383
  - `{status: "no_index", …}` when the semantic index isn't built (fixable by `symgrep --index`) vs
367
384
  `{status: "unavailable", reason: …, message: …}` when it cannot run (no embedder / corrupt). These
368
385
  are distinguishable **without** reading the prose — the honesty feature a program needs.
@@ -26,7 +26,7 @@ name = "symgrep-codesearch"
26
26
  # semantic search. Minor bump (dependency surface changed; no breaking import/CLI/MCP change).
27
27
  # 0.5.0: added the public `file_structure` library API (+ its MCP tool and `symgrep structure` CLI), import
28
28
  # module-name normalization, and the FileStructure `connects` list (structure schema v2).
29
- version = "0.6.4"
29
+ version = "0.7.0"
30
30
  description = "Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning"
31
31
  readme = "README.md"
32
32
  # 3.11+ : the maintained grammar pack (tree-sitter-language-pack, a CORE dep below) ships abi3 wheels for
@@ -0,0 +1,86 @@
1
+ """Archive/container files are OPAQUE to symgrep — the honest skip, named so it is never a silent one.
2
+
3
+ symgrep never extracts or descends into a .zip / .tar.gz / .jar / … : it imports no zipfile/tarfile/gzip and
4
+ shells out to no unzip/tar, and the index/symbol/data pipelines gate on an extension ALLOWLIST that contains no
5
+ archive extension. So an archive's inner files and bytes are never indexed, never embedded, and never
6
+ content-searched — a `.tar.gz` that IS a data bundle is invisible to both name search and search-by-meaning.
7
+
8
+ That is the right default (auto-extraction would resurrect the whole-tree describe runaway this tool is built to
9
+ avoid), but invisible-by-policy must not read as "nothing there". This module enumerates the archives present
10
+ under a search scope so the search/semantic renderers can SAY the contents were not covered — the same
11
+ "honest zero-hit expansion" discipline the name search already applies, extended to containers.
12
+
13
+ Detection is a BOUNDED filesystem walk (no subprocess, so no ripgrep dependency and nothing to fail closed on).
14
+ When the walk hits its file-count cap it STOPS and says so (`capped=True`) rather than silently returning an
15
+ undercount — a count that looks complete but is not is exactly the truncation this estate forbids.
16
+ """
17
+ import os
18
+ import pathlib
19
+ from typing import List, Optional, Tuple
20
+
21
+ # Matched on the full lowercased filename so the COMPOUND forms (.tar.gz) are recognised, and a bare .gz / .bz2
22
+ # (a single compressed file, not a container of subfiles) is deliberately NOT treated as an archive.
23
+ ARCHIVE_SUFFIXES = (".zip", ".jar", ".war", ".whl", ".egg", ".tar",
24
+ ".tgz", ".tbz2", ".txz",
25
+ ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst")
26
+
27
+ # Directories never worth walking for this advisory scan — VCS metadata and dependency dumps. A leading-dot dir
28
+ # is pruned separately (keeps the walk off .git, .venv, caches) in the spirit of symgrep's other bounded walks.
29
+ _SKIP_DIRS = {"node_modules", "__pycache__", ".git", ".hg", ".svn"}
30
+
31
+ _DEFAULT_CAP = 50000 # files examined before the scan stops; a stop is REPORTED (capped=True), never silent
32
+
33
+
34
+ def is_archive(name: str) -> bool:
35
+ """True if `name` is an opaque container by extension (compound .tar.* included). Pure format test."""
36
+ n = (name or "").lower()
37
+ return any(n.endswith(s) for s in ARCHIVE_SUFFIXES)
38
+
39
+
40
+ def archive_files(roots, cap: int = _DEFAULT_CAP) -> Tuple[List[str], bool]:
41
+ """(archive_paths, capped) under `roots` — a bounded walk with dot-dirs and dependency dumps pruned.
42
+ `capped` is True when the walk stopped at `cap` files BEFORE exhausting the tree, so the path list is a
43
+ floor the caller must report as such (never present a capped count as complete). Never raises on a bad
44
+ root; a missing or unreadable path is skipped."""
45
+ out: List[str] = []
46
+ seen = 0
47
+ for root in (roots or []):
48
+ try:
49
+ rp = pathlib.Path(root)
50
+ if rp.is_file():
51
+ if is_archive(rp.name):
52
+ out.append(str(rp))
53
+ continue
54
+ if not rp.is_dir():
55
+ continue
56
+ for dirpath, dirnames, filenames in os.walk(rp):
57
+ dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in _SKIP_DIRS]
58
+ for fn in filenames:
59
+ seen += 1
60
+ if seen > cap:
61
+ return out, True # bounded AND reported — the count is a floor, say so
62
+ if is_archive(fn):
63
+ out.append(os.path.join(dirpath, fn))
64
+ except OSError:
65
+ continue
66
+ return out, False
67
+
68
+
69
+ def archive_note(roots, prefix: str = " ") -> Optional[str]:
70
+ """One advisory line about opaque archives under `roots`, or None when there are none AND the scan was
71
+ complete — so a caller appends it unconditionally and it vanishes when irrelevant. Reports an exact count
72
+ normally, and 'at least N (scan stopped at the cap)' when the walk was capped, so an undercount can never
73
+ read as the full picture."""
74
+ found, capped = archive_files(roots)
75
+ if not found and not capped:
76
+ return None
77
+ count = (f"at least {len(found)}" if capped else f"{len(found)}")
78
+ tail = (" The archive scan stopped at its file cap, so more may be present." if capped else "")
79
+ if not found and capped:
80
+ # Scope too large to finish scanning and no archive seen yet — honest 'unknown', not a false 'none'.
81
+ return (f"{prefix}⚠ archive scan did not finish under this large scope (stopped at the file cap); "
82
+ f"any .zip/.tar.gz/… present would be OPAQUE — symgrep does not index or search archive "
83
+ f"contents. Narrow the scope to check, or extract archives you care about.")
84
+ return (f"{prefix}⚠ {count} archive(s) under scope (.zip/.tar.gz/.jar/…) are OPAQUE: symgrep never "
85
+ f"extracts or indexes archive contents, so files INSIDE them are not covered by this result. Extract "
86
+ f"an archive first (e.g. `tar xf <a>` / `unzip <a>`) to search or index what it contains.{tail}")
@@ -84,6 +84,19 @@ BULK_BACKOFF_S_DEFAULT = 20 # $SYMGREP_BULK_BACKOFF_S — sleep betwe
84
84
  def build_report(pattern, roots, case_insensitive=False, fixed_string=False,
85
85
  multiline=False, globs=None, include_ignored=False,
86
86
  max_sites=DEFAULT_MAX_SITES):
87
+ """Run the name/regex search and return a grouped, annotated Report (the core search seam).
88
+
89
+ pattern: ripgrep regex (literal if fixed_string). roots: dirs/files to search; an EMPTY roots falls back
90
+ to the host cwd and the resolved scope is recorded on Report.roots (so a result is never attributed to an
91
+ unrecorded ambient cwd). case_insensitive/fixed_string/multiline/globs/include_ignored map to the rg
92
+ flags. max_sites bounds how many enclosing-symbol sites are SHOWN (Report.shown_sites); counts
93
+ (total_line_matches/total_files) are over everything, never the cap. On zero hits the Report carries
94
+ mechanical `expansions`; whenever a glob excluded files that also match, it carries `glob_excluded` +
95
+ `glob_excluded_note`. Coverage gaps ride `skipped_errors`. Propagates ripgrep.RipgrepNotFound (and other
96
+ rg errors) from the underlying passes rather than masking them as an empty result."""
97
+ # Scope resolution: an omitted `roots` falls back to the host cwd. The RESOLVED scope is recorded on the
98
+ # returned Report (`roots=roots` below) as provenance, so the result names exactly which directories were
99
+ # searched rather than leaving it to an unrecorded ambient value.
87
100
  roots = [os.path.abspath(r) for r in (roots or [os.getcwd()])]
88
101
  rg = run_rg(pattern, roots, case_insensitive=case_insensitive,
89
102
  fixed_string=fixed_string, multiline=multiline, globs=globs,
@@ -92,13 +105,22 @@ def build_report(pattern, roots, case_insensitive=False, fixed_string=False,
92
105
  expansions = []
93
106
  if not sites:
94
107
  expansions = zero_hit_expansions(pattern, roots, case_insensitive, include_ignored)
108
+ # Honest recall for --glob: name the files that match the pattern but the glob EXCLUDED, so a too-narrow
109
+ # glob is never a silent false-negative (glob_recall reuses rg's own passes; no-op without globs).
110
+ from .glob_recall import glob_excluded_matches
111
+ glob_excluded, glob_note = glob_excluded_matches(pattern, roots, globs,
112
+ case_insensitive=case_insensitive,
113
+ fixed_string=fixed_string,
114
+ include_ignored=include_ignored)
95
115
  return Report(pattern=pattern, roots=roots, sites=sites,
96
116
  total_line_matches=rg.total_line_matches,
97
117
  total_files=len({m.path for m in rg.matches}),
98
118
  truncated_events=rg.truncated_events,
99
119
  shown_sites=min(max_sites, len(sites)), # what is SHOWN, not the cap asked for
100
120
  expansions=expansions,
101
- skipped_errors=rg.skipped_errors)
121
+ skipped_errors=rg.skipped_errors,
122
+ glob_excluded=glob_excluded,
123
+ glob_excluded_note=glob_note)
102
124
 
103
125
 
104
126
  def _describer():
@@ -483,7 +505,10 @@ def main(argv=None) -> int:
483
505
  ap.add_argument("-U", "--multiline", action="store_true",
484
506
  help="let PATTERN span lines (rg -U)")
485
507
  ap.add_argument("--glob", action="append", dest="globs",
486
- help="restrict files by glob, repeatable, e.g. --glob '*.py' (rg --glob)")
508
+ help="restrict files by glob, repeatable, e.g. --glob '*.py' (rg --glob). Also scopes "
509
+ "--semantic (meaning is ranked only WITHIN the matching files). On a regex search a "
510
+ "too-narrow glob is made VISIBLE: files that match the pattern but fall outside the "
511
+ "glob are reported as excluded-but-matching (drop/widen it, or use --semantic)")
487
512
  ap.add_argument("--no-ignore", action="store_true", dest="include_ignored",
488
513
  help="search gitignored + hidden files too (rg --no-ignore --hidden)")
489
514
  ap.add_argument("--max-sites", type=int, default=DEFAULT_MAX_SITES,
@@ -513,7 +538,8 @@ def main(argv=None) -> int:
513
538
  help="treat PATTERN as an AST SHAPE (pattern-by-example: $NAME=one node, $$$=variadic) "
514
539
  "and match it structurally via tree-sitter — a third question beside name/meaning")
515
540
  ap.add_argument("--lang", default=None,
516
- help="--structural: force the pattern's language (else inferred per file by extension)")
541
+ help="--structural: force the pattern's language (else inferred per file by extension). "
542
+ "--semantic: restrict meaning-search to files of this language (by extension)")
517
543
  ap.add_argument("--reindex", action="store_true",
518
544
  help="with --index: FORCE re-describe of every function under ROOTS, ignoring the "
519
545
  "body-hash cache (use after changing the describer or its instruction)")
@@ -523,7 +549,14 @@ def main(argv=None) -> int:
523
549
  "block), data (schema/data/config symbol), both, or all")
524
550
  ap.add_argument("--symbol-kind", dest="kind", default=None,
525
551
  help="--semantic: filter within a unit, e.g. data_schema, sql_table, proto_message")
526
- args = ap.parse_args(argv)
552
+ # parse_intermixed_args, NOT parse_args: `pattern` (nargs="?") and `roots` (nargs="*") are two
553
+ # variadic positionals, and on argparse < 3.12 a value-taking option BETWEEN them — the ordinary
554
+ # `symgrep PAT --glob '*.py' ROOT` — makes parse_args drop the trailing ROOT as "unrecognized
555
+ # arguments" (silently wrong scope on 3.11, the version floor). parse_intermixed_args parses the
556
+ # optionals first and distributes the positionals after, so the same command behaves identically on
557
+ # every supported Python. Equivalent on 3.12+, so this only widens recall; it never changes a parse
558
+ # that already worked.
559
+ args = ap.parse_intermixed_args(argv)
527
560
 
528
561
  if args.index:
529
562
  # `symgrep --index src` used to parse `src` as the PATTERN and index the cwd instead — a silent
@@ -553,7 +586,8 @@ def main(argv=None) -> int:
553
586
  progress=lambda i, n: print(f" {i}/{n} described", flush=True))
554
587
  print(f"symgrep index: {r}")
555
588
  return 0
556
- hits = semantic.search_by_meaning(args.pattern, roots, k=args.k, unit=args.unit, kind=args.kind)
589
+ hits = semantic.search_by_meaning(args.pattern, roots, k=args.k, unit=args.unit, kind=args.kind,
590
+ globs=args.globs, lang=args.lang)
557
591
  print(semantic.render_meaning_hits(args.pattern, hits))
558
592
  return 0 if hits else 1
559
593
  except semantic.Unavailable as e:
@@ -0,0 +1,54 @@
1
+ """Honest recall for --glob: a literal glob is a GUESS about WHERE code lives, and a wrong guess hides
2
+ relevant files SILENTLY — the exact false-negative-with-no-signal this tool exists to refuse, reintroduced
3
+ through a filename filter. An agent whose real intent is semantic ("the auth code", "the config loader")
4
+ reaches for a glob that encodes that intent as a path pattern; when the pattern is narrower than the intent,
5
+ the matching files outside it vanish from the result with nothing to say they were dropped.
6
+
7
+ This reports the files that MATCH the pattern but the glob EXCLUDED, so a too-narrow --glob is VISIBLE. It is
8
+ not a "semantic glob" (selecting files by meaning would itself silently drop relevant files, the same failure
9
+ one level up) — the glob stays a mechanical bound; this only makes what it dropped honest. Finding files by
10
+ MEANING regardless of filename is --semantic's job, and the note points there.
11
+
12
+ Mechanism (no new spawn here — reuses ripgrep's own passes): the EXCLUDED-but-matching set is the files that
13
+ match the pattern with NO glob, minus the files the glob admits (glob membership, ignore-neutral so the glob
14
+ alone decides). Best-effort and advisory: it never changes the result, only annotates it, and any rg failure
15
+ yields no note rather than a false "nothing excluded" (the main search already reports its own coverage).
16
+ """
17
+ import os
18
+ from typing import List, Optional, Tuple
19
+
20
+ _SAMPLE = 5 # how many excluded paths to name inline before summarising the remainder
21
+
22
+
23
+ def glob_excluded_matches(pattern, roots, globs, *, case_insensitive=False, fixed_string=False,
24
+ include_ignored=False) -> Tuple[List[str], Optional[str]]:
25
+ """(excluded_paths, note) — files under `roots` that MATCH `pattern` but the `globs` filter EXCLUDED.
26
+
27
+ Returns ([], None) when there is no glob, nothing was excluded, or an rg pass could not run — never a
28
+ false "nothing excluded". The flags mirror the main search so the counterfactual is the same question
29
+ minus the glob. Paths are reported exactly as ripgrep prints them (no host-dependent rewriting)."""
30
+ if not globs:
31
+ return [], None
32
+ from . import ripgrep
33
+ try:
34
+ # Files matching the pattern under the SAME ignore rules, WITHOUT the glob — the honest denominator.
35
+ matching = ripgrep.count_files_with_match(pattern, roots, case_insensitive=case_insensitive,
36
+ fixed_string=fixed_string, include_ignored=include_ignored)
37
+ # Files the glob ADMITS (ignore-neutral: the glob alone decides membership, so an indexed/tracked
38
+ # file is judged by the pattern, never hidden by an ignore rule here).
39
+ members = {os.path.realpath(p) for p in ripgrep.list_files(roots, include_ignored=True, globs=globs)}
40
+ except (ripgrep.ExpansionFailed, ripgrep.RipgrepNotFound, OSError):
41
+ # The extra passes are advisory; if they cannot run, emit no note (the main search reports coverage).
42
+ # Narrow catch so a genuine deliberate-stop elsewhere is never swallowed here.
43
+ return [], None
44
+ excluded = [p for p in matching if os.path.realpath(p) not in members]
45
+ if not excluded:
46
+ return [], None
47
+ shown = sorted(excluded)[:_SAMPLE]
48
+ sample = ", ".join(shown)
49
+ more = f", +{len(excluded) - _SAMPLE} more" if len(excluded) > _SAMPLE else ""
50
+ note = (f"⚠ --glob EXCLUDED {len(excluded)} file(s) that ALSO match this pattern ({sample}{more}) — "
51
+ f"a glob is a guess about WHERE the code is, and these matches fall outside it, so they are NOT in "
52
+ f"the results above. If your intent was broader than the filename pattern, drop or widen --glob, or "
53
+ f"search by MEANING with symgrep --semantic.")
54
+ return excluded, note
@@ -78,7 +78,18 @@ TOOL_DESCRIPTION = (
78
78
  "'python' | 'go' | 'javascript' | 'typescript' | 'tsx' | 'rust' | 'java' | 'c' | 'cpp' | 'ruby' | 'php' |\n"
79
79
  "'c_sharp' | 'kotlin' | 'scala' | 'bash'); with no usable grammar it reports UNAVAILABLE rather than\n"
80
80
  "silently falling back to text. Use it for structural queries a name/regex search cannot express; use\n"
81
- "plain regex or semantic=true for everything else."
81
+ "plain regex or semantic=true for everything else.\n\n"
82
+ "globs= and lang= SCOPE EVERY MODE, semantic=true included: they are literal file bounds (ripgrep glob\n"
83
+ "engine; lang by extension), and under --semantic meaning is ranked only WITHIN that file set, never\n"
84
+ "used to SELECT files. A glob is a guess about WHERE code lives, so a REGEX search also reports what the\n"
85
+ "glob HID: structured=true returns `glob_excluded` (files that match the pattern but the glob excluded —\n"
86
+ "the silent false-negatives of a too-narrow glob; empty without a glob), and the prose render warns the\n"
87
+ "same. If the glob was narrower than your intent, drop/widen it or switch to semantic=true.\n\n"
88
+ "ARCHIVES ARE OPAQUE: .zip/.tar.gz/.jar/etc. are NEVER indexed or searched, so their contents cannot\n"
89
+ "appear in any result. When archives sit under the searched scope the prose render NAMES them (so a miss\n"
90
+ "is not misread as 'nothing anywhere'); structured=true returns them under `archives` — on a semantic\n"
91
+ "search always, and on a name search when there were zero hits (mirroring the prose, no extra walk on the\n"
92
+ "hit path)."
82
93
  )
83
94
 
84
95
 
@@ -100,7 +111,8 @@ def _search(pattern, roots, case_insensitive=False, fixed_string=False,
100
111
  # remember at the moment remembering fails.
101
112
  from . import semantic as sem
102
113
  try:
103
- hits = sem.search_by_meaning(pattern, roots or [os.getcwd()], k=k, unit=unit, kind=kind)
114
+ hits = sem.search_by_meaning(pattern, roots or [os.getcwd()], k=k, unit=unit, kind=kind,
115
+ globs=globs, lang=lang)
104
116
  return sem.render_meaning_hits(pattern, hits)
105
117
  except sem.Unavailable as e:
106
118
  # An unavailable search is NOT an empty result, and saying "no matches" here would recreate the
@@ -157,7 +169,11 @@ def _run_fastmcp(http=None) -> bool:
157
169
  structural=True: match by AST SHAPE, not regex — `pattern` is a code snippet with metavariables
158
170
  ($NAME matches one node, $$$ is variadic). Requires lang= (the tree-sitter grammar the pattern parses
159
171
  in, e.g. "python"/"go"/"rust"); reports UNAVAILABLE when no grammar is usable rather than falling back
160
- to text. lang is used ONLY by structural search.
172
+ to text.
173
+
174
+ globs / lang also SCOPE semantic=True: globs restricts meaning-search to files matching an rg glob
175
+ (e.g. ["*.py"]) — needs ripgrep, else UNAVAILABLE rather than a silently unfiltered result — and lang
176
+ restricts it to one language by file extension. They select the file set; meaning only ranks within it.
161
177
 
162
178
  unit: --semantic granularity — "symbol" (default; a whole function/method, the only pre-v6 behaviour,
163
179
  byte-stable) | "block" (a sub-function AST block, Type-4 clone retrieval) | "data" (a schema/data/config
@@ -358,7 +374,23 @@ def main() -> None:
358
374
  sys.exit(2)
359
375
  host = os.environ.get("SYMGREP_MCP_HOST", HTTP_HOST_DEFAULT)
360
376
  port = int(os.environ.get("SYMGREP_MCP_PORT", HTTP_PORT_DEFAULT))
361
- _run_fastmcp(http=(host, port))
377
+ # WARM SHARED DAEMON = SINGLETON (one per port): every session CONNECTS to this warm HTTP server via urllib
378
+ # instead of spawning a stdio copy. A second start detects the incumbent (flock via spendguard's single-instance
379
+ # primitive) and exits 0 — never races FastMCP into a bind crash — so a supervisor can (re)start it idempotently.
380
+ try:
381
+ from spendguard import config as _sgc
382
+ _lock_cm = _sgc.single_instance_lock(f"symgrep-mcp-http-{port}")
383
+ except Exception:
384
+ _lock_cm = None
385
+ if _lock_cm is not None:
386
+ with _lock_cm as _got:
387
+ if not _got:
388
+ sys.stderr.write(f"symgrep-mcp: a warm HTTP daemon is already serving on :{port} — not starting "
389
+ f"a second (the warm daemon is a singleton; connect to the running one).\n")
390
+ return
391
+ _run_fastmcp(http=(host, port))
392
+ else:
393
+ _run_fastmcp(http=(host, port))
362
394
  return
363
395
  if not _run_fastmcp():
364
396
  _run_fallback()
@@ -15,6 +15,15 @@ ONE hit shape for BOTH search modes, so a consumer writes one parser:
15
15
  {symbol, path, line, start_line, end_line, kind, basis, score?, desc?, match_lines?}
16
16
  - name search : score/desc absent; match_lines = the matched line numbers in that symbol
17
17
  - semantic : score + desc present; match_lines absent; span resolved from the file's AST
18
+
19
+ Two result-level HONESTY keys make a silent false-negative visible to a PROGRAM, not just a human reader:
20
+ - glob_excluded : (name mode) files that MATCH the pattern but --glob EXCLUDED — the dropped matches of a
21
+ too-narrow glob; empty without a glob. The machine twin of the prose warning.
22
+ - archives : opaque containers (.zip/.tar.gz/.jar/…) under the searched scope, which are NEVER
23
+ indexed or searched, so their contents can never appear in `hits`. Present on a
24
+ semantic search always, and on a name search only when there were zero hits (mirroring
25
+ the prose render; no filesystem walk on the hit path). archives_capped flags a truncated
26
+ walk, never a silent undercount.
18
27
  """
19
28
  import os
20
29
  from typing import List, Optional
@@ -39,13 +48,20 @@ def structured_search(pattern, roots, *, case_insensitive=False, fixed_string=Fa
39
48
  structural=False, lang=None, usage=False, resolve=False, unit="symbol", kind=None) -> dict:
40
49
  """Return a dict: {status, mode, hits, ...}. status ∈ ok | no_index | unavailable — mechanical, so a
41
50
  consumer never parses prose to learn availability."""
51
+ # Resolve the search scope ONCE and RECORD it on every result below as `roots` provenance: an empty
52
+ # `roots` falls back to the host cwd, and an ambient cwd that silently decides which files are scanned —
53
+ # and therefore the findings — is not reproducible unless the output names it. A caller that passed roots
54
+ # sees them echoed back unchanged; a caller that passed none sees exactly which directory was searched.
55
+ # Every return below carries "roots": eff_roots so no result's scope is left to an unrecorded ambient value.
56
+ eff_roots = list(roots) if roots else [os.getcwd()]
42
57
  if resolve:
43
58
  # R0 cross-file candidate resolution: definitions (cross-file symbol table) + references, with
44
59
  # AMBIGUITY surfaced (n_def>1). Candidates are by name (basis=candidate-name), never binding-resolved.
45
60
  from .resolve import resolve_name
46
- r = resolve_name(pattern, roots or [os.getcwd()], case_insensitive=case_insensitive,
61
+ r = resolve_name(pattern, eff_roots, case_insensitive=case_insensitive,
47
62
  fixed_string=fixed_string, include_ignored=include_ignored, globs=globs)
48
- return {"status": "ok", "mode": "resolve", "verdict": r.verdict, "ambiguous": r.ambiguous,
63
+ return {"status": "ok", "mode": "resolve", "roots": eff_roots, "verdict": r.verdict,
64
+ "ambiguous": r.ambiguous,
49
65
  "n_def": r.n_def, "n_ref": r.n_ref, "files_scanned": r.files_scanned,
50
66
  # R1: how many references were narrowed to a single binding vs left as R0 candidates. Each
51
67
  # reference carries its own basis (resolved-local/-import/-external/candidate-name) + resolved_to.
@@ -58,36 +74,38 @@ def structured_search(pattern, roots, *, case_insensitive=False, fixed_string=Fa
58
74
  # Count ALL occurrences (not the display cap) — a truncated count could make a used symbol look
59
75
  # unused, the silent undercount the tool refuses. references[] is still trimmed to max_sites for the
60
76
  # payload, but n_ref/verdict are computed over everything.
61
- rep = build_report(pattern, roots or None, case_insensitive=case_insensitive,
77
+ rep = build_report(pattern, eff_roots, case_insensitive=case_insensitive,
62
78
  fixed_string=fixed_string, multiline=multiline, globs=globs,
63
79
  include_ignored=include_ignored, max_sites=10_000_000)
64
80
  mark_definitions(rep, pattern, fixed_string=fixed_string, case_insensitive=case_insensitive)
65
81
  s = usage_summary(rep)
66
- return {"status": "ok", "mode": "usage", "verdict": s.verdict, "n_def": s.n_def, "n_ref": s.n_ref,
82
+ return {"status": "ok", "mode": "usage", "roots": eff_roots, "verdict": s.verdict, "n_def": s.n_def,
83
+ "n_ref": s.n_ref,
67
84
  "ref_files": s.ref_files, "definitions": s.definitions, "references": s.references[:max_sites],
68
85
  "lexical": True}
69
86
  if structural:
70
87
  from . import structural as st
71
88
  try:
72
- report = st.structural_report(pattern, roots or [os.getcwd()], lang=lang,
89
+ report = st.structural_report(pattern, eff_roots, lang=lang,
73
90
  include_ignored=include_ignored, max_sites=max_sites)
74
91
  except st.StructuralUnavailable as e:
75
- return {"status": "unavailable", "mode": "structural", "message": str(e)}
92
+ return {"status": "unavailable", "mode": "structural", "roots": eff_roots, "message": str(e)}
76
93
  hits = [{"symbol": site.symbol.name, "path": site.path,
77
94
  "line": site.matches[0].line_number if site.matches else site.symbol.start,
78
95
  "start_line": site.symbol.start, "end_line": site.symbol.end, "kind": site.symbol.kind,
79
96
  "basis": "ast-pattern", "match_lines": [m.line_number for m in site.matches]}
80
97
  for site in report.sites[:max_sites]]
81
- return {"status": "ok", "mode": "structural", "hits": hits,
98
+ return {"status": "ok", "mode": "structural", "roots": eff_roots, "hits": hits,
82
99
  "total_sites": len(report.sites), "skipped": report.skipped_errors}
83
100
  if semantic:
84
101
  from . import semantic as sem
85
102
  try:
86
- hits = sem.search_by_meaning(pattern, roots or [os.getcwd()], k=k, unit=unit, kind=kind)
103
+ hits = sem.search_by_meaning(pattern, eff_roots, k=k, unit=unit, kind=kind,
104
+ globs=globs, lang=lang)
87
105
  except sem.Unavailable as e:
88
106
  code = getattr(e, "code", "unavailable")
89
107
  return {"status": "no_index" if code == "no_index" else "unavailable",
90
- "mode": "semantic", "reason": code, "message": str(e)}
108
+ "mode": "semantic", "roots": eff_roots, "reason": code, "message": str(e)}
91
109
  # Payloads aligned to `hits` (search_by_meaning.last_hits) carry the unit + span, so a BLOCK or DATA
92
110
  # hit surfaces its OWN span + kind + enclosing while a SYMBOL hit keeps the exact pre-v6 key set (the
93
111
  # AST-resolved enclosing span). A consumer parses ONE shape; block/data-only keys are purely additive.
@@ -110,10 +128,16 @@ def structured_search(pattern, roots, *, case_insensitive=False, fixed_string=Fa
110
128
  out.append({"symbol": qual, "path": path, "line": lineno,
111
129
  "start_line": start, "end_line": end, "kind": skind, "basis": basis,
112
130
  "score": score, "desc": what})
113
- return {"status": "ok", "mode": "semantic", "hits": out,
114
- "scope": getattr(sem.search_by_meaning, "last_scope", None)}
131
+ # Opaque archives under scope, so a machine consumer sees what the prose render already NAMES — a
132
+ # .zip/.tar.gz is never indexed, so its contents can never appear here, and that absence must be
133
+ # visible, not inferred. Always on a semantic search (the semantic render always notes them).
134
+ from . import archives
135
+ _apaths, _acapped = archives.archive_files(eff_roots)
136
+ return {"status": "ok", "mode": "semantic", "roots": eff_roots, "hits": out,
137
+ "scope": getattr(sem.search_by_meaning, "last_scope", None),
138
+ "archives": _apaths, "archives_capped": _acapped}
115
139
 
116
- report = build_report(pattern, roots or None, case_insensitive=case_insensitive,
140
+ report = build_report(pattern, eff_roots, case_insensitive=case_insensitive,
117
141
  fixed_string=fixed_string, multiline=multiline, globs=globs,
118
142
  include_ignored=include_ignored, max_sites=max_sites)
119
143
  # Kythe/OpenGrok anchor: tag which of a hit's occurrences DECLARE a matching symbol (definition)
@@ -132,10 +156,25 @@ def structured_search(pattern, roots, *, case_insensitive=False, fixed_string=Fa
132
156
  "match_lines": match_lines,
133
157
  # occurrences on this symbol's declaration line (a subset of match_lines); [] = all refs
134
158
  "def_lines": defs, "role": "definition" if defs else "reference"})
135
- return {"status": "ok", "mode": "name", "hits": out,
159
+ # Opaque archives under scope — ONLY on a zero-hit name search, mirroring the prose render (report.py
160
+ # appends the archive note only when there are no sites) and avoiding a filesystem walk on the common
161
+ # hit path. A .zip/.tar.gz is never indexed/searched, so on a miss a consumer must be able to tell
162
+ # "nothing matched, and there were also these unopened containers" from "nothing anywhere".
163
+ if report.sites:
164
+ _apaths, _acapped = [], False
165
+ else:
166
+ from . import archives
167
+ _apaths, _acapped = archives.archive_files(eff_roots)
168
+ return {"status": "ok", "mode": "name", "roots": eff_roots, "hits": out,
136
169
  "total_sites": len(report.sites), "shown_sites": min(max_sites, len(report.sites)),
137
170
  "total_line_matches": report.total_line_matches, "total_files": report.total_files,
138
171
  "truncated_events": report.truncated_events,
172
+ # Files matching the pattern that --glob excluded (honest recall for a too-narrow glob); empty
173
+ # without a glob. A structured consumer sees the silent false-negatives, not just what matched.
174
+ "glob_excluded": report.glob_excluded,
175
+ # Opaque archives under scope on a zero-hit search (empty when there were hits); contents are
176
+ # never indexed/searched, so this is how a program learns they were skipped, not absent.
177
+ "archives": _apaths, "archives_capped": _acapped,
139
178
  "expansions": report.expansions, "skipped_errors": report.skipped_errors}
140
179
 
141
180
 
@@ -16,6 +16,7 @@ from collections import OrderedDict
16
16
  from dataclasses import dataclass, field
17
17
  from typing import Dict, List, Optional
18
18
 
19
+ from .archives import archive_note
19
20
  from .enclosing import Symbol, file_symbols
20
21
  from .ripgrep import RgResult, RawMatch, count_files_with_match, ExpansionFailed
21
22
 
@@ -75,6 +76,11 @@ class Report:
75
76
  # line where that symbol's own name matches the query). Everything else is a reference. Lists (not sets)
76
77
  # so dataclasses.asdict → json.dumps stays serializable. Populated by mark_definitions(), empty until then.
77
78
  def_lines: Dict[str, List[int]] = field(default_factory=dict)
79
+ # Files that MATCH the pattern but --glob EXCLUDED (honest recall for a too-narrow glob), plus the
80
+ # rendered advisory note. Empty/None when no glob was given or nothing matching was excluded. Populated by
81
+ # build_report; surfaced by render_report whether or not there were hits.
82
+ glob_excluded: List[str] = field(default_factory=list)
83
+ glob_excluded_note: Optional[str] = None
78
84
 
79
85
 
80
86
  def build_sites(rg: RgResult) -> List[Site]:
@@ -364,6 +370,13 @@ def render_report(report: Report, max_lines_per_site: int = DEFAULT_MAX_LINES_PE
364
370
  if len(report.skipped_errors) > 5:
365
371
  lines.append(f" … {len(report.skipped_errors) - 5} more")
366
372
 
373
+ # GLOB RECALL HONESTY (whether or not there were hits): a --glob is a GUESS about where code lives, and a
374
+ # wrong guess hides relevant files SILENTLY. If the glob excluded files that ALSO match the pattern, say
375
+ # so here so a too-narrow glob is visible, never a silent false-negative. (Finding files by MEANING is
376
+ # --semantic's job; this only makes the glob's exclusions honest.)
377
+ if getattr(report, "glob_excluded_note", None):
378
+ lines.append(report.glob_excluded_note)
379
+
367
380
  if not report.sites and report.mode == "structural":
368
381
  # A structural zero-hit is "no code of this SHAPE" — NOT the name-search story below, and NOT a
369
382
  # reason to reach for --semantic. Coverage (skipped_errors above) is already stated.
@@ -392,6 +405,12 @@ def render_report(report: Report, max_lines_per_site: int = DEFAULT_MAX_LINES_PE
392
405
  "nothing DOES this.")
393
406
  lines.append(f" before writing something new, ask by meaning: symgrep --semantic '{report.pattern}' "
394
407
  f"(or describe the job: --semantic \"what the function would do\")")
408
+ # An absent NAME is one way a result reads falsely as "nothing here"; an OPAQUE ARCHIVE is another —
409
+ # rg does not search inside .zip/.tar.gz and symgrep does not index them, so a hit living inside one is
410
+ # invisible with no signal. Say so when archives sit under the searched scope (advisory; no extraction).
411
+ _arch = archive_note(report.roots)
412
+ if _arch:
413
+ lines.append(_arch)
395
414
  return "\n".join(lines)
396
415
 
397
416
  shown = report.sites[:report.shown_sites]
@@ -568,9 +568,18 @@ def _module_name(f: pathlib.Path) -> str:
568
568
 
569
569
 
570
570
  def search_by_meaning(query: str, roots: List[str], k: int = 8,
571
- unit: str = "symbol", kind: Optional[str] = None) -> List[Tuple[float, str, str, int, str]]:
571
+ unit: str = "symbol", kind: Optional[str] = None,
572
+ globs: Optional[List[str]] = None,
573
+ lang: Optional[str] = None) -> List[Tuple[float, str, str, int, str]]:
572
574
  """[(score, qual, path, lineno, what)] — the closest capabilities BY MEANING. Retrieval only.
573
575
 
576
+ `globs`/`lang` SCOPE the result to files matching an rg glob (e.g. ["*.py"]) and/or a tree-sitter
577
+ language (by extension). They select the FILE SET mechanically — exactly as the literal name search does
578
+ with --glob — and meaning still only RANKS within it; there is no meaning-based file selection. The filter
579
+ is applied BEFORE the top-k cut so it never under-fills. A glob uses ripgrep's OWN glob engine (so the
580
+ scope matches the name search's, byte for byte); if rg is unavailable the call raises Unavailable rather
581
+ than silently returning results that ignore the requested glob.
582
+
574
583
  Raises Unavailable when the index or the embedder is missing, so a caller can never mistake "this could
575
584
  not run" for "nothing in the codebase does that". The ranking is veccore's (dot == cosine over the
576
585
  L2-normalized description vectors); the roots scope + staleness signal + liveness filter are symgrep's.
@@ -637,6 +646,32 @@ def search_by_meaning(query: str, roots: List[str], k: int = 8,
637
646
  # function deleted since the last build that covered it); the next such build reclaims the vector via
638
647
  # veccore's delete seam (_embed_live), so orphans do not accumulate.
639
648
  kept = [h for h in hits if h.id in live and in_scope((h.payload or {}).get("path", ""))]
649
+
650
+ # GLOB / LANG SCOPING (honest: the literal path/extension decides membership — meaning only RANKS within
651
+ # it, it never SELECTS files). Applied BEFORE the top-k cut below so filtering never under-fills. GLOB
652
+ # reuses ripgrep's OWN glob engine (ignore-neutral via --no-ignore, so an indexed-but-gitignored file
653
+ # still matches its glob and the scope equals what the name search would show); if rg is absent we RAISE
654
+ # rather than return a result that quietly ignores the glob. LANG filters by the same extension→grammar
655
+ # map symbol extraction uses. Both are mechanical set membership (parsing), not a meaning judgement.
656
+ def _hit_path(h):
657
+ return os.path.realpath((h.payload or {}).get("path", "") or "")
658
+ if globs:
659
+ from . import ripgrep
660
+ try:
661
+ ripgrep.rg_binary() # RipgrepNotFound is a deliberate stop — surfaced as Unavailable, not swallowed
662
+ except ripgrep.RipgrepNotFound as e:
663
+ raise Unavailable(
664
+ "--glob scoping needs ripgrep, which is not available — refusing to return a semantic result "
665
+ "that silently ignores the requested glob. Install rg, or drop --glob.",
666
+ code="glob_needs_rg") from e
667
+ glob_roots = root_abs or [os.path.realpath(r) for r in indexed_roots()]
668
+ allowed = {os.path.realpath(p) for p in ripgrep.list_files(glob_roots, include_ignored=True, globs=globs)}
669
+ kept = [h for h in kept if _hit_path(h) in allowed]
670
+ if lang:
671
+ from .enclosing import TS_LANG_BY_EXT
672
+ kept = [h for h in kept
673
+ if TS_LANG_BY_EXT.get(os.path.splitext(_hit_path(h))[1].lower()) == lang]
674
+
640
675
  scored = [(h.score, (h.payload or {}).get("qual"), (h.payload or {}).get("path"),
641
676
  (h.payload or {}).get("lineno"), (h.payload or {}).get("what")) for h in kept[:k]]
642
677
  # Full payloads aligned to `scored`, for the structured consumer to read block span + unit/parent WITHOUT
@@ -670,7 +705,16 @@ def search_by_meaning(query: str, roots: List[str], k: int = 8,
670
705
  # (rglob + INDEXABLE_EXTS + not _is_generated — see functions()) and count those absent from the index. A
671
706
  # DISPLAY signal (how far behind the semantic view is), never a decision; scanned over the roots asked
672
707
  # about, or the index's own recorded roots when the search is unscoped.
673
- indexed_paths = {os.path.realpath(p) for p in file_stamp}
708
+ # CONTROL for the not_indexed count below (so the number matches its claim). The claim is: "indexable
709
+ # files on disk under the scanned roots that are in NO index record". The generating constraint is set
710
+ # membership against the FULL bookkeeping set `book` — NOT the in-scope-only `file_stamp` — so an
711
+ # already-indexed file that happens to sit outside the in-scope subset can never be miscounted as a fresh,
712
+ # never-described one. (For a scoped search scan_roots IS the in-scope domain, so this only hardens the
713
+ # invariant; the count is unchanged there. For an unscoped search it closes the one path by which the
714
+ # measured count could exceed the claimed one.) The denominator for the freshness rate above is
715
+ # file_stamp (asserted non-empty by `in_index`); this count is a raw count, no rate, so it needs only its
716
+ # named membership constraint, which is `indexed_paths`.
717
+ indexed_paths = {os.path.realpath(p) for _k, p, _s in book}
674
718
  scan_roots = root_abs or [os.path.realpath(r) for r in indexed_roots()]
675
719
  not_indexed = 0
676
720
  for _root in scan_roots:
@@ -685,7 +729,8 @@ def search_by_meaning(query: str, roots: List[str], k: int = 8,
685
729
  "bad_dim": 0, "roots": root_abs,
686
730
  "files_checked": len(file_stamp), "stale_files": stale,
687
731
  "unknown_age": unknown_age, "gone_files": gone_files,
688
- "not_indexed_files": not_indexed}
732
+ "not_indexed_files": not_indexed,
733
+ "glob": list(globs) if globs else None, "lang": lang}
689
734
  return scored
690
735
 
691
736
 
@@ -699,6 +744,15 @@ def render_meaning_hits(query: str, hits, index=None) -> str:
699
744
  lines.append(f" scope: {scope.get('in_index', 0)} indexed function(s); "
700
745
  f"{scope.get('out_of_scope', 0)} outside the roots asked about (excluded, say so); "
701
746
  f"{scope.get('bad_dim', 0)} with incompatible vectors (excluded)")
747
+ if scope.get("glob"):
748
+ lines.append(f" scope: restricted to files matching --glob {scope['glob']} (ripgrep glob "
749
+ f"engine) — meaning was ranked only WITHIN that file set")
750
+ if scope.get("lang"):
751
+ lines.append(f" scope: restricted to --lang {scope['lang']} (by file extension)")
752
+ from . import archives
753
+ arch_note = archives.archive_note(scope.get("roots") or [])
754
+ if arch_note:
755
+ lines.append(arch_note)
702
756
  stale = scope.get("stale_files", 0); unk = scope.get("unknown_age", 0); gone = scope.get("gone_files", 0)
703
757
  new = scope.get("not_indexed_files", 0)
704
758
  if stale or unk or gone:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symgrep-codesearch
3
- Version: 0.6.4
3
+ Version: 0.7.0
4
4
  Summary: Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/ashdamle/symgrep
@@ -140,11 +140,20 @@ that needed "every function with its scope-qualified name and span" wrote its ow
140
140
  ## Use
141
141
 
142
142
  ```bash
143
- symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
143
+ symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--lang L] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
144
144
  symgrep --index src/ # build the semantic index (incremental)
145
145
  symgrep --semantic "what the function would do" src/ # search by meaning
146
+ symgrep --semantic "…" --glob '*.py' --lang python src/ # meaning, scoped to a file set (ranked WITHIN it)
146
147
  ```
147
148
 
149
+ `--glob` and `--lang` scope **every** mode, `--semantic` included: they are literal file bounds, and under
150
+ `--semantic` meaning is ranked only *within* the matching files — never used to *select* files (selecting by
151
+ meaning would silently drop relevant files, the exact blindness symgrep exists to remove). Because a glob is
152
+ a guess about *where* code lives, a **regex** search also reports what the glob **hid** — files that match the
153
+ pattern but fall outside the glob — so a too-narrow `--glob` is visible, not a silent false-negative. And
154
+ archives (`.zip`/`.tar.gz`/`.jar`/…) are **opaque**: never indexed or searched, but **named** in the output
155
+ when they sit under the scope, so their contents can't be mistaken for *not there*.
156
+
148
157
  The index stores one sentence per function describing its **job**, keyed by body hash, so a rebuild only
149
158
  costs the functions that changed. Embeddings are local (`fastembed` / bge-small): no API key, no network,
150
159
  nothing about the code leaves the machine.
@@ -384,7 +393,9 @@ wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionS
384
393
  (fastmcp requires both media types in Accept even though the reply is plain JSON).
385
394
  - Call: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{…}}}`
386
395
  with the same arguments as the stdio tool: `pattern`, `roots` (required), `case_insensitive`,
387
- `fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`.
396
+ `fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`, `lang`.
397
+ `globs` and `lang` scope **every** mode including `semantic` (literal file bounds; under `semantic`,
398
+ meaning is ranked only within them, never used to select files).
388
399
  - Reply: `result.content[0].text` = the same rendered report the stdio tool returns (honest zero-hits,
389
400
  FRESHNESS line, scope line included). Tool errors arrive as JSON-RPC errors, not empty results.
390
401
  - **Structured output**: pass `"structured": true` (or the alias `"json_output": true`) and
@@ -394,6 +405,12 @@ wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionS
394
405
  basis, score?, desc?, match_lines?}], …}` — name hits carry `match_lines`; semantic hits carry
395
406
  `score` + `desc` and resolve the span from the file's AST. `scope` (semantic) carries the
396
407
  staleness/coverage counters.
408
+ - Two result-level **honesty** keys expose a silent false-negative to a program, not just a human:
409
+ `glob_excluded` (name mode) lists files that match the pattern but `--glob` excluded — the dropped
410
+ matches of a too-narrow glob, empty without a glob; `archives` lists opaque containers
411
+ (`.zip`/`.tar.gz`/…) under the scope whose contents are never indexed/searched — present on a semantic
412
+ search always, and on a name search only at zero hits (mirroring the prose; no walk on the hit path),
413
+ with `archives_capped` flagging a truncated walk rather than a silent undercount.
397
414
  - `{status: "no_index", …}` when the semantic index isn't built (fixable by `symgrep --index`) vs
398
415
  `{status: "unavailable", reason: …, message: …}` when it cannot run (no embedder / corrupt). These
399
416
  are distinguishable **without** reading the prose — the honesty feature a program needs.
@@ -2,12 +2,14 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/symgrep/__init__.py
5
+ src/symgrep/archives.py
5
6
  src/symgrep/blocks.py
6
7
  src/symgrep/classfile.py
7
8
  src/symgrep/cli.py
8
9
  src/symgrep/data_symbols.py
9
10
  src/symgrep/docsymbols.py
10
11
  src/symgrep/enclosing.py
12
+ src/symgrep/glob_recall.py
11
13
  src/symgrep/mcp_server.py
12
14
  src/symgrep/mcp_structured.py
13
15
  src/symgrep/report.py
@@ -49,6 +51,7 @@ tests/test_resolve_python.py
49
51
  tests/test_resolve_r2.py
50
52
  tests/test_review_regressions.py
51
53
  tests/test_semantic_freshness_signal.py
54
+ tests/test_semantic_glob_lang.py
52
55
  tests/test_sites_and_symbols.py
53
56
  tests/test_structural.py
54
57
  tests/test_symbol_vocabulary.py
@@ -0,0 +1,239 @@
1
+ """Glob/lang scoping under --semantic, and the opaque-archive honest-skip note.
2
+
3
+ Two fixes, both guarding a SILENT non-handling that read as "nothing there":
4
+ 1. `--glob`/`--lang` were PARSED but never passed to search_by_meaning, so a semantic search silently
5
+ ignored the requested scope. These pin the plumbing (the exact drop) AND the mechanics (rg glob
6
+ membership, the extension→grammar map) AND the honest render + the Unavailable-when-rg-absent contract.
7
+ 2. Archives (.zip/.tar.gz/…) are opaque — never indexed or searched. These pin the detector and that both
8
+ the literal zero-hit report and the semantic render SAY SO instead of letting archive contents vanish.
9
+
10
+ Also pins the ambient-cwd provenance fix: structured_search records `roots` on every result.
11
+
12
+ Run: python tests/test_semantic_glob_lang.py
13
+ """
14
+ import os
15
+ import sys
16
+ import tempfile
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, os.path.join(os.path.dirname(HERE), "src"))
20
+ sys.path.insert(0, HERE)
21
+ from checklib import Checks # noqa: E402
22
+ _t = Checks("test_semantic_glob_lang")
23
+ check = _t.check
24
+
25
+ from symgrep import archives # noqa: E402
26
+ from symgrep import semantic # noqa: E402
27
+ from symgrep import mcp_structured # noqa: E402
28
+ from symgrep import mcp_server # noqa: E402
29
+ from symgrep.enclosing import TS_LANG_BY_EXT # noqa: E402
30
+ from symgrep.report import Report, render_report # noqa: E402
31
+
32
+
33
+ def _fixture_tree():
34
+ d = tempfile.mkdtemp(prefix="symgrep-glob-test-")
35
+ open(os.path.join(d, "a.py"), "w").close()
36
+ open(os.path.join(d, "b.js"), "w").close()
37
+ open(os.path.join(d, "bundle.zip"), "w").close()
38
+ open(os.path.join(d, "data.tar.gz"), "w").close()
39
+ open(os.path.join(d, "notes.txt.gz"), "w").close() # bare .gz — a single compressed file, NOT a container
40
+ sub = os.path.join(d, "sub")
41
+ os.makedirs(sub)
42
+ open(os.path.join(sub, "lib.jar"), "w").close()
43
+ return d
44
+
45
+
46
+ # ---- fix 2: archive detection (the OPAQUE contract made visible) --------------------------------------
47
+ check("is_archive: .zip / .tar.gz / .jar are archives", all(archives.is_archive(n)
48
+ for n in ("x.zip", "x.tar.gz", "x.tgz", "lib.jar")), "a known archive suffix was not recognised")
49
+ check("is_archive: source + bare .gz are NOT archives (a .gz is one file, not a container)",
50
+ not any(archives.is_archive(n) for n in ("a.py", "b.js", "notes.txt.gz", "r.txt")),
51
+ "a non-container was misread as an archive")
52
+
53
+ _fx = _fixture_tree()
54
+ found, capped = archives.archive_files([_fx])
55
+ _names = sorted(os.path.basename(p) for p in found)
56
+ check("archive_files finds the containers (recursively), not the source/.gz",
57
+ _names == ["bundle.zip", "data.tar.gz", "lib.jar"], f"got {_names}")
58
+ check("archive_files reports capped=False on a small complete scan", capped is False, f"capped={capped}")
59
+ check("archive_note names the opaque archives and says contents are not covered",
60
+ (lambda n: n is not None and "archive(s)" in n and "OPAQUE" in n)(archives.archive_note([_fx])),
61
+ "archive_note did not produce the honest-skip line")
62
+ check("archive_note is None when there are no archives (vanishes when irrelevant)",
63
+ archives.archive_note([tempfile.mkdtemp(prefix="empty-")]) is None, "note fired with no archives")
64
+
65
+ # capped scan must SIGNAL, never silently undercount (the truncation doctrine)
66
+ _f2, _cap2 = archives.archive_files([_fx], cap=1)
67
+ check("archive_files signals capped=True when the file cap is hit (no silent undercount)",
68
+ _cap2 is True, f"capped={_cap2} with cap=1")
69
+
70
+
71
+ # ---- fix 2 wiring: the literal zero-hit report emits the archive note ----------------------------------
72
+ _rep = Report(pattern="zzz_no_such_symbol_qpz", roots=[_fx], sites=[], total_line_matches=0,
73
+ total_files=0, truncated_events=False, shown_sites=0)
74
+ _rendered = render_report(_rep)
75
+ check("literal zero-hit report appends the opaque-archive note when archives are under scope",
76
+ "archive(s)" in _rendered and "OPAQUE" in _rendered, _rendered[-200:])
77
+
78
+
79
+ # ---- fix 1 plumbing: --glob/--lang actually REACH search_by_meaning (the exact drop that was the bug) --
80
+ class _Recorder:
81
+ """Stands in for search_by_meaning; records the kwargs it was called with and returns no hits."""
82
+ def __init__(self):
83
+ self.calls = []
84
+ self.last_hits = []
85
+ self.last_scope = {}
86
+
87
+ def __call__(self, pattern, roots, k=8, unit="symbol", kind=None, globs=None, lang=None):
88
+ self.calls.append({"globs": globs, "lang": lang, "roots": roots, "k": k, "unit": unit})
89
+ self.last_scope = {"roots": roots, "glob": globs, "lang": lang, "in_index": 0, "out_of_scope": 0,
90
+ "bad_dim": 0, "files_checked": 0, "stale_files": 0, "unknown_age": 0,
91
+ "gone_files": 0, "not_indexed_files": 0}
92
+ return []
93
+
94
+
95
+ _orig = semantic.search_by_meaning
96
+
97
+ rec = _Recorder()
98
+ semantic.search_by_meaning = rec
99
+ try:
100
+ # structured path (structured=True)
101
+ out = mcp_structured.structured_search("find the thing", [_fx], semantic=True,
102
+ globs=["*.py"], lang="python")
103
+ check("structured_search(semantic) forwards globs to search_by_meaning",
104
+ rec.calls and rec.calls[-1]["globs"] == ["*.py"], f"calls={rec.calls}")
105
+ check("structured_search(semantic) forwards lang to search_by_meaning",
106
+ rec.calls[-1]["lang"] == "python", f"calls={rec.calls}")
107
+ check("structured_search records roots provenance on the result (ambient-cwd fix)",
108
+ out.get("roots") == [_fx], f"roots={out.get('roots')}")
109
+
110
+ # MCP non-structured semantic path (_search)
111
+ rec.calls.clear()
112
+ mcp_server._search("find the thing", [_fx], semantic=True, globs=["*.js"], lang="javascript")
113
+ check("mcp _search(semantic) forwards globs+lang to search_by_meaning",
114
+ rec.calls and rec.calls[-1]["globs"] == ["*.js"] and rec.calls[-1]["lang"] == "javascript",
115
+ f"calls={rec.calls}")
116
+
117
+ # CLI path (symgrep --semantic --glob --lang)
118
+ from symgrep import cli
119
+ rec.calls.clear()
120
+ _orig_render = semantic.render_meaning_hits
121
+ semantic.render_meaning_hits = lambda *a, **k: ""
122
+ try:
123
+ cli.main(["myquery", "--semantic", "--glob", "*.py", "--lang", "python", _fx])
124
+ finally:
125
+ semantic.render_meaning_hits = _orig_render
126
+ check("CLI --semantic forwards --glob and --lang to search_by_meaning (the parsed-but-dropped bug)",
127
+ rec.calls and rec.calls[-1]["globs"] == ["*.py"] and rec.calls[-1]["lang"] == "python",
128
+ f"calls={rec.calls}")
129
+ finally:
130
+ semantic.search_by_meaning = _orig
131
+
132
+
133
+ # ---- fix 1 mechanics: the building blocks the filter composes --------------------------------------
134
+ check("lang map: .py→python, .js→javascript, .go→go (the extension→grammar filter basis)",
135
+ TS_LANG_BY_EXT.get(".py") == "python" and TS_LANG_BY_EXT.get(".js") == "javascript"
136
+ and TS_LANG_BY_EXT.get(".go") == "go", "TS_LANG_BY_EXT is missing an expected mapping")
137
+
138
+ # rg glob membership is what the glob filter intersects against — pin it if rg is present.
139
+ from symgrep import ripgrep # noqa: E402
140
+ try:
141
+ _rg = ripgrep.rg_binary()
142
+ except Exception:
143
+ _rg = None
144
+ if _rg:
145
+ py_only = {os.path.realpath(p) for p in ripgrep.list_files([_fx], include_ignored=True, globs=["*.py"])}
146
+ check("rg glob membership (the filter's engine): *.py selects a.py, excludes b.js",
147
+ os.path.realpath(os.path.join(_fx, "a.py")) in py_only
148
+ and os.path.realpath(os.path.join(_fx, "b.js")) not in py_only, f"py_only={py_only}")
149
+ else:
150
+ _t.skip("rg absent — glob-membership mechanics not exercised here")
151
+
152
+
153
+ # ---- fix 1 render: the semantic output SAYS what was scoped and what is opaque ------------------------
154
+ semantic.search_by_meaning.last_scope = {"roots": [_fx], "glob": ["*.py"], "lang": "python",
155
+ "in_index": 3, "out_of_scope": 0, "bad_dim": 0,
156
+ "files_checked": 3, "stale_files": 0, "unknown_age": 0,
157
+ "gone_files": 0, "not_indexed_files": 0}
158
+ _r = semantic.render_meaning_hits("find the thing", [])
159
+ check("semantic render states the --glob scope restriction", "--glob" in _r and "*.py" in _r, _r)
160
+ check("semantic render states the --lang scope restriction", "--lang" in _r and "python" in _r, _r)
161
+ check("semantic render appends the opaque-archive note (archives under the searched scope)",
162
+ "archive(s)" in _r and "OPAQUE" in _r, _r)
163
+
164
+
165
+ # ---- the glob-exclusion honesty signal: a too-narrow --glob on a REGEX search is never silent ----
166
+ # The concern: an agent whose real intent is semantic reaches for a --glob to encode WHERE the code is;
167
+ # when that guess is narrower than the intent, matching files outside it vanish with no signal. This set
168
+ # pins that glob_excluded_matches names those dropped-but-matching files, that the note vanishes when the
169
+ # glob excludes nothing, and that build_report + render SURFACE the exclusion ALONGSIDE the in-glob hits.
170
+ from symgrep import glob_recall # noqa: E402
171
+ from symgrep.cli import build_report # noqa: E402
172
+
173
+ _gx = tempfile.mkdtemp(prefix="symgrep-glob-excl-")
174
+ open(os.path.join(_gx, "widget.py"), "w").write("def handle_WIDGETZ(): pass\n")
175
+ open(os.path.join(_gx, "widget.js"), "w").write("function handle_WIDGETZ(){}\n")
176
+ open(os.path.join(_gx, "readme.md"), "w").write("WIDGETZ documentation\n")
177
+
178
+ if _rg: # glob_excluded_matches + build_report both need ripgrep
179
+ _excl, _note = glob_recall.glob_excluded_matches("WIDGETZ", [_gx], ["*.py"])
180
+ _exn = sorted(os.path.basename(p) for p in _excl)
181
+ check("glob_excluded_matches names the matching files a *.py glob EXCLUDED (widget.js, readme.md)",
182
+ _exn == ["readme.md", "widget.js"], f"excluded={_exn}")
183
+ check("glob_excluded_matches: the note fires and points at --semantic as the meaning-based alternative",
184
+ _note is not None and "--glob EXCLUDED" in _note and "--semantic" in _note, f"note={_note!r}")
185
+ check("glob_excluded_matches: no glob → ([], None), never a false 'nothing excluded'",
186
+ glob_recall.glob_excluded_matches("WIDGETZ", [_gx], None) == ([], None), "fired without a glob")
187
+ check("glob_excluded_matches: a glob that drops no MATCHING file → ([], None) (nothing to warn about)",
188
+ glob_recall.glob_excluded_matches("WIDGETZ", [_gx], ["*.py", "*.js", "*.md"]) == ([], None),
189
+ "warned when the glob excluded no matching file")
190
+
191
+ # end-to-end: there ARE hits (in widget.py), yet the report still surfaces what the glob hid.
192
+ _repx = build_report("WIDGETZ", [_gx], globs=["*.py"])
193
+ check("build_report populates glob_excluded with the matching files the glob hid",
194
+ sorted(os.path.basename(p) for p in _repx.glob_excluded) == ["readme.md", "widget.js"],
195
+ f"glob_excluded={_repx.glob_excluded}")
196
+ check("build_report sets glob_excluded_note (the honest-recall warning) when a glob hid matches",
197
+ _repx.glob_excluded_note is not None and "--glob EXCLUDED" in _repx.glob_excluded_note,
198
+ f"note={_repx.glob_excluded_note!r}")
199
+ _renderx = render_report(_repx)
200
+ check("rendered report SURFACES the glob-exclusion warning next to the in-glob hit (not silent)",
201
+ "--glob EXCLUDED" in _renderx and "--semantic" in _renderx, _renderx[-400:])
202
+
203
+ # a search WITHOUT a glob carries no exclusion note (the signal is glob-specific, not always-on).
204
+ _repn = build_report("WIDGETZ", [_gx])
205
+ check("build_report without a glob leaves glob_excluded empty and the note unset",
206
+ _repn.glob_excluded == [] and _repn.glob_excluded_note is None,
207
+ f"glob_excluded={_repn.glob_excluded} note={_repn.glob_excluded_note!r}")
208
+
209
+ # the structured (machine) twin exposes the same dropped set, so a programmatic consumer sees it too.
210
+ _structx = mcp_structured.structured_search("WIDGETZ", [_gx], globs=["*.py"])
211
+ check("structured_search(name) exposes glob_excluded so a program sees the silent false-negatives",
212
+ sorted(os.path.basename(p) for p in _structx.get("glob_excluded", [])) == ["readme.md", "widget.js"],
213
+ f"glob_excluded={_structx.get('glob_excluded')}")
214
+ else:
215
+ _t.skip("rg absent — glob-exclusion signal not exercised here")
216
+
217
+
218
+ # ---- archive honesty reaches the STRUCTURED payload too (machine parity with the prose render) -------
219
+ # A .zip/.tar.gz is never indexed/searched, so a programmatic consumer must be able to tell "searched,
220
+ # nothing matched, and there were these unopened containers" from "nothing anywhere". The prose render
221
+ # names archives on a name-search ONLY at zero hits; the structured name payload mirrors that exactly.
222
+ _ax = _fixture_tree() # has bundle.zip, data.tar.gz, sub/lib.jar + a.py/b.js (empty)
223
+ if _rg:
224
+ _zero = mcp_structured.structured_search("zzz_no_such_symbol_qpz", [_ax])
225
+ _azn = sorted(os.path.basename(p) for p in _zero.get("archives", []))
226
+ check("structured_search(name) surfaces opaque archives on a ZERO-hit search (machine parity)",
227
+ _azn == ["bundle.zip", "data.tar.gz", "lib.jar"], f"archives={_azn}")
228
+ check("structured_search(name) reports archives_capped=False on a small complete scan",
229
+ _zero.get("archives_capped") is False, f"archives_capped={_zero.get('archives_capped')}")
230
+
231
+ # a name search WITH hits does NOT walk for archives (empty), matching the prose (no note when hits) and
232
+ # keeping the hit path free of an extra filesystem walk — the cost discipline, pinned.
233
+ open(os.path.join(_ax, "hit.py"), "w").write("def zzz_no_such_symbol_qpz(): pass\n")
234
+ _hit = mcp_structured.structured_search("zzz_no_such_symbol_qpz", [_ax])
235
+ check("structured_search(name) with HITS omits the archive walk (archives empty on the hit path)",
236
+ _hit.get("total_sites", 0) >= 1 and _hit.get("archives") == [],
237
+ f"total_sites={_hit.get('total_sites')} archives={_hit.get('archives')}")
238
+
239
+ _t.finish()