Skip to content

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

@cabgab-dev

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)

  1. 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()
  1. 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, ...)
  1. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions