symgrep-codesearch 0.6.2__tar.gz → 0.6.4__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 (60) hide show
  1. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/PKG-INFO +1 -1
  2. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/pyproject.toml +10 -1
  3. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/cli.py +60 -0
  4. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/enclosing.py +7 -4
  5. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/mcp_server.py +29 -1
  6. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep_codesearch.egg-info/PKG-INFO +1 -1
  7. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep_codesearch.egg-info/SOURCES.txt +1 -0
  8. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_blocks_extraction.py +2 -1
  9. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_blocks_retrieval.py +2 -1
  10. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_data_symbols.py +2 -1
  11. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_file_structure.py +3 -2
  12. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_index_reconstruct.py +2 -1
  13. symgrep_codesearch-0.6.4/tests/test_nudge_classify.py +147 -0
  14. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/LICENSE +0 -0
  15. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/README.md +0 -0
  16. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/setup.cfg +0 -0
  17. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/__init__.py +0 -0
  18. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/blocks.py +0 -0
  19. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/classfile.py +0 -0
  20. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/data_symbols.py +0 -0
  21. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/docsymbols.py +0 -0
  22. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/mcp_structured.py +0 -0
  23. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/report.py +0 -0
  24. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/resolve.py +0 -0
  25. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/resolve_js.py +0 -0
  26. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/resolve_python.py +0 -0
  27. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/ripgrep.py +0 -0
  28. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/semantic.py +0 -0
  29. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/structural.py +0 -0
  30. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep/structure.py +0 -0
  31. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep_codesearch.egg-info/dependency_links.txt +0 -0
  32. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep_codesearch.egg-info/entry_points.txt +0 -0
  33. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep_codesearch.egg-info/requires.txt +0 -0
  34. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/src/symgrep_codesearch.egg-info/top_level.txt +0 -0
  35. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_bulk_describe.py +0 -0
  36. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_classfile.py +0 -0
  37. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_describe_lane_confinement.py +0 -0
  38. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_doc_symbols.py +0 -0
  39. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_freshness.py +0 -0
  40. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_honest_recall.py +0 -0
  41. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_http_ensure.py +0 -0
  42. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_index_scope.py +0 -0
  43. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_index_worker.py +0 -0
  44. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_mcp_structured.py +0 -0
  45. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_name_uniqueness.py +0 -0
  46. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_nudge_runaway.py +0 -0
  47. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_report_filters.py +0 -0
  48. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_resolve.py +0 -0
  49. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_resolve_js.py +0 -0
  50. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_resolve_python.py +0 -0
  51. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_resolve_r2.py +0 -0
  52. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_review_regressions.py +0 -0
  53. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_semantic_freshness_signal.py +0 -0
  54. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_sites_and_symbols.py +0 -0
  55. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_structural.py +0 -0
  56. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_symbol_vocabulary.py +0 -0
  57. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_ts_fallback_loud.py +0 -0
  58. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_ts_symbol_coverage.py +0 -0
  59. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_usage.py +0 -0
  60. {symgrep_codesearch-0.6.2 → symgrep_codesearch-0.6.4}/tests/test_veccore_adoption.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symgrep-codesearch
3
- Version: 0.6.2
3
+ Version: 0.6.4
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
@@ -7,6 +7,15 @@ build-backend = "setuptools.build_meta"
7
7
  # Semgrep team. The IMPORT name is unchanged — `import symgrep` — so consumers (honestreview, spendguard,
8
8
  # 7thsense, warden) are unaffected by the distribution rename. The package dir stays src/symgrep.
9
9
  name = "symgrep-codesearch"
10
+ # 0.6.4: grammar fix — C# (.cs) now parses. The tree-sitter-language-pack migration left C# pointing at the
11
+ # legacy grammar name "c_sharp"; the maintained pack's manifest calls it "csharp", so `get_parser("c_sharp")`
12
+ # raised TreeSitterUnavailable and every .cs file silently degraded to nearest_decl. enclosing._TS_PACK_NAME now
13
+ # maps c_sharp→csharp (same mechanism as embedded_template→embeddedtemplate). Patch: a pip-installed user on
14
+ # 0.6.3 gets real C# treesitter spans again. (Also lands this release's dev-only work: the one-shot nudge-cost
15
+ # fix and a CI import fix — neither ships in the wheel.)
16
+ # 0.6.3: MCP surface completeness — TOOL_DESCRIPTION now documents structural=/lang= (the last undescribed
17
+ # params), and a new `symgrep install-mcp` subcommand self-registers the server via `claude mcp add`. No API
18
+ # change; keeps PyPI current with the capability descriptions pip-installed MCP clients see.
10
19
  # 0.6.2: ripgrep is a system binary and cannot be a normal pip dependency, but the new `[rg]` extra pulls
11
20
  # `ripgrep-bin` (a wheel bundling the rg binary) for a self-contained install; rg_binary() now also finds an
12
21
  # rg next to the interpreter (venv bin), so the pip-only path works even under `.venv/bin/python -m …`.
@@ -17,7 +26,7 @@ name = "symgrep-codesearch"
17
26
  # semantic search. Minor bump (dependency surface changed; no breaking import/CLI/MCP change).
18
27
  # 0.5.0: added the public `file_structure` library API (+ its MCP tool and `symgrep structure` CLI), import
19
28
  # module-name normalization, and the FileStructure `connects` list (structure schema v2).
20
- version = "0.6.2"
29
+ version = "0.6.4"
21
30
  description = "Code search with honest recall — structure-aware grep that tells you what it does not know, plus search by meaning"
22
31
  readme = "README.md"
23
32
  # 3.11+ : the maintained grammar pack (tree-sitter-language-pack, a CORE dep below) ships abi3 wheels for
@@ -409,10 +409,70 @@ def _cmd_structure(rest) -> int:
409
409
  return 0
410
410
 
411
411
 
412
+ def _resolve_mcp_entrypoint() -> "Optional[str]":
413
+ """Absolute path to symgrep's MCP stdio entrypoint for THIS install, resolved — never hardcoded. The
414
+ `symgrep-mcp` console script sits next to the interpreter (venv bin) for a pip OR editable install; a bare
415
+ checkout with no install falls back to the repo's bin/symgrep-mcp wrapper. None if neither is found."""
416
+ import shutil
417
+ exe_dir = os.path.dirname(sys.executable or "")
418
+ cand = os.path.join(exe_dir, "symgrep-mcp")
419
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
420
+ return cand
421
+ on_path = shutil.which("symgrep-mcp")
422
+ if on_path:
423
+ return on_path
424
+ repo_bin = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
425
+ "bin", "symgrep-mcp")
426
+ if os.path.isfile(repo_bin) and os.access(repo_bin, os.X_OK):
427
+ return repo_bin
428
+ return None
429
+
430
+
431
+ def _cmd_install_mcp(rest) -> int:
432
+ """`symgrep install-mcp [--scope user|local|project] [--name NAME] [--dry-run]` — register symgrep's own
433
+ MCP server with Claude Code, so a fresh install advertises itself as a tool. It does NOT write
434
+ ~/.claude.json itself (one shared file must have ONE writer); it shells to `claude mcp add`, the safe
435
+ single writer that ships with the client — so this command carries no config-mutation logic to drift."""
436
+ import shutil
437
+ import subprocess
438
+ sp = argparse.ArgumentParser(prog="symgrep install-mcp")
439
+ sp.add_argument("--scope", default="user", choices=["local", "user", "project"],
440
+ help="Claude Code config scope to register in (default: user)")
441
+ sp.add_argument("--name", default="symgrep", help="server name to register under (default: symgrep)")
442
+ sp.add_argument("--dry-run", action="store_true", help="print the exact `claude mcp add` command, write nothing")
443
+ a = sp.parse_args(rest)
444
+ claude = shutil.which("claude")
445
+ if not claude:
446
+ print("install-mcp: the `claude` CLI is not on PATH. symgrep delegates registration to it rather than "
447
+ "writing ~/.claude.json itself — install Claude Code, then re-run.", file=sys.stderr)
448
+ return 2
449
+ entry = _resolve_mcp_entrypoint()
450
+ if not entry:
451
+ print("install-mcp: could not locate symgrep's MCP entrypoint (`symgrep-mcp` console script or "
452
+ "bin/symgrep-mcp). Install symgrep in this interpreter (`pip install symgrep-codesearch`) first.",
453
+ file=sys.stderr)
454
+ return 2
455
+ cmd = [claude, "mcp", "add", "--scope", a.scope, a.name, "--", entry]
456
+ if a.dry_run:
457
+ print("DRY RUN — would run:\n " + " ".join(cmd))
458
+ return 0
459
+ print("registering symgrep's MCP server:\n " + " ".join(cmd))
460
+ rc = subprocess.run(cmd).returncode
461
+ if rc == 0:
462
+ # MCP servers are read at client START: a just-registered server is callable in the NEXT session, not
463
+ # this one — say so rather than imply a live pickup this cannot deliver.
464
+ print(f"registered '{a.name}' at scope '{a.scope}'. It becomes callable in the NEXT Claude session "
465
+ "(MCP servers load at client start). `claude mcp add` leaves an existing entry intact, so this "
466
+ "is safe to re-run.")
467
+ return rc
468
+
469
+
412
470
  def main(argv=None) -> int:
413
471
  _argv = list(sys.argv[1:] if argv is None else argv)
414
472
  if _argv and _argv[0] == "structure": # `symgrep structure <path>` — a subcommand over the flag CLI
415
473
  return _cmd_structure(_argv[1:])
474
+ if _argv and _argv[0] == "install-mcp": # `symgrep install-mcp` — self-register as an MCP server
475
+ return _cmd_install_mcp(_argv[1:])
416
476
  ap = argparse.ArgumentParser(prog="symgrep", description=__doc__)
417
477
  ap.add_argument("pattern", nargs="?", default=None,
418
478
  help="regex to search (omit with --index: then every positional is a ROOT)")
@@ -213,10 +213,13 @@ _TS_UNAVAILABLE_ANNOUNCED: List[bool] = [False]
213
213
 
214
214
 
215
215
  # symgrep's internal grammar name -> the MAINTAINED pack's (tree-sitter-language-pack) name, ONLY where they
216
- # differ. The 15 code languages (TS_LANG_BY_EXT) and "html" resolve identically in both packs; the embedded
217
- # ERB/EJS grammar is "embedded_template" in the legacy pack but "embeddedtemplate" in the maintained one. A
218
- # name absent here passes through unchanged. This is a fixed spelling map (parsing, not a judgement).
219
- _TS_PACK_NAME = {"embedded_template": "embeddedtemplate"}
216
+ # differ. Most code languages (TS_LANG_BY_EXT) and "html" resolve identically in both packs; the two that
217
+ # differ: the embedded ERB/EJS grammar is "embedded_template" in the legacy pack but "embeddedtemplate" in the
218
+ # maintained one, and C# is "c_sharp" in the legacy pack but "csharp" in the maintained pack's manifest
219
+ # (requesting "c_sharp" there routes to a download manifest that lacks it → DownloadError, which is why every CI
220
+ # run went red on C# after the pack migration). A name absent here passes through unchanged. This is a fixed
221
+ # spelling map (parsing, not a judgement).
222
+ _TS_PACK_NAME = {"embedded_template": "embeddedtemplate", "c_sharp": "csharp"}
220
223
 
221
224
 
222
225
  def _ts_parser(lang: str):
@@ -55,7 +55,30 @@ TOOL_DESCRIPTION = (
55
55
  "nothing is CALLED that, never that nothing DOES it. That gap is how duplicate implementations get "
56
56
  "written — measured in one codebase: `share.scrub` beside `share._scrub_text`, `adapters.call_complete` "
57
57
  "beside `adapters.call`, `_save_state` in three files. Requires an index (symgrep --index <root>); if "
58
- "there is none it says so rather than returning an empty result."
58
+ "there is none it says so rather than returning an empty result.\n\n"
59
+ "THREE MORE MODES, each a different question:\n"
60
+ " usage=true — is this name DEFINED, REFERENCED, both, or neither? Returns a verdict in\n"
61
+ " {defined_referenced, defined_unused, undefined_referenced, absent}. LEXICAL,\n"
62
+ " by name: 'defined_unused' is a possibly-unused HINT, never proof of dead code.\n"
63
+ " resolve=true — cross-file candidate resolution: {resolved_unique, ambiguous, external, absent}.\n"
64
+ " When >1 definition shares a name it reports AMBIGUOUS with candidates rather\n"
65
+ " than picking a winner. Use this before trusting any single definition of a\n"
66
+ " name that occurs more than once — import order decides it at runtime.\n"
67
+ " structured=true — machine-readable JSON with a mechanical `status` (ok | no_index | unavailable)\n"
68
+ " instead of prose. Use whenever a program, not a human, reads the result.\n\n"
69
+ "unit= sets --semantic GRANULARITY: 'symbol' (default, a whole function) | 'block' (a sub-function\n"
70
+ "AST block — Type-4 clone retrieval, finds copy-pasted fragments whole-function search misses) |\n"
71
+ "'data' (a schema/config/proto/sql definition) | 'both'/'all'. block and data hits need\n"
72
+ "structured=true to read their span. kind= filters within a unit, e.g. 'data_schema', 'sql_table',\n"
73
+ "'proto_message'.\n\n"
74
+ "structural=true SWITCHES FROM REGEX TO AST-PATTERN MATCH — `pattern` becomes a code snippet with\n"
75
+ "metavariables ($NAME matches one node, $$$ is variadic), matched by syntactic SHAPE, so it finds forms a\n"
76
+ "regex cannot express (e.g. `$A == $A` for self-comparisons) without tripping on whitespace or naming. It\n"
77
+ "REQUIRES lang= to pick the grammar the pattern is parsed in (lang= one of the tree-sitter languages:\n"
78
+ "'python' | 'go' | 'javascript' | 'typescript' | 'tsx' | 'rust' | 'java' | 'c' | 'cpp' | 'ruby' | 'php' |\n"
79
+ "'c_sharp' | 'kotlin' | 'scala' | 'bash'); with no usable grammar it reports UNAVAILABLE rather than\n"
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."
59
82
  )
60
83
 
61
84
 
@@ -131,6 +154,11 @@ def _run_fastmcp(http=None) -> bool:
131
154
  definitions, references, resolution_basis:"candidate-name"}. verdict ∈ resolved_unique | ambiguous |
132
155
  external | absent. When >1 definition shares the name it is AMBIGUOUS (candidates, not one winner).
133
156
 
157
+ structural=True: match by AST SHAPE, not regex — `pattern` is a code snippet with metavariables
158
+ ($NAME matches one node, $$$ is variadic). Requires lang= (the tree-sitter grammar the pattern parses
159
+ in, e.g. "python"/"go"/"rust"); reports UNAVAILABLE when no grammar is usable rather than falling back
160
+ to text. lang is used ONLY by structural search.
161
+
134
162
  unit: --semantic granularity — "symbol" (default; a whole function/method, the only pre-v6 behaviour,
135
163
  byte-stable) | "block" (a sub-function AST block, Type-4 clone retrieval) | "data" (a schema/data/config
136
164
  symbol — a data_schema file unit or a proto/sql definition) | "both"/"all". block/data hits need
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symgrep-codesearch
3
- Version: 0.6.2
3
+ Version: 0.6.4
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
@@ -40,6 +40,7 @@ tests/test_index_scope.py
40
40
  tests/test_index_worker.py
41
41
  tests/test_mcp_structured.py
42
42
  tests/test_name_uniqueness.py
43
+ tests/test_nudge_classify.py
43
44
  tests/test_nudge_runaway.py
44
45
  tests/test_report_filters.py
45
46
  tests/test_resolve.py
