symgrep-codesearch 0.5.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.5.0/LICENSE +21 -0
- symgrep_codesearch-0.5.0/PKG-INFO +424 -0
- symgrep_codesearch-0.5.0/README.md +395 -0
- symgrep_codesearch-0.5.0/pyproject.toml +84 -0
- symgrep_codesearch-0.5.0/setup.cfg +4 -0
- symgrep_codesearch-0.5.0/src/symgrep/__init__.py +0 -0
- symgrep_codesearch-0.5.0/src/symgrep/blocks.py +326 -0
- symgrep_codesearch-0.5.0/src/symgrep/classfile.py +221 -0
- symgrep_codesearch-0.5.0/src/symgrep/cli.py +588 -0
- symgrep_codesearch-0.5.0/src/symgrep/data_symbols.py +457 -0
- symgrep_codesearch-0.5.0/src/symgrep/docsymbols.py +192 -0
- symgrep_codesearch-0.5.0/src/symgrep/enclosing.py +727 -0
- symgrep_codesearch-0.5.0/src/symgrep/mcp_server.py +340 -0
- symgrep_codesearch-0.5.0/src/symgrep/mcp_structured.py +176 -0
- symgrep_codesearch-0.5.0/src/symgrep/report.py +440 -0
- symgrep_codesearch-0.5.0/src/symgrep/resolve.py +278 -0
- symgrep_codesearch-0.5.0/src/symgrep/resolve_js.py +328 -0
- symgrep_codesearch-0.5.0/src/symgrep/resolve_python.py +293 -0
- symgrep_codesearch-0.5.0/src/symgrep/ripgrep.py +213 -0
- symgrep_codesearch-0.5.0/src/symgrep/semantic.py +956 -0
- symgrep_codesearch-0.5.0/src/symgrep/structural.py +259 -0
- symgrep_codesearch-0.5.0/src/symgrep/structure.py +365 -0
- symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/PKG-INFO +424 -0
- symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/SOURCES.txt +57 -0
- symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/dependency_links.txt +1 -0
- symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/entry_points.txt +3 -0
- symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/requires.txt +9 -0
- symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/top_level.txt +1 -0
- symgrep_codesearch-0.5.0/tests/test_blocks_extraction.py +99 -0
- symgrep_codesearch-0.5.0/tests/test_blocks_retrieval.py +119 -0
- symgrep_codesearch-0.5.0/tests/test_bulk_describe.py +296 -0
- symgrep_codesearch-0.5.0/tests/test_classfile.py +111 -0
- symgrep_codesearch-0.5.0/tests/test_data_symbols.py +121 -0
- symgrep_codesearch-0.5.0/tests/test_describe_lane_confinement.py +70 -0
- symgrep_codesearch-0.5.0/tests/test_doc_symbols.py +82 -0
- symgrep_codesearch-0.5.0/tests/test_file_structure.py +174 -0
- symgrep_codesearch-0.5.0/tests/test_freshness.py +81 -0
- symgrep_codesearch-0.5.0/tests/test_honest_recall.py +83 -0
- symgrep_codesearch-0.5.0/tests/test_http_ensure.py +95 -0
- symgrep_codesearch-0.5.0/tests/test_index_reconstruct.py +73 -0
- symgrep_codesearch-0.5.0/tests/test_index_scope.py +85 -0
- symgrep_codesearch-0.5.0/tests/test_index_worker.py +138 -0
- symgrep_codesearch-0.5.0/tests/test_mcp_structured.py +108 -0
- symgrep_codesearch-0.5.0/tests/test_name_uniqueness.py +80 -0
- symgrep_codesearch-0.5.0/tests/test_nudge_runaway.py +80 -0
- symgrep_codesearch-0.5.0/tests/test_report_filters.py +84 -0
- symgrep_codesearch-0.5.0/tests/test_resolve.py +81 -0
- symgrep_codesearch-0.5.0/tests/test_resolve_js.py +83 -0
- symgrep_codesearch-0.5.0/tests/test_resolve_python.py +84 -0
- symgrep_codesearch-0.5.0/tests/test_resolve_r2.py +207 -0
- symgrep_codesearch-0.5.0/tests/test_review_regressions.py +136 -0
- symgrep_codesearch-0.5.0/tests/test_semantic_freshness_signal.py +63 -0
- symgrep_codesearch-0.5.0/tests/test_sites_and_symbols.py +111 -0
- symgrep_codesearch-0.5.0/tests/test_structural.py +91 -0
- symgrep_codesearch-0.5.0/tests/test_symbol_vocabulary.py +153 -0
- symgrep_codesearch-0.5.0/tests/test_ts_fallback_loud.py +149 -0
- symgrep_codesearch-0.5.0/tests/test_ts_symbol_coverage.py +78 -0
- symgrep_codesearch-0.5.0/tests/test_usage.py +76 -0
- symgrep_codesearch-0.5.0/tests/test_veccore_adoption.py +151 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ash Damle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: symgrep-codesearch
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/ashdamle/symgrep
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: tree-sitter-language-pack>=1.0
|
|
23
|
+
Provides-Extra: semantic
|
|
24
|
+
Requires-Dist: veccore[fastembed]; extra == "semantic"
|
|
25
|
+
Provides-Extra: mcp
|
|
26
|
+
Requires-Dist: fastmcp>=2.0; extra == "mcp"
|
|
27
|
+
Provides-Extra: structural
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# symgrep — code search that never lets you mistake *not found* for *not there*
|
|
31
|
+
|
|
32
|
+
A structural layer over ripgrep, built for AI agents. Same matching semantics as `rg`; a different **output
|
|
33
|
+
contract**, and one extra question it can answer that no name search can.
|
|
34
|
+
|
|
35
|
+
## The problem it exists for
|
|
36
|
+
|
|
37
|
+
An agent searches for something, gets zero hits, concludes it does not exist, and writes it again.
|
|
38
|
+
|
|
39
|
+
That is not carelessness — it is a correct reading of a wrong answer. `rg`, and symgrep's own expansions,
|
|
40
|
+
match **names**. A function that does the job under a different name is invisible to every one of them. In
|
|
41
|
+
one codebase, in one day, that produced:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
share.scrub beside share._scrub_text same job, no shared word
|
|
45
|
+
adapters.call beside adapters.call_complete the duplicate written an hour after a
|
|
46
|
+
tool was built to find this pattern
|
|
47
|
+
_save_state in three files, byte-identical
|
|
48
|
+
_load_state / _state_path in four
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
So symgrep does two things about it.
|
|
52
|
+
|
|
53
|
+
**Zero hits say what they do not mean.** Every miss now ends with:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
⚠ this is a NAME search. 0 hits means nothing is CALLED this — it does NOT mean nothing DOES this.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Free, no dependencies, and on its own it would have stopped several of the duplicates above.
|
|
60
|
+
|
|
61
|
+
**And you can ask by meaning instead.** `--semantic` takes a description of the *job* and returns the
|
|
62
|
+
functions that already do it:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
$ symgrep --semantic "make an LLM call and retry if the reply was cut off at the token limit" src/
|
|
66
|
+
0.786 bulkgate.is_truncated src/bulkgate.py:277
|
|
67
|
+
Checks whether a generated response was truncated due to hitting token or length limits.
|
|
68
|
+
0.688 gate._autotune src/gate.py:1133
|
|
69
|
+
Learns and applies a per-call token cap to prevent waste or premature truncation.
|
|
70
|
+
0.683 adapters.call src/adapters.py:172
|
|
71
|
+
Runs a single prompt against a single model, enforcing per-call token budgeting.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A name search for that query returns 0. Those three are exactly what the person about to write a fourth
|
|
75
|
+
implementation needed to see.
|
|
76
|
+
|
|
77
|
+
## The output contract
|
|
78
|
+
|
|
79
|
+
1. **Sites, not lines.** Matches group by (file, enclosing symbol) with the symbol's span — no follow-up
|
|
80
|
+
file reads just to learn context. Basis is labelled per symbol in trust order: `ast` (Python's parser) /
|
|
81
|
+
`treesitter` (real grammars) / `nearest_decl` (regex declaration scan — LAST RESORT, weakest label) /
|
|
82
|
+
`module`.
|
|
83
|
+
2. **Mechanical ordering, never filtering.** src > test > vendored, then match density. Nothing `rg` matched
|
|
84
|
+
is dropped; truncation always reports counts and the top hidden files.
|
|
85
|
+
3. **Honest zero-hits.** A miss reports the scope searched, labelled mechanical expansions (case,
|
|
86
|
+
snake/camel/Pascal, token co-occurrence) with per-variant file counts, and the NAME-search caveat above.
|
|
87
|
+
4. **Unavailable is not empty.** If `--semantic` has no index or no embedder it says so and exits non-zero.
|
|
88
|
+
It never returns an empty result list, because that is indistinguishable from "nothing matches" and the
|
|
89
|
+
whole point is to stop that confusion.
|
|
90
|
+
|
|
91
|
+
**Retrieval, not judgement.** symgrep never decides relevance. Cosine ranking proposes candidates the same
|
|
92
|
+
way ripgrep proposes sites; both are handed over with their basis stated. The calling agent judges. Nothing
|
|
93
|
+
is filtered by a threshold — a weak top score is *reported* as weak, which is a different statement from
|
|
94
|
+
"nothing exists" and must not collapse into it.
|
|
95
|
+
|
|
96
|
+
## Architecture
|
|
97
|
+
|
|
98
|
+
[INTENT.md](INTENT.md) is the North Star — what symgrep is *for*, the invariants it commits to, and how each
|
|
99
|
+
is enforced. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) is the "how" — the module map, the
|
|
100
|
+
three-questions/one-contract model, the honesty invariant every module defends, and the non-goals (no index
|
|
101
|
+
trusted blind, no dependency on a foreign indexer — symgrep owns its indexes and keeps them freshness-honest).
|
|
102
|
+
|
|
103
|
+
## Install
|
|
104
|
+
|
|
105
|
+
**Prerequisite — ripgrep (`rg`).** symgrep IS ripgrep underneath: it shells out to `rg` for every name
|
|
106
|
+
search. `rg` is a system binary, **not** a pip dependency, so install it separately:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
brew install ripgrep # macOS
|
|
110
|
+
apt install ripgrep # Debian/Ubuntu
|
|
111
|
+
dnf install ripgrep # Fedora
|
|
112
|
+
pacman -S ripgrep # Arch
|
|
113
|
+
# other platforms: https://github.com/BurntSushi/ripgrep#installation
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If `rg` is missing, symgrep fails with a clear, actionable error (never a silent zero-hit). An `rg` installed
|
|
117
|
+
somewhere off `PATH` can be pointed to explicitly with `SYMGREP_RG=/path/to/rg`.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# Distribution name is `symgrep-codesearch` (the bare `symgrep` is parked on PyPI by the Semgrep team);
|
|
121
|
+
# the import name and console scripts are unchanged — `import symgrep`, `symgrep`, `symgrep-mcp`.
|
|
122
|
+
pip install symgrep-codesearch # `symgrep` + `symgrep-mcp` land on PATH
|
|
123
|
+
pip install 'symgrep-codesearch[semantic]' # + local embeddings (veccore/fastembed) for --semantic
|
|
124
|
+
pip install 'symgrep-codesearch[mcp]' # + fastmcp for the HTTP MCP transport
|
|
125
|
+
pip install -e /path/to/symgrep # editable, for local development
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Being importable matters more than it looks: while symgrep was a `PYTHONPATH`-only tool, a neighbouring repo
|
|
129
|
+
that needed "every function with its scope-qualified name and span" wrote its own extractor rather than use
|
|
130
|
+
`enclosing.py`, which already did it with an mtime cache. The duplicate was caused by the missing packaging.
|
|
131
|
+
|
|
132
|
+
## Use
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
symgrep PATTERN [ROOT ...] [-i] [-F] [-U] [--glob G] [--max-sites N] [--within PAT2] [--usage] [--resolve] [--json]
|
|
136
|
+
symgrep --index src/ # build the semantic index (incremental)
|
|
137
|
+
symgrep --semantic "what the function would do" src/ # search by meaning
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The index stores one sentence per function describing its **job**, keyed by body hash, so a rebuild only
|
|
141
|
+
costs the functions that changed. Embeddings are local (`fastembed` / bge-small): no API key, no network,
|
|
142
|
+
nothing about the code leaves the machine.
|
|
143
|
+
|
|
144
|
+
Writing those sentences needs a model, and symgrep holds no opinion about which — set
|
|
145
|
+
`SYMGREP_DESCRIBE_CMD` to any command that reads a function body on stdin and prints one sentence, or
|
|
146
|
+
install the `claude` CLI and it will use that. It will **not** fall back to summarising function names:
|
|
147
|
+
names are what a name search already covers, and indexing them would rebuild the blindness this feature
|
|
148
|
+
removes.
|
|
149
|
+
|
|
150
|
+
## MCP (Claude Code)
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
claude mcp add symgrep -- /path/to/symgrep/bin/symgrep-mcp
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
One tool, two questions: `search(pattern, roots)` for names, `search(pattern, roots, semantic=true)` for
|
|
157
|
+
jobs. Not two tools — the caller reaching for code search is exactly the caller who needs to ask by meaning,
|
|
158
|
+
and a separate tool is one more thing to remember at the moment remembering fails.
|
|
159
|
+
|
|
160
|
+
Runs dependency-free (stdlib + ripgrep, with a stdlib JSON-RPC fallback when `fastmcp` is absent). Both
|
|
161
|
+
transports are tested for parameter parity, because a flag live on one and missing from the other is the
|
|
162
|
+
same half-wiring this tool exists to catch.
|
|
163
|
+
|
|
164
|
+
## Tests
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python tests/test_honest_recall.py
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Pins the invariants that are easy to break quietly: a zero-hit result must carry the NAME-search caveat and
|
|
171
|
+
a hit result must not, both MCP transports must expose the same parameters, and an unavailable semantic
|
|
172
|
+
search must raise rather than return empty.
|
|
173
|
+
|
|
174
|
+
## Reviewed
|
|
175
|
+
|
|
176
|
+
2026-08-19 — full 5-axis honestreview (per-file 5-vendor panel · concept · seam · invariant · name, plus
|
|
177
|
+
silent/unwired), $0.42 API. Fixed from it: rg partial-coverage errors were swallowed when any match existed
|
|
178
|
+
(now `⚠ COVERAGE INCOMPLETE`); `semantic.build` pruned the WHOLE index on a partial-root build (now prunes
|
|
179
|
+
only within its roots — 1,161 rows recovered from the sqlite freelist); non-UTF-8 paths crashed the search;
|
|
180
|
+
describer truncated bodies to 4000 chars and one slow unit killed a whole index run; `search`/`render`
|
|
181
|
+
name collisions across modules; duplicated rg command builder; import-time `$SYMGREP_INDEX`; JSON-RPC
|
|
182
|
+
fallback answered parse/unknown-method with silence. Second pass (all remaining high/medium): rg stderr
|
|
183
|
+
pipe could deadlock on large `--no-ignore` trees (now drained concurrently); semantic index key was
|
|
184
|
+
`<stem>.<name>` so same-named files overwrote each other (now `<realpath>::<symbol>`, schema v2 with in-place
|
|
185
|
+
migration); `--semantic` ignored `roots` and a changed embed model returned `[]` silently (both now honest);
|
|
186
|
+
stale rows survived an undescribable rewrite; `symgrep --index src` indexed the cwd; py3.9-incompatible
|
|
187
|
+
annotation would stop the MCP server; unbounded symbol cache; `files_searched` reported files-with-matches.
|
|
188
|
+
Regression tests: `tests/test_review_regressions.py`.
|
|
189
|
+
Open (design, not bugs): `path_class` is a directory-name convention used for ORDERING only and now prints
|
|
190
|
+
its basis; the agentic source for "what IS this file" is a catalog (warden), not symgrep.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
2026-08-29 — deep wave (all 32 files × 5 vendors, ~$0.5 billed / rest plan-served) + invariant (intent) +
|
|
194
|
+
silent. Fixed the confirmed bugs: a malformed bulk-result JSONL line aborted a chunk (now counted +
|
|
195
|
+
skipped, batch still lands); the freshness worker silently DROPPED a batch on a failed/timed-out `--index`
|
|
196
|
+
(now re-queued, retried); `.class` constant-pool strings decoded as plain UTF-8 not JVM modified-UTF-8
|
|
197
|
+
(now a proper mUTF-8 decoder); a non-numeric `schema_version` and a bad `SYMGREP_MAX_PARSE_BYTES` could
|
|
198
|
+
crash (now guarded); NotebookEdit events used the wrong payload key; the JSON-RPC fallback leaked a
|
|
199
|
+
traceback; test-honesty (rglob for subpackages, a vacuous system-file assertion, env restore). The
|
|
200
|
+
invariant axis flagged the meaning-by-regex rule — adjudicated a FALSE POSITIVE (as before): `_cos`/`_rank`
|
|
201
|
+
are retrieval handed to the caller, `judge_is_code_search` already calls the LLM (regex is only the
|
|
202
|
+
prefilter), `_identifier_tokens`/`_is_generated` are format-parsing; the one actual meaning decision (the
|
|
203
|
+
description) IS an LLM. So the intent holds. name/concept axes hit honestreview-side tracebacks (their bug).
|
|
204
|
+
|
|
205
|
+
## Adoption (measured) and the nudge hook
|
|
206
|
+
|
|
207
|
+
Transcripts 2026-08-02 → 08-19 (MCP registered, description says "use instead of Grep"): **2%** of
|
|
208
|
+
code-search calls used symgrep (54 / 2,853). The built-in Grep tool was used once — agents search with
|
|
209
|
+
`rg`/`grep` in Bash. Description-steering does not change behaviour; a hook does.
|
|
210
|
+
|
|
211
|
+
`hooks/symgrep_nudge.py` (PreToolUse, matcher Bash): prefilter by POSITION (rg/grep first in a pipeline
|
|
212
|
+
segment — `ps | grep` is never touched), then a MODEL judges "search vs utility" (haiku via spendguard,
|
|
213
|
+
plan lane, refuse-metered, purpose=symgrep-nudge, verdicts cached). Code search → blocked with the
|
|
214
|
+
symgrep command; utility (`--files`, `--version`) → allowed; any failure → allowed (fail-open). Escape
|
|
215
|
+
hatch: `SYMGREP_ALLOW_RG=1 rg …`. Register in `~/.claude/settings.json` under `hooks.PreToolUse`.
|
|
216
|
+
|
|
217
|
+
## Freshness (self-maintaining index)
|
|
218
|
+
|
|
219
|
+
Three layers keep `--semantic` honest about time, measured against the failure mode "silently ranking
|
|
220
|
+
over old descriptions":
|
|
221
|
+
|
|
222
|
+
1. **PostToolUse hook** (`hooks/symgrep_index_freshness.py`): every Write/Edit of an indexable file
|
|
223
|
+
inside the index's own recorded scope enqueues it; a detached worker drains the queue in debounced
|
|
224
|
+
batches through `symgrep --index` (content-hash cached, spendguard-routed, refuse-metered). Hook
|
|
225
|
+
latency ~50ms; fail-open everywhere.
|
|
226
|
+
2. **Query-time staleness signal**: every indexed row carries the file's `(mtime_ns:size)` stamp from
|
|
227
|
+
when it was last VERIFIED (described or hash-checked). `--semantic` output states how many in-scope
|
|
228
|
+
files changed on disk since — a stat-stamp SIGNAL, explicitly not a content verdict (content truth is
|
|
229
|
+
body_hash, re-checked by every build; a `touch` makes the signal conservatively noisy, never falsely
|
|
230
|
+
fresh together with size).
|
|
231
|
+
3. **Weekly mop-up** (launchd `com.ashdamle.symgrep-index-refresh`, Sun 04:10): incremental re-index of
|
|
232
|
+
the configured roots — catches out-of-band changes (git pull, other machines) the hook cannot see.
|
|
233
|
+
|
|
234
|
+
Backup: launchd `com.ashdamle.symgrep-index-backup` (03:55) ships a consistent sqlite snapshot to B2
|
|
235
|
+
(rolling latest + Monday weeklies).
|
|
236
|
+
|
|
237
|
+
## Bulk index builds ($0, concurrent across plan lanes)
|
|
238
|
+
|
|
239
|
+
When spendguard is present, `symgrep --index` fans the describe work for large todo lists (≥
|
|
240
|
+
`$SYMGREP_BULK_MIN_TODO`, default 10) across the idle subscription lanes concurrently via
|
|
241
|
+
`spendguard lanes --bulk symgrep-index --jsonl --system-file … --refuse-billed --checkpoint …` —
|
|
242
|
+
$0 by construction (a lane miss is an error row → that unit stays UNDESCRIBED and re-runnable, never a
|
|
243
|
+
bill), content-keyed resume on crash, and per-row provenance (`described_by = "<model> via <lane>"`)
|
|
244
|
+
stored in the index. The fan is **confined to completion lanes** (`SYMGREP_DESCRIBE_LANES`, default
|
|
245
|
+
`zai-coding,codex,gemini`): a conversational agent lane treats the function body as a chat turn and returns
|
|
246
|
+
junk, so a describer must describe, not converse (`tests/test_describe_lane_confinement.py`; measured
|
|
247
|
+
2026-09-10 over 23,332 rows: the claude-code lane returned 80–87% conversational non-answers before it was
|
|
248
|
+
excluded). Small batches (the freshness
|
|
249
|
+
worker's 1–3 files) stay per-unit. Disable with `SYMGREP_BULK=0`; `--reindex` forces re-describe after a
|
|
250
|
+
describer/instruction change.
|
|
251
|
+
|
|
252
|
+
**Macro-bootstrap of a large STATIC tree (OpenAI Batch API).** Plan lanes are right for incremental
|
|
253
|
+
freshness but slow and quota-bound for tens of thousands of functions in a legacy tree that is not being
|
|
254
|
+
edited. `tools/batch_describe.py` is the other tradeoff: `build` emits provider-neutral TASKS
|
|
255
|
+
(`{custom_id, content}`, the function body as content) + a shared system file + a custom_id→metadata sidecar;
|
|
256
|
+
spendguard builds the per-model batch envelope and runs the **gated, estimate-first** submit/fetch (~50% off,
|
|
257
|
+
≤24h turnaround, never touches plan quota); `ingest` writes the descriptions **non-clobbering** (a key that
|
|
258
|
+
already has a description is skipped and counted, never overwritten) and LOUD (malformed / unknown-id /
|
|
259
|
+
errored / empty rows each counted, every missing id named for re-submit), then embeds locally. symgrep never
|
|
260
|
+
hand-rolls the request envelope — models.py in spendguard owns tokens-param / reasoning / output floor. This
|
|
261
|
+
is deliberately NOT for actively-edited code (a 24h turnaround would make just-written code unsearchable until
|
|
262
|
+
tomorrow); the freshness hook owns that. `tests/test_bulk_describe.py`.
|
|
263
|
+
|
|
264
|
+
**Index scope = ALL of `~/Documents/claude`** (as of 2026-08-25): one root, so the freshness hook and
|
|
265
|
+
weekly refresh inherit full-estate coverage from `indexed_roots` automatically. Scope rules are the
|
|
266
|
+
search's own: hidden dirs, vendored/generated trees, and minified files are excluded; iCloud-dataless
|
|
267
|
+
placeholders are skipped (reading one hangs) and re-enter once materialized.
|
|
268
|
+
|
|
269
|
+
## Structural search (`--structural`) — a third question, native, no new dependency
|
|
270
|
+
|
|
271
|
+
Beside name (regex) and meaning (`--semantic`), symgrep matches by **AST shape** — pattern-by-example
|
|
272
|
+
over the tree-sitter grammars it already loads (leverages ast-grep's idea; takes no ast-grep binary):
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
symgrep --structural '$X.get($K, $D)' src/ # dict.get with a default, however X/K/D are spelled
|
|
276
|
+
symgrep --structural 'foo($$$)' src/ # any call to foo, any arguments
|
|
277
|
+
symgrep --structural '$X == $X' src/ # a value compared to itself (repeated metavar = same text)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`$NAME` matches one AST node (a repeated `$NAME` must match the SAME text); `$$$` is variadic. Matches
|
|
281
|
+
flow through the normal contract — grouped by enclosing symbol, ordered, `basis=ast-pattern`. Scope is
|
|
282
|
+
symgrep's own file walk, identical to name search. A language with no grammar, or a pattern that won't
|
|
283
|
+
parse, is reported **unavailable/skipped** (mechanical `status`), never silently text-matched. On the
|
|
284
|
+
MCP: `search(pattern, roots, structural=true[, lang])`, structured output carries the flat hit shape.
|
|
285
|
+
|
|
286
|
+
## Refining a name search: `--within` and def/reference anchors
|
|
287
|
+
|
|
288
|
+
Two refinements make a name search answer a sharper question without leaving the ripgrep base.
|
|
289
|
+
|
|
290
|
+
**`--within PAT2`** keeps only sites whose **enclosing symbol also contains** a match of `PAT2` — the
|
|
291
|
+
"inside any function whose body also mentions X" query, as a same-symbol co-occurrence:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
symgrep 'subprocess' src/ --within 'json' # functions that use BOTH subprocess and json
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Honesty holds: a site whose symbol span is unknown (basis `nearest_decl`/`module` — no reliable
|
|
298
|
+
`[start,end]`) **cannot** be tested for containment, so it is **dropped and the drop is counted** in the
|
|
299
|
+
coverage line, never silently kept or discarded.
|
|
300
|
+
|
|
301
|
+
**Definition vs reference anchor.** On a name search, each occurrence is tagged: an occurrence on its
|
|
302
|
+
enclosing symbol's **declaration line**, where that symbol's own name matches the query, is a
|
|
303
|
+
**definition** (marked `▸def` in the rendered output); every other occurrence is a **reference**. This is
|
|
304
|
+
the Kythe/OpenGrok distinction, computed from the AST the sites already carry — no index, no name-guessing.
|
|
305
|
+
A query that names no declared symbol yields zero definition anchors (all references), and an import or
|
|
306
|
+
whole-file (`basis=module`) occurrence is never miscalled a definition. On the MCP, each name hit carries
|
|
307
|
+
`def_lines` (the declaration-line subset of `match_lines`) and `role: definition|reference`.
|
|
308
|
+
|
|
309
|
+
**Usage / possibly-unused (`--usage`).** Summarises a name into definitions vs references and flags the
|
|
310
|
+
defined-but-never-referenced case:
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
symgrep 'build_report' src/ --usage # definitions: 1 · references: 7 · verdict: DEFINED and REFERENCED
|
|
314
|
+
symgrep 'some_helper' src/ --usage # verdict: DEFINED but 0 references — POSSIBLY UNUSED
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
verdict ∈ `defined_referenced` | `defined_unused` | `undefined_referenced` (used here, defined elsewhere) |
|
|
318
|
+
`absent`. It counts **all** occurrences (never the display cap — a truncated count could make a used symbol
|
|
319
|
+
look unused). It is **LEXICAL**, by name: a same-named symbol elsewhere, dynamic dispatch, string-built
|
|
320
|
+
calls, or use outside these roots are invisible to it — so `defined_unused` is a *hint to confirm*, never
|
|
321
|
+
proof. On the MCP: `search(pattern, roots, usage=true)` returns `{verdict, n_def, n_ref, definitions,
|
|
322
|
+
references, lexical:true}`.
|
|
323
|
+
|
|
324
|
+
**Cross-file resolution (`--resolve`, R0).** A step past lexical `--usage`: it builds a cross-file **symbol
|
|
325
|
+
table** from the definitions symgrep extracts, and ties references to the actual definition(s) they could
|
|
326
|
+
bind to — **surfacing ambiguity** instead of guessing.
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
symgrep 'build_report' src/ --resolve # definitions: 1 · verdict: one definition in scope
|
|
330
|
+
symgrep 'main' src/ --resolve # definitions: 2 — AMBIGUOUS: candidates among both, not one winner
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
verdict ∈ `resolved_unique` | `ambiguous` (>1 definition of the name) | `external` (referenced, defined
|
|
334
|
+
elsewhere) | `absent`. Where two files define `foo`, R0 lists both and says AMBIGUOUS rather than picking one.
|
|
335
|
+
|
|
336
|
+
**R1 narrows each reference to a single binding** where Python import/scope makes it certain — so an ambiguous
|
|
337
|
+
name still resolves *per use-site*:
|
|
338
|
+
|
|
339
|
+
```
|
|
340
|
+
symgrep 'estimate' src/ --resolve
|
|
341
|
+
definitions: 3 — AMBIGUOUS …
|
|
342
|
+
references: 40 — R1 bound 31 to a single definition (import/scope), 9 left as candidates:
|
|
343
|
+
a/user.py:12 → a/core.py:4 [resolved-import] via `from a.core import estimate`
|
|
344
|
+
a/core.py:9 → a/core.py:4 [resolved-local]
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Each reference carries its own basis: `resolved-local` (defined in this file), `resolved-import` (`from m
|
|
348
|
+
import foo` → the definition in `m`), `resolved-external` (imported from outside the scanned roots),
|
|
349
|
+
`resolved-attr` (**R2/R2b**: an attribute access whose receiver type is *certain* — `self.`/`this.`method →
|
|
350
|
+
the enclosing class's member; `Class.`member; `mod.`member for an aliased module import; or `x.`member where
|
|
351
|
+
`x` was bound **once, unconditionally** to `ClassName()`/`new Ctor()` in scope — **R2b** local type tracking),
|
|
352
|
+
or `candidate-name` when it **declines** — a star/default import, or an attribute on an instance of *unknown*
|
|
353
|
+
type: a factory call, a parameter, or a **reassigned / conditionally-assigned** variable stays at R0's
|
|
354
|
+
candidates, **never a guessed winner**. R1 covers
|
|
355
|
+
**Python** (stdlib `ast`) and **JavaScript/TypeScript** (`.js/.jsx/.mjs/.cjs/.ts/.tsx`, ES-module +
|
|
356
|
+
CommonJS `require`, via tree-sitter); a language with no resolver stays at R0. Resolvers plug into a registry
|
|
357
|
+
(`resolve_<lang>.py`), so adding one (Go next) touches no shared code. Recomputed live; the persistent
|
|
358
|
+
delta-graded index is R's later refinement (see [docs/IMPROVEMENT_PLAN.md](docs/IMPROVEMENT_PLAN.md)). On the
|
|
359
|
+
MCP: `search(pattern, roots, resolve=true)` returns `n_refs_bound` / `n_refs_candidate` and per-reference bases.
|
|
360
|
+
|
|
361
|
+
## HTTP MCP (for programmatic consumers)
|
|
362
|
+
|
|
363
|
+
`symgrep-mcp --http` serves the same `search` tool at `POST http://127.0.0.1:4911/mcp` (env:
|
|
364
|
+
`SYMGREP_MCP_HOST/PORT`) — stateless JSON-RPC (`json_response`, no session handshake), loopback-only.
|
|
365
|
+
Built for engines like honestreview that need symgrep WITHOUT spawning a stdio server per call or
|
|
366
|
+
importing in-process into a venv that lacks fastembed.
|
|
367
|
+
|
|
368
|
+
**Estate loopback port registry** (one engine per port; a collision means consumers silently query the
|
|
369
|
+
wrong engine): `4900` ccwatch · `4910` 7thsense · `4911` symgrep. The SessionStart ensure-hook probes
|
|
370
|
+
`serverInfo.name` on its port and ANNOUNCES a collision instead of assuming a live port is ours.
|
|
371
|
+
|
|
372
|
+
**The exact contract** (verified 2026-08-25, ~15ms/call):
|
|
373
|
+
- No handshake needed: each request is independent. `initialize` is optional and answers
|
|
374
|
+
`serverInfo: {name: "symgrep", version: <package version>}` — the identity probe.
|
|
375
|
+
- Headers: `Content-Type: application/json` and `Accept: application/json, text/event-stream`
|
|
376
|
+
(fastmcp requires both media types in Accept even though the reply is plain JSON).
|
|
377
|
+
- Call: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{…}}}`
|
|
378
|
+
with the same arguments as the stdio tool: `pattern`, `roots` (required), `case_insensitive`,
|
|
379
|
+
`fixed_string`, `multiline`, `globs`, `include_ignored`, `max_sites`, `semantic`, `k`.
|
|
380
|
+
- Reply: `result.content[0].text` = the same rendered report the stdio tool returns (honest zero-hits,
|
|
381
|
+
FRESHNESS line, scope line included). Tool errors arrive as JSON-RPC errors, not empty results.
|
|
382
|
+
- **Structured output**: pass `"structured": true` (or the alias `"json_output": true`) and
|
|
383
|
+
`content[0].text` is JSON with a mechanical top-level `status` and ONE flat hit shape for both search
|
|
384
|
+
modes — no prose parsing, ever:
|
|
385
|
+
- `{status: "ok", mode: "name"|"semantic", hits: [{symbol, path, line, start_line, end_line, kind,
|
|
386
|
+
basis, score?, desc?, match_lines?}], …}` — name hits carry `match_lines`; semantic hits carry
|
|
387
|
+
`score` + `desc` and resolve the span from the file's AST. `scope` (semantic) carries the
|
|
388
|
+
staleness/coverage counters.
|
|
389
|
+
- `{status: "no_index", …}` when the semantic index isn't built (fixable by `symgrep --index`) vs
|
|
390
|
+
`{status: "unavailable", reason: …, message: …}` when it cannot run (no embedder / corrupt). These
|
|
391
|
+
are distinguishable **without** reading the prose — the honesty feature a program needs.
|
|
392
|
+
**Languages.** Source is handled by real parsers: Python (`ast`), and tree-sitter grammars for JS/TS,
|
|
393
|
+
**Java**, Go, Rust, C/C++, Ruby, PHP, C#, Kotlin, Scala, bash — name-search + semantic, `basis=ast`/
|
|
394
|
+
`treesitter`. Compiled **`.class`** bytecode is the one path that is not source: ripgrep cannot search
|
|
395
|
+
binary, so a JVM classfile's constant pool is read directly (pure Python, no JDK) to list its class,
|
|
396
|
+
methods (overloads disambiguated by descriptor), and fields with `basis=classfile` — line spans only
|
|
397
|
+
when the class carries debug info, honestly absent otherwise. It feeds the structural `file_symbols`
|
|
398
|
+
tool (a dependency jar's symbols become queryable without source), and is deliberately kept OUT of the
|
|
399
|
+
semantic index: a one-sentence "what it does" from bytecode would be a confident-wrong-answer.
|
|
400
|
+
|
|
401
|
+
- **Two more tools** (same structured discipline, both transports): `file_symbols(path)` →
|
|
402
|
+
`{status, basis, symbols: [{name, kind, start_line, end_line, basis}]}` (the AST layer without an
|
|
403
|
+
in-process import — for def-span lookups); `indexed_roots()` →
|
|
404
|
+
`{status, roots: […], index_path}` (so an un-hit query reads as "not indexed here" vs "indexed,
|
|
405
|
+
nothing near").
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
def mcp_query(engine_url, tool, args, timeout=60):
|
|
409
|
+
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
410
|
+
"params": {"name": tool, "arguments": args}}).encode()
|
|
411
|
+
req = urllib.request.Request(engine_url, data=body, headers={
|
|
412
|
+
"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
|
|
413
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
414
|
+
return json.loads(resp.read())
|
|
415
|
+
|
|
416
|
+
mcp_query("http://127.0.0.1:4911/mcp", "search",
|
|
417
|
+
{"pattern": "record a charge to the ledger", "roots": ["~/Documents/claude/llm-spendguard"],
|
|
418
|
+
"semantic": True, "k": 5})
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Supervision is SESSION-CONTEXT, not launchd: macOS TCC denies launchd agents ~/Documents (measured:
|
|
422
|
+
the launchd weekly-refresh died `Operation not permitted`/exit 126 on every run), so a SessionStart
|
|
423
|
+
hook (`hooks/symgrep_http_ensure.py`, containment-asserted spawns, fail-open) revives the server —
|
|
424
|
+
and the weekly mop-up refresh, stamped only on success — with the session's inherited file access.
|