Skip to content

fix(skills): --update must prune files a new ignore rule excluded (#2773) - #2806

Open
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/update-prunes-newly-excluded-files
Open

fix(skills): --update must prune files a new ignore rule excluded (#2773)#2806
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/update-prunes-newly-excluded-files

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Fixes #2773.

Thanks @oilsinwater — the report was right that this survives --force and a purged cache, and the reason is worse than a stale cache: the pruning step never asked for those files at all.

The bug

detect_incremental deliberately splits a manifest row whose file is gone from disk (deleted_files) from one that still exists but has left the scan because an ignore rule or --exclude changed (excluded_files, #1908) — precisely so an exclusion is never mis-reported as a deletion.

graphify's own library path consumes both. graphify/cli.py:

_raw_prune_sources: list[str] = list(deleted_files)
for _src in list(excluded_files) + graph_stale_sources:

The --update runbook — which is how the skill drives incremental rebuilds — read only the first list:

deleted = list(incremental.get('deleted_files', []))
prune = list(deleted) or None

So .graphifyignore was honoured by the scan and ignored by the graph.

Two failures, not one

The early exit. With nothing else changed, new_total == 0 and not deleted held:

if new_total == 0 and not deleted:
    print('No files changed since last run. Nothing to update.')
    raise SystemExit(0)

Adding an ignore rule on its own — the exact thing you do when you want content out of the graph — was a no-op that reported success and never reached the merge. This is the case worth fixing first; the prune set below only matters once the run gets that far.

The prune set. When the run did continue because some other file changed, prune = list(deleted) or None still left the excluded file's nodes and edges in place.

The leak is permanent, not deferred

This is why "the next run will catch it" does not hold. save_manifest(scan_corpus=...) drops the excluded row, so on the following run the file is neither deleted nor excluded:

after adding '.graphifyignore: archive/':
  deleted_files : []
  excluded_files: ['old.py']

after one more run (manifest re-stamped):
  deleted_files : []
  excluded_files: []
  -> archive/old.py is now invisible to BOTH lists; nothing can prune it.

One --update after adding the rule and the stale content is unreachable short of a full rebuild — which matches the report's "same count and same 4 edges, byte-for-byte, before and after the forced/purged rebuild".

Reproduced

Two-file corpus, archive/ newly ignored, driving the runbook's own merge call:

  runbook  (prune=deleted)            -> nodes: ['arch_free_port', 'src_live_bind']
  cli.py:3756 (prune=deleted+excluded) -> nodes: ['src_live_bind']

The leaked node is an archived helper joined by a calls edge to live production code — the same shape as the report's tests_integration_free_port -> src_loro_irohlorotransport_bind, which is what makes it more than clutter: a false edge from dead code into live code feeds GRAPH_REPORT.md's architectural claims.

The change

One fragment, tools/skillgen/fragments/references/shared/update.md; everything else in the diff is python -m tools.skillgen output plus a re---bless of expected/. All five validators pass.

I kept the fix to excluded_files and did not port _stale_graph_sources (#1909), which is the library's more complete answer — it derives prune candidates from the graph's own source_files and so also catches files whose manifest row is already gone. That would fix the permanence for graphs already in the leaked state, but it means calling a private cli helper from the runbook, which felt like your call rather than mine. Happy to add it if you want the stronger version.

Tests

tests/test_update_prunes_excluded.py (7 tests). Being straight about what each one does:

  • Three assert the shipped runbook text — that it reads excluded_files, that the early exit accounts for it, and that every host's rendered references/update.md agrees rather than one lagging. These three fail without the fix.
  • Four exercise the library semantics the fix depends on: that a newly ignored file lands in excluded_files and not deleted_files, that prune=deleted leaves the node while prune=deleted+excluded removes it, and that the manifest re-stamp makes the row disappear from both lists. Those pass either way by construction — they are the evidence for the fix, not a gate on it, and I would rather say so than let the count imply more than it does.

Validation

Windows 11, Python 3.12, branched off 4fca621 (0.9.44).

  • Full suite: 20 failed, 4469 passed -> 20 failed, 4476 passed. Identical failure set — no regressions; the +7 are the new tests.
  • All five skillgen validators pass.

Note on #2801

My other open PR also edits this fragment (removing the rm -f cleanup a few lines below), so expect a small textual conflict and none semantically. Happy to rebase whichever lands second.

…aphify-Labs#2773)

detect_incremental deliberately splits a manifest row whose file is GONE from
disk (deleted_files) from one that still exists but has left the scan because an
ignore rule or --exclude changed (excluded_files, Graphify-Labs#1908), so that an exclusion is
never mis-reported as a deletion. graphify's library path consumes both --
cli.py prunes `list(excluded_files) + graph_stale_sources`. The --update runbook
read only deleted_files, so adding a .graphifyignore rule never removed what it
excluded from graph.json.

Two separate failures, both fixed:

1. The early exit. With nothing else changed, `new_total == 0 and not deleted`
   held, so the run printed "No files changed since last run. Nothing to update."
   and exited 0 without ever reaching the merge. Adding an ignore rule on its own
   -- the exact thing a user does when they want content out of the graph -- was
   therefore a no-op that reported success.
2. The prune set. Even when the run did continue because some other file changed,
   `prune = list(deleted) or None` left the excluded file's nodes and edges in
   place.

The leak is permanent, not merely deferred: save_manifest(scan_corpus=...) drops
the excluded row, so on the next run the file is neither deleted nor excluded and
nothing can prune it again. One --update after adding the rule and the stale
content is unreachable without a full rebuild. That is what Graphify-Labs#2773 reports as
surviving a forced rebuild with the cache purged.

Reproduced on a two-file corpus: with `archive/` newly ignored, prune=deleted
leaves arch_free_port in the graph and prune=deleted+excluded removes it, and a
second run reports empty deleted_files AND empty excluded_files.
Copilot AI lite review requested due to automatic review settings August 16, 2026 19:27

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

Propagates newly excluded files through the incremental update path in every references/update.md skill variant (agents, amp, claude, codex, claw, droid, kilo, opencode, pi, vscode, windows, etc.) and their skillgen expected outputs, so a new .graphifyignore/--exclude rule triggers a prune instead of exiting early with "Nothing to update" (#2773). Reads excluded_files from the incremental result to gate the no-op exit and folds it into prune = (deleted + excluded) alongside deletions. Adds test_update_prunes_excluded covering the exclude-then-prune flow.

No blocking issues surfaced.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 131 functions depend on the 131 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _seed_graph() — 4 callers, 3 callees
  • new: test_the_leak_is_permanent_once_the_manifest_is_restamped() — 0 callers, 6 callees

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

· 2 grounded finding(s) anchored inline below.

return tmp_path


def _seed_graph(root: Path) -> 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_seed_graph()

high coupling complexity (Ca·Ce = 12).

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

for _, _, d in G.edges(data=True))


def test_the_leak_is_permanent_once_the_manifest_is_restamped(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 regressiontest_the_leak_is_permanent_once_the_manifest_is_restamped()

fans out to 6 callees (efferent coupling).

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

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.

.graphifyignore matches a nested path but edges from it still land in graph.json (survives forced rebuild + cache purge)

2 participants