Skip to content

feat(affected): show files that change together in git history - #5068

Open
rohit-jsfreaky wants to merge 2 commits into
Graphify-Labs:v8from
rohit-jsfreaky:feat/affected-cochange
Open

rohit-jsfreaky wants to merge 2 commits into
Graphify-Labs:v8from
rohit-jsfreaky:feat/affected-cochange

Conversation

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor

Closes #5066

What does this PR do?

graphify affected now also shows the files that keep changing together with the seed in git history, in its own labelled section. The code-link results do not change.

$ graphify affected app/pricing.py
Affected nodes for pricing.py
Depth: 2

Changed together in git history (not code links):
- CHANGELOG.md [co-change 0.83, 5 of 6 commits]
- config/rates.yaml [co-change 0.83, 5 of 6 commits]
- docs/pricing.md [co-change 0.83, 5 of 6 commits]
- tests/test_pricing.py [co-change 0.33, 2 of 6 commits] (also a code link)

Code links:
- checkout.py [imports_from] app/checkout.py:L1
- test_pricing.py [imports_from] tests/test_pricing.py:L1
- total() [calls] app/checkout.py:L4
- test_price() [calls] tests/test_pricing.py:L4

Mining (graphify/git_cochange.py, one plain function mine_cochange(repo_root, ...)):

  • One git log --no-merges --relative -M --name-status call, argv list with no shell, core.quotepath=false, %x1e/%x1f separators.
  • Last 3 years or 10,000 commits, counted back from HEAD's commit date (not the wall clock), renames followed, commits with more than 30 files skipped.
  • At most 50 partners per file with support >= 2, sorted output, 5 MB cap.
  • A partner must be tracked at HEAD and pass the same scan rules graphify update uses: detect.ignored_predicate (.graphifyignore/.gitignore chain, saved --exclude patterns, noise dirs, skipped names) and detect._is_sensitive, plus lockfile, generated and vendored rules. Tracked docs, configs and tests stay, since they are the point.

When it runs: at the end of a successful graphify update (and so the hooks and watch, which share the locked rebuild), it writes graphify-out/cochange.json. It is skipped when HEAD and the ignore settings did not change, and it never fails the rebuild. Without git it prints one note. On a shallow clone it uses what is there and suggests git fetch --unshallow. GRAPHIFY_NO_COCHANGE=1 turns mining off.

In affected: at most 10 files, pairs below 0.2 confidence hidden. The section comes before the graph list because on hub files the graph list is long (1,006 lines for django/conf/__init__.py), and printed after it the section was never in the top 10. --no-cochange hides it. --json lists the same items in the same order, each with source: graph or source: cochange.

Without cochange.json or with --no-cochange, the text output is byte for byte the same as before. Co-change is never written into graph.json.

Diff: git_cochange.py +349, affected.py +126, cli.py +49/-11, watch.py +32, help/README/ARCHITECTURE +5, tests +465 (25 tests).

Numbers

Git-history replay, six repos, graph and history taken at a cut commit about 6 months before HEAD, 2,070 later queries (seed = a changed code file, truth = the other files in that commit). recall@10 / hit@10:

repo v8 this PR
django (760) 0.114 / 0.250 0.322 / 0.566
flask (31) 0.058 / 0.161 0.415 / 0.677
requests (47) 0.365 / 0.681 0.519 / 0.830
hono (473) 0.318 / 0.414 0.698 / 0.888
jsoup (297) 0.117 / 0.337 0.514 / 0.902
caddy (462) 0.103 / 0.197 0.422 / 0.626
all 0.179 / 0.340 0.482 / 0.748

Paired hit@10: 860 queries only this PR gets right, 7 only v8.

Failing tests (a check that does not come from git history): raise RuntimeError as the first line of 107 real functions, one at a time, the repo's own suite run, failing test files = truth, co-change mined only from history before the tested commit. recall@10 / recall@20 / hit@10:

repo v8 this PR
click (35) 0.223 / 0.359 / 0.543 0.281 / 0.396 / 0.743
flask (33) 0.262 / 0.297 / 0.485 0.214 / 0.309 / 0.485
httpx (39) 0.027 / 0.209 / 0.128 0.189 / 0.253 / 0.538
all 0.164 / 0.285 / 0.374 0.227 / 0.317 / 0.589

Paired hit@10: 29 vs 6 (p = 0.0001). Flask is worse on recall@10: up to 10 history lines push its code links down.

Cost, graphify update on Django, warm: about 2.8 s with or without this PR when HEAD did not move; about 4.0 s when HEAD moved and history is mined again.

Limitations

  • The section-first layout was picked on the same data it is measured on. I compared it with "graph list first" (recall@10 0.373) and interleaving (0.473, but it mixes history into the code-link list).
  • The sensitive-name rule is graphify's own, so it also drops real files, for example Django's change_password.html template.
  • The cache key only looks at the root ignore files. Editing a nested .graphifyignore without a commit does not re-mine until HEAD moves (the filter itself does read nested files).

