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
extract internally:
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 is dedup=not no_dedup → dedup=True unless --no-dedup is 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 of dedup/--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 when force=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.
Summary
graphify <path>(and its internal aliasgraphify extract <path>) defaults todedup=True, and on any normal successful run writes withforce=Trueregardless of dedup outcome — meaning the #479 shrink guard into_json()never actually engages unless the user already knows to pass--no-dedup. A carefully built, curated graph can be silently shrunk by a routinegraphify .with zero warning.Environment
Confirmed against a fresh install of
graphifyy==0.9.66in an isolated venv (latest release as of today) — not a stale-version report. Also present, in a cruder form (no--no-dedupescape hatch existed at all), in0.8.31.What happened
A project's
graphify-out/graph.jsonwas carefully built by hand viabuild_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. Runninggraphify . --no-vizright 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)extractinternally:extract's default isdedup=not no_dedup→dedup=Trueunless--no-dedupis explicitly passed:_extraction_incompleteis onlyTruewhen a chunk genuinely failed — on a clean run it'sFalse, so_force_writeisTrue. 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-dedupdoes give real protection (it makesbuild_mergeraiseValueErrorand 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) snapshotsgraph.json+ report + labels + manifest tographify-out/{YYYY-MM-DD}/before an overwrite, if the existing graph has a.graphify_semantic_markeror 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=1disables 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):
--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.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-dedupescape hatch but the default path (what the README/quickstart and the/graphifyClaude Code skill's ownreferences/update.mdactually 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 withforce=Trueon the default/most common invocation.