Add graphify stale — flag nodes referencing files changed since last extraction - #3130
Add graphify stale — flag nodes referencing files changed since last extraction#3130captjc9012 wants to merge 1 commit into
graphify stale — flag nodes referencing files changed since last extraction#3130Conversation
…ce last extraction file-level manifest hashing (ast_hash/semantic_hash) already skips re-extracting unchanged files on --update, but a node in an UNCHANGED file that calls/imports/ references/inherits-from a node in a file that DID change keeps whatever was true about that relationship when it was last extracted — the update never revisits it. `graphify stale` unions the ast/semantic changed-file sets a project has actually populated (checking a kind never stamped would report every file as changed forever), then reuses affected.py's existing reverse-traversal to list nodes elsewhere with a structural reason to double-check. It does not verify the claims themselves, same as `graphify affected` narrows a blast-radius query without answering it.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. 2 change(s) alter behavior, breaking input(s) attached.
Behavior changes: \_\_getattr\_\_ changes behavior, here is the input that shows it.
The verifier found a concrete input on which \_\_getattr\_\_ behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"name":"'save\_query\_result'"\}, the old code produced raises AttributeError but the new code produces \<function save\_query\_result at 0x7fc1de85d620\>. Paste that input straight into a regression test.
Behavior changes: run\_benchmark changes behavior, here is the input that shows it.
The verifier found a concrete input on which run\_benchmark behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph\_path":"None","corpus\_words":"None","questions":"None"\}, the old code produced raises TypeError but the new code produces \{'corpus\_tokens': 1018866, 'corpus\_words': 764150, 'nodes': 15283, 'edges': 28426, 'avg\_query\_tokens': 6356, 'reduction\_ratio': 160\.3, 'per\_question': \[\{'question': 'how does authentication…. Paste that input straight into a regression test.
Graphify review — findings
Bootstraps the repo's CI/CD and project scaffolding: adds a CI workflow that runs the skillgen anti-drift check plus coverage/schema/round-trip validators and a 3.10/3.12 pytest matrix, a trusted-publishing Publish to PyPI workflow guarded by a tag-version match, and a release workflow that extracts/clusters graphify's own source into a downloadable graph asset. Adds a pre-commit config wiring the same skillgen --check guard and ruff locally, plus .dockerignore, .gitattributes (marks generated HTML as vendored so Linguist reports Python), FUNDING.yml, expanded .gitignore entries, and an AGENTS.md describing the on-disk knowledge graph. Non-blocking bandit and pip-audit security scans run on every PR.
Worth a look
- Manual PyPI publish bypasses the release tag version guard —
.github/workflows/publish.yml:38· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Manual workflow dispatch can publish arbitrary checked-out code to PyPI —
.github/workflows/publish.yml:55· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Privileged PyPI publishing workflow uses mutable action tags —
.github/workflows/publish.yml:55· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- README gives contradictory query logging default contract —
README.md· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Japanese README is truncated mid-section —
docs/translations/README.ja-JP.md:238· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 14948 functions depend on the 14948 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 498 callers, 42 callees - new:
_rebuild_code()— 98 callers, 50 callees - new:
build_from_json()— 187 callers, 18 callees - new:
detect()— 108 callers, 15 callees - new:
deduplicate_entities()— 63 callers, 21 callees - new:
build_merge()— 46 callers, 14 callees - new:
save_semantic_cache()— 58 callers, 9 callees - new:
to_obsidian()— 36 callers, 13 callees - …and 219 more — each is listed as a finding
Verification — 14948 functions in the blast radius were not formally verified this run (proofs are advisory here).
Health delta baseline: last indexed commit 43d54ac (diverged from this PR's base — delta is approximate).
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: 14948 function(s) in the blast radius were not formally verified this run
Formal verification
Behavior changes: \_\_getattr\_\_ changes behavior, here is the input that shows it.
The verifier found a concrete input on which \_\_getattr\_\_ behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"name":"'save\_query\_result'"\}, the old code produced raises AttributeError but the new code produces \<function save\_query\_result at 0x7fc1de85d620\>. Paste that input straight into a regression test.
Behavior changes: run\_benchmark changes behavior, here is the input that shows it.
The verifier found a concrete input on which run\_benchmark behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph\_path":"None","corpus\_words":"None","questions":"None"\}, the old code produced raises TypeError but the new code produces \{'corpus\_tokens': 1018866, 'corpus\_words': 764150, 'nodes': 15283, 'edges': 28426, 'avg\_query\_tokens': 6356, 'reduction\_ratio': 160\.3, 'per\_question': \[\{'question': 'how does authentication…. Paste that input straight into a regression test.
No difference found (not proven): No behavior difference found in main (not a proof).
The verifier ran both versions of main 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 \_cross\_community\_surprises (not a proof).
The verifier ran both versions of \_cross\_community\_surprises 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 \_cross\_file\_surprises (not a proof).
The verifier ran both versions of \_cross\_file\_surprises 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 \_file\_category (not a proof).
The verifier ran both versions of \_file\_category 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 \_is\_file\_node.
The verifier did not have enough to check \_is\_file\_node, 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: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_surprise\_score.
The verifier did not have enough to check \_surprise\_score, 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: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in god\_nodes (not a proof).
The verifier ran both versions of god\_nodes 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 graph\_diff.
The verifier did not have enough to check graph\_diff, 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: not verifiable: the input domain has 25 values but only 5 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)
Could not verify: Could not verify suggest\_questions.
The verifier did not have enough to check suggest\_questions, 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: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly ValueError — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in \_query\_subgraph\_tokens (not a proof).
The verifier ran both versions of \_query\_subgraph\_tokens 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 print\_benchmark.
The verifier did not have enough to check print\_benchmark, 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: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly KeyError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify build.
The verifier did not have enough to check build, 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: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify build\_from\_json.
The verifier did not have enough to check build\_from\_json, 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: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify cache\_dir.
The verifier did not have enough to check cache\_dir, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify cached\_files.
The verifier did not have enough to check cached\_files, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify check\_semantic\_cache.
The verifier did not have enough to check check\_semantic\_cache, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify clear\_cache.
The verifier did not have enough to check clear\_cache, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify file\_hash.
The verifier did not have enough to check file\_hash, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify load\_cached.
The verifier did not have enough to check load\_cached, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify save\_cached.
The verifier did not have enough to check save\_cached, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify save\_semantic\_cache.
The verifier did not have enough to check save\_semantic\_cache, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in \_split\_community (not a proof).
The verifier ran both versions of \_split\_community 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 cluster.
The verifier did not have enough to check cluster, 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: non-vacuity: domain too small (only 2 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Summary
File-level manifest hashing (
ast_hash/semantic_hashindetect.py) already skips re-extracting unchanged files on--update, but a node in an unchanged file thatcalls/imports/references/inherits-from a node in a file that did change keeps whatever was true about that relationship when it was last extracted — the update never revisits it. Nothing currently surfaces that.This adds a new
graphify stalesubcommand that:ast/semanticchanged-file sets the project has actually populated (checking a hash kind that was never stamped would report every file as changed forever — e.g. a pure-code project using onlygraphify updatenever populatessemantic_hash).affected.py(affected_nodes) to list nodes elsewhere in the graph that reference a node in a changed file.Like
graphify affected, this narrows a blast-radius query — it does not verify whether the flagged nodes' descriptions are actually wrong, only that they have a structural reason to be double-checked.Changes
graphify/affected.py: newstale_from_changed_files()andformat_stale(), built on the existingaffected_nodes()primitive. No existing function touched.graphify/cli.py: newstalesubcommand, modeled directly on the existingaffectedsubcommand's arg parsing (--graph,--depth,--relation).graphify/__main__.py: help text entry.tests/test_stale_from_changed_files.py: 7 new unit tests (depth control, same-file self-exclusion, no-dependents omission, unknown-file omission, formatting).259 lines added, 0 existing lines changed.
Test plan
pytest tests/test_stale_from_changed_files.py— 7/7 passpytest tests/test_affected_cli.py tests/test_affected_member_seed.py tests/test_js_dynamic_import_affected.py tests/test_incremental.py tests/test_detect.py— 306/306 pass, no regressionsgraphify updatepipeline (AST-only, no LLM key needed) on a 2-file toy repo (base.pydefiningBaseHandler.retry(),worker.pycalling it):graphify update .:graphify stalereports nothing.base.py:graphify stalecorrectly flagsWorker.run(), theworker.pyimport, and theWorkerclass use.graphify staleis read-only — rerunning without a newupdatereports the same result.graphify update .re-baselines:graphify stalegoes quiet again.Two real bugs were caught during this verification (not left in): an initial version hardcoded
kind="semantic", which reports every file as changed forever on an AST-only project sincesemantic_hashis never stamped; the first fix (unconditionally checking both kinds) had the same failure in reverse. The final version only compares hash kinds the project's manifest has actually populated.