Skip to content

Promote staging -> testing - #1040

Merged
tirth8205 merged 120 commits into
testingfrom
staging
Sep 18, 2026
Merged

tirth8205 merged 120 commits into
testingfrom
staging

Conversation

@tirth8205

Copy link
Copy Markdown
Owner

Pull Request

Linked issue

Closes #

What & why

How it was tested

uv run pytest tests/ --tb=short -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • Docs updated where behavior changed (README, docs/, docstrings)

tirth8205 and others added 30 commits September 16, 2026 21:33
Untrack docs/superpowers/ and .serena/, drop the private scratch-file list
from .gitignore, de-duplicate GEMINI.md, point the CI badge at the default
branch, and rename the PR-numbered test modules after the behaviour they pin.

- docs/superpowers/specs/: two dated internal design specs were tracked even
  though .gitignore already lists docs/superpowers/. Untracked with
  git rm -r --cached; the ignore rule now holds. The older 2026-07-17 plan and
  spec files under that directory were already removed from the tree.
- .serena/: 160 lines of untouched vendor boilerplate for one contributor's
  tooling. Added .serena/ next to .codex/ under "Local tool configuration" and
  untracked it, matching how .claude/, .codex/ and .qoder/ are handled.
- GEMINI.md was a byte-identical copy of .github/copilot-instructions.md. It is
  now a short pointer to AGENTS.md and CLAUDE.md. Nothing reads it expecting
  full content: the GEMINI.md references in skills.py and test_skills.py are
  about files written into a target repo, under tmp_path.
- .gitignore: the "One-off docs" section named about a dozen private scratch
  files. Replaced with a single scratch/ entry. None of those lines excluded
  build output, so nothing was kept.
- README.md: the CI badge was pinned to ?branch=main while the default branch
  is staging. Now points at staging.
- tests/: 21 modules named after their originating PR are renamed to the
  behaviour they cover, via git mv, with the PR number moved into the module
  docstring. Overlapping modules are folded: #831/#845/#866 into
  test_js_module_resolution.py, #830/#833 into test_go_receiver_resolution.py,
  #824/#862 into test_kotlin_import_comments.py. The one non-JS test in the
  #866 module moved to test_affected_flows_limits.py.
- Updated the references: CLAUDE.md's "Regressions by pull request" line, and
  the self-referencing filename in the stale-SDK scan (was test_pr856_edges.py,
  now test_genai_packaging.py). CI workflow files and tests/test_documentation.py
  do not name any of the renamed files.

Collected tests: 3370 before, 3370 after. Full suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure layout. `_parents` in test_go_receiver_resolution.py and
`_parse`/`_import_targets` in test_js_module_resolution.py landed mid-file
when the modules were folded together. They now sit with the other helpers
above the tests. No test bodies changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lations

Every claim below was re-checked against this checkout before it was changed.

README.md

- Token reduction table: the Reduction column did not divide out from the two
  columns beside it. It printed the benchmark's average_reduction_ratio, which
  is the mean of the five per-question ratios, while the table prints the mean
  of graph_tokens. Replaced with naive_corpus_tokens / avg graph_tokens:
  357.6x, 65.4x, 65.5x, 60.3x, 53.5x, 34.6x. Headline figures follow:
  median 63x, max 358x, range 35x to 358x.
- "~2,000 to 3,500 tokens" contradicted the express row (3,936). Now
  "~2,200 to 3,900".
- "On this repository, 208,821 source tokens" was present tense but measured at
  84bde35, 981 commits back. Re-measured on staging HEAD with the benchmark's
  own definition: 1,217,838 tokens over 267 files, and a build gives 6,513
  nodes / 57,038 edges against the table's 1,446 / 9,094. The snapshot rows are
  kept and labelled as a snapshot.
- Edge confidence: AMBIGUOUS is never written. graph.py and migrations.py
  default to EXTRACTED; scoped_resolver.py and event_resolver.py write
  INFERRED. Now "Two-tier confidence (EXTRACTED/INFERRED)".
- Slash commands: `code-review-graph install` writes no .claude/commands and no
  plugin manifest, so /code-review-graph:* cannot resolve. It writes four
  skills. Table replaced with the four skills install actually writes, plus the
  three extra ones Qoder gets from skills/.
- SVG export exits 1 with ImportError on a default install; matplotlib is only
  in the eval extra. Said so in the CLI reference, the feature table and the
  extras list.
- `unregister <id>`: the positional is path_or_alias and no command prints an
  id. Now `unregister <path|alias>`.
- Added `forget` and `dead-code`, both shipped and documented in
  docs/COMMANDS.md but absent from the README, and said the block is a
  selection rather than a full reference.
- Language list: application.properties / application.yml are promoted to
  spring_config and parsed into ConfigProperty nodes, so "Other YAML is not
  treated as source code" was wrong. Listed Spring Boot application config and
  narrowed the closing sentence.
- Added Java framework awareness (spring_resolver, event_resolver,
  temporal_resolver, Spring Boot config keys), which shipped unmentioned.
- Diagram alt text: fixed the flask, funnel and benchmark-board ratios, and
  added the missing Hermes Agent to the platform list (16 platforms, not 15).
- Two links to docs/REPRODUCING.md#calibration-result-committed were dead; the
  heading is "Calibration table".

docs/FAQ.md, docs/USAGE.md, docs/REPRODUCING.md

- Same AMBIGUOUS fix, same dead anchor, and a note on REPRODUCING's avg ratio
  column explaining why it does not divide out and what the README reports.

Translations

- README.zh-CN.md, README.ja-JP.md, README.ko-KR.md and README.hi-IN.md were
  last touched on 2026-08-02 and were 337 to 339 lines against 659. Rebuilt
  from the corrected English: same sections, same tables, same numbers, same
  commands, same links, 677 lines each. Commands, flags, paths, code blocks and
  CLI output are verbatim. The stale "this translation is older" notices are
  gone because the files are now current.

The PNGs under diagrams/ still bake in the old ratios and show 15 platform
cards. They are exported by hand from Excalidraw, so they are not touched here.

Gates: pytest 3359 passed, 10 skipped, 2 xpassed; ruff clean; mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs: correct the README against the code, and rebuild the four translations
…a test

`visualize --format svg` died with a full traceback on a default install
because matplotlib only ships in the `eval` extra. export_svg now raises a
MissingOptionalDependencyError (an ImportError subclass, so existing
callers are unaffected) and the CLI catches exactly that, prints the
one-line install hint the README already documents, and exits 1. Catching
the subclass rather than bare ImportError keeps a genuinely broken install
of a required package visible as a traceback.

Every other optional import already degrades cleanly: igraph falls back to
file-based communities, tiktoken returns None, jedi skips enrichment,
sentence-transformers/google-genai resolve to no provider, watchdog falls
back to polling, and tomli is a hard dependency below 3.11. SVG export was
the only raw traceback.

tests/test_readme_parity.py holds the five READMEs together on the parts a
reader depends on and a translator must not change: heading-level sequence,
byte-identical fenced code blocks, outbound links and image sources in
order, table row counts, and the language switcher. Prose is excluded, as
are same-document anchors, whose targets are translated headings. Failures
name the file, the check and the first differing item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hygiene change moved the English badge to ?branch=staging while the
translations still carried ?branch=main. Each change was right on its own;
together they drifted, which is exactly what the new parity test is for.
It caught this on its first run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: report missing matplotlib as one line, and pin README parity in a test
get_review_context spent its 800-line source budget first come first
served over the changed-file list, which is the order git handed us. One
alphabetically early 500-line file could take 500 of the 800 lines while
the riskiest changed function in the pull request got nothing.

