Skip to content

update writes manifest.json to the CWD instead of the target — cross-project contamination, and the target gets no manifest #2316

Closed

Description

@kamusmg

Summary

graphify update <target> writes manifest.json into the current working directory's graphify-out/, not the target's — while every other artifact (graph.json, graph.html, GRAPH_REPORT.md, cache/, .graphify_root) goes to the target correctly.

Two things break at once:

  1. The CWD project gets a foreign manifest. Its graphify-out/manifest.json now describes another project's files. Any tool that reads the manifest as "what the indexer saw for this project" gets a confidently wrong answer.
  2. The target ends up with no manifest at all, so its incremental detection has nothing to compare against on the next run.

Running graphify update over a list of N projects from one script therefore leaves N-1 manifests wrong and one project's graphify-out/ holding the last-processed project's file list.

This is the same root cause family as #2081 (built_at_commit read from CWD instead of the extraction target), but a different field and a different code path — #2081 is extract, this is the watch-based update path.

Reproduction

mkdir -p /tmp/repro/projA /tmp/repro/projB
printf 'def alpha_only_in_A():\n    return 1\n' > /tmp/repro/projA/a_file.py
printf 'def beta_only_in_B():\n    return 2\n'  > /tmp/repro/projB/b_file.py
for d in projA projB; do (cd /tmp/repro/$d && git init -q . && git add -A && git commit -qm init); done

# run from projA, targeting projB
cd /tmp/repro/projA
graphify update /tmp/repro/projB

ls /tmp/repro/projA/graphify-out/
ls /tmp/repro/projB/graphify-out/
cat /tmp/repro/projA/graphify-out/manifest.json

Actual (graphify 0.9.13):

/tmp/repro/projA/graphify-out/:   manifest.json          <-- only this, and it is projB's
/tmp/repro/projB/graphify-out/:   cache/ graph.json graph.html GRAPH_REPORT.md
                                  .graphify_labels.json .graphify_root
                                  (no manifest.json)

$ cat /tmp/repro/projA/graphify-out/manifest.json
{
  "b_file.py": {                 <-- projB's file, in projA's output dir
    "mtime": 1785360486.2794108,
    "ast_hash": "259d778f11c851feb2ab5285781ebcd3",
    "semantic_hash": ""
  }
}

Expected — manifest.json lands next to the graph it describes, in projB/graphify-out/, and projA is not touched.

Root cause

detect.py:28 freezes the manifest location at import time, from a relative path:

# graphify/paths.py:23
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")   # relative by default

# graphify/paths.py:224
def out_path(*parts: str) -> Path:
    return Path(GRAPHIFY_OUT, *parts)

# graphify/detect.py:28
_MANIFEST_PATH = str(out_path("manifest.json"))   # module-level -> resolves against the CWD

save_manifest, load_manifest and detect_incremental all default manifest_path=_MANIFEST_PATH.

cli.py does the right thing and passes the path explicitly (e.g. cli.py:2472):

_save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target)

But the watch rebuild path does not — watch.py:970, 1009, 1087:

save_manifest(detected["files"], kind="ast", root=project_root)

It passes root=project_root, so the correct root is already in hand, but lets the file location fall back to the CWD-frozen constant. The correct output dir is also already in scope as out — used two lines earlier:

(out / ".graphify_root").write_text(str(watch_path), encoding="utf-8")

try:
    from graphify.detect import save_manifest
    save_manifest(detected["files"], kind="ast", root=project_root)
except Exception:
    pass

Suggested fix (verified)

One line at each of the three watch.py call sites:

save_manifest(detected["files"], manifest_path=str(out / "manifest.json"), kind="ast", root=project_root)

Applied locally against 0.9.13 and re-ran the reproduction above:

projA/graphify-out (CWD) projB/graphify-out/manifest.json (target)
before manifest.json, containing b_file.py absent
after not created at all present, containing b_file.py

Side note

The three call sites are wrapped in except Exception: pass. A manifest write that fails for any other reason is also silent, so incremental detection would quietly degrade to a full rescan with nothing in the output to say why. Not the bug reported here, but it is why this one was invisible for so long — worth a log line even if the swallow stays.

Workaround

cd into the target before calling update (or set the process CWD to the target per project when driving update from a script). With CWD == target, the frozen relative path happens to resolve to the right place.

Environment

  • graphify 0.9.13
  • Windows 11, Python 3.10 (uv tool install), also reproducible with a POSIX shell

Activity

  1. Rishet11 commented on Jul 31, 2026

    @Rishet11
    Contributor

    Reproduced on current v8 (4fe1109, 0.9.31) — the report is accurate, and there are two extra consequences worth recording before a fix lands.

    Confirmed mechanism. _rebuild_code (graphify/watch.py:861) computes the correct output dir at watch.py:897:

    out = watch_path / _GRAPHIFY_OUT

    and uses it for every artifact, including one line above each manifest write (out / ".graphify_root", out / "needs_update"). But the three save_manifest(...) calls at watch.py:1232, 1275, 1421 pass kind=, root=project_root and scan_corpus= and omit manifest_path=, so they fall through to detect._MANIFEST_PATH (graphify/detect.py:29), a module-import-time constant built from the relative out_path("manifest.json") (graphify/paths.py:288) and therefore resolved against the process CWD. cli.py is the reference-correct wiring: manifest_path = graphify_out / "manifest.json" at cli.py:2815, passed explicitly at 2853, 3404, 3510, 3657.

    Extra 1 — it is data loss on the CWD project, not just a foreign file. save_manifest also reads the same path first (detect.py:1615, existing = load_manifest(manifest_path, root=root)), and the full-scan callers pass scan_corpus (the #1908 pruning). So the CWD project's own rows are pruned as "not in the scan corpus" and overwritten:

    $ cd projA && graphify update .              # projA's legitimate manifest
    { "a_file.py": {...} }
    
    $ graphify update /tmp/repro/projB           # from projA
    $ cat projA/graphify-out/manifest.json
    { "b_file.py": {...} }                       # a_file.py is gone
    

    projA now needs a full rescan too, so the incremental baseline is lost on both projects, not only the target.

    Extra 2 — with a relative target the manifest keys also come out absolute. watch.py:950:

    project_root = Path.cwd().resolve() if not watch_path.is_absolute() else watch_root

    For graphify update ../projB the root= handed to save_manifest is projA, so projB's files are out-of-root and _to_relative_for_storage writes them absolute — breaking the #777/#1964 portability guarantee even after the location is fixed:

    $ cd projA && graphify update ../projB
    $ cat projA/graphify-out/manifest.json
    { "/private/tmp/repro/projB/b_file.py": {...} }   # absolute, not "b_file.py"
    

    The reporter's repro uses an absolute path, which is why this one stayed hidden. Both the location and the anchor need to point at the target; fixing only the location would leave relative-target runs writing a non-portable manifest into the right directory.

    I'm working on this now (manifest_path=str(out / "manifest.json") at the three sites, plus anchoring project_root to the target), with tests covering all three branches of _rebuild_code and the relative-target case. Happy to hand it over if someone is already on it.

  2. safishamsi commented on Aug 1, 2026

    @safishamsi
    Member

    Fixed in v0.9.32. graphify update now writes manifest.json to the target graphify-out instead of the current working directory, so running it from elsewhere can no longer prune the target manifest rows. Thanks.

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