tensor-grep Debugging Playbook
A symptom-first runbook for the recurring ways tg (or its CI/release pipeline) breaks. Every
row below was a real, previously-diagnosed failure in this repo — not a hypothetical. The single
biggest time-waster on record is theorizing from a stack trace instead of reading the structured
failure first: a README rewrite once cost 4 CI cycles because the team guessed at causes from
tracebacks instead of decoding which CI check actually failed (AGENTS.md). Do not repeat that.
When NOT to use this skill
This is a triage skill (symptom → cause → experiment → fix pointer), not a how-to or a history book. Reach for a sibling instead when:
| You need... | Use instead |
|---|---|
| The 4 registration sites for a new command/flag, PR-title→release-intent rules, what you may not edit | tensor-grep-change-control |
| The full postmortem of a settled battle (PyO3 FFI revert, README-rewrite gate break, fork-bomb binary disable) | tensor-grep-failure-archaeology |
The architecture of the ComputeBackend contract / registration system itself, not just "how do I diagnose a violation" |
tensor-grep-architecture-contract |
How to extend the native-delegation field-coverage ratchet for a new SearchConfig field (forward / refuse / KNOWN_GAP), not "why is this test red" |
tensor-grep-config-and-flags |
Env var reference (TG_RG_TIMEOUT_SECONDS, TG_SESSION_MAX, …) beyond the ones a failure mode below needs |
tensor-grep-config-and-flags |
Toolchain/build setup (cargo off PATH, maturin develop, Windows gotchas) unrelated to a live failure |
tensor-grep-build-and-env |
tg doctor / tg dogfood field-by-field reference |
tensor-grep-diagnostics-and-tooling |
| Local validation gate command reference (ruff/mypy/pytest) as a checklist, not a debug session | tensor-grep-validation-and-qa |
| Full release-and-positioning procedure, not "why didn't THIS release publish" | tensor-grep-release-and-positioning |
| Writing a NEW regression test for a hang-class bug (ReDoS/deadlock/lock-race/unbounded subprocess), or deciding whether a long-silent test/agent run is genuinely hung vs. slow-but-working | global skill anti-hang-test-protocol |
| The mandatory adversarial security-gate review before merging a money/auth/security/migration diff (verdict shape, Opus-as-codex-substitute) | tensor-grep-backlog-campaign Hard Rule 11 (cross-referenced from tensor-grep-change-control) |
If your symptom isn't in the table below, it's probably not covered here — check
tensor-grep-failure-archaeology for a prior occurrence before assuming it's novel.
Jargon, defined once
- Front door — the entry point argv must pass through to be routed correctly.
tg's Python front door istensor_grep.cli.bootstrap:main_entry; it intercepts plain-text searches and forwards them torgbefore the Typer app sees argv.CliRunnerin tests calls the Typer app directly and bypasses this front door, so a routing bug can be invisible to green unit tests. - Fail-closed — on a real failure, raise/error instead of silently returning a clean-looking empty result or swapping to an engine that can't honor the requested semantics.
- Push-race — two
main-bound merges overlapping so the secondgit push origin mainfrom an in-flight semantic-release job is rejected non-fast-forward. - Registration site — one of several places a new command/flag/route must be added; missing one makes it silently misroute instead of erroring loudly.
- argv/flag injection (CWE-88) — a user- or LLM-controlled value that begins with
-gets parsed by a subprocess's own argument parser as a flag instead of as data, even when the parent process used list-argv (shell=False), which only stops shell injection. - Capture surface — the mechanism a test reads a command's output through.
CliRunner'sresult.stdout/result.outputcaptures only in-process writes (typer.echo/click.echoduring.invoke()); pytest'scapfdcaptures at the OS file-descriptor level, which is the only way to see output written by a real exec'd subprocess. They are not interchangeable, and using the wrong one doesn't error — it silently reads back empty. See §9.
Triage table
| Symptom | Likely cause | Discriminating experiment | Fix pointer |
|---|---|---|---|
| CI check is red, unclear why | Wrong assumption from the traceback instead of the actual failing check (e.g. registration-completeness gate, not the code you touched) | gh pr checks <PR> → find the named failing job, then gh run view <run-id> --json jobs → gh run view <run-id> --log-failed |
§1 |
PR merged, main CI green, but the version never showed up on PyPI / no chore(release) commit |
EITHER a push-race (another merge landed mid-flight) OR a needs:-job flake (Semantic Release itself skipped) — these need DIFFERENT recovery, don't assume push-race by default |
gh run view <run-id> --json jobs on the Semantic Release job: ! [rejected] main -> main in its log = push-race (self-heals, don't rerun); a bare skipped conclusion with no rejection line = flaky upstream job (gh run rerun --failed) |
§2 |
Local gh pr merge fails, but GitHub may have accepted the merge |
The local checkout/worktree cannot update main; the remote merge request can still have succeeded |
gh pr view <PR> --json mergedAt — a non-null mergedAt is the remote truth |
Do not retry or double-merge; refresh local refs |
tg search hangs, or errors after a long wait |
Whole-repo / unscoped search hit one of THREE route-dependent bounds: the Python bootstrap 60s timeout, the native implicit-walk ceiling, or the native route's UNBOUNDED spawned-rg wait (often because .tensor-grep/, _tg_refs/, or a vendored external_repos/ dir got walked) |
Check the exit code — 124 = Python bootstrap timeout, 2 + "broad root scan refused" = native ceiling, no exit at all = the unbounded native arm |
§3 |
tg returns 0 matches / empty result but you expect matches |
A backend swallowed a real failure (native panic, PCRE2 semantics mismatch, OOM'd subprocess) and returned a clean empty SearchResult instead of raising |
Re-run with --format rg or check routing_reason / fallback_reason in --json output; compare against rg directly on the same pattern/path |
§4 |
A pattern/path argument starting with - is silently interpreted as a flag by rg/tg/git (wrong output, not a crash) |
A subprocess argv builder appended a user-controlled value as a bare positional with no -- end-of-options sentinel |
tg search -- --weird-pattern PATH vs tg search --weird-pattern PATH (should error) — same probe against any MCP tool call path |
§5 |
| A test suite is green but the real binary/extension does the wrong thing (dropped flags, dead code path) | Test mocked the boundary (a monkeypatched function, a stubbed PyO3 class) instead of exercising the compiled extension or the published binary | Run the same call through the installed tg (not CliRunner, not a mocked backend) and check tg doctor --json / HAVE_RUST |
§6 |
A fresh Python install resolves tensor-grep to an old version with no error |
An upper-bound dependency pin (e.g. typer<0.26) has no release compatible with the new Python, so the resolver silently downgrades the whole package |
pip index versions tensor-grep vs what actually installed; check pyproject.toml for < pins on typer/click/pydantic |
§7 |
| Agent-capsule primary target flipped after an unrelated change (wrong file promoted to top) | The agent capsule's flat, no-IDF candidate scorer is corpus-fragile — a small corpus change can flip which candidate wins a tie. (tg search --rank and semantic search use a different, IDF-weighted BM25 scorer and are not known to share this bug.) |
Re-run tg agent PATH QUERY --json before/after the change and diff primary_target + ambiguity/ask_reasons fields |
§8 |
A CliRunner test reading capfd starts returning empty output / JSONDecodeError right after a delegation, routing-gate, or --rank/--sort-files-style flag change — often only on main/release CI, green on the PR |
The code path moved from a delegated subprocess (needs fd-level capfd) to in-process typer.echo (needs result.stdout), or vice versa — the test's capture fixture didn't move with it. At the time of the incident PR CI did not build the native binary, so the mismatch never surfaced there (DATED — see §19's IN DISPUTE note). |
Grep the refuse-tuple for the field you touched (_NATIVE_TG_DELEGATION_DEFAULT_REQUIRED_FIELDS, src/tensor_grep/cli/main.py:1980 — re-derive with: grep -n '_NATIVE_TG_DELEGATION_DEFAULT_REQUIRED_FIELDS' src/tensor_grep/cli/main.py) — did it just start refusing (or allowing) native delegation? |
§9 |
| A latency "fix" doesn't move the needle, or a reported regression can't be reproduced / doesn't match the diff | The hot path was inferred by reading code (a review/design pass) instead of measured — the real bottleneck is often a pure helper called redundantly in a hot loop, invisible from reading the "expensive-looking" function alone | Profile the actual slow command at realistic scale (not a toy input) and check top cumulative-time frames; Counter-wrap a suspect function to see call-count-vs-unique-input redundancy before designing a cache | §10 |
PyPI/chore(release) published fine, "latest main run green" -- but a real regression shipped anyway |
The workflow run's aggregate status hides one late-stage job's own red conclusion -- specifically the NEEDS-gated release-tag-smoke job (re-runs scripts/agent_readiness.py against an EDITABLE install of the release tag's source — not the PyPI wheel), which can stay red for releases at a time while publish-pypi/publish-success-gate keep going green; later non-release runs never re-run it |
gh run view <run-id> --json jobs on the release run -> find the job named release-tag-smoke specifically -> read its own conclusion, don't infer from the run's overall status |
S11 |
Dependency & License Audit job is red, but your diff doesn't touch any dependency file, and it reds EVERY open PR at once |
A newly-disclosed CVE/RUSTSEC advisory against an already-pinned, unmodified dependency -- the strict-on-fixable pip-audit/cargo-audit gate fails for everyone until the floor moves, not just your branch |
gh run view <run-id> --log-failed on the Dependency & License Audit job -- decode pip-audit's/cargo-audit's OWN structured output for the exact package + advisory ID + fixed-version |
S12 |
A shell one-liner that pipes tg/a probe script into tail/grep/python -c ... reports success (exit 0) even though the FIRST command in the pipe actually failed |
Pipe exit-code masking: a shell pipeline's exit code is the LAST command's, not the first's | Re-run the first command alone and check its own $?/$LASTEXITCODE; or use ${PIPESTATUS[0]} (bash) / split into two statements |
S13 |
| An automated dogfood/verdict script says PASS or FAIL, but the underlying behavior looks wrong when you inspect it directly | The scoring logic misread the JSON shape (a renamed field, a nested-vs-top-level key) — a shape misread can silently read as either a clean pass or a clean fail | Read the RAW --json output at least once by eye before trusting the automated verdict for a new/changed probe |
S14 |
A macOS-only CI job with a Setup Rust step (e.g. test-rust-core) fails with a network/timeout error during Rust toolchain setup — not a compile error, not something your diff touched |
rust_core/rust-toolchain.toml pins an exact Rust version; the first cargo invocation with rust_core/ as its working directory triggers an on-demand rustup fetch for that pin, and unlike the rustup-init bootstrap curl (--retry 10), rustup's own pinned-toolchain download had no retry |
gh run view <run-id> --log-failed on the Setup Rust step specifically — a transient network/timeout message on the pinned-toolchain fetch, not a rustc/cargo compile error |
S15 |
gh pr create (or the GitHub UI) rejects a branch push with "No commits between main and <branch>" even though a worktree agent reported it committed real work |
The agent committed on a DETACHED HEAD (not the named branch), then git push origin <branchname> pushed the branch REF — still sitting at main's tip — instead of the commit; the work is not lost, just not reachable from the ref that was pushed |
git -C <worktree> rev-parse HEAD vs git -C <worktree> rev-parse <branchname> — if they differ, the commit is real but the branch ref never moved |
S16 |
| Your fix to a search behaviour has NO observable effect, and tracing shows your new code IS being called | You are editing a code path the invocation never takes. A bare tg search PAT (and any invocation without a _requires_full_cli flag) is dispatched by bootstrap.main_entry straight to ripgrep via _run_rg_passthrough, which raise SystemExit(...)s with rg's exit code — Typer never runs, so every emitter in cli/main.py is downstream of a branch that never executes. A trace calling main.app() directly WILL show your code running, which is what makes this so convincing and so wrong. |
Trace sys.exit and print the LINE it fires from: m.sys.exit = lambda c: (print(traceback.extract_stack()[-2].lineno), orig(c)). If it exits inside bootstrap.py, your edit in main.py is unreachable for that invocation. Then re-read tensor-grep-architecture-contract §"The front door: intercept before Typer" — this is documented, and not loading it cost a multi-hour detour on 2026-07-29. |
tensor-grep-architecture-contract |
| A wall-clock/timing-ratio test flakes on a loaded Windows CI runner, and each attempt to widen its tolerance either doesn't fix it or makes it worse | A max(baseline * N, floor) assertion silently degenerates to the floor alone once the baseline collapses below the platform's clock resolution (or the "fix" attributed the flake to the wrong noise source without profiling) |
gh run view <run-id> --log-failed for the exact overshoot numbers, then re-measure the baseline in isolation (does it read as a real, non-zero number across several runs?) and cProfile the real command before touching the assertion |
§17 |
| A diagnostic control reports a capability "present" and rules out an otherwise-live hypothesis for a red gate, which then sits "cause unknown" | The control checked a symbol adjacent to, but different from, the one the code actually branches on (e.g. the importable rust_core extension module vs. the resolved native-binary path resolve_native_tg_binary()) |
Grep the real branch point for the exact symbol it reads, then restate the control as "I set <that symbol> to <value>" rather than the capability you believe it proves |
§18 |
| A control reproduces a CI failure byte-for-byte and gets treated as "confirmed" before a fix is designed and dispatched around it | The control's forced mechanism is sufficient to reproduce the symptom, but nobody checked whether the REAL failing job's own config can even reach that mechanism | Read the real failing job's own config/log for the step in question -- does it execute the code path your control forced? | §19 |
A merge-gate or release-monitor check runs gh run list --branch ... --limit N (optionally filtered by SHA) and reports "0 in flight" / "all terminal" while a real run is still mid-publish |
The limited window filled with unrelated rows sharing the same filter (other workflows on the branch, or cron-scheduled runs that happen to fire on the same commit SHA), pushing the real run out of view | Query the ONE run by its unique ID (gh run view <run-id>), never a list plus a filter; if you must list first, read every row's workflow name, not just whether the filter matched |
§20 |
A dogfood/verification run through a Windows-built tg binary invoked from Git Bash reports zero files found against a fix that actually works |
The invocation handed the binary a POSIX-style path (e.g. /tmp/...) it cannot resolve — NOT the default Git-Bash mode (which converts the cwd), but a path-conversion-disabled or BRIDGED invocation (a shim/env-var/argument carrying the shell's untranslated POSIX string), so the binary walks an empty/nonexistent directory in its own path domain |
Re-run the identical command with an explicit Windows-form path (C:\...) instead of the defaulted/bridged path, and compare |
§21 |
A stray untracked nul file appears in git status on Windows (and Remove-Item/Test-Path can't touch it), OR a WSL-side test run misbehaves and you suspect the wrong interpreter/venv is executing |
2>nul redirect artifact (reserved device name blocks PowerShell removal), or a broken system WSL stdlib / a WSL uv pointed at the Windows .venv (A60) |
rm -f ./nul via Git Bash; probe WSL interpreter provenance with a bare import shutil and confirm a WSL-local managed venv |
§22 |
A skill/draft "where's the file?" shell probe hangs ~1–2 min then exits -1 / 4294967295 with empty output, even though the skill already exists in the worktree |
Get-ChildItem -Recurse -Force $env:TEMP (or similar whole-TEMP walk) hits locked/inaccessible Windows temp trees and never finishes usefully; HTML-escaped redirects like 2>$null can also mangle the command |
Test-Path .claude/skills/<name>/SKILL.md; git status --porcelain -- .claude/skills/<name>; never recurse all of $TEMP — top-level $TEMP filter only if needed |
Look in the worktree skill folder / PR branch first; the hang is the instrument, not a missing skill |
1. CI red — decode the structured check first
Do not read the traceback and start theorizing. Identify which named job/check failed, then read only that job's failed-step log.
gh pr checks <PR-number> # which named check(s) actually failed
gh run view <run-id> --json jobs # confirm the job name, e.g. "Semantic Release", "test-python"
gh run view <run-id> --log-failed # only the failed step's log — not the whole 20-minute run
Why this matters here specifically: this repo's CI enforces far more than tests — formatting,
typing, cross-platform behavior, release-workflow contracts, package-manager contracts, and
artifact/version parity all block the same pipeline (docs/CI_PIPELINE.md). A registration
mismatch (new command/flag missing one of its sites) fails the blocking registration-completeness
gate, which is a different job than test-python, and reading a Python traceback from the
wrong job wastes a cycle. Registration sites and rules live in tensor-grep-change-control; the
checker itself is src/tensor_grep/core/registration_check.py (check_group_smart,
extract_members), exercised by tests/unit/test_registration_check.py.
Known real incident: a README rewrite broke ~14 governance tests and a separate
agent-readiness release-blocker gate; 4 CI cycles were wasted because the team theorized from
tracebacks instead of reading which check failed first (root cause was two unrelated layers: a
missing ast-grep CLI dependency, and uv run re-syncing away the [dev] tree-sitter extra).
Decode the check name before touching code.
A second, more subtle version of the same trap: a red test-python job whose failure signature
(JSONDecodeError from an empty captured string) looks like a routing regression but is actually
a stale test fixture — the test was reading the wrong capture stream after a delegation-routing
change moved the command from a subprocess path to an in-process one. Reading the traceback alone
sends you looking for a routing bug that doesn't exist; the fix pointer is §9, not a backend change.
If the failing check is the Semantic Release job specifically, go to §2, not here.
2. Release did not publish (push-race)
The real publish step is the Semantic Release job inside .github/workflows/ci.yml, which
compiles native assets before publishing (~6 minutes) — that whole window is a race window where a
second merge to main can knock out the first run's final push.
Discriminating experiment:
gh run view <run-id> --json jobs # find the "Semantic Release" job's run/conclusion
gh run view <run-id> --log-failed # read its failed step only
A line reading ! [rejected] main -> main is the push-race signature. Do not panic-rerun — the
failure self-heals on the next push-to-main (version is derived from git tags, not the failed
run's state). Full mechanism, the v1.17.23/#318/#319 receipt, and the one-merge-per-tick
discipline to prevent recurrence: tensor-grep-release-and-positioning §1.5 /
tensor-grep-failure-archaeology Battle 6.
A SECOND, different release-failure branch does NOT self-heal — read the job conclusion before picking a recovery, don't assume every "release didn't publish" is a push-race:
| Branch | Signature | Recovery |
|---|---|---|
| Push-race (this section) | ! [rejected] main -> main in the Semantic Release job's own log |
Self-heals on the next push. Do NOT rerun. |
needs:-job flake (C-release-flake) |
Semantic Release shows skipped (not failure), no rejection line — a flaky upstream job in its needs: list failed |
Does NOT self-heal — the flaky job's cause doesn't change between pushes. Run gh run rerun --failed on the SAME run (re-executes only the failed job). Receipts: v1.76.9/#612-613 (a timing-flaky heartbeat test); v1.92.2/#701 (the index-lock concurrency test rewritten after 2 releases of flaking). |
Rapid-window batch-merge is a third, benign shape — don't misdiagnose it as either of the above.
Several independently-green PRs merging ~15-20s apart can show an intermediate cancelled or
rejected-push run that looks alarming in isolation, but is fine IF the LAST run in the sequence
completes and publishes (receipt: v1.93.0/#703-706, runs 29890576036 rejected-only / 29890612228
published). See tensor-grep-change-control Part 7 (C-batch) before treating a mid-sequence
cancelled conclusion as a failure needing recovery at all.
3. Search hangs/slow
tg search does NOT have one timeout contract — it has three distinct outcomes depending on
which route executes the search (verified 2026-08-12 against bootstrap.py +
rust_core/src/rg_passthrough.rs; SUPERSEDES this section's earlier wording that claimed BOTH
routes fail fast at 60s/exit 124):
| Route | Bound | Outcome on a pathological walk |
|---|---|---|
Python bootstrap rg-forwarding (bootstrap.main_entry plain-text passthrough) |
TG_RG_TIMEOUT_SECONDS wall timeout, default 60s (configured_ripgrep_timeout_seconds(), src/tensor_grep/cli/subprocess_policy.py) |
child killed, process exits 124 with a scope-the-search stderr hint |
Native route, IMPLICIT (no user path) walk (execute_ripgrep_search, rust_core/src/rg_passthrough.rs) |
IMPLICIT_SEARCH_WALK_FILE_CEILING (= 1500) bounded walk probe BEFORE any rg spawn (check_implicit_walk_ceiling, the function's first statement) |
refusal to stderr ("broad root scan refused as a safety guard") + exit 2, fail-fast, rg never spawned |
| Native route, spawned rg (a scoped search, or an implicit walk under the ceiling) | NONE — the spawned rg is waited on via Command::status() with NO wall timeout |
rg itself walks unbounded; the native route does not kill it, so a genuinely hung native-route search has no exit at all |
The 60s default was lowered from 600s specifically because ripgrep does GB/s and a >60s search
means something pathological is being scanned (an unexcluded huge/index directory), not a
legitimately slow query. On the PYTHON route's timeout, the child is killed and the process exits
124 with a stderr hint to scope the search or raise the timeout (src/tensor_grep/cli/bootstrap.py,
backward-compat shim path and the primary Popen/_terminate_child path both return 124 —
re-verify with grep -n "return 124" src/tensor_grep/cli/bootstrap.py; was :1020/:1063-1071,
then :1269/:1320, now :1353/:1404 — line numbers drift every release). The native route's
ceiling applies ONLY when the walk is implicit (path_was_implicit); a user-scoped native search
skips the ceiling and spawns rg directly into the unbounded-wait arm.
Discriminating experiment: check the exit code — it names the route. 124 = the PYTHON
bootstrap timeout fired (not a crash). 2 with the "broad root scan refused" marker = the native
implicit-walk ceiling fired BEFORE any rg spawn. NO exit at all (a real hang) = the unbounded
spawned-rg arm — Ctrl-C is legitimate there, and scoping the search is the fix. Compare a scoped
vs. unscoped run:
tg search PATTERN # unscoped over a large/whole repo — can hit the 60s wall
tg search PATTERN src/ # scoped — typically <1s
Root cause when it fires on a legitimately-sized repo: tg's own index/state directories
(.tensor-grep/, _tg_refs/, .tg_semantic_index/) and vendored corpora (e.g.
benchmarks/external_repos/) are not excluded from an unscoped walk, so searching from the repo
root walks tg's own indices too.
Fix / workaround: always scope searches to a path, glob, or file type. Raise
TG_RG_TIMEOUT_SECONDS (or TG_SUBPROCESS_TIMEOUT_SECONDS for non-search subprocess calls) only
for a genuinely huge monorepo — do not raise it to paper over an unscoped-walk problem. A
trigram-hybrid index is the tracked structural fix; own-dir excludes alone were tried and did not
fully resolve full-tree speed. Full env-var reference: tensor-grep-config-and-flags.
Related, known limitation — tg inventory --deadline on a pathological workspace-union tree:
tg inventory --deadline is a different command from tg search but shares the same root-cause
class as the hang above (an unbounded directory read), and normally bounds cleanly per project
(truncates at N files, stamps truncation_cause = "deadline" — build_inventory has since moved out
of main.py into its own module; re-verify with
grep -n 'truncation_cause = "deadline"' src/tensor_grep/cli/inventory.py; was in main.py -- the :8404/:8420 pins pointed INSIDE a --deadline option block deleted
by the 2026-08-23 de-duplication, so they have no successor; now inventory.py:318). On a PATHOLOGICAL workspace-union tree — many
unrelated repos flattened under one huge root, not a single normal project — it can still blow its
deadline: the shared walker _iter_repo_files (re-verify with grep -n "def _iter_repo_files" src/tensor_grep/cli/repo_map.py; was :1143, now :1144) reads an entire huge directory's entries
in one non-lazy list(os.scandir(normalized_root)) call inside that same function (re-verify with
grep -n "list(os.scandir(normalized_root))" src/tensor_grep/cli/repo_map.py; was :1009, now
:1172 — the def itself barely moved but this internal call drifted much further as the
function's docstring grew) before its own per-file deadline check gets a chance to run, so one
abnormally large subdirectory can exceed the deadline before the mid-walk check fires even once. This is a KNOWN, accepted, low-priority edge (rare shape; verified against a real
300k+-file multi-project workspace) — not worth a load-bearing lazy-scandir rewrite. Don't
re-diagnose it as a new bug; if the SAME deadline-blown symptom shows up on a normal single-project
repo (not a workspace union), that IS a regression and should be treated as a new incident, not
this one.
4. Silent-empty result (fail-closed contract)
Every ComputeBackend must raise BackendExecutionError on a real failure — never return a clean
0-match SearchResult, and never silently swap to an engine that cannot preserve the requested
semantics (src/tensor_grep/backends/base.py:6-14). This has been violated repeatedly; the
recurring anti-pattern is a bare except Exception: that returns empty or falls through to a
different engine. A context tool reporting a trustworthy-looking "no matches" when the real
answer is "the backend crashed" is the one failure this repo treats as unacceptable
(AGENTS.md, "Backend Fail-Closed Contract").
Discriminating experiment: run the same pattern/path directly through rg and compare. If rg
finds matches but tg reports zero, suspect a swallowed backend error, not a real no-match. Then
inspect --json output for routing_reason / fallback_reason — a populated fallback_reason
means a visible, legitimate degraded path (e.g. CyBERT provider unavailable); an absent one on
a result you believe is wrong means look for a silent swap. Current, still-live example of the
correct visible-degrade shape (v1.77.0, #189): tg find's JSON carries rank_fallback_reason when
the dense leg degrades to BM25-only (the semantic extra or model is unavailable) — a legitimate,
fully-supported result, distinguishable from a real backend failure (which instead raises
BackendExecutionError -> exit 2, per tensor-grep-run-and-operate §11c). If you see a tg find
result with NEITHER rank_fallback_reason set NOR a nonzero exit on a run you expected the dense leg
to participate in, that is the silent-swap bug this section targets, not a normal degrade.
Ground-truth example of the correct pattern (src/tensor_grep/backends/rust_backend.py:260-278):
a PCRE2 search that fails inside the native ripgrep bridge raises BackendExecutionError and
explicitly refuses to fall back to an engine that doesn't implement PCRE2 semantics — it does NOT
silently re-run the pattern through the Python-regex engine (which would return wrong matches,
not zero matches, but the principle is the same: don't swap engines invisibly for a
semantics-changing flag). Contrast with a legitimate degraded fallback (limit/sort flags the
Python fallback can't honor), which instead sets a visible bridge_fallback_reason on the result.
Fix pointer: if you find a bare except Exception: return SearchResult(...) (or similar) in a
backend, that is the bug class. Fail closed for any flag/contract the fallback cannot preserve
(raise, don't swap); if a degraded fallback is legitimate, set fallback_reason +
routing_reason so JSON/CLI consumers can tell degraded output from real output. Deep architecture
of this contract: tensor-grep-architecture-contract.
5. Argv/flag injection
A list-argv subprocess call (shell=False) stops shell injection but not flag injection: a
value beginning with - is parsed by the child's own option parser as a flag. This is CWE-88 —
the same class behind live MCP-server CVEs (CVE-2026-5058 aws-mcp-server, CVE-2026-23744,
CVE-2026-30623 Anthropic MCP SDK) — and it matters here because MCP tool handlers forward
LLM-controlled parameter values straight into tg/rg/git subprocess argv.
Discriminating experiment:
tg search -- --looks-like-a-flag PATH # with -- sentinel: treated as pattern data
tg search --looks-like-a-flag PATH # without: rg/tg's own parser errors on the "flag"
Run the same probe through any code path that builds subprocess argv from a
pattern/path/replacement value (MCP tool handlers, rewrite commands) — a value beginning with -
should error or be treated as data, never silently change tg's own behavior.
Fixed reference implementation (src/tensor_grep/cli/mcp_server.py, _build_rewrite_command /
_build_index_search_command — re-verify with
grep -n "def _build_rewrite_command\|def _build_index_search_command" src/tensor_grep/cli/mcp_server.py;
was :1259/:1310, now :1328/:1379, +69 each):
a -- end-of-options sentinel is inserted before the user-controlled pattern/path positionals,
with an inline comment explaining why.
Round-4 native-passthrough gap — RESOLVED, do not reopen (verified current at v1.49.3).
rust_core/src/rg_passthrough.rs appending paths directly with no -- sentinel (a directory
literally named -l parsed by rg as the -l/files-with-matches flag instead of a path) was fixed
in #326 (v1.17.26), silently regressed by a later refactor, then restored in #370 (v1.28.1) as
the extracted, unit-tested ripgrep_operand_args helper (rust_core/src/rg_passthrough.rs — see the
grep below; was :581-600, now :584-603)
— the sentinel is now pushed unconditionally before the path loop whenever !args.paths.is_empty().
Patterns going through -e were never affected (-e consumes the next token as its value regardless
of a leading -); only bare path positionals were ever at risk, and that risk is now closed. Verify
with grep -n "fn ripgrep_operand_args" -A 20 rust_core/src/rg_passthrough.rs before relying on this
— do not trust the naive grep -n "for path in &args.paths" re-check below, it still matches (the
loop still exists, just now after the unconditional sentinel push) and would misread as "still
open" if you stop at the grep hit without reading the surrounding function.
Caveats worth knowing before you conclude a builder is safe: -- protects only what comes
after it — a positional placed before -- is still injectable; it does not gate
--flag=VALUE forms; and not every binary honors -- the same way, so dogfood the real binary
rather than trusting the argv list alone. None of {validate the value, list-argv, -- sentinel}
alone is complete — they layer.
6. Mock-green-real-dead
A test can pass because it mocked the exact boundary that was actually broken — a monkeypatched
function, or a Python-side stub standing in for the compiled PyO3 extension. This has happened for
real: mock-based FFI tests were green while the real Rust bridge was dead (it dropped every
forwarded flag and silently fell back to the Python engine) — the dead-passthrough bug and the
missing-flag bug compounded, because the bridge call itself never got exercised
(AGENTS.md, "Local Dev Gotchas").
Discriminating experiment: does the test import/patch tensor_grep.rust_core (or its Python
wrapper RustCoreBackend, the try: from tensor_grep.rust_core import RustBackend as NativeRustBackend / HAVE_RUST block in src/tensor_grep/backends/rust_backend.py — re-verify with
grep -n "HAVE_RUST" src/tensor_grep/backends/rust_backend.py; was :28-33, now :9-14), or does it
patch something around that boundary? If a test replaces bootstrap.run_subprocess or stubs
RustCoreBackend.inner, it is validating call shape, not that the real extension does the right
thing.
uv run python -c "from tensor_grep.backends.rust_backend import HAVE_RUST; print(HAVE_RUST)"
# then, separately, exercise the REAL installed binary end to end (not CliRunner):
tg search --pcre2 'foo(bar)?' src/ # confirm the flag actually reaches rg with real semantics
Same principle one layer up: CliRunner invokes the Typer app directly and bypasses the
tensor_grep.cli.bootstrap:main_entry front door entirely, so a routing bug in the bootstrap layer
is invisible to CliRunner-based tests no matter how many pass. After any change to a search flag,
a command, or the FFI boundary, dogfood the installed published binary with the harness at
scripts/dogfood/ (Dockerfile + dogfood_features.py) rather than trusting unit tests alone
(AGENTS.md, "Dogfood the Real Binary, Not CliRunner"). See dogfood-the-shipped-artifact (global
skill) for the full post-release procedure.
7. Dependency-cap silent downgrade
An upper-bound pin (e.g. typer<0.26) can silently downgrade the entire package on a newer
Python if no release in that range is compatible with it — pip/uv resolve the whole install
down to a stale version with no error, because requires-python>=X has no upper bound to catch
the mismatch. Receipt: on Python 3.14, uv tool install tensor-grep with an unsatisfiable
typer<0.25 range resolved to a stale 1.13.35 instead of erroring. Current pin, chosen to thread
both constraints (pyproject.toml:560-566):
typer>=0.12,<0.26
The comment there (pyproject.toml:560-565) explains why the cap can't simply be dropped: typer
0.26 removed click.testing.CliRunner inheritance, breaking CliRunner.isolated_filesystem()
which ~49 tests rely on.
Discriminating experiment:
pip index versions tensor-grep # what SHOULD be installable
uvx --refresh-package tensor-grep --from tensor-grep==<expected-version> tg --version
If a fresh install on a new Python resolves to an old tg --version, do not assume
requires-python is wrong — grep pyproject.toml for < upper bounds on typer, click,
pydantic, or other transitive deps first; that is the class of bug this was.
8. Ranking flip
The agent capsule's primary-target candidate selection relies on three scoring helpers in
repo_map.py — _score_symbol, _score_import_entry, _score_file_source_terms — plus
score_term_overlap (src/tensor_grep/core/retrieval_lexical.py:15), which
_score_file_source_terms calls. Re-verify their positions before citing one; they do NOT move
together, only relative to each other:
grep -n "def _score_symbol\|def _score_import_entry\|def _score_file_source_terms" src/tensor_grep/cli/repo_map.py
grep -n "score_term_overlap(" src/tensor_grep/cli/repo_map.py
_score_symbol used to sit after the other two (:8211 vs :7725/:7732, with the call site
at :7737) and now sits before them (:8194 vs :8221/:8228, call site now :8233) — a
relative reordering, not a uniform shift, so don't assume a fixed offset holds between any two of
these four line numbers. Together they implement a flat, no-IDF set-membership scorer plus a
hard top-N candidate cap — an acknowledged, not-yet-fixed weak point. A small, unrelated corpus change can flip which
candidate wins a near-tie, and that flip is invisible to the call graph (nothing "broke" in the
traditional sense — the ranking function just picked a different winner). This produced a real
incident: an unrelated GPU-code change flipped the agent capsule's top pick from "tied, ask the
user" to "confidently pick the wrong marker/no-op function" with zero call-graph signal.
Note: tg search --rank (rerank_by_bm25(), src/tensor_grep/core/reranker.py) and local semantic
search (src/tensor_grep/core/semantic_index.py) both route through Bm25Index
(src/tensor_grep/core/retrieval_bm25.py) — a real Okapi BM25 scorer with IDF, term-frequency
saturation, and length normalization. They are a different, IDF-weighted scorer and are not known to
share this
…(truncated)