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.
- {symgrep_codesearch-0.6.4/src/symgrep_codesearch.egg-info → symgrep_codesearch-0.7.0}/PKG-INFO +20 -3
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/README.md +19 -2
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/pyproject.toml +1 -1
- symgrep_codesearch-0.7.0/src/symgrep/archives.py +86 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/cli.py +39 -5
- symgrep_codesearch-0.7.0/src/symgrep/glob_recall.py +54 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/mcp_server.py +36 -4
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/mcp_structured.py +52 -13
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/report.py +19 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/semantic.py +57 -3
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0/src/symgrep_codesearch.egg-info}/PKG-INFO +20 -3
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/SOURCES.txt +3 -0
- symgrep_codesearch-0.7.0/tests/test_semantic_glob_lang.py +239 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/LICENSE +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/setup.cfg +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/__init__.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/blocks.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/classfile.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/data_symbols.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/docsymbols.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/enclosing.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/resolve.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/resolve_js.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/resolve_python.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/ripgrep.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/structural.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep/structure.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/dependency_links.txt +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/entry_points.txt +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/requires.txt +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/top_level.txt +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_blocks_extraction.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_blocks_retrieval.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_bulk_describe.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_classfile.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_data_symbols.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_describe_lane_confinement.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_doc_symbols.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_file_structure.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_freshness.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_honest_recall.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_http_ensure.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_index_reconstruct.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_index_scope.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_index_worker.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_mcp_structured.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_name_uniqueness.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_nudge_classify.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_nudge_runaway.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_report_filters.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve_js.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve_python.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_resolve_r2.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_review_regressions.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_semantic_freshness_signal.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_sites_and_symbols.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_structural.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_symbol_vocabulary.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_ts_fallback_loud.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_ts_symbol_coverage.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_usage.py +0 -0
- {symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_veccore_adoption.py +0 -0
{symgrep_codesearch-0.6.4/src/symgrep_codesearch.egg-info → symgrep_codesearch-0.7.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: symgrep-codesearch
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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", "
|
|
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,
|
|
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", "
|
|
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,
|
|
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,
|
|
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
|
-
|
|
114
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
{symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0/src/symgrep_codesearch.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: symgrep-codesearch
|
|
3
|
-
Version: 0.
|
|
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.
|
{symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/SOURCES.txt
RENAMED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/requires.txt
RENAMED
|
File without changes
|
{symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/src/symgrep_codesearch.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_describe_lane_confinement.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{symgrep_codesearch-0.6.4 → symgrep_codesearch-0.7.0}/tests/test_semantic_freshness_signal.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|