The budget is now allocated once from the file list ranked by
changes.compute_risk_score, the same score detect_changes reports:

  * every served file gets a 20-line floor, so nothing drops to zero
    while the budget lasts,
  * no file may hold more than 40% of the budget, so one huge file
    cannot starve the rest,
  * lines a file does not need are handed down the ranking rather than
    wasted, and _extract_relevant_lines now reports what it emitted so
    the accounting is exact,
  * max_files cuts the ranked list, so the riskiest files survive it.

Ranking is bounded (8 nodes per file, 400 nodes overall, round-robin so
every file is scored before any file is scored twice) and falls back to
diff order if scoring fails. Measured on this repository with an
eight-file diff: code_review_graph/search.py, the riskiest file at 0.90,
went from 0 source lines to 200, files served went from 4 to 8, and the
total stayed at exactly 800.

compute_risk_score also has a change-frequency term worth up to 0.15,
but include_churn defaulted to False and only cli.py passed it, so every
agent-driven review scored that term at zero. detect_changes_func and
get_minimal_context now pass it. Because that puts git log --since
--numstat inside a tool call an agent waits on, compute_file_churn is
memoised per (repo, HEAD, window), capped at CRG_CHURN_MAX_COMMITS
commits, given its own CRG_CHURN_TIMEOUT (5s) rather than the 30-second
diff timeout, and caches failures so a slow repository pays the timeout
once and then degrades to the previous zero-churn behaviour.

Measured median of five detect_changes_func runs on this repository:
160ms before, 447ms cold (one 275ms git log walk over 543 commits),
175ms warm. Overall risk score on the same diff moved 0.65 to 0.755.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The whole-repo view stops being useful well before a real repository stops
growing. The D3 page falls back to one bubble per community past 3000 nodes
or 9000 edges, and a shallow clone of django indexes to 45111 nodes and
397762 edges, 15x and 44x past that ceiling. Every user with a serious
codebase gets the bubble view, and bubbles are not reviewable.

Seed the view instead. `visualize` grows a payload outward from a symbol,
a set of files (including the ones this review changed), or an execution
flow, and ships only the nodes within --depth hops and the edges among
them. Nodes outside the neighbourhood are absent from the payload, not
drawn dimmed, because payload size is the constraint: a depth-2
neighbourhood of django/urls/base.py::resolve is a fraction of the
whole-repo page.

A hop is a semantic edge (CALLS, IMPORTS_FROM, INHERITS, IMPLEMENTS,
TESTED_BY, DEPENDS_ON). CONTAINS is structural and deliberately not a hop:
counting it would put a seed's file one hop away and every sibling symbol
in that file two hops away, so a depth-2 neighbourhood of one function
would swallow its whole module. Containing File nodes are still shipped, at
the hop of their closest member, because clustering and collapse are built
on CONTAINS.

--render-depth (default 1) is how much is drawn on open; clicking a node at
the edge of what is drawn reveals its next ring and "+1 hop" reveals a
whole ring, both out of the payload already in the page. --path-from and
--path-to answer "how does A reach B", walking CALLS, IMPORTS_FROM and
INHERITS with edge direction where a directed route exists and undirected
otherwise, and highlighting the result in gold.

The renderer is not rewritten. D3 stays, and search, node and edge kind
filters, community colouring, the flow selector, the detail panel and the
keyboard shortcuts all keep working on a seeded page; tests/
test_neighbourhood_browser.py drives each of them in headless Chromium. The
default output is still one self-contained HTML file you can email;
--sidecar moves the payload to graph.data.js beside it and is opt-in.

build_parser() is split out of main() so the flag surface can be tested
without running a command; the three subparsers main() still needs
afterwards are registered in _SUBCOMMANDS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A persistent graph rots invisibly: nothing errors, caller lists quietly
get shorter, and a reviewer gets a confidently incomplete answer. A clean
rebuild of the same tree is a free and perfect oracle for that drift and
nothing used it.

incremental_fidelity copies a repository's tracked files into a throwaway
git tree, builds a clean graph, applies one of seven edits (trailing
comment, rename a function, delete a file, add a file, move a function
between files, change an import, revert a change), commits it, runs
incremental_update plus the post-processing build_or_update_graph runs,
builds a second clean graph from the same edited tree, and compares the
two databases table by table.

The comparator keys every projection on something stable (a qualified
name, a community name, a flow's path resolved to qualified names) and
excludes autoincrement ids and wall-clock columns, so a reported
difference is a real difference. nodes_fts is compared through fts5vocab,
which reads the FTS index rather than the nodes content table behind it.
graph_diff.take_snapshot and diff_snapshots, which nothing called before,
supply the headline node/edge summary; they are not sufficient alone,
which the module docstring explains.

All seven edit kinds diverge on this repository. They are recorded in
KNOWN_FAILURES so a run stays green while the report stays honest, and
tests/test_incremental_fidelity.py guards against a kind that currently
passes starting to diverge. No defect is fixed here; docs/REPRODUCING.md
carries the measured per-table numbers and the three root causes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every incremental update called rebuild_fts_index, which drops nodes_fts,
recreates it and repopulates it from the whole nodes table. Saving one file
reindexed the entire graph on the hook path while the user waited.

search.update_fts_index rewrites only the entries that changed. The full
rebuild stays for full builds, for the postprocess command, and as the
fallback.

nodes_fts is an external content table, so removing an entry needs the
values that were indexed, and those are gone once the node row is deleted.
Deleting with the wrong values leaves a phantom entry: it still matches a
query and then resolves to no node, which hybrid_search drops silently.
Migration v11 adds nodes_fts_state, a mirror of what the index holds, filled
by rebuild_fts_index in the same transaction that rebuilds the index, so the
delta always deletes with the values that were indexed.

The rewrite set is the union of nodes with no index entry, index entries
with no node, and the rows of the files the caller named on both sides. The
first two run whatever the caller passes, so a missing hint cannot leave a
phantom or an unfindable node. With no hint at all the delta compares the
indexed text against the nodes table instead. A delta touching more than 35%
of the graph, or a database whose index predates the mirror, falls back to a
rebuild.

Measured on a build of this repository (6,509 nodes, 515,743 index entries),
medians of seven alternating runs per arm: the FTS sync drops from 42.6 ms to
3.8 ms after touching one file, and from 50.7 ms to 9.2 ms after twenty. A
delta's index entries are identical to a full rebuild's, entry for entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects made text search miss what it should find.

1. `_fts_search` wrapped the whole user query in one pair of double
   quotes, so every multi-word search was an exact-adjacency phrase
   match. On a build of this repository `password hashing` returned 0
   rows. Queries are now built from tokens: each token is quoted (which
   is what neutralises AND/OR/NOT/NEAR, `col:`, `^`, parentheses and a
   typed `*`) and given a prefix term when it carries at least three
   alphanumeric characters. The tokens are ANDed first and ORed only if
   the AND finds nothing, so recall widens without costing precision. A
   phrase the user typed in quotes stays a phrase and suppresses the OR
   widening.

2. 2,451 of 6,517 nodes carried a docstring that the parser had already
   extracted and the embedding text builder already used, but that the
   FTS index could not see: an external-content FTS5 table can only
   index real columns of its content table. Migration v11 adds
   `nodes.docstring`, backfills it from `extra` in SQL, and recreates
   `nodes_fts` with it. Both node write paths populate it, including the
   batched `_replace_file_data` path a real build takes.

`nodes.name_tokens` carries the camelCase splits `unicode61` cannot
produce. Measured on this repository, indexing them raises inner-camel
segment recall@20 from 24.7% to 54.7% for a 2.7% larger FTS index.

BM25 needed help to stay precise once prefix terms and docstrings
widened the candidate set: every row carries its file path in two
indexed columns, so path tokens dominate the length normalisation and a
long test name that repeats the query words outscores the function the
query describes. Two boosts fix that, an exact-identifier boost and a
symbol-name coverage boost, and the shipped search_quality benchmark
goes from MRR 0.667 to 1.000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CONTRIBUTING.md makes every supported AI tool permanent maintenance
surface: config path, schema, install merge, uninstall and tests all
have to keep working on every release. Nothing exercised that end to
end, so a platform could break in a release without any test noticing.

tests/test_platform_lifecycle.py drives the real install and uninstall
code paths against a scratch git repository inside a throwaway HOME,
HERMES_HOME and CRG_HOME, once for each of the 16 platforms in
skills.PLATFORMS, through five steps:

1. first install writes the exact config path, settings key, server
   entry, instruction block, skills and hooks that platform reads;
2. a second install is byte-identical, with no duplicated entry,
   hook or instruction block;
3. installing over a pre-existing config preserves unrelated user
   settings, including another MCP server;
4. uninstall removes the registration, leaves no file it created and
   leaves the user's own settings intact;
5. reinstalling over an older release's entry (an absolute interpreter
   path, a previous hook shape) replaces it instead of leaving it
   beside the new one.

