- Fix: an incomplete extraction no longer force-writes a partial graph over a complete one (#1951, thanks @TPAteeq). A crashed AST/semantic pass, a some-chunks-failed run, or a walk that couldn't fully enumerate the corpus (permission-denied subtree) produced a smaller graph that the
to_json(force=True)path wrote anyway, bypassing the #479 shrink guard; the--no-clusterraw dump had no guard at all. Both paths now refuse to overwrite a larger existing graph when the run was incomplete (exit 1, nothing written) unless--allow-partialis passed, and a present-but-unparseable existing graph fails closed (a corrupt/mid-write file could be hiding a complete graph).detect()'swalk_errorsnow count as incomplete too. - Fix:
graph.json,manifest.json, and the other JSON artifacts are now written atomically (#1952, thanks @TPAteeq). A kill, OOM, or ENOSPC mid-write used to leave a truncated file — and a truncatedgraph.jsonthen wedged every later run via the shrink guard's fail-safe. Writes now go to a temp file in the same directory andos.replaceinto place (writing through a symlink so shared-store setups keep working); the writers the original change missed (the--no-clusterdump,merge-graphs/merge-chunks/merge-semantic, the analysis/labels sidecars, and the global graph/manifest) are routed through it too. - Fix: truncated LLM chunks are no longer promoted to the semantic cache as complete (#1950, thanks @TPAteeq). A chunk that hit the output-token limit and couldn't be split further was cached and manifest-stamped as authoritative, so its incomplete node set replayed forever. Such chunks are now marked partial (treated as a cache miss and re-dispatched), including the common case where the truncation parses to nothing: the give-up sites record the chunk's own files independently of parsed items, so a sliced document whose later slice truncated empty is no longer stamped complete, and a clean slice can't re-promote a partial entry.
- Fix: untrusted subagent chunk JSON is validated before
merge-chunksmerges it (#1953, thanks @TPAteeq). A malformed chunk used to crash the whole merge (aborting good chunks) or silently pass an adversarial node id (path-escape) into the graph. Each chunk is now validated (reusing the #825 fragment validator, size-capped, id-charset checked) and a bad one is skipped with a warning rather than aborting; non-numeric token counts can no longer TypeError the merge either. - Fix: code-typed semantic nodes with no evidence in the source are flagged (#1949, thanks @TPAteeq). The LLM can surface a
file_type:"code"node from a document whose symbol name never actually appears in that source (an inferred or hallucinated symbol); it's now flaggedverification: "unverified"(a dedicated node field, reported bygraphify diagnose) rather than presented as a read fact. The check verifies against the node's label and id, only touches nodes the model itself presented as solid, and never drops a node. - Fix:
watch/updateno longer re-scans and duplicates concept/rationale-only semantic docs on every rebuild (#1954, thanks @jw3b-dev). The #1915 semantic-doc gate in_rebuild_coderecognized a doc as having a semantic (LLM) layer only via afile_type=="document"node, but the extraction spec's preferred shape for a doc full of named concepts is to represent it with ONLYconcept/rationalenodes and no separatedocumentnode — such a doc never enteredsemantic_doc_identities, stayed in the AST quick-scan set, and got duplicate heading nodes minted on top of its real semantic nodes on everygraphify update/watch/hook rebuild (the #1915 symptom returning for this doc shape). The gate now recognizes the full doc-shaped subset of the canonical six-valuefile_typeenum (document,concept,rationale,paper— the same setbuild.pyalready treats as doc-representation types), whilecode/imagenodes and AST-origin-marked nodes are still excluded, so the pre-#1865 legacy-graph safeguard the gate relies on is unchanged. - Fix:
save_manifestno longer seeds a stalesemantic_hashfor a file that was dispatched this run but produced no stamped output, masking an LLM-omitted doc on the next run (#1948, thanks @rsolanilla). A file omitted from the semantic result (a--forcere-run where the model drops its chunk) is dropped from thefilesdictcli.py's_stamped_manifest_files()passes tosave_manifest, per the #933/#1890 never-stamp-a-failed-chunk contract — but the seed loop that carries forward untouched rows for subset saves (#917) then copied that file's row from the on-disk manifest verbatim, including itssemantic_hashfrom an earlier successful run.detect_incremental(kind="semantic")compared current content against that inherited hash, found a match, and silently reported the file unchanged — defeating the #1890 retry promise the exact way the issue's manual "blank the hash by hand" workaround worked around.save_manifestnow accepts aclear_semanticset; the seed loop forcessemantic_hashto""for any file in it instead of inheriting the stale value.cli.pyderives the set assemantic_files— what was actually sent to the backend this run (narrowed by the incremental gate and--code-only, widened by deep mode) — minus_stamped_manifest_files()'s result: dispatched-but-not-stamped, regardless of why. Untouched live files that were never dispatched are deliberately not in the set, so a partial incremental run cannot blank the rest of the corpus's stamps. The set is passed at all three_save_manifestcall sites.clear_semanticdefaults toNone(no-op), so every existing caller and the #917/#1908 seed/pruning behavior for untouched and excluded-but-alive rows is unchanged. - Fix: the semantic cache no longer replays extractions from an older prompt after an upgrade (#1939, thanks @HunterMcGrew and @SinghAman21). Entries were keyed on
sha256(file content + path)alone, with no component for the extraction prompt that produced them, so a release that changed the prompt left every unchanged file a cache hit: the run exited 0,cost.jsonlooked cheap, and the graph silently carried two prompt generations side by side. Semantic entries are now namespaced by a fingerprint of the extraction prompt (cache/semantic/p{fingerprint}/, mirroring the AST cache'sv{version}/layout), keeping both properties #1252 wanted — entries survive releases that don't touch the prompt, and invalidate only when it actually changed. The fingerprint normalizes line endings so a CRLF checkout doesn't look like a prompt change. Both extraction paths pass their prompt: the Python/CLI path (llm.py's_EXTRACTION_SYSTEM, all backends) automatically, and the skill path via a newprompt_fileargument in Step B0/B3 pointing at thereferences/extraction-spec.mdthe subagents were handed. Pre-existing entries predate fingerprinting and have unknowable vintage: they are still served rather than re-billing a whole corpus, butcheck_semantic_cachenow warns with the count, so the "no signal at all" the report describes becomes a visible one;--force(orGRAPHIFY_FORCE=1) re-extracts them. Old-fingerprint entries are pruned by liveness only, never swept wholesale the way stale AST versions are — two hosts with different prompts can share onegraphify-out/, and a wholesale sweep would have each run delete the other's entries. (The two monolith skills, aider and devin, inline their prompt instead of shipping a spec sidecar and stay on the unfingerprinted path for now.) - Fix: the Stage 1 sensitive-directory check no longer silently drops legitimate source under
secrets/orcredentials/directories (#1943, thanks @HerenderKumar). A directory namedsecrets/,.secrets/, orcredentials/is as often a real source package (Gointernal/secrets, acredentials/service module) as a credential store, but_is_sensitivepruned everything beneath one wholesale, with no trace and no override. The dir list is now split: dedicated credential stores (.ssh,.gnupg,.aws,.gcloud) still drop everything unconditionally, while the ambiguous bare-name dirs spare genuine programming-language source — the same carve-out Stage 3 applies to keyword-named files (#1666), extracted into a shared_is_graphable_sourcepredicate so the two stages can't drift. Rescued source still falls through the Stage 2/3 filename screens (secrets/service_account.pyandcredentials/id_rsastay dropped), and data/config formats under those dirs (secrets/db.json,.secrets/token.yaml) remain flagged — those are exactly the formats credentials ship in. - Fix: PostgreSQL foreign-key
referencesedges are no longer dropped when a routine in the same schema is unparseable (#1854, thanks @sekmur).pg_introspectbuilds one synthetic DDL document and parsed it with the function stubs emitted before the FKALTER TABLEs, so a C-language (or otherwise unparseable) routine's stub parsed as a tree-sitter ERROR node that swallowed the trailing FK statements into the error region, losing every FK edge after it. The FK DDL is now emitted before the function stubs, so table-to-tablereferencesedges are produced first and can't be eaten by a later unparseable routine. - Fix:
graphify extract <root> --out <dir>no longer reduces every node'ssource_fileto a bare filename, so agraph.jsonstays resolvable against its scan root (#1941, thanks @JensD-git).--outpasses the output dir ascache_rootto relocate the cache, but that value also anchored relativization — so every scanned file failedrelative_to(root), fell through to the #1899 out-of-root fallback, tripped itsupdepth > 3walk-up guard, and collapsed to a basename; on Windows an--outon another drive hit the cross-drive branch and basenamed unconditionally.extract()now takes an explicitrootanchor the CLI pins to the scan root, independent of where the cache lives (completing the #1774 cache/anchor decoupling and matchingbuild(root=target)). Cache location, out-of-root portability (#1899), and the no---out/watch paths are unchanged. A graph already written with basenamed paths does not self-heal on an unchanged corpus (the stale nodes are pruned as out-of-scope, leaving them empty); rungraphify extract --forceonce, or re-extract after a file change, to rebuild it correctly.