Repository navigation
Default dedup=True + force=True on every normal run leaves the #479 shrink guard permanently bypassed unless --no-dedup is known and passed #3774
Description
Summary
graphify <path> (and its internal alias graphify extract <path>) defaults to dedup=True, and on any normal successful run writes with force=True regardless of dedup outcome — meaning the #479 shrink guard in to_json() never actually engages unless the user already knows to pass --no-dedup. A carefully built, curated graph can be silently shrunk by a routine graphify . with zero warning.
Environment
Confirmed against a fresh install of graphifyy==0.9.66 in an isolated venv (latest release as of today) — not a stale-version report. Also present, in a cruder form (no --no-dedup escape hatch existed at all), in 0.8.31.
What happened
A project's graphify-out/graph.json was carefully built by hand via build_merge(..., dedup=False) (the documented workaround for #2881/#3094-style cross-file dedup) to 6,319 nodes / 9,240 edges, with curated (non-default) community labels and real Gemini token spend. Running graphify . --no-viz right after — no subcommand, no flags beyond --no-viz — silently rewrote it to 5,412 nodes / 7,056 edges. No warning, no error, no diff summary.
Root cause (traced in graphify/cli.py, v0.9.66)
- A bare positional path is rewritten to
extractinternally:
elif Path(cmd).exists() or cmd in (".", "..") or cmd.startswith(("./", "../", "/", "~")):
# User ran `graphify <path>` directly — treat as `graphify extract <path>`.
sys.argv.insert(2, sys.argv[1])
sys.argv[1] = "extract"
_reenter_main()extract's default isdedup=not no_dedup→dedup=Trueunless--no-dedupis explicitly passed:
G = _build_merge([merged], graph_path=existing_graph_path, ..., dedup=not no_dedup, ...)
# or, full build:
G = _build([merged], dedup=not no_dedup, ...)- The write is always forced on a normal run:
_force_write = cli_allow_partial or not _extraction_incomplete
...
_to_json(G, communities, str(graph_json_path), force=_force_write, ...)_extraction_incomplete is only True when a chunk genuinely failed — on a clean run it's False, so _force_write is True. The in-line comment even says this is intentional: "force=True bypasses the #479 shrink guard entirely. A full build legitimately shrinks (fuzzy dedup collapse, deleted code) so it keeps force=True".
--no-dedup does give real protection (it makes build_merge raise ValueError and abort before any write, per the comment at line ~4508 referencing #2881) — but it's opt-in, unadvertised on a normal run, and not the default even though the shrink guard the library ships and documents is otherwise dead code without it.
What saved us
backup_if_protected() (graphify/export.py) snapshots graph.json + report + labels + manifest to graphify-out/{YYYY-MM-DD}/ before an overwrite, if the existing graph has a .graphify_semantic_marker or a curated (non-default) label — both were true here, so today's damage was recoverable. But this is a single dated folder, overwritten in place if it differs from what's already there: a second accidental run on the same day would have backed up the already-damaged state over the good one, with no way back. GRAPHIFY_NO_BACKUP=1 disables it entirely. It's a nice safety net, not a substitute for the shrink guard actually being armed by default.
Suggested fix
Pick one (or both):
- Default
--no-dedup's shrink-guard protection to on regardless ofdedup/--no-dedup— i.e. decouple "skip fuzzy merging" from "verify the write doesn't silently drop nodes from untouched files." The two are orthogonal concerns bundled into one flag today. - At minimum, print a warning before a forced write that shrinks the graph (
new_n < existing_n) even whenforce=True, instead of writing silently — so a user gets some signal, even if they don't want the run to abort.
Related
Not a duplicate: #2881 (closed) added the --no-dedup escape hatch but the default path (what the README/quickstart and the /graphify Claude Code skill's own references/update.md actually invoke) still ships the unsafe default. #3579/#3580/#2229/#2976 discuss the shrink guard failing to fire on other, unrelated code paths — this is specifically about the guard being deliberately bypassed with force=True on the default/most common invocation.
Activity
SrijanSriv commentedon Oct 2, 2026
I plan to fix this issue.
a finished graphify extract writes with
force=True. that write skips the shrink check into_json. dedup can then replacegraph.jsonwith a smaller graph. the command prints no error.the planned change has three rules.
graph.jsonwhen dedup merged nodes and the new node count is lower than the saved count, after nodes from deleted files are subtracted.--allow-dedup-shrink.--allow-dedup-shrinkwrites that smaller graph. a deleted file still writes. that case does not need the new flag.--allow-partial staysthe flag for an incomplete extraction.--no-dedupstays the flag that skips dedup.this change does not change the dedup match rule. that rule is #3094
the test uses two nodes in one file that share a label, plus one node that must remain. dedup still merges that pair after issue 3094. a second test deletes a file and expects the write.
let me know if this makes sense
cc: @cabgab-dev @safishamsi
safishamsi commentedon Oct 2, 2026
SrijanSriv commentedon Oct 2, 2026
#4014 is up.
A finished extract no longer replaces graph.json when dedup made the graph smaller, unless you pass --allow-dedup-shrink. A deleted file still writes.
Merge upstream v8 into fix-3412-shrink-guard-self-explained
fix(build): sweep source-less placeholders orphaned by re-extraction (#…
Merge upstream v8 into fix 3412 shrink guard self explained