Expectations are restated in the test rather than read back out of
skills.PLATFORMS, so a moved config path or renamed key fails here. A
drift test holds the installer set, the README --platform list and
cli._PLATFORM_CHOICES to the same 16 names and reports any that differ,
and a canary refuses to pass unless every step really ran against every
platform and really compared something.

Known-broken behaviour is recorded as xfail(strict=True) rather than
fixed, so the suite stays green while a change in either direction
fails loudly. 42 of them currently hold, covering: the pre-commit hook
left as an unparseable "#!/bin/sh\nfi" after uninstall, which makes
every later git commit fail; Qoder skill files and the Gemini CLI
settings.json.bak surviving uninstall; every platform left with an
empty config husk; stale config entries never replaced (GH #558,
all 16 platforms, not one); and stale hook shapes duplicated rather
than upgraded on five platforms.

The suite is slow and writes real files, so it is gated behind a new
platform_lifecycle marker and skipped by an opt-in hook in
tests/conftest.py unless the run selects it:

    uv run --python 3.13 python -m pytest tests/test_platform_lifecycle.py \
        -m platform_lifecycle -q

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composite action at action.yml is the project's most public surface and
only its markdown renderer had tests. tests/test_action_e2e.py drives the
action's own steps, extracted verbatim from action.yml by a YAML parser,
against scratch Git repositories with a real origin, a real branch and a
real diff, then validates the result the way the two workflows and a GitHub
reader would.

Covered: the ordered steps and the report they produce (changed files, risk
scores, test gaps, token-savings honesty); the rendered comment as a
consumer sees it (well-formed tables, no unescaped pipe, no control
characters, links, one sticky marker, hostile symbol names); the inputs that
change behaviour (fail-on-risk at every level, comment false, cache key
versus migrations.LATEST_VERSION); the failure paths (shallow clone with no
merge base, a PR with no changed files, a corrupt cache, a cold cache); and
the security properties the docs claim (unprivileged workflow does not post,
privileged one verifies source event and analysed commit, nothing
interpolated into a shell command, no source code in the report).

Opt-in behind the action_e2e marker: CI's coverage job runs
pytest -m "not browser", which would otherwise select these. Run with

    uv run --python 3.13 python -m pytest tests/test_action_e2e.py -m action_e2e -q

Ten checks are xfail(strict=True) and carry the bug they document, so each
flips to a failure the moment it is fixed. No product code is changed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI installs with `pip install -e .`, which reads every file straight out of
the checkout and never exercises the build backend. Nothing in the suite
could catch a file that fails to reach the wheel; that is how the shipped
skills went missing from the wheel in #909 while `test_qoder_bundled_skills`
stayed green, because its editable-install fallback resolved to the
checkout's own `skills/` directory.

tests/test_packaging.py builds a wheel and an sdist with `python -m build`,
installs each into its own throwaway virtual environment, and drives the
installed program from a working directory unrelated to this checkout:
`--version`, `build` over a small real repository assembled from this
project's own sources, `status --json`, `detect-changes`, `visualize` (the
only reader of the vendored D3 asset), and the MCP server over stdio spoken
to with the real `mcp` client. Each smoke test first asserts the installed
package resolves inside the environment's site-packages and not under the
repository root, so a pass can never come from the source tree leaking in.

The inventory of data files the wheel must carry is derived from the code
rather than from the pyproject include list: every packaged module is parsed
and its `importlib.resources.files("code_review_graph")` and
`Path(__file__).parent` expressions are statically evaluated.
`test_discovery_finds_the_known_data_surface` is the canary for that
derivation, so a broken evaluator fails loudly instead of leaving the
inventory check asserting nothing.

Also checked: the wheel carries no tests, dot-directories, agent config
files or databases; the sdist's only dot-path is the `.gitignore` hatchling
cannot suppress; version agrees across `__init__`, pyproject and both
artefacts; the declared Python floor both parses and installs on a real
interpreter of that version; entry points import and are callable; and the
core path runs with no optional-extra distribution present.

The marker is registered in pyproject and skipped by default by a hook in
tests/conftest.py. Run it with:

    uv run --python 3.13 python -m pytest tests/test_packaging.py -m packaging

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing verified that the same input produces the same graph. If a build
is not reproducible then every benchmark, every diff and every "what
changed" answer is measuring build noise as well as code, and the failure
mode is silent.

tests/test_determinism.py builds one corpus (this package plus the
multi-language fixture tree, committed to a scratch git repo so the file
inventory is the sorted `git ls-files` order) nine times, each build in
its own subprocess writing its own database, and compares the sorted
contents of every table: nodes, edges, metadata, flows, flow_memberships,
communities, the per-node community assignment, community_summaries,
flow_snapshots, risk_index, the FTS5 index (both what it answers and its
on-disk image) and the embedding pipeline. Counts are never compared on
their own. tests/determinism_dump.py holds the dump and diff, including
the enumerated list of what is excluded and why: wall-clock columns, and
autoincrement surrogate keys, which are resolved to the names they point
at rather than dropped.

It then attacks the likely sources instead of waiting for luck: the
parallel executor against CRG_SERIAL_PARSE=1, a process pool against a
thread pool with a different worker count, PYTHONHASHSEED 0 against
987654321, the same inventory presented in reverse, and the same tree
built at two different absolute paths.

Community detection: igraph's Leiden is randomised but communities.py
pins the RNG seed before every call, so exact equality is the right
property and a canary proves it by showing the partition move when
CRG_LEIDEN_SEED changes. Without igraph the file-based fallback has no
RNG at all, and a matching canary proves the seed is a no-op there.

Five canaries guard the gate itself, because a check that passes having
compared nothing is worse than no check: the build did real work; every
section cleared a declared row floor and no section may omit one; no
table in the database escapes the dump; an injected single-row change is
detected and described; and path normalisation is removing a path that
is genuinely in the data.

Findings are recorded as strict xfails and are deliberately not fixed
here: node id assignment and the FTS index image follow inventory order,
community_summaries changes with inventory order, the non-git file
inventory is unsorted, search ranking depends on the absolute checkout
path, and community_summaries.purpose can store the checkout directory
name.

The gate is opt-in. tests/conftest.py skips anything marked determinism
unless the marker is selected, so `pytest tests/` is unaffected and
`pytest -m determinism` runs it in roughly 90 seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every migration test in this suite builds its database with the code under
test, so a migration is only ever exercised against a database the test just
made. The schema moved to version 10 this week; if that migration is wrong on
a real version 9 database, every existing user's first update after the
release breaks and nothing in CI says so.

tests/test_upgrade_path.py closes that gap against the released artefact
rather than a reconstruction of it. For each of the last three releases it
installs code-review-graph==<version> from PyPI into a throwaway uv
environment, builds a graph with that release over this repository's own tree
exported at the matching tag, then points the current code at the database and
runs what a user runs first: status, an update, and thirteen MCP tool
functions. It asserts the migration lands on LATEST_VERSION, that opening
loses no node or edge, that the v10 symbol backfill covers every pre-existing
row, that the first update keeps every file and the bulk of the graph, and
that no tool raises or returns an error.