@@ -11,8 +11,9 @@ import tempfile
11
11
  import pathlib
12
12
 
13
13
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # tests dir on path: checklib imports when run directly (python tests/<file>.py), as CI does
14
15
  from symgrep import blocks, structural # noqa: E402
15
- from tests.checklib import Checks # noqa: E402
16
+ from checklib import Checks # noqa: E402
16
17
 
17
18
  c = Checks("test_blocks_extraction")
18
19
 
@@ -15,7 +15,8 @@ import tempfile
15
15
  import pathlib
16
16
 
17
17
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
18
- from tests.checklib import Checks # noqa: E402
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # tests dir on path: checklib imports when run directly (python tests/<file>.py), as CI does
19
+ from checklib import Checks # noqa: E402
19
20
 
20
21
  c = Checks("test_blocks_retrieval")
21
22
 
@@ -12,8 +12,9 @@ import tempfile
12
12
  import pathlib
13
13
 
14
14
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # tests dir on path: checklib imports when run directly (python tests/<file>.py), as CI does
15
16
  from symgrep import data_symbols as ds # noqa: E402
16
- from tests.checklib import Checks # noqa: E402
17
+ from checklib import Checks # noqa: E402
17
18
 
18
19
  c = Checks("test_data_symbols")
19
20
  _SEM_LINE = re.compile(r"^\s+(\d\.\d+)\s+(\S+)\s+(~?\S+):(\d+)\s*$") # honestreview's exact parse
@@ -16,8 +16,9 @@ import pathlib
16
16
  import contextlib
17
17
 
18
18
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # tests dir on path: checklib imports when run directly (python tests/<file>.py), as CI does
19
20
  from symgrep.structure import file_structure, STRUCTURE_SCHEMA_VERSION # noqa: E402
20
- from tests.checklib import Checks # noqa: E402
21
+ from checklib import Checks # noqa: E402
21
22
 
22
23
  c = Checks("test_file_structure")
23
24
 
@@ -121,7 +122,7 @@ c.check("CLI == library API (no drift)", rc == 0 and cli_out == s.to_dict())
121
122
  # 6. a tree-sitter language (Go). tree-sitter is an OPTIONAL dependency: assert the contract that actually
122
123
  # holds here — extraction when the grammar is present, LANGUAGE-NAMED honest degradation when it is not
123
124
  # (never a silent basis downgrade dressed as a clean result). Both are real contracts, so neither branch skips.
124
- from tests.checklib import ts_available
125
+ from checklib import ts_available
125
126
  HAVE_TS = ts_available("go") # symgrep's own loader (either grammar pack), not one pack's import name
126
127
 
127
128
  GO = pathlib.Path(d) / "m.go"
@@ -12,8 +12,9 @@ import sys
12
12
  import tempfile
13
13
 
14
14
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # tests dir on path: checklib imports when run directly (python tests/<file>.py), as CI does
15
16
  from symgrep import semantic # noqa: E402
16
- from tests.checklib import Checks # noqa: E402
17
+ from checklib import Checks # noqa: E402
17
18
 
18
19
  c = Checks("test_index_reconstruct")
19
20
 