Related

#4544 adds a standalone graphify cochange report. This PR does not copy its code and touches none of its files. The safe choices are the same (argv list, --relative, core.quotepath=false, %x1e, bulk-commit skip, tracked files only), and the miner is one plain function, so whichever lands second can reuse the other.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

Invariant: the graph result set, order and edge direction of affected do not change, and graph.json is untouched. A test checks the text output is identical with --no-cochange and without cochange.json. Mining is deterministic: same repo and HEAD give the same file, also under a different PYTHONHASHSEED. New persisted state: graphify-out/cochange.json only.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

How was this tested?

uv run --frozen pytest tests/test_affected_cochange.py -q   # 25 passed; on v8: 24 failed, 1 passed (the "unchanged" guard)
PYTHONPATH="$PWD" uv run --frozen pytest tests -q           # 10226 passed, 21 skipped, 1 xfailed (v8: 10198 passed)
uv run --frozen ruff check .                                # clean
pyright graphify/git_cochange.py graphify/affected.py graphify/cli.py graphify/watch.py   # no new errors

Tests cover: no git, shallow clone, renames, the bulk-commit skip, .graphifyignore and sensitive files, determinism (twice and with another PYTHONHASHSEED), --no-cochange, the JSON tags, and the byte-identical fallback.

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments.
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

`graphify affected` only follows code links, so it misses the files that
are edited with a change but never imported: docs, configs, changelogs,
templates, tests reached through strings. On six repos (django, flask,
requests, hono, jsoup, caddy; 2,070 queries replayed from the six months
after a cut commit) this change raises recall@10 of the files changed in
the same commit from 0.18 to about 0.48. On a failing-tests key (107
mutated functions in click, flask and httpx) the gain is smaller, about
0.16 to 0.23, and flask does not improve.

What changes:
- graphify/git_cochange.py: a small miner, `mine_cochange(repo_root, ...)`.
  One `git log --no-merges --relative -M --name-status` call (argv list,
  core.quotepath=false, %x1e/%x1f separators), last 3 years or 10,000
  commits counted back from HEAD's commit date, renames followed, commits
  with more than 30 files skipped, at most 50 partners per file with
  support >= 2, sorted output and a 5 MB cap.
- Only files tracked at HEAD that `graphify update` would itself scan:
  `corpus_filter` uses detect.ignored_predicate (.graphifyignore/.gitignore
  chain, persisted --exclude patterns, noise dirs, skipped names) and
  detect._is_sensitive, plus lockfile/generated/vendored rules. Tracked
  docs, configs and tests stay.
- `graphify update` (and the hooks and watch, which share the locked
  rebuild) writes graphify-out/cochange.json after a successful rebuild,
  with the same persisted excludes. It is skipped when HEAD and the ignore
  settings are unchanged, never fails the rebuild, prints one note without
  git, and notes a shallow clone with a hint to run `git fetch --unshallow`.
  GRAPHIFY_NO_COCHANGE=1 turns it off.
- `graphify affected` prints a "Changed together in git history (not code
  links)" section of at most 10 files, with confidence and commit counts
  and pairs below 0.2 confidence hidden, BEFORE a "Code links:" label and
  the unchanged graph list. Printed after the graph list it was lost on hub
  files (1,006 graph lines for django/conf/__init__.py). `--no-cochange`
  hides it; `--json` lists the same items in the same order, each tagged
  `graph` or `cochange`.

The graph results of `affected` (set, order and direction) are unchanged;
with --no-cochange or without cochange.json the text output is
byte-identical to before. Co-change is never written into graph.json.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Thanks for the pull request, @rohit-jsfreaky. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 4 changed function(s) (2 sampled, not proven, 2 not verified).

Formal verification. PR-changed functions: 2/4 verified (0 proven, 2 may-equivalent, 0 distinguished) · 2 not verified (1 vacuous, 1 unsupported).

Not verified on this run: dispatch\_command (vacuous: never exercised), \_rebuild\_code (unsupported).


Graphify review — findings

Adds git co-change mining: graphify update now writes graphify-out/cochange.json from commit history (renames followed, large commits skipped, partners limited to files the update would scan), and the refresh keys off the cached file, which is where a shallow clone's data can stay stale after git fetch --unshallow. Extends graphify affected with a short "Changed together in git history (not code links)" section printed before the code links, and a --json mode that tags each result graph or cochange, where the file field mixes repo-relative cochange paths with raw graph source_file values. Adds --no-cochange to hide the section and GRAPHIFY_NO_COCHANGE=1 to skip writing the file; without git the step is skipped with a note, and the README and ARCHITECTURE docs describe both.