Canaries keep it from passing vacuously: the old interpreter must have
imported the wheel and not the working tree, the old build must have produced
a graph of real size, the database it produced must be strictly behind
LATEST_VERSION, and the newest release under test must be the newest tag.

Two strict xfails record the reverse direction, which is broken today: an
older release opens a version 10 database without complaint, and its update
writes rows with a NULL nodes.symbol while leaving schema_version at 10, so
the backfill never runs again and dotted-tail queries answer not_found for
nodes that are present.

Registers the "upgrade" marker, excludes it from the coverage job, and adds a
manual workflow_dispatch job to run it before cutting a release. The module
also skips unless CRG_UPGRADE_TEST is set, since it needs PyPI and minutes:

    CRG_UPGRADE_TEST=1 pytest -m upgrade -q

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/test_e2e_mcp_client.py drives about seven tools over stdio. This
extends that to everything the server publishes, behind an opt-in
`surface` marker.

What it covers:

* All 30 registered tools, called through mcp.ClientSession against a
  real built graph with realistic arguments, asserting on returned
  content (names, counts, orderings, files on disk) rather than on the
  absence of an error. State the tools need is built rather than faked:
  a git repository with a committed change and a dirty working tree, a
  populated registry via the CLI, and real embeddings computed against a
  stub OpenAI-compatible endpoint served from the test process, so the
  vector path in semantic_search_nodes_tool runs end to end without a
  network or an optional dependency.
* All 5 registered prompts, fetched through prompts/get and asserted to
  render the arguments they declare.
* Argument validation for every tool: a missing required argument, a
  wrong type, and an out-of-range value must each come back as a clean
  error rather than a traceback or a hang.
* Safety: repo_root outside a project root is refused by every tool that
  takes one; a node name carrying NUL, ESC and 600 characters of padding
  is planted in the database and what comes back over the wire is
  checked; the DEFAULT_BUDGET column of tests/test_token_budget.py is
  re-measured on the bytes the client receives.
* Protocol conformance: the requirements issue #919 names are
  hand-checked over raw JSON-RPC, and @hasmcp/mcp-spec-test is run when
  npx is available.

Every case carries a canary that fails if it silently did nothing: the
tool set is read from tools/list and compared against the cases, the
planted hostile name has to reach at least four responses, the budget
case asserts the measured payloads were non-empty, and the schemas drive
the required/optional split rather than a hand-written list.

Known deviations found while writing this are documented in module
constants and reported, not fixed here.

Run with:

    uv run --python 3.13 python -m pytest tests/test_mcp_surface.py -m surface -q

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds tests/test_export_formats.py, covering all six `visualize --format`
values (html, json, graphml, cypher, obsidian, svg) plus `wiki`. Until now
only the HTML path had any test, so the five non-JSON exporters could emit
invalid output unnoticed.

Every artefact is produced from a real built graph (a real git repo run
through full_build plus run_post_processing) and validated with a real
consumer, not a substring check:

- GraphML: xml.etree, networkx.read_graphml, and the official GraphML 1.0
  XSD vendored under tests/fixtures/graphml_xsd/.
- Cypher: an openCypher lexer and statement parser in the test file that
  decodes string literals back to Python and round-trips them against the
  graph payload.
- Obsidian: a vault walker that resolves every [[wikilink]] to a file and
  parses every YAML frontmatter block with yaml.safe_load.
- SVG: xml.etree.
- HTML: html.parser, json.loads on the embedded payload, and a SHA-384
  check of the written D3 build against the pinned SRI hash.
- Wiki: a Markdown table and link walker.

Hostile node names are planted in the corpus (</script>, a template
expression, a Cypher-breaking quote and backslash and newline, a Markdown
link, control characters, a pipe, and a newline/space pair) so escaping is
exercised where injection lives. Each export is produced twice and compared
byte for byte, with the only tolerated differences named explicitly.

Every check records what it actually compared; the recorder refuses a
zero-sized comparison and a final canary fails if a whole format never
reported in, so a check that silently degraded to a no-op cannot pass.

Nine live bugs are recorded as strict xfails, each with a companion test
that pins the current wrong behaviour so the pair cannot rot:
- GraphML declares xmlns=http://graphml.graphstruct.org/graphml, so no
  GraphML reader accepts the file (networkx, already a hard dependency,
  rejects it).
- xsi:schemaLocation carries one URI instead of a namespace/location pair.
- node/@id is typed NMTOKEN, which no qualified name satisfies.
- A newline in a name is written raw into node/@id and XML attribute-value
  normalisation collapses it onto the space spelling, merging two nodes.
- exports._cypher_props tests int/float before bool, so the bool branch is
  dead and booleans serialise as Python True/False.
- exports._cypher_escape leaves newline and tab unescaped.
- Obsidian frontmatter values are bare YAML scalars, so a file path with
  ": " makes the page unparseable.
- export_svg leaves matplotlib mathtext on (a name with two "$" aborts the
  export) and pins neither svg.hashsalt nor SOURCE_DATE_EPOCH (two exports
  of one graph differ).
- The wiki writes names and community names into Markdown tables without
  escaping "|" or newline, and looks members up by their sanitised
  qualified name while nodes are stored under the raw one, silently
  dropping any node whose name contains a control character.

These are slow and need two non-default consumers, so they are behind the
new `exports` marker and skipped by the ordinary suite with the exact
opt-in command in the skip reason:

    uv run --python 3.13 --with xmlschema --with matplotlib \
        python -m pytest tests/test_export_formats.py -m exports -q

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…selines

Every parser test in the suite writes a small fixture that exercises one
construct. A regression that only shows on real code passes all of them and
reaches a release.