@@ -0,0 +1,147 @@
1
+ """Guards for the nudge's COST DELIVERY — the fix for the measured $70/day defect (2026-10-07).
2
+
3
+ The classification itself is correct and must stay agentic; what these pin is HOW the verdict is fetched, so
4
+ the expensive path can never come back silently:
5
+
6
+ 1. NEVER a Claude Code session / the capped Anthropic plan. The old hook shelled `spendguard ask … --budget 0`,
7
+ which routed to the claude-code subscription lane — a full `claude -p` session (~80K cold tokens, Opus).
8
+ Tests: the hook's miss-path shells the one-shot CLASSIFIER (not `spendguard ask`/`--budget 0`); the model is
9
+ non-anthropic (cannot reach the claude-code lane); and the classifier REFUSES a claude-code executor outright.
10
+ 2. STAYS AGENTIC. With no gated LLM path available the verdict is None (fail open) — there is NO mechanical
11
+ string/regex classifier that decides true/false. The WHOLE command is the evidence (never sliced).
12
+ 3. CACHE IN FRONT. A repeat command returns from cache without spawning anything.
13
+
14
+ Run: python tests/test_nudge_classify.py (command is `python …`, so the LIVE hook sees no grep)
15
+ """
16
+ import json
17
+ import os
18
+ import sys
19
+ import types
20
+
21
+ HERE = os.path.dirname(os.path.abspath(__file__))
22
+ sys.path.insert(0, HERE)
23
+ sys.path.insert(0, os.path.join(os.path.dirname(HERE), "hooks"))
24
+ from checklib import Checks # noqa: E402
25
+ _t = Checks("test_nudge_classify")
26
+ check = _t.check
27
+
28
+ import symgrep_nudge as h # noqa: E402
29
+
30
+
31
+ # ---- 1a. NON-ANTHROPIC model: the classifier can never reach the claude-code lane / the capped plan ----------
32
+ import symgrep_nudge_classify as clf # noqa: E402
33
+ check("classifier default model is NON-anthropic (cannot route to the claude-code session / capped plan)",
34
+ not clf.NUDGE_VENDOR_DEFAULT.lower().startswith("anthropic"),
35
+ f"NUDGE_VENDOR_DEFAULT={clf.NUDGE_VENDOR_DEFAULT!r}")
36
+
37
+
38
+ # ---- 1b. The hook's miss-path shells the CLASSIFIER, never `spendguard ask … --budget 0` (the session path) --
39
+ _captured = {}
40
+
41
+
42
+ def _fake_run(argv, **kw):
43
+ _captured["argv"] = list(argv)
44
+ _captured["input"] = kw.get("input")
45
+ out = types.SimpleNamespace()
46
+ out.stdout = json.dumps({"code_search": True, "why": "test", "executor": "api", "cost": 1e-5})
47
+ out.stderr = ""
48
+ out.returncode = 0
49
+ return out
50
+
51
+
52
+ def _with(tmp_cache, py_exists=True):
53
+ """Point the cache at a throwaway sqlite and the classifier python at a real (any) existing file."""
54
+ os.environ["SYMGREP_NUDGE_CACHE"] = tmp_cache
55
+ os.environ["SYMGREP_NUDGE_PYTHON"] = sys.executable if py_exists else os.path.join(tmp_cache, "nope")
56
+
57
+
58
+ import tempfile # noqa: E402
59
+ _tmp = tempfile.mkdtemp(prefix="nudge-test-")
60
+ _cache = os.path.join(_tmp, "verdicts.sqlite")
61
+
62
+ _orig_run = h.subprocess.run
63
+ h.subprocess.run = _fake_run
64
+ try:
65
+ _with(_cache)
66
+ v = h.judge_is_code_search("rg -n uniquepattern_abc123 src/")
67
+ argv = _captured.get("argv", [])
68
+ check("miss-path verdict is the classifier's JSON (True here)", v is True, f"v={v!r}")
69
+ check("miss-path shells a PYTHON interpreter, not the spendguard binary",
70
+ bool(argv) and "python" in os.path.basename(argv[0]).lower(), f"argv0={argv[:1]}")
71
+ check("miss-path runs symgrep_nudge_classify.py (the one-shot), not `ask`",
72
+ len(argv) >= 2 and argv[1].endswith("symgrep_nudge_classify.py"), f"argv1={argv[1:2]}")
73
+ check("miss-path argv contains NEITHER 'ask' NOR '--budget' (the old session path is gone)",
74
+ "ask" not in argv and "--budget" not in argv, f"argv={argv}")
75
+ check("the WHOLE command is sent to the classifier on stdin (no slice)",
76
+ _captured.get("input") == "rg -n uniquepattern_abc123 src/", f"input={_captured.get('input')!r}")
77
+
78
+ # ---- 3. CACHE IN FRONT: a repeat returns from cache WITHOUT spawning the classifier -----------------------
79
+ _captured.clear()
80
+
81
+ def _boom(*a, **k):
82
+ raise AssertionError("cache HIT must not spawn the classifier subprocess")
83
+
84
+ h.subprocess.run = _boom
85
+ v2 = h.judge_is_code_search("rg -n uniquepattern_abc123 src/")
86
+ check("a repeat command is served from the content cache (no subprocess)", v2 is True, f"v2={v2!r}")
87
+ finally:
88
+ h.subprocess.run = _orig_run
89
+
90
+
91
+ # ---- 2a. STAYS AGENTIC: with no gated LLM path, there is NO mechanical verdict (fail open = None) -------------
92
+ for k in ("SYMGREP_SPENDGUARD",):
93
+ os.environ.pop(k, None)
94
+ os.environ["SYMGREP_NUDGE_PYTHON"] = os.path.join(_tmp, "does-not-exist")
95
+ os.environ["SYMGREP_NUDGE_CACHE"] = os.path.join(_tmp, "empty.sqlite")
96
+ # A clearly-code-search command: a regex/keyword classifier WOULD answer True here. The honest answer with no
97
+ # LLM reachable is None (fail open) — proving no mechanical path decides the meaning.
98
+ check("no LLM path reachable → judge returns None (no regex/keyword fallback decides it)",
99
+ h.judge_is_code_search("rg -n 'def handler' src/") is None,
100
+ "a non-None verdict would mean a mechanical classifier exists")
101
+
102
+
103
+ # ---- 1c. The classifier REFUSES a claude-code executor (the session) even if routing ever drifted there -------
104
+ # Inject a fake spendguard so classify() runs with no network/spend: its adapters.call returns a claude-code hit.
105
+ _fake_sg = types.ModuleType("spendguard")
106
+ _fake_adapters = types.ModuleType("spendguard.adapters")
107
+ _prompt_seen = {}
108
+
109
+
110
+ def _fake_call(model, prompt, **kw):
111
+ _prompt_seen["model"] = model
112
+ _prompt_seen["prompt"] = prompt
113
+ _prompt_seen["kw"] = kw
114
+ return {"executor": "claude-code", "text": '{"code_search": true, "why": "x"}', "cost": 0.0, "error": None}
115
+
116
+
117
+ _fake_adapters.call = _fake_call
118
+ _fake_sg.adapters = _fake_adapters
119
+ _fake_sg.require = lambda *a, **k: None
120
+ sys.modules["spendguard"] = _fake_sg
121
+ sys.modules["spendguard.adapters"] = _fake_adapters
122
+
123
+ res = clf.classify("rg -n foo src/")
124
+ check("classifier REFUSES a claude-code executor (the ~80K Opus session this fix removes)",
125
+ res["code_search"] is None and "claude-code" in (res["error"] or ""),
126
+ f"res={res}")
127
+ check("the refused (session) verdict carries executor='claude-code' for the guard to see",
128
+ res["executor"] == "claude-code", f"executor={res['executor']!r}")
129
+
130
+ # ---- 2b. WHOLE command is the evidence — classify() sends the command verbatim, never a slice ----------------
131
+ long_cmd = "rg -n '" + ("x" * 5000) + "' src/ --glob '*.py'"
132
+
133
+
134
+ def _fake_call_ok(model, prompt, **kw):
135
+ _prompt_seen["prompt"] = prompt
136
+ return {"executor": "api", "text": '{"code_search": true, "why": "x"}', "cost": 1e-5, "error": None}
137
+
138
+
139
+ _fake_adapters.call = _fake_call_ok
140
+ res2 = clf.classify(long_cmd)
141
+ check("classify() sends the ENTIRE command to the model (no truncation of the evidence)",
142
+ _prompt_seen.get("prompt") == long_cmd, "prompt was sliced")
143
+ check("classify() does not pass a strict schema= (so the $0 lane stays reachable)",
144
+ "schema" not in _prompt_seen.get("kw", {}) or _prompt_seen["kw"].get("schema") is None,
145
+ f"kw keys={list(_prompt_seen.get('kw', {}))}")
146
+
147
+ _t.finish()