Worth a look

  • Cached shallow-clone co-change data is never re-mined after git fetch --unshallow — graphify/git_cochange.py · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1239 functions depend on the 376 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _rebuild_code() — 262 callers, 74 callees
  • worse: dispatch_command() — 5 callers, 148 callees
  • worse: main() — 123 callers, 3 callees
  • new: refresh_cochange() — 9 callers, 6 callees
  • new: mine_cochange() — 3 callers, 5 callees
  • worse: format_affected() — 2 callers, 6 callees
  • new: test_renames_are_followed_and_pure_renames_add_nothing() — 0 callers, 6 callees

Verification — 1239 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1222 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

527 of 527 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety (noncode-change, uncovered-code) — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/pi.test.ts — full-run-safety
  • tests/test_4874_jvm_resolution.py — impact, full-run-safety
  • tests/test_4909_reference_binding_scale.py — full-run-safety
  • tests/test_4978_reconcile_cost.py — impact, full-run-safety
  • tests/test_4992_ts_import_types.py — full-run-safety
  • tests/test_affected_cli.py — impact, full-run-safety
  • tests/test_affected_cochange.py — impact, changed-test, full-run-safety
  • tests/test_affected_defines.py — impact, full-run-safety
  • tests/test_affected_member_seed.py — impact, full-run-safety
  • tests/test_affected_overrides.py — impact, full-run-safety
  • tests/test_affected_path_file_node.py — impact, full-run-safety
  • tests/test_affected_seed_reference_forms.py — impact, full-run-safety
  • tests/test_agents_platform.py — impact, full-run-safety
  • tests/test_alias_folding.py — full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_backend_opt_in.py — full-run-safety
  • tests/test_benchmark.py — impact, full-run-safety
  • tests/test_benchmark_raw_graph.py — impact, full-run-safety
  • tests/test_benchmark_real_query.py — impact, full-run-safety
  • tests/test_binary_media_words.py — full-run-safety
  • tests/test_blade_extractor.py — full-run-safety
  • tests/test_build.py — full-run-safety
  • tests/test_build_located_semantic_identity.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_ghost_fold_4955.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_c_include_resolution.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_cache_long_paths.py — full-run-safety
  • tests/test_cache_stale_import_target.py — impact, full-run-safety
  • tests/test_cache_windows_case_4925.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_cargo_missing_manifest.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_only_decl_ids_4904.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • … and 477 more

non-code file(s) changed (ARCHITECTURE.md, README.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (ARCHITECTURE.md, README.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).

The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in format\_affected (not a proof).

The verifier ran both versions of format\_affected on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: nondeterministic output (both versions disagreed with themselves on the witness) — not a behaviour change; a function whose output is not a function of its inputs (random / time / uuid / os.urandom, or state carried across calls) cannot be soundly distinguished by execution, so this tier abstains for the whole function

· 5 grounded finding(s) anchored inline below; 2 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/affected.py
return lines


def format_affected(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — format_affected()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/git_cochange.py
return {"version": FORMAT_VERSION, "status": "unavailable", "reason": reason}


def mine_cochange(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — mine_cochange()

high coupling complexity (Ca·Ce = 15).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/git_cochange.py
"ignore_files_sha256": digest.hexdigest()}


def refresh_cochange(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — refresh_cochange()

fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
print(f"[graphify watch] {note}")


def _rebuild_code(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _rebuild_code()

fans out to 74 callees (efferent coupling); 262 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert cochange_partners(data, "seed.py", min_confidence=0.1)[0]["file"] == "weak.md"


def test_renames_are_followed_and_pure_renames_add_nothing(tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_renames_are_followed_and_pure_renames_add_nothing()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

…paths

- After `git fetch --unshallow` HEAD does not move, so the cache check kept
  the shallow-clone history. A cached shallow result is now reused only
  while the repo is still shallow.
- `--json` printed the graph's raw `source_file` next to repo-relative
  co-change paths; graph items and the seed now use the same repo-relative
  form (only differs for graphs that stored absolute paths).

Co-Authored-By: Claude <noreply@anthropic.com>
@rohit-jsfreaky

Copy link
Copy Markdown
Contributor Author

Thanks, both points were real. Fixed in 233775b:

  • Shallow clone after git fetch --unshallow: HEAD doesn't move, so the cache check kept the 2-commit history. A cached shallow result is now reused only while the repo is still shallow. test_unshallow_re_mines_at_the_same_head clones with --depth 2, unshallows, and checks that commits_used goes from 2 to 4 at the same HEAD. It fails without the fix.
  • Mixed paths in --json: graph items and the seed now use the same repo-relative file as the co-change items. This only changed anything for graphs that store absolute source_file paths. Covered by test_json_file_fields_are_repo_relative_for_an_absolute_path_graph, which also fails without the fix.

The coupling notes are about the size of the new functions and of _rebuild_code, which gains a single call. I left those as they are. tests/test_affected*.py and tests/test_cli*.py: 156 passed. ruff is clean, and pyright has no new errors (the 2 it reports in affected.py are already on v8).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: affected misses the docs, configs and tests that always change with a file; show git co-change next to it

1 participant