Skip to content

fix(rebuild): keep a --code-only graph code-only on rebuilds (#4923) - #4939

Closed
Deepjyoti-Sarmah wants to merge 5 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:test/4923-code-only-scope
Closed

Deepjyoti-Sarmah wants to merge 5 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:test/4923-code-only-scope

Conversation

@Deepjyoti-Sarmah

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4923.

A graph built with graphify extract . --code-only grows .md, .mdx and .qmd nodes on the next git hook, graphify update or graphify watch rebuild. _rebuild_code has no --code-only flag and nothing recorded the scope, so it re-detects the repo and adds every markdown-family file that has an AST extractor (the #4750 doc quick-scan).

This PR records the scope when extract --code-only runs, and _rebuild_code reads it back.

 extract . --code-only
   detect -> skip docs
+  write .graphify_build.json {"code_only": true}

 _rebuild_code        (hooks, update, watch all land here)
   _read_build_gitignore
+  _read_build_code_only
   detect
-  add .md/.mdx/.qmd to the AST quick-scan
+  add .md/.mdx/.qmd to the AST quick-scan, unless code_only

Type of change

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

Evidence

Before (upstream/v8 at 32e5efe9): scratch repo with 2 .py and 3 .md files.

$ graphify extract . --code-only
nodes 5, edges 7, from .md: 0
$ graphify update .
nodes 17, edges 16, from .md: 12

After:

$ graphify extract . --code-only
nodes 5, edges 7, from .md: 0
$ graphify update .
nodes 5, edges 7, from .md: 0

The issue's own steps are covered by a test that installs the real post-commit hook, commits a changed .py and .md, waits for the new code symbol to land, then checks for doc nodes.

Tests:

$ .venv/bin/python -m pytest tests/test_code_only_scope_4923.py -q
14 passed

$ .venv/bin/python -m pytest -q
1 failed, 9401 passed, 18 skipped, 1 xfailed in 868.46s
FAILED tests/test_language_override_lifecycle.py::test_watch_root_config_lifecycle_rebuilds_and_refreshes_event_mapping[create]

$ .venv/bin/python -m ruff check graphify/watch.py graphify/cli.py \
    tests/test_code_only_scope_4923.py tests/test_extract_code_only_cli.py
All checks passed!

The one failure is a file-watcher timing test that does not touch this change. It failed 1 run in 5 on the unmodified base (32e5efe9), and it passes 5 of 5 on this branch when run alone.

tests/test_extract_code_only_cli.py::test_explicit_exclude_replaces_persisted_setting_with_custom_out asserted the whole .graphify_build.json equals {"excludes": [...]} after a --code-only run. The file now also carries "code_only": true, so the expectation includes it. The test still checks what it was written for: an explicit --exclude replaces the persisted list.

Verification & Invariants

  • 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.

The invariant: a rebuild keeps the scope the graph was built with. A code-only graph stays code-only until the user runs a flagless extract that ingests docs.

The tests drive the real CLI and assert on the resulting graph. They pin:

  • The scope survives a full rebuild (checkout, merge, rewrite), an incremental rebuild (commit), graphify update, and a new doc added later.
  • A rebuild of a code-only graph still picks up new code and drops deleted code.
  • A graph with no recorded scope still gets doc nodes (graphify update silently skips .md files classified as paper (no option to disable) #4750 unchanged).
  • Doc nodes already in the graph survive turning the scope on.
  • An aborted flagless extract and a flagless extract on a repo with no docs keep the scope.
  • A flagless extract that ingests docs clears it.
  • The build state differs by scope.

Known limitations:

  • A graph built before this change has no recorded scope. Run extract --code-only once to record it.
  • Doc nodes already in the graph are not evicted when the scope turns on, and a rebuild no longer refreshes them.
  • The two side notes in the issue (the CLI and the hook disagreeing about docs without a key, and manifest.json changing on every rebuild) are not part of this PR.
  • The reset-on-flagless-extract test reaches the backend through a dead local Ollama port, not a real LLM backend.

How was this tested?

.venv/bin/python -m pytest tests/test_code_only_scope_4923.py tests/test_extract_code_only_cli.py -q
.venv/bin/python -m pytest -q
.venv/bin/python -m ruff check graphify/watch.py graphify/cli.py \
  tests/test_code_only_scope_4923.py tests/test_extract_code_only_cli.py

Branch test/4923-code-only-scope is based on upstream/v8 (32e5efe9, v0.9.100). It is five commits, and each one passes its own tests: the tests (red by design), the recorded scope, the rebuild fix, the no-change gate, and the reset.

Merge Danger

Door: Two-way.

A graph with no code_only key rebuilds exactly as before. No cache schema bump and no graph format change. Reverting leaves a stray code_only key in .graphify_build.json, which older code ignores.

Blast Radius: Rebuild scope.

The change only affects graphs built with --code-only. Someone who built one and still wanted doc headings from hook rebuilds will stop getting them until they run a flagless extract.

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. No source fragments changed.
  • 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). The change reads one boolean from a file the tool wrote itself.
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…builds (Graphify-Labs#4923)

A graph built with `extract --code-only` grew .md/.mdx/.qmd nodes on the next
hook, `graphify update` or full rebuild, because the rebuild has no CLI flag and
nothing recorded the scope. These tests drive the real CLI and a real post-commit
hook and assert on the resulting graph only, with controls proving a rebuild still
tracks new and deleted code and that a default graph still gets doc nodes.

They fail on this commit by design; the following commits make them pass.
…raphify-Labs#4923)

Persist `code_only` beside `excludes` and `gitignore` (the Graphify-Labs#1886/Graphify-Labs#1971 pattern)
so a later rebuild can tell a code-only graph from a full one. Nothing reads it
yet; this commit changes no rebuild behaviour.

test_explicit_exclude_replaces_persisted_setting_with_custom_out asserted the
whole file equals {"excludes": [...]} after a --code-only run; the file now also
carries code_only, so the expectation includes it.
… rebuilds (Graphify-Labs#4923)

_rebuild_code now reads the recorded scope and skips the .md/.mdx/.qmd AST
quick-scan for a code-only graph. Doc nodes already in the graph are left as they
are, matching how --code-only already treats the semantic layer (Graphify-Labs#2923). No new
parameter: the hooks, `graphify update` and `graphify watch` all go through
_rebuild_code, so every entry point is covered.
…raphify-Labs#4923)

The Graphify-Labs#4740 gate skips a rebuild when inputs and build state match. The scope
changes what a rebuild writes, so it has to be part of that state; otherwise
flipping the scope with unchanged files would be skipped as "No relevant changes".
…nly scope (Graphify-Labs#4923)

Running `extract` without --code-only on a corpus with docs, past the API-key
check, is an explicit move back to a full graph, so the recorded scope is cleared.
An aborted run (no key) and a corpus with no docs leave it alone, so a plain
`extract` cannot silently drop the scope.
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

Thanks for the pull request, @Deepjyoti-Sarmah. 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.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Nov 1, 2026

@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.

View in Graphify

Graphify reviewed this change.

No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.

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

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

Not verified on this run: dispatch\_command (vacuous: never exercised), \_current\_build\_state (vacuous: never exercised), \_rebuild\_code (unsupported), \_write\_build\_config (vacuous: never exercised).


Graphify review — findings

Fixes #4923 by persisting extract --code-only as a code_only flag in the build config, so hook, update and watch rebuilds keep the graph code-only instead of growing doc nodes. _rebuild_code reads the flag with _read_build_code_only (defaulting to False if the config is missing or unreadable), skips the markdown quick-scan, and leaves existing doc nodes untouched. The flag also feeds _current_build_state, but dispatch_command records the scope switch before extraction finishes, and the config write is not atomic, so an aborted or concurrent run can leave the recorded scope wrong. A flagless extract that actually ingests docs resets the scope to a full graph. A run with no docs, or one that aborts at the key check, leaves the recorded scope alone. Graphs with no recorded scope behave as before.

No blocking issues surfaced. 5 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 837 functions depend on the 258 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _rebuild_code() — 221 callers, 68 callees
  • new: test_issue_repro_via_real_post_commit_hook() — 0 callers, 8 callees

Verification — 837 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: 783 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

73 of 490 test file(s) selected (15%) via static blast radius.

  • tests/test_affected_cli.py — impact
  • tests/test_affected_seed_reference_forms.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_cache_stale_import_target.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cloud_cta.py — impact
  • tests/test_code_only_scope_4923.py — impact, changed-test
  • tests/test_codebuddy.py — impact
  • tests/test_compact_graph_json.py — impact
  • tests/test_confidence_score_output.py — impact
  • tests/test_csharp_method_owner_labels.py — impact
  • tests/test_dedup_shrink_refuses_force_write.py — impact
  • tests/test_devin.py — impact
  • tests/test_edge_also_seen.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_elixir_qualified_calls.py — impact
  • tests/test_elixir_unqualified_call_scope.py — impact
  • tests/test_explain_cli.py — impact
  • tests/test_explain_same_file_tie.py — impact
  • tests/test_export_direction.py — impact
  • tests/test_external_stub_endpoints.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_extract_code_only_cli.py — impact, changed-test
  • tests/test_extract_cost_ledger.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_go_receiver_member_calls.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_hook_guard_token_match.py — impact
  • tests/test_hook_nudge_wording.py — impact
  • tests/test_hook_out_of_project_paths.py — impact
  • tests/test_hook_strict.py — impact
  • tests/test_incomplete_build_guard.py — impact
  • tests/test_incremental_unscanned_placeholders.py — impact
  • tests/test_install.py — impact
  • tests/test_install_references.py — impact
  • tests/test_java_member_calls.py — impact
  • tests/test_kotlin_grammar.py — impact
  • tests/test_labeling.py — impact
  • tests/test_language_override_lifecycle.py — impact
  • tests/test_lookup_exit_codes.py — impact
  • tests/test_lost_saved_semantic_nodes.py — impact
  • tests/test_markdown_code_spans.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • tests/test_no_cluster_undeclared_endpoints.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_opencode_plugin_refresh.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_path_cli.py — impact
  • … and 23 more

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

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 \_current\_build\_state.

The verifier did not have enough to check \_current\_build\_state, 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 1 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)

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

Could not verify: Could not verify \_write\_build\_config.

The verifier did not have enough to check \_write\_build\_config, 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 9 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)

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

return False


def test_issue_repro_via_real_post_commit_hook(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_issue_repro_via_real_post_commit_hook()

fans out to 8 callees (efferent coupling).

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

@safishamsi

Copy link
Copy Markdown
Member

Landed in 0.9.101 (published to PyPI), cherry-picked with your authorship preserved. Fixes #4923. Thanks @Deepjyoti-Sarmah.

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.

Hook rebuild ignores --code-only scope: code-only graph becomes full graph

2 participants