An Obsidian vault written before the ownership manifest existed had no record of which
notes graphify wrote, so on the next export those notes were orphaned. Seed ownership by
recognizing graphify's own notes (a graphify/ frontmatter tag for node notes; the
_COMMUNITY_ prefix plus the FROM #community/ Dataview marker for community notes) when the
manifest is absent, so the manifest starts out honest. The .obsidian config is not
adopted (Obsidian writes it too).
The #1757 guard scoped the semantic-cache WRITE to dispatched files, but the unfiltered
fresh result still fed build_merge, whose replace-set logic swapped a non-dispatched
file's entire prior contribution for a stray misattributed fragment (and logged the
'skipped out-of-scope source_file' warning). Apply the same allowlist to the result dict
before it reaches the merge, via a shared _semantic_source_matcher so the write guard and
the graph filter cannot drift.
A semantic result with no nodes and no hyperedges (only edges, or nothing) was cached and
stamped into the manifest, so an empty/degenerate LLM reply for a file froze that file:
detect_incremental saw it unchanged and never re-dispatched it. Reject zero-node results
from the cache read and write, drop edges from the manifest stamp tuple, and heal an
existing manifest by re-queueing files that were already stamped with a zero-node result.
A `--code-only --force` rebuild over an existing graph dropped the doc/paper/image
semantic tier, because force took the full-rebuild path and code-only never re-dispatched
those files. When an existing graph is present, keep incremental mode so build_merge
carries the semantic layer forward; files deleted from disk are still pruned.
Installed via `uv tool install`, the post-commit hook could not find graphify: the pin and
launcher/shebang probes miss the uv tool venv, so the hook silently exit-0'd. Add a fourth
fallback probe that scans $UV_TOOL_DIR and the conventional uv tool locations for a python
whose env has graphify importable, firing only after the earlier probes miss and verifying
importability before adopting. Also gate the launcher read on a real #! header so a binary
trampoline is not misparsed as a shebang.
A class extending a built-in base (PHP `extends \Exception`) emits a sourceless supertype
stub, and the corpus rewire bound it to the unique same-labelled real definition anywhere
in the corpus — so in a PHP+TS monorepo a PHP class inherited from the TypeScript
Exception, a phantom cross-language edge / god node. Gate the supertype-target rewire with
a per-language curated builtin-base set: refuse the rewire only when the referring file's
language names the base as a builtin and the resolved target is in a different language
family. Same-language inheritance and user classes named like a builtin still link.
Three fixes landing the PDF-slicing set coherently:
- bisect_slice read raw container bytes, so the adaptive-retry path (a lone oversized
slice that still overflows, #2880) searched for the newline cut in binary coordinates
and could cut mid-line for a compressed PDF. Index unit_source_text instead, matching
read_slice_text and expand_oversized_files; any converter failure falls back to None.
- #2900's test asserted a PDF is not splittable, which #2906 overturns; rewrite it to
guard the real invariant (an image has no addressable text and is never sliced). The
unreadable-PDF passthrough case is covered by test_pdf_slicing.
- add a bisect_slice-on-PDF regression test: the cut lands on an extracted-text line
boundary and the halves tile the slice exactly.
An oversized PDF was sliced as raw container bytes, so slice offsets indexed binary data
instead of the extracted text the prompt carries. Introduce unit_source_text so both the
oversized-file expansion and per-slice reads index the same extracted text, and make PDFs
splittable through the converter.
The oversized-file slicer only split five suffixes while the doc allowlist reaching the
LLM was larger, so a large .qmd/.skill/.html/.yaml/.yml was truncated at the char cap
and silently dropped past it. Extend the splittable-text set to cover every text
document type, with a contract test so future allowlist additions fail loudly.
Token estimation for a PDF read the raw container bytes, which are mostly binary and
bear no relation to the extractable text, so a small-text PDF could be judged oversized
(or vice versa). Estimate from the extracted text instead, memoized on path+size+mtime.
A type used as a generic argument at a call site (repo.Get<User>(),
services.AddSingleton<IFoo, Foo>()) did not emit a references edge, so DI registrations
and generic calls dropped their type dependencies. Walk the invocation's type-argument
list (member and static forms, nested args included); builtins and in-scope type
parameters are not fabricated.
A type used as a generic argument in a field declaration (private List<Order> _orders)
did not emit a references edge to the argument type, so field-injected dependencies were
invisible. Walk the field's type through the shared type-ref collector, bringing fields
to parity with the property handler; builtins and in-scope type parameters are not
fabricated.
Record the run-scoped-clear contract on both loader docstrings so the next reader
does not reintroduce the process-durable-cache assumption the fix removed.
The tsconfig/jsconfig alias and baseUrl caches were keyed by config path with no
mtime/content component and never cleared, so an edit to compilerOptions.paths or
baseUrl was never observed again for the life of the process — graphify watch and the
MCP server, which call extract() repeatedly in one process, kept resolving imports
through the stale alias map. Clear both caches per run alongside the other run-scoped
caches (within-run caching is preserved).
Ships this cycle: the LLM-resilience batch — hollow-response same-chunk retry (#2880),
reasoning-first JSON recovery (#2882), deliberately-declined data JSON not counted as
failed (#2879); extractor fixes — C++ nested types + C++/CLI (#2876), markdown vault-wide
wikilinks (#2875); export fixes — control-char no longer aborts export (#2897), graph.html
restored for large graphs (#2853); and the --no-dedup opt-out (#2881).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PR's tests spy on the dedup kwarg reaching build/build_merge. Add graph-level
assertions for what the kwarg does: dedup=False preserves a fuzzy near-duplicate pair
that dedup=True merges, and exact-id collisions still collapse to one node either way
(the structural invariant that makes the flag safe).
Adds --no-dedup (default off, so dedup stays on) to skip the fuzzy near-duplicate merge
pass on build and incremental merge, for operators who would rather keep distinct symbols
that fuzzy-matched than pay the merge. Exact-id uniqueness is unaffected (it is a graph
structural invariant, not a dedup responsibility), and the flag arms the #479 shrink
guard so a surprising node drop is refused loudly. Mutually exclusive with --dedup-llm.
The label and cluster-only commands did not pass the viz node limit to to_html, so a
graph over the node limit raised and the except branch silently unlinked the graph.html
that update had produced. Always pass the limit (the aggregated community meta-graph
renders instead of raising), preserve the existing file on a failed render via an atomic
publish plus a stale marker, and regenerate a missing graph.html on the no-topology-change
fast path without reclustering.
A C0 control character in a node label or id (from a mangled extraction or an odd
filename) crashed the GraphML export (XML 1.0 forbids C0 except tab/LF/CR) and the
Obsidian export (EINVAL on Windows paths), aborting the whole artifact. Fold a
control-char scrub into the existing per-format coercion hooks so only the illegal
characters are stripped; tab/LF/CR and non-ASCII letters are preserved, and to_json
(and its byte-identity round-trip) is untouched.
A response that parses but carries no symbols (a "hollow" reply) was routed into the
truncation-bisection path, which just re-split a chunk the model had already answered
emptily, wasting calls. Give hollow its own finish_reason and route it through a bounded
same-chunk retry with backoff instead; on persistent hollow, give up loudly and mark the
files partial. GRAPHIFY_MAX_RETRY_DEPTH=0 now disables the hollow retry too, so a chunk
costs exactly one call. The #2866 timeout and the truncation paths still bisect.
A reasoning sketch that lists ids as bare strings (`{"nodes": ["A", "B"]}`) has truthy
arrays but no node/edge objects. The winner gate tested the raw parsed value, so the
sketch won and then sanitized to empty, shadowing the real fragment that followed and
re-triggering the #2880 hollow-response bisection. Sanitize before the emptiness gate so
such a sketch is demoted to the empty-fragment tier. Also document the keyed-candidate
cap crowd-out as a known gap.
Reasoning-first models think out loud, fence the answer, or both, so the reply is not a
bare JSON object and the strict parse dropped the whole chunk. Try the whole reply first
(unchanged fast path), then each balanced-brace candidate, preferring braces that carry
the extraction keys so narration braces do not shadow the answer; a shape restatement
that carries the keys but no content is kept only as a last resort.
Nested class/struct types were dropped because the field_declaration branch returned
before walking the class_specifier in its type field. Walk it instead, so nested types
and their members are emitted with a contains edge from the enclosing type. C++/CLI
(ref class, gcnew, ^/% handles) cannot be parsed by tree-sitter-cpp at all (it produces
ERROR nodes and fabricates symbols), so normalize those tokens to plain C++ before
parsing, preserving byte length and line breaks; SCREAMING_CASE constants and attached
XOR/modulo operators are kept out of the handle rewrite.
An Obsidian-style [[wikilink]] resolves to a note anywhere in the vault by basename, but
resolution only tried sibling files, so cross-folder links were silently lost. Add a
vault-wide fallback that fires only when relative/sibling resolution misses, with a
deterministic tiebreak on ambiguous basenames (shallowest path, then lexicographic
root-relative path) and a once-per-scan index so it stays O(N).
A data-shaped JSON that extract_json intentionally declines (returning a `skipped`
marker, not an `error`) was counted as a failed extraction, so it was kept out of the
incremental manifest and re-processed on every run. Treat the `skipped` decline as a
valid empty outcome, disjoint from genuine failures (which still return `error` and are
still reported).
The docstring documents the `\w` regex class; as a non-raw string that is an
invalid escape sequence (DeprecationWarning now, SyntaxError in a future Python).
Mark it raw.
The partial-parse warning ended every message with a hardcoded (#2551) — a Kotlin-specific
issue that is now closed — which misdirected reporters into recording unrelated failures as
already-tracked. Drop the citation and append real per-file data instead: the surviving
symbol count, distinguishing a total-loss file from a partially-recovered one.
The out-of-project read guard treated any non-absolute path as cwd-relative, but on
Windows a rooted-but-driveless path (`\foo\bar`) is not absolute yet resolves against
the current drive root, outside the project. Classify with a platform-correct predicate
(`not root and not drive` under the host path flavour) so the containment check is
reached. On POSIX the predicate reduces to `not is_absolute()`, so no behaviour changes
there.
A timeout on a multi-file extraction chunk failed the whole chunk. Route recognized
timeouts (subprocess.TimeoutExpired, SDK APITimeoutError, botocore read/connect
timeouts) through the same bounded bisection/merge path already used for
context-window-exceeded errors, so a single slow file no longer takes its chunk-mates
down. A single unsplittable file that times out is left unstamped and retried next run,
as before.
file_hash salted the content digest with the resolved (symlink-collapsed) path, so a
symlink alias and its target hashed identically and one displaced the other from the
graph on a warm cache. Salt with the walked (lexical abspath) path relative to root
instead, giving aliases distinct keys; when the scan root itself is a symlink, derive
the leaf-relative salt from the lexical ancestor matching the resolved root. The
detect-layer containment guard (which resolves to block symlink escape) is untouched.
Converted Office/Google-Workspace sidecars were written to `root/GRAPHIFY_OUT/converted`,
always anchored to the scanned source tree, so `graphify extract --out <dir>` against a
read-only or pinned checkout polluted the source tree. Route sidecar writes through the
cache root while keeping the content hash anchored to the scan root, so sidecar filenames
stay stable across checkouts.
Fails on the current sanitizer in 4 of 5 cases: Korean/Japanese/accented labels
lose their letters, distinct communities collapse to the same tag, and the
graph-view colour group queries a tag no note carries.
_obsidian_tag stripped every non-ASCII character, so a Korean or Japanese community label collapsed to underscores and every note in that community carried the same tag. Python's \w is Unicode-aware, so switching the filter keeps letters from any script while still dropping spaces and punctuation.
Also route the .obsidian/graph.json colour-group query through the same sanitizer: it was built from the raw label, so on a non-ASCII label it queried a tag that no note carries.
The header logic is unit-tested, but only a real subprocess run proves the CLI
query command actually passes the resolved graph path through — the wiring that
was the point of #2789. Cover both the under-CWD relative form and an explicit
--graph outside the CWD shown in full. Plus 0.9.47 changelog entry.
The query header now leads with the graph file it opened and its node count,
shown relative to the CWD when the graph sits underneath it and absolute when it
does not — surfacing the case where a query run from a parent project silently
answers from the wrong corpus. graph_path is optional, so the header is
byte-identical for callers that do not pass it; the CLI query command and the MCP
query tool, which already resolve the path for querylog, now pass it.
The factory-object owner path emitted the `contains` edge inside the per-member
loop; add_node dedups on id but add_edge does not, so N methods assigned to one
object flooded the graph with N identical contains edges. Emit it once per owner.
Tests: assert a single contains edge across four methods, cover arrow-function
assignment (the dominant modern factory shape), and lock the negative case that a
non-object-literal receiver (`external.handler = fn`) is not captured.
Preserve callable members assigned to a local object-literal factory API
(`const api = {}; api.foo = fn`), modeling the API object beneath its factory
and attaching the assigned functions as methods. The lazy owner-node minting
(only for identifiers proven to be object-literal bindings in the enclosing
function) avoids the #1077 config-object flood.
Partially addresses #2524 (the object-literal-method shorthand form
`return { foo() {} }` remains out of scope).
Covers the ensure_ascii write path the field-order stabilization implicitly
relies on: a reordered node dict carrying escaped-unicode values must serialize
byte-identically across a read-rebuild.
node_link_data always appends the node key (id) last, so id sat mid-dict on a
cold build but last after a read-rebuild — churning graph.json key order with
no content change and defeating byte-stable caching. to_json now canonicalizes
each node/link dict (id / source,target,relation first, remaining keys sorted)
before serialization, so a load -> rebuild -> write round-trip is byte-identical.
Pure key permutation; values are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the multiple-text-block test the deep-dive flagged: the helper must return
the FIRST text block (graphify's claude calls carry a single JSON payload, so
concatenating would corrupt it). Adds the CHANGELOG entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extended thinking is on by default on current Claude models, so a response's
content[0] is a ThinkingBlock and the old content[0].text raised AttributeError
on the SDK claude backend. A shape-aware helper (mirroring the existing
_bedrock_response_text) skips leading non-text blocks and returns the first
text block; a thinking-only response falls back to the default. Only the two
SDK claude call sites are touched; the claude-cli JSON-envelope path is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the two cases the deep-dive flagged: the kind='ast' path (the literal
graphify update scenario) stays byte-identical on a no-op and updates on a real
edit; and a corrupt/unparseable existing manifest still gets written (the
byte-equality skip's except must never silently drop a real update). Also adds
the CHANGELOG entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
save_manifest unconditionally stamped a fresh 'seen' timestamp for every file
on every run, so a no-op graphify update rewrote all manifest entries and left
graphify-out/ permanently dirty (a trailing graph commit on every push). Now
the per-file 'seen' is preserved for genuinely unchanged entries, and the disk
write is skipped entirely when the serialized manifest is byte-identical to
what is already on disk; a real change still refreshes and persists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>