`pytest -m corpus` clones eight projects at exact commits (Python, Go,
TypeScript, Java, C#, Rust, Ruby, PHP), builds the graph over each, and
compares twelve measured properties against tests/corpus_baselines.json: the
file inventory, files that produced no node, node/edge counts, the share of
edges and of imports whose target resolves to a real node, CONTAINS edges whose
source is not a node, control characters in node names, a build time budget,
and whether the repository's own language survived.

fastapi and gin reuse the pins already in code_review_graph/eval/configs rather
than forking a second set; a fast guard test fails if the two drift apart.

The slow check is opt-in twice over: it carries the new `corpus` marker, and
tests/conftest.py skips every corpus-marked item unless the run's -m expression
names the marker, so neither `pytest tests/` nor CI's `-m "not browser"` job
pays for eight clones.

Teeth: tests/test_real_repo_corpus_guard.py runs the comparison against a
synthetic measurement and requires each of the twelve properties to fail on its
own, and the slow module repeats that against the real measurement it just
took, after asserting the build was non-trivial and checked out at the pinned
SHA. Verified by hand as well: dropping ".go" from the parser's extension map
fails gin on six properties with the exact deltas, and returning None from the
Java module resolver fails gson on imports_resolved_share alone.

Baselines, band rationale and the measured numbers are in
docs/REPRODUCING.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds tests/test_cli_surface.py, an opt-in release gate that enumerates the
command tree from the live argparse parser and the environment variables
from the package source, so neither list can drift away from the code.

Covers all 39 commands (32 top-level plus 7 daemon subcommands), 150
command/flag pairs, and all 56 environment variables the package reads.
Every command is run in a scratch repository with git history and a built
graph; 14 flags are asserted to actually change the output; every failure
mode listed below is asserted to produce one clear line and a non-zero exit
rather than a traceback, matching the house style set by update/watch and by
the missing-matplotlib branch of visualize --format svg.

Behaviour that does not yet meet that bar is recorded as xfail(strict=True)
rather than fixed here, so the suite stays green, the bug stays visible, and
fixing it forces this file to be updated instead of letting the coverage rot:

  - a corrupt graph.db tracebacks with sqlite3.DatabaseError on every command
  - an unwritable data directory tracebacks with sqlite3.OperationalError
  - detect-changes reports "No changes detected." and exits 0 when git is
    absent or git diff times out, even when the tree has changed
  - a graph.db built for another repository root is served silently
  - a tracked file the OS cannot stat tracebacks with PermissionError
  - 20 numeric environment variables raise ValueError at import (#912)
  - postprocess creates an empty graph.db and exits 0 when none exists
  - embed reports a missing optional dependency as "ERROR:" via logging
    rather than the house "Error:" on stderr

test_no_new_unguarded_numeric_env_parse freezes the unguarded int()/float()
environment parses into a baseline, so a new one fails immediately in either
direction.

Registers the cli_surface marker and skips it unless selected by name:

  uv run --python 3.13 python -m pytest tests/test_cli_surface.py -m cli_surface -q

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…calls from guessed

CHANGE 1, call sites on the row.

Every edge carries a file and a line, and none carries line 0, but
query_graph put nodes in `results` and edges in a separate `edges` list
with no key joining them. A reading agent could name a caller but not
say where the call is without opening the file, which is the file read
the graph exists to avoid.

callers_of, callees_of and references_to now return one row per call
site, not one per node, each carrying `call_site`. The row's own
`file_path` is not repeated: `file` appears only where the call is
written elsewhere, so a header or generated call is still located while
the common case costs one integer. Call sites are interleaved so every
distinct node appears before any node's second call site, which keeps a
truncated answer as wide as the full one. The response adds
`distinct_nodes` and the summary counts call sites and nodes separately.
get_impact_radius attaches the same `call_site` to an impacted node that
reaches the changed code in one hop, plus `call_site_count`; a node
further out has no single call site and gets none.

CHANGE 2, stored target-resolution certainty.

A CALLS edge either points at an indexed node or carries a bare name
that the read path can only match by name, subject to a receiver
evidence check. On this repository that is 11,442 direct against 21,232
unresolved, and nothing in a response said which kind an answer rested
on. Migration v11 promotes the distinction to `edges.target_resolution`
with `idx_edges_kind_target_resolution`, back-filled for CALLS and
REFERENCES and left NULL for kinds where the question is meaningless.
Every build and update path refreshes it after the resolvers run, since
those rewrite bare targets into qualified ones.

callers_of, callees_of and references_to take `resolution` ("all",
"direct", "unresolved") and report a `resolution_split` of the whole
answer. get_impact_radius takes `resolution` ("all", "direct") and
reports `unresolved_call_sites`, the call sites that name a changed
symbol but were never bound to it, so an empty radius is not read as
proof of absence. list_graph_stats reports the repository-wide split
from a covering-index group-by: 1.0 ms against 26.7 ms for deriving the
same answer, and 466 ms for the COALESCE form the index replaced.

Impact output is unchanged, row for row, on the impact_accuracy
benchmark. A bare target cannot equal a qualified seed, so an unresolved
hop was already unreachable under both traversal engines; the filter is
a guard that keeps that true, not a narrowing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extension refuses a database newer than the version it was built
against. v11 only adds a nullable edges column and an index, which the
extension's own queries never read, so raising the ceiling is the whole
fix. The schema-sync CI job pins the two constants together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four release checks built as PRs #1012, #1013, #1014 and #1016 each run
on demand and gate nothing. Alongside them, the end-to-end MCP client tests,
the browser tests and the coverage matrix already run on every pull request
but are not required checks, so nothing they say has ever stopped a
promotion. This wires all seven into one workflow that runs when code lands
on `testing`, which is the branch a release is cut from.

Not on pull requests. Every job here takes minutes and a contributor must
not wait for them; ci.yml is untouched.

What blocks and what only reports is decided per check and justified in
CONTRIBUTING.md. Blocking: upgrade-path (a database a user already has is
not ours to lose), packaging (a broken wheel reaches everyone at once),
determinism (no network, so a failure is a real difference), the coverage
matrix, and the end-to-end tests on Linux and macOS. Reporting: the pinned
real-repository corpus (it clones eight repositories that belong to other
people, and a failed clone must not stop a release), the browser tests, and
the Windows end-to-end leg.

Reporting is not ignoring. scripts/promotion_gate.py wraps every check and
fails it when the check did not actually run: its test module is not in the
checkout, the selection collected almost nothing, or the tests were skipped
by an opt-in hook instead of executed. Three of the four new checks are
opt-in through a pytest_collection_modifyitems hook in tests/conftest.py and
those hooks arrive on four branches that overwrite one another on merge, so
a bare `pytest -m corpus` step exiting 0 on twenty skipped tests is a real
prospect, not a hypothetical.

The result is posted once to the job summary and once to the open issue
labelled promotion-gate, naming every check and, for a failure, the
assertion that moved. The gate opens no pull request and merges nothing.

tests/test_promotion_gate.py asserts the policy, drives each canary to
failure with a real junit report, and checks the workflow against the
registry so a check cannot be added to one and forgotten in the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects found while verifying the depth-2 neighbourhood of
django/urls/base.py::resolve against the graph rather than by eye.

An allow-list of six hop edge kinds silently dropped REFERENCES, the
fourth-most-common kind in this repository's graph (787 edges) and the
fourth in django's (15928). The depth-2 neighbourhood of that symbol came
back with 144 nodes where a breadth-first search over the whole graph
found 173, so 29 nodes a reviewer would have wanted were simply absent and
nothing said so. The rule is now an exclusion: every edge is a hop except
CONTAINS, so a parser that starts emitting a new relationship is included
the day it does rather than the day someone remembers to add it.

export_graph_data de-duplicates on the raw qualified name but emits the
_sanitize_name-truncated one, so two nodes whose paths differ only past
256 characters reach the payload sharing a key. That double-counted them
against --max-nodes (the django hub seed produced 1506 nodes under a cap
of 1500) and would have had D3 bind two data records to one key. Keep the
first record per key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds tests/test_concurrency_and_daemon.py (31 tests, opt-in behind a new
`concurrency` marker) covering the four things that only break when more
than one process touches one graph.db, or when one of them dies:

  uv run --python 3.13 python -m pytest \
      tests/test_concurrency_and_daemon.py -m concurrency -q

1. Cross-process access. Two builds at once, a reader running through a
   rebuild, the shipped PostToolUse hook firing while a daemon watcher is
   mid-update, plus a hand-run build on top. The protection surface is
   pinned directly: WAL, busy_timeout=5000, autocommit, and nothing on
   disk. There is no lock file and no advisory lock, so SQLite's
   five-second wait is the whole of cross-process safety.
2. Interruption. A real build SIGKILLed at four points: mid-parse, inside
   an open per-file transaction, between a file's DELETE and its INSERT,
   and mid FTS rebuild. Each asserts integrity plus what the next run
   does about it.
3. Daemon lifecycle. start/status/reconcile/stop against the real
   crg-daemon, PID-file accuracy at each step, stale and recycled PIDs,
   an unexpected child death, and a SIGKILLed daemon.
4. Watcher stress. Rapid create/modify/delete/rename, a watched
   directory removed and recreated under a split (non-recursive) root,
   and an exhausted OS watch budget.

Eight bugs are pinned as xfail(strict=True) so they flip to failures when
fixed; the worst is that full_build's serial loop files a "database is
locked" from the store call as a parse error, exits 0, writes the VCS
anchor as if complete, and loses those files permanently.

Every test asserts a canary (the lock was really held, the process really
died of a signal, the watcher really saw events, the churn really moved
files) so none can pass by doing nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR review comment this project posts on its own pull requests was
asking authors to write tests for their tests. On PR #1014 it listed
tests/test_upgrade_path.py::_check, _base_env, _venv_python and
_venv_script as test gaps.

Two causes, fixed at their own level.

The parser only ever passed is_test when it built a Function or Test
node, so every Class in a test file was stored with is_test = 0, and so
was every private helper and fixture whose name did not match a test
pattern. parse_bytes now marks each node it returns from a test file,
whatever its kind, in one pass after extraction. Running it after
extraction leaves TESTED_BY generation, which keys off the test
functions alone, unchanged.

Test-file detection itself was three regexes in three modules, each
with a different idea of what a test file is: flows.py knew nothing
about Go, refactor.py knew nothing about tests/ directories, and the
parser's directory patterns matched anywhere in the string, so
src/latest/ read as test/ and contests/ read as tests/. There is now
one definition in parser.is_test_file, anchored on path separators and
extended to the conventions it was missing: Ruby spec/ and *_spec.rb,
C# *Tests.cs, conftest.py, Java and Kotlin *Tests, and the e2e and
test-utils directories refactor.py already knew about. flows.py and
refactor.py use it.

changes.py and tools/review.py exclude a node whose file is a test file
regardless of what is_test says on the row, so a graph built before
this change stops producing the bad report without a rebuild.

Measured on real diffs from this repository, rebuilding the graph each
time:

- PR #1014's diff: 20 test gaps, all 20 test-file artefacts, to 0.
- detect-changes --base HEAD~6 at that commit, a test-heavy 46-file
  diff: 55 gaps of which 53 were test-file artefacts, down to 2. The
  two production gaps, MissingOptionalDependencyError and main, are
  the same before and after.
- PR #997's production files (graph.py, parser.py, tools/query.py):
  107 gaps to 98. The 9 dropped are all test-file artefacts; the 98
  production gaps are identical sets before and after, including
  parser.py::_collect_python_receiver_evidence.

The eval suite runs all 42 benchmarks over 6 repositories before and
after. impact_accuracy, search_quality, agent_baseline and
multi_hop_retrieval are unchanged cell for cell. token_efficiency
ratios are unchanged. flow_completeness keeps its entry-point recall
but reports fewer flows (fastapi 401 to 374, gin 186 to 161) because
test-file entry points are no longer traced as production flows, which
is what include_tests=False asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds tests/test_export_formats.py, covering all six `visualize --format`
values (html, json, graphml, cypher, obsidian, svg) plus `wiki`. Until now
only the HTML path had any test, so the five non-JSON exporters could emit
invalid output unnoticed.

Every artefact is produced from a real built graph (a real git repo run
through full_build plus run_post_processing) and validated with a real
consumer, not a substring check:

- GraphML: xml.etree, networkx.read_graphml, and the official GraphML 1.0
  XSD vendored under tests/fixtures/graphml_xsd/.
- Cypher: an openCypher lexer and statement parser in the test file that
  decodes string literals back to Python and round-trips them against the
  graph payload.
- Obsidian: a vault walker that resolves every [[wikilink]] to a file and
  parses every YAML frontmatter block with yaml.safe_load.
- SVG: xml.etree.
- HTML: html.parser, json.loads on the embedded payload, and a SHA-384
  check of the written D3 build against the pinned SRI hash.
- Wiki: a Markdown table and link walker.

Hostile node names are planted in the corpus (</script>, a template
expression, a Cypher-breaking quote and backslash and newline, a Markdown
link, control characters, a pipe, and a newline/space pair) so escaping is
exercised where injection lives. Each export is produced twice and compared
byte for byte, with the only tolerated differences named explicitly.

Every check records what it actually compared; the recorder refuses a
zero-sized comparison and a final canary fails if a whole format never
reported in, so a check that silently degraded to a no-op cannot pass.

Nine live bugs are recorded as strict xfails, each with a companion test
that pins the current wrong behaviour so the pair cannot rot:
- GraphML declares xmlns=http://graphml.graphstruct.org/graphml, so no
  GraphML reader accepts the file (networkx, already a hard dependency,
  rejects it).
- xsi:schemaLocation carries one URI instead of a namespace/location pair.
- node/@id is typed NMTOKEN, which no qualified name satisfies.
- A newline in a name is written raw into node/@id and XML attribute-value
  normalisation collapses it onto the space spelling, merging two nodes.
- exports._cypher_props tests int/float before bool, so the bool branch is
  dead and booleans serialise as Python True/False.
- exports._cypher_escape leaves newline and tab unescaped.
- Obsidian frontmatter values are bare YAML scalars, so a file path with
  ": " makes the page unparseable.
- export_svg leaves matplotlib mathtext on (a name with two "$" aborts the
  export) and pins neither svg.hashsalt nor SOURCE_DATE_EPOCH (two exports
  of one graph differ).
- The wiki writes names and community names into Markdown tables without
  escaping "|" or newline, and looks members up by their sanitised
  qualified name while nodes are stored under the raw one, silently
  dropping any node whose name contains a control character.

These are slow and need two non-default consumers, so they are behind the
new `exports` marker and skipped by the ordinary suite with the exact
opt-in command in the skip reason:

    uv run --python 3.13 --with xmlschema --with matplotlib \
        python -m pytest tests/test_export_formats.py -m exports -q

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tirth8205 and others added 27 commits September 17, 2026 02:00
The directory branch of one relaxation hop must be an index seek on
(target_qualified, kind). Left to itself SQLite drove it from the covering
index on kind alone and rescanned every IMPORTS_FROM row once per frontier
directory: 18 seconds of a 19-second kubernetes traversal, against 1.5
with CROSS JOIN and INDEXED BY. The same shape of mistake filled
_impact_frontier_dirs by scanning every File node in the graph instead of
the frontier.

Neither is visible in a token budget or on a small fixture, so the SQL
moves to module level where a test can EXPLAIN the text the traversal
actually runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spend the review source budget on whole changed regions, and make churn degrade once
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(search): build FTS5 queries from tokens and index node docstrings
Draw a neighbourhood instead of a repository
cli.py collided on the same function: staging split main() into
build_parser() plus main() so tests can read the flag surface without
running a command, and this branch split main() into a lock-catching
main() plus _dispatch(). The two splits compose: main() keeps the
sqlite lock wrapper, _dispatch() is staging's parse-and-run body, and
build_parser() stays the parser. The seed-flag helpers, _SUBCOMMANDS
and the neighbourhood visualize wiring are unchanged from staging; the
build-state reporting in status and postprocess is unchanged from this
branch.

Also fix the v1 seed in the migration-race test. It hand-rolls a nodes
table, and parent_name and extra are in the base CREATE TABLE that no
migration adds, so a real v1 database has both. Migration v13, which
arrived from staging after that test was written, backfills from them,
so the seed failed on a missing column instead of exercising the
check-then-act window it gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two conflicts, both resolved by keeping what each side meant.

code_review_graph/cli.py: staging split the parser out of main() into
build_parser() and added the visualize seed-flag helpers; this branch added
_open_graph_store(), the corrupt-cache recovery wrapper. Both are kept:
_open_graph_store() stays where it was, staging's helpers and build_parser()
follow it, and the stale main() stub the conflict duplicated is gone. The two
_open_graph_store() call sites main() already had merged cleanly.

tests/test_action_e2e.py: both sides added this suite independently. This
branch's copy is staging's plus the four strict-xfail markers it retires
(corrupt cache recovery, the truncation budget, git-quoted diff paths, the
sticky comment author filter) and the tests that replace them. Kept that copy,
and restored staging's assertion that the unescaped markdown link is absent
alongside this branch's assertion that the escaped span survives; neither
literal is a bare URL, so tests/test_codeql_url_substring.py stays green.

The five strict xfails that remain record bugs this branch does not fix:
malformed and non-finite risk scores, and line_start escaping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six files collided. Resolutions:

pyproject.toml: the pytest markers list. Union of both sides; the
cli_surface entry was identical on each.

tests/conftest.py: three pytest_collection_modifyitems definitions would
have met here (staging already carried two, so its own packaging gate was
dead: pytest resolves the hook by name and only the last definition runs).
Folded into staging's single _OPT_IN_MARKERS hook, with packaging added to
the set and its CRG_RUN_PACKAGING_TESTS escape hatch preserved as a
per-marker override. tests/test_conftest_gates.py now fails if a second
hook is ever added.

code_review_graph/changes.py: both sides kept. The require_vcs path and the
diff_ranges_unavailable summary line sit alongside staging's churn cache,
churn_status and is_test_file-based test-gap detection. Staging's two new
numeric settings (CRG_CHURN_TIMEOUT, CRG_CHURN_MAX_COMMITS) were bare
int()/float() of os.environ.get, which is exactly what #912 removed, so
they now read through constants.env_float / env_int and are covered by the
numeric env var table in tests/test_cli_surface.py.

code_review_graph/graph.py: both sides kept. The GraphStore.__init__ guard
(_assert_usable_database plus the GraphStoreError wrapper) wraps staging's
schema, which now reaches v13; `import os` is gone because env_int replaced
its only use, and `import re` came in for staging's identifier tokenizer.

code_review_graph/cli.py: staging split main() into build_parser() + main();
this branch split it into main() + _run(). Merged as
build_parser() + main() (the CodeReviewGraphError reporter) + _run() (the
dispatch body), so the parser is still importable for tests and every
self-explaining failure still becomes one Error: line and exit 1.

tests/test_cli_surface.py: add/add. Staging's copy is byte-identical to this
branch's own first commit, so this branch's later version supersedes it.

All five fixes re-probed end to end against the merged tree: absent git and
CRG_GIT_TIMEOUT=0 both exit 1 with a named cause instead of "No changes
detected"; four kinds of unusable graph.db each report one line; an
unwritable data directory reports one line for status, detect-changes, wiki
and build; eight numeric variables set to "" and "abc" exit 0 with a warning
naming the variable; a donor repository's graph.db is refused by both the
CLI and _get_store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…this branch

An independent re-measurement found the kubernetes build-time row wrong in
the direction that favoured this change. It read 149.4s on staging against
129.0s here, which says this branch is faster than staging. Measured isolated
and alternating on one idle machine it is not: staging 115.1s and 122.8s
against 148.7s and 146.5s here, and 4.41 GB of database against 5.36 GB. The
branch is about 24% slower to build and 21.5% larger, never faster.

The CHANGELOG now says that plainly, alongside what the cost buys: on cli/cli
the CALLS edges bound to an indexed node go from 5503 to 22564 of the same
67503, because a bound import gives the call resolver a file to attribute a
cross-file call to.

The comparisons that do hold are against the pre-fix revision of this branch,
which emitted one edge per member file: 524.6s of build against about 146s,
588,972 IMPORTS_FROM edges against 93,650, 73,512 edges naming an unindexed
path against 5, and a 189,163-token get_impact_radius against 23,669. Those
are kept and labelled as what they are.

Also re-measured and corrected, on clean alternating builds of cli/cli,
cobra, gin, jekyll and rubocop against origin/staging:

- the grouped-block line defect is 8636 of cli/cli's 8687 import edges, not
  8648 of 8690
- jekyll's non-require import edges are 31 of 227 (14%), not 34 (15%)
- internal/ is 1317 of the 3495 cli/cli go import edges that bind in-repo,
  not "3493 of 8687"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stop the graph and the daemon from corrupting quietly
graph.py was the only overlap. Staging added a read-transaction helper
(contextlib.contextmanager, _active_read_txn, _read_transaction,
_get_stats_locked) and migration-safety retries; this branch added the
package-scoped import resolver and the pinned impact traversal, which
needs collections.abc.Iterable. The import block now carries both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Staging brought the three-state build marker in build_state.py and the
state-aware reporting that `status` and `postprocess` print from it, along
with the contention wrapper that renamed main() to _dispatch() and put a
new main() in front of it to report a locked database instead of raising.

cli.py was the only conflict, and only because both sides inserted a
different block at the same point above _SUBCOMMANDS. Both are kept:
_open_graph_store() stays exactly where it was and is still what both
store-opening sites call, so a corrupt CI cache is discarded by `build`
and reported as one line by everything else; the lock classifier and the
main()/_dispatch() split are taken from staging unchanged, so the two
store-opening sites now sit inside _dispatch() and a contended open is
reported by the wrapper. The _SUBCOMMANDS and build_parser comments take
staging's wording, which names _dispatch.

Verified by probe rather than by reading the diff: a not-a-database file
and a valid SQLite file holding a foreign `nodes` table each produce one
line and exit 1 from a read-only command, and `build` discards and
rebuilds both with no traceback; `status` prints nothing extra for
complete, the missing-files line for in-progress and the post-processing
line for postprocess-pending, with build_incomplete matching in --json;
`postprocess` keeps the marker and prints the "cannot add them" line for
in-progress, and clears it to complete for postprocess-pending. The
branch's own fixes still hold: GraphML carries the graphdrawing namespace
with synthetic NMTOKEN ids and names in <data>, _cypher_props emits true
rather than True, and get_all_tracked_files reads a C-quoted path back as
src/café.py.

Gates: pytest 3981 passed, 793 skipped; the opt-in exports (69 passed),
action_e2e (88 passed) and concurrency (40 passed) suites; ruff clean;
mypy clean over 76 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides wrapped the CLI's dispatch function to report a class of failure
as one line instead of a traceback, and both named their wrapper main().

cli.py: one main() catching both. A CodeReviewGraphError is printed in the
house style (one Error line, exit 1); lock contention keeps its own message
and its own advice. The dispatch function keeps staging's _dispatch name, so
the branch's _run is gone and its tests follow.

graph.py: the two failures meet inside GraphStore.__init__, where the branch
turns any sqlite3.Error into a GraphStoreError. run_migrations re-raises a
held write lock once its retries are spent, so a contended open was being
described as an unreadable database and answered with "delete it and
rebuild". Lock errors are now re-raised untouched and reach main()'s
contention report. The classifier moved to errors.py so cli.py and graph.py
cannot drift apart on what counts as contention.

incremental.py: the import conflict keeps both sides. Staging's new
CRG_MAX_UNWATCHED_TRACKED was parsed with a bare int(os.environ.get(...)),
which is the import-time crash the branch removed from 22 other settings; it
reads through env_int now and joins the coverage table.

Also fixed while probing the branch's five cases: a data directory that
cannot be created still tracebacked out of pathlib.mkdir, because
get_data_dir creates it while the path is being resolved, before any
GraphStore exists. It is now the same one-line GraphStoreError as a
directory that cannot be written to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: make the GraphML export readable, and a corrupt CI cache recoverable
One conflict, in GraphStore.__init__ of code_review_graph/graph.py. Staging
wrapped the schema init and migration run in a guard that turns an unreadable
or wrongly shaped database into CorruptGraphDatabaseError; this branch added
the crg_parent_dir SQLite function that the directory-scoped import traversal
joins on. Both are kept: the guard keeps its original scope, and the function
is registered after it, because a failure to register a function is not a
corrupt file and must not reach the code path that deletes a database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides handled a corrupt graph.db and they had to become one thing.

This branch turned every failure the tool understands about itself into a
single `Error: ...` line: a corrupt or foreign database, a data directory it
may not write to, a graph built for another repository root, a git it could
not run, and 22 numeric environment variables that used to raise at import.
Staging added `_open_graph_store`, which lets `build` discard a database it
cannot open and rebuild it, so a restored CI cache stops failing every run.

Resolution:

* `CorruptGraphDatabaseError` now subclasses `GraphStoreError` instead of
  `sqlite3.DatabaseError`. It keeps staging's job (naming the one unusable
  shape a rebuild can fix by itself) and gains this branch's contract
  (nothing the tool can explain reaches a caller as a raw SQLite error). Its
  message is the house line, so `main` reports it and no second message in
  `_open_graph_store` can drift from it.
* `GraphStore.__init__` keeps this branch's structure (mkdir inside the try,
  the connection closed on every failure path, `_assert_usable_database`
  refusing a foreign file or a newer schema before any DDL runs) and gains
  staging's classification step, in an order that protects a healthy graph:
  an already-classified `GraphStoreError` first, contention second,
  corruption only then, and the described failure last.
* A foreign SQLite file and a newer-schema graph stay plain
  `GraphStoreError`s, so `build` never deletes somebody else's data or a
  graph this build is merely too old to read. `_assert_usable_database`
  catches the foreign file before staging's classifiers see it; a readable
  graph.db whose own tables are the wrong shape still reaches them and is
  still recovered.
* cli.py drops staging's second copy of the lock classifier; `is_lock_error`
  in errors.py is the single one.
* Docs say what `build` now does by itself, and that contention is never in
  the discardable list.

Also fixes a canary that was stale on this branch: action.yml's "Fail when
the analysis did not run" step was missing from the expected step list.

Probed, not just read: detect-changes with no git on PATH exits 1 naming the
cause; a read command reports a corrupt database in one line while `build`
discards and rebuilds it; a wrong-shape schema recovers the same way; an
invoices database survives `build` untouched; an unwritable data directory
gives one line; an invalid numeric env var warns and falls back; a donor
repository's graph is refused; and a held write lock is reported as
contention with the database present and byte-identical afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(parser): resolve Go and Ruby imports to files, not bare strings
One conflict, in GraphStore.__init__. Staging (via #1027) registers the
SQLite function crg_parent_dir after the open/migrate guard; this branch
ends that guard with a described GraphStoreError fallback. Kept both: the
fallback stays the last arm of the except, the registration stays after
it, so a failure registering the helper is still not mistaken for a
corrupt database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: report what is wrong instead of a false all-clear or a traceback
@github-actions

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.85 (CRITICAL) — 500 changed function(s)/class(es), 125 affected flow(s), 387 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.85 critical code-review-graph-vscode/src/backend/sqlite.ts::SqliteReader.checkSchemaCompatibility code-review-graph-vscode/src/backend/sqlite.ts:194 no
0.85 critical code_review_graph/tools/query.py::_attach_call_site code_review_graph/tools/query.py:96 no
0.85 critical code_review_graph/tools/query.py::lookup_node code_review_graph/tools/query.py:503 no
0.85 critical code_review_graph/tools/query.py::add_call_site code_review_graph/tools/query.py:508 no
0.80 high code_review_graph/daemon.py::_signal_until_dead code_review_graph/daemon.py:831 no
0.80 high code_review_graph/search.py::_QueryTerm code_review_graph/search.py:553 no
0.80 high code_review_graph/search.py::_split_query_terms code_review_graph/search.py:561 no
0.80 high code_review_graph/tools/query.py::_interleave_call_sites code_review_graph/tools/query.py:120 no
0.75 high code_review_graph/main.py::query_graph_tool code_review_graph/main.py:264 no
0.75 high code_review_graph/search.py::detect_query_kind_boost code_review_graph/search.py:425 yes

Affected execution flows

  • activate — criticality 0.76, 37 node(s) across 8 file(s)
  • registerBlastRadiusCommand — criticality 0.65, 12 node(s) across 3 file(s)
  • main — criticality 0.65, 52 node(s) across 6 file(s)
  • run — criticality 0.64, 28 node(s) across 3 file(s)
  • registerReviewCommand — criticality 0.64, 12 node(s) across 3 file(s)
  • ...and 120 more affected flow(s)

Test gaps

  • code-review-graph-vscode/src/backend/sqlite.ts::SqliteReader (code-review-graph-vscode/src/backend/sqlite.ts:160)
  • code-review-graph-vscode/src/backend/sqlite.ts::SqliteReader.checkSchemaCompatibility (code-review-graph-vscode/src/backend/sqlite.ts:194)
  • code_review_graph/build_state.py::read_build_state (code_review_graph/build_state.py:52)
  • code_review_graph/build_state.py::graph_contents_are_complete (code_review_graph/build_state.py:60)
  • code_review_graph/build_state.py::advance_to_postprocess_pending (code_review_graph/build_state.py:72)
  • ...and 382 more without direct tests

Token savings: this graph-backed report used ~1,249,768 fewer tokens (~88%) than reading every changed file in full (estimated, chars/4 approximation).

Note: analysis was capped at the configured maximum number of changed functions (set CRG_MAX_CHANGED_FUNCS to adjust).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@tirth8205
tirth8205 merged commit 6fea02d into testing Sep 18, 2026
39 of 40 checks passed
tirth8205 added a commit to gowrishacv/code-review-graph that referenced this pull request Sep 18, 2026
Three reviews of the previous commit found the automation trusted three
things it had no right to trust, and asserted a fourth that is not true.

**It merged whatever `gh pr list --head staging` returned.** That filter
matches a branch called `staging` in any repository -- `gh pr list --help`
says outright that `<owner>:<branch>` syntax is not supported -- and this
repository is public with 2,871 forks. An outsider opening
`theirfork:staging -> testing` became the PR the daily job resumed, had its
body rewritten, and was merged: `testing`'s ruleset requires zero approvals
and their own `pull_request` run satisfies the eight contexts. The same
defect had a no-effort denial-of-service form, a merely conflicting fork PR
making every run decide BLOCKED and exit green for ever.

**It merged a PR number, and a PR's base branch is mutable by its author.**
Repointing the base at `main` starts no `pull_request` run, so the green
checks stay put, and `main`'s ruleset is identical to `testing`'s. Every
"this can never target main" control -- the env vars, `assert_safe_targets`,
the YAML test -- constrained what the job asks for, none constrained what
the PR it merges is.

Selection now requires the PR to be same-repository, aimed exactly
`staging -> testing`, and to carry a new `auto-promotion` label, and
`verify` re-reads all of it from the API seconds before the merge. The
label also fixes a third case: a promotion PR the maintainer opened by hand
and deliberately did not merge used to be indistinguishable, so it was
silently merged with its body overwritten first. It is now refused by
number, with the reason.

**The commit whose checks were read was not the commit that got merged.**
The PR head tracks a branch, so a push mid-run moved it. `headRefOid` was
fetched and never read. It is compared now, and `gh pr merge` passes
`--match-head-commit`, which GitHub enforces.

**The premise that a GITHUB_TOKEN PR triggers no `pull_request` run is
false.** PR tirth8205#991, opened by app/github-actions, produced a ci.yml run three
seconds later; PR tirth8205#1040 re-queued all eight already-green contexts on the
same SHA and sat BLOCKED for fourteen minutes. The merge step waited sixty
seconds and polled `mergeable`, which is only the conflict computation and
says MERGEABLE throughout. It now polls `mergeStateStatus` for up to thirty
minutes. Every refusal also used to blame the ruleset's
require_extra_approval_for_unattributed_changes; that rule has never fired
here -- tirth8205#991 merged under it with zero reviews -- so the refusal quotes
GitHub and lists causes in the order they actually occur.

Also: the PR number is read from the URL `gh pr create` prints, because
re-listing raced GitHub's index and an empty answer skipped the merge, the
refusal report and everything else while the run stayed green and the
summary said "Promoting"; a failed create names the repository setting that
causes it, *Allow GitHub Actions to create and approve pull requests*, which
is off today and has already failed the manual Promote workflow; a run stuck
on its own PR is red rather than a green warning every morning; a `testing`
tip the release gate never ran on is repaired by the next run; a failed gate
on `testing` blocks tomorrow's promotion, which is what replaces the
maintainer's per-landing decision; and ci.yml is no longer dispatched --
those contexts are satisfied by the manual `testing -> main` PR's own run,
and dispatching it switched on a manual-only 45-minute job that is not a
required context.

uv run pytest tests/ -q: 4171 passed, 842 skipped. ruff and mypy clean.
actionlint clean on the workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant