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.
Files changed (59) hide show
  1. symgrep_codesearch-0.5.0/LICENSE +21 -0
  2. symgrep_codesearch-0.5.0/PKG-INFO +424 -0
  3. symgrep_codesearch-0.5.0/README.md +395 -0
  4. symgrep_codesearch-0.5.0/pyproject.toml +84 -0
  5. symgrep_codesearch-0.5.0/setup.cfg +4 -0
  6. symgrep_codesearch-0.5.0/src/symgrep/__init__.py +0 -0
  7. symgrep_codesearch-0.5.0/src/symgrep/blocks.py +326 -0
  8. symgrep_codesearch-0.5.0/src/symgrep/classfile.py +221 -0
  9. symgrep_codesearch-0.5.0/src/symgrep/cli.py +588 -0
  10. symgrep_codesearch-0.5.0/src/symgrep/data_symbols.py +457 -0
  11. symgrep_codesearch-0.5.0/src/symgrep/docsymbols.py +192 -0
  12. symgrep_codesearch-0.5.0/src/symgrep/enclosing.py +727 -0
  13. symgrep_codesearch-0.5.0/src/symgrep/mcp_server.py +340 -0
  14. symgrep_codesearch-0.5.0/src/symgrep/mcp_structured.py +176 -0
  15. symgrep_codesearch-0.5.0/src/symgrep/report.py +440 -0
  16. symgrep_codesearch-0.5.0/src/symgrep/resolve.py +278 -0
  17. symgrep_codesearch-0.5.0/src/symgrep/resolve_js.py +328 -0
  18. symgrep_codesearch-0.5.0/src/symgrep/resolve_python.py +293 -0
  19. symgrep_codesearch-0.5.0/src/symgrep/ripgrep.py +213 -0
  20. symgrep_codesearch-0.5.0/src/symgrep/semantic.py +956 -0
  21. symgrep_codesearch-0.5.0/src/symgrep/structural.py +259 -0
  22. symgrep_codesearch-0.5.0/src/symgrep/structure.py +365 -0
  23. symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/PKG-INFO +424 -0
  24. symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/SOURCES.txt +57 -0
  25. symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/dependency_links.txt +1 -0
  26. symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/entry_points.txt +3 -0
  27. symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/requires.txt +9 -0
  28. symgrep_codesearch-0.5.0/src/symgrep_codesearch.egg-info/top_level.txt +1 -0
  29. symgrep_codesearch-0.5.0/tests/test_blocks_extraction.py +99 -0
  30. symgrep_codesearch-0.5.0/tests/test_blocks_retrieval.py +119 -0
  31. symgrep_codesearch-0.5.0/tests/test_bulk_describe.py +296 -0
  32. symgrep_codesearch-0.5.0/tests/test_classfile.py +111 -0
  33. symgrep_codesearch-0.5.0/tests/test_data_symbols.py +121 -0
  34. symgrep_codesearch-0.5.0/tests/test_describe_lane_confinement.py +70 -0
  35. symgrep_codesearch-0.5.0/tests/test_doc_symbols.py +82 -0
  36. symgrep_codesearch-0.5.0/tests/test_file_structure.py +174 -0
  37. symgrep_codesearch-0.5.0/tests/test_freshness.py +81 -0
  38. symgrep_codesearch-0.5.0/tests/test_honest_recall.py +83 -0
  39. symgrep_codesearch-0.5.0/tests/test_http_ensure.py +95 -0
  40. symgrep_codesearch-0.5.0/tests/test_index_reconstruct.py +73 -0
  41. symgrep_codesearch-0.5.0/tests/test_index_scope.py +85 -0
  42. symgrep_codesearch-0.5.0/tests/test_index_worker.py +138 -0
  43. symgrep_codesearch-0.5.0/tests/test_mcp_structured.py +108 -0
  44. symgrep_codesearch-0.5.0/tests/test_name_uniqueness.py +80 -0
  45. symgrep_codesearch-0.5.0/tests/test_nudge_runaway.py +80 -0
  46. symgrep_codesearch-0.5.0/tests/test_report_filters.py +84 -0
  47. symgrep_codesearch-0.5.0/tests/test_resolve.py +81 -0
  48. symgrep_codesearch-0.5.0/tests/test_resolve_js.py +83 -0
  49. symgrep_codesearch-0.5.0/tests/test_resolve_python.py +84 -0
  50. symgrep_codesearch-0.5.0/tests/test_resolve_r2.py +207 -0
  51. symgrep_codesearch-0.5.0/tests/test_review_regressions.py +136 -0
  52. symgrep_codesearch-0.5.0/tests/test_semantic_freshness_signal.py +63 -0
  53. symgrep_codesearch-0.5.0/tests/test_sites_and_symbols.py +111 -0
  54. symgrep_codesearch-0.5.0/tests/test_structural.py +91 -0
  55. symgrep_codesearch-0.5.0/tests/test_symbol_vocabulary.py +153 -0
  56. symgrep_codesearch-0.5.0/tests/test_ts_fallback_loud.py +149 -0
  57. symgrep_codesearch-0.5.0/tests/test_ts_symbol_coverage.py +78 -0
  58. symgrep_codesearch-0.5.0/tests/test_usage.py +76 -0
  59. 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.