825 Commits
Author SHA1 Message Date
safishamsiandClaude Opus 4.8 7e955d223b fix(detect): honor ignore files saved with a UTF-8 BOM (#2163)
.gitignore/.graphifyignore/info-exclude read with encoding=utf-8 kept a
leading BOM (U+FEFF) on the first line, so the first pattern (e.g. *.log
or .fable-wt/) silently matched nothing and a BOM'd full-line comment
became a bogus pattern. git strips a single leading BOM; switching the
two ignore read sites to utf-8-sig matches git exactly (strips at most
one, file-start only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:47:25 +01:00
HerenderKumar 0019fc4d90 fix(extract): resolve bash source edges built from ${VAR} paths (#2079)
`source "${BENCH_DIR}/lib/x.sh"` (the `dirname "${BASH_SOURCE[0]}"` idiom)
took the bare-name branch, which baked the unexpanded `${BENCH_DIR}` text
into the target id via `_make_id`. That id matches no node, so the edge was
flagged dangling and dropped at export — shared shell libraries looked
orphaned and were split into separate communities.

Detect a `$`-expansion in the source argument, strip the leading
expansion segment(s), and resolve the literal suffix against the script's
own directory (which is what the canonical idiom makes the variable). Emit
`imports_from` as INFERRED only when it resolves to a real file on disk;
skip otherwise instead of emitting a dead id. Bare-name sources keep their
existing behavior.
2026-07-24 23:41:28 +01:00
HerenderKumar bdcae25a26 fix(extract): resolve bash calls into sourced-file functions (#2141)
extract_bash only linked calls whose callee was defined in the same file, so a
call to a function from a `source`d library was silently dropped and shell
scripts looked disconnected from the libraries they use. The resolver for exactly
this, resolve_bash_source_edges, already existed with tests but had no call site
and no raw_calls/bash_sources to work from.

extract_bash now emits `bash_sources` (the files it `source`s) and `raw_calls`
(calls whose callee isn't defined locally), and the pipeline runs them through
resolve_bash_source_edges after the id-remap passes, so caller and function node
ids are final and the already-emitted source edge is de-duped. Resolution is
scoped to the source relationship: a call to an external command never binds to a
same-named function in an unsourced file, and bash raw_calls are excluded from the
generic global-name resolver for the same reason.
2026-07-24 23:37:14 +01:00
Rishet Mehra 0da6929e57 fix(hooks): match the post-checkout log prefix in the timeout fallback
The post-checkout body logs with [graphify] throughout, but the new
non-SIGALRM fallback used [graphify hook], so the same timeout read
differently depending on the platform. The post-commit body does use
[graphify hook] everywhere, so only the checkout copy was wrong.

Assert each body uses a single log prefix so this cannot drift again.
2026-07-24 23:37:14 +01:00
Rishet Mehra ca3113ac7b fix(hooks): arm the rebuild timeout without SIGALRM on Windows
The GRAPHIFY_REBUILD_TIMEOUT watchdog in both embedded rebuild bodies was
guarded by hasattr(signal, 'SIGALRM') with no else branch, so on Windows it
never armed and a hung rebuild survived indefinitely -- the exact tail case
the #791 timeout was added to catch.

Fall back to a daemon threading.Timer that prints the same message and calls
os._exit(1). The hard exit is deliberate: the process is already stuck, so a
clean shutdown may itself be blocked, and _rebuild_lock degrades to a no-op
yield on platforms without fcntl, so there is no lock to leave stale.
2026-07-24 23:37:14 +01:00
Rishet Mehra e1e041b09b fix(hooks): accept Windows backslash paths in interpreter allowlist (#2126)
The post-commit/post-checkout hook's interpreter-detection allowlist
silently rejected valid Windows paths (C:\...\python.exe) on Git-Bash.
bash treats a lone backslash inside [...] as an escape that consumes
itself, so the emitted glob never matched a real backslash at runtime,
even though the pattern looked correct in Python's install-time re dialect.

Fix both allowlists (.graphify_python file path and shebang-parsed
launcher path) to emit [!a-zA-Z0-9/_.@:\\-], a doubled-backslash form
verified against bash and dash: it accepts Windows paths and still
rejects ; ` $ injection. The shebang allowlist additionally lacked :
and backslash entirely. Install-time _pinned_python() re is left as-is.

Add shell-runtime tests that execute the emitted case/esac glob directly
against Windows paths and shell metacharacters.
2026-07-24 23:37:14 +01:00
Rishet Mehra f6742bb6f0 fix(extract): exclude class refs from indirect_call edges (#2137)
Classes are callable via their constructor but are frequently referenced as
descriptive values, not invoked: ORM args (select(Model), db.get(Model, id)),
exception tuples (except (ErrorA, ErrorB)), and string-literal getattr resolving
to a same-named class. The indirect_call guard treated any callable-def target
identically, so these produced false edges (~41% of indirect_call edges in the
reported sample targeted classes), inflating centrality and traversals.

Track class defs in a callable_class_nids set parallel to callable_def_nids,
mark class nodes with a _callable_class attribute, and exclude class targets
from indirect_call emission in both the intra-file (_emit_indirect_by_name) and
cross-file resolver paths. Marker is stripped before output like _callable.

Covers all languages: both class-node creation sites (the generic
config.class_types branch and the Ruby Struct.new/Class.new/Data.define
synthesis) register into the new set.

Tradeoff: suppression is context-blind, so a genuine higher-order class
callback (e.g. map(Point, coords)) also loses its indirect_call edge. This is
far rarer than the false-positive noise removed and matches the issue framing.

Verified before/after on the same input: 4 class-targeted indirect_call edges
-> 0, function callbacks preserved.
2026-07-24 23:37:14 +01:00
safishamsiandClaude Opus 4.8 0d1f25c221 refactor(detect): remove dead .graphifyinclude handling (#2112)
The .graphifyinclude loader and its two matcher helpers had no consumers:
commit df40e4d (#873, index dot dirs) removed the blanket dot-prefix
exclusion and with it the only call sites, leaving detect() parsing the
file on every run and then discarding the result. A .graphifyinclude was
silently a no-op.

Delete _load_graphifyinclude, _is_included, _could_contain_included_path
and the orphaned assignment; add .graphifyinclude to _SKIP_FILES so a
leftover file no longer lands in unclassified; and print a one-time
stderr note when one is present at the scan root, pointing to ! negation
patterns in .graphifyignore. Bump to 0.9.25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:35:54 +01:00
safishamsi 2da192db56 fix(extract): bound the XAML code-behind .cs scan so it can't hang
_xaml_csharp_class_nodes did `sorted(root.rglob("*.cs"))` where `root` comes from
_xaml_project_root walking up for a .csproj/.sln marker. On a standalone
extract_xaml call (no corpus boundary), a .xaml under a large/shared parent (a
temp dir, or a big monorepo) could resolve `root` to a broad ancestor, and the
rglob then recursively scanned the entire tree — effectively hanging (it stalled
the test suite intermittently once the shared temp dir grew). Replace the rglob
with an os.walk that prunes noise/hidden dirs (node_modules/.venv/.git/...) during
traversal and caps directories visited, so a real project scans fully while a
runaway root degrades to a fast, partial scan instead of a hang. Regression test
asserts a decoy .cs in node_modules is pruned and the real ViewModel still links.
2026-07-22 19:31:19 +01:00
safishamsi 8658cc3105 fix(skillgen): commit rendered skill artifacts for the #2106 traceability line (missed in 65b81bf) 2026-07-22 19:01:53 +01:00
safishamsi 36b5e770eb fix(detect): stop the sensitive-file filter from dropping topic docs and real source (#2106)
The heuristic over-matched and silently dropped legitimate files:
- prose `.md`/`.rst` whose topic slug ends in a keyword (privacy-tokens.md,
  token-economics.md) — only code was exempt, not prose;
- the unbounded Stage-2 `service.account` substring (regex `.` wildcard) matched
  real source (google/oauth2/service_account.py) and prose slugs.
It also MISSED real secrets (.npmrc, .pypirc, secring, .git-credentials, and
case variants on case-insensitive filesystems), which were being indexed.

Fix: move service_account/aws_credentials to the boundary-checked keyword path
(so real source is spared, downloaded key files still drop), add a prose-note
carve-out (multi-word slugs indexed, bare `secrets.md`/`token.md` still dropped),
tighten id_rsa with a left boundary, add the missed secret dotfiles + secring,
lowercase the dir/segment comparisons, and count multi-dot slugs as multi-word.
Net effect is stricter on real secrets and stops the false-positive data loss.

Traceability: `graphify extract` now names the files skipped as sensitive (not
just a count), so a wrongly-flagged file is visible.
2026-07-22 18:40:11 +01:00
safishamsi 866d503b48 fix(dedup): order-independent gap-fill, source_file gate, _origin/None handling (follow-up to #2102) 2026-07-22 16:11:15 +01:00
Synvoya 5a4b207ddf fix(dedup): preserve same-source node attributes 2026-07-22 16:03:42 +01:00
safishamsi 16315f1956 fix(llm): parse claude-cli structured_output, not the prose result field (follow-up to #2095) 2026-07-22 15:57:54 +01:00
Yyunozor 7116a6f5f5 fix(llm): pin claude-cli output to a JSON schema when supported (#2076)
The claude-cli backend delivers the extraction schema in the user turn and
trusts the model to emit raw JSON. Newer Claude Code releases treat that
prompt as an agentic task and report the result in prose instead ("Knowledge
graph extracted — 21 nodes, 20 edges…"), so the graph parses empty, reads as
truncation, and adaptive-retry bisects without ever converging.

Pass --json-schema (structured output) when the CLI advertises it — probed
once via `claude --help` and cached — so the object shape is constrained
regardless of prompt framing. Older CLIs that predate the flag keep the
user-turn prompt as a fallback. The `result` envelope still carries the JSON
string, so the parse path is unchanged.
2026-07-22 15:51:03 +01:00
Yyunozor cc70085f3b fix(extract): resolve calls edges through an aliased Python import (#2082)
`from pkg import mod as alias` correctly emits the file-level
`imports_from` edge, but every downstream `alias.func()` call was
dropped: the module arm of the cross-file member-call resolver
(#1883, _resolve_python_member_calls in extract.py) matches a call
receiver against the imported module's own file stem, with no
awareness that the local binding in the importing file can be a
different name. `from pkg import mod` / `mod.func()` resolves because
the receiver ("mod") equals the stem; aliasing breaks the match
because the receiver ("alias") never does, and the calls edge
silently disappears while imports_from stays present -- the graph
looks connected, only the symbol-level reverse query comes back
empty. `import pkg.mod as alias` regresses the same way through the
same resolver.

Root cause is a missing propagation, not a missing feature: the local
alias is already parsed correctly in two places (_python_imported_names
in extractors/resolution.py, and the aliased_import branch of
_import_python in extract.py) but discarded before it reaches the
edges the module arm reads.

Fix threads the alias through as a `local_alias` field on the
`imports`/`imports_from` edge (mirroring the existing `target_file`
transient-hint pattern, #1814, including its pop-once-consumed
hygiene so the hint never reaches graph.json):
- _import_python now splits the alias off `import pkg.mod as alias`
  and stamps it on the edge instead of only using it to compute the
  bare module_name.
- _SymbolResolutionFacts.module_imports gains a 4th `local_name` slot
  so the `from pkg import submod [as alias]` submodule path (#1146)
  carries the binding through to _apply_symbol_resolution_facts,
  which now stamps `local_alias` on the edge whenever it differs from
  the submodule's own stem.
- The module arm's receiver match now accepts the tracked alias in
  addition to the module's real stem, keyed per (importing file,
  target module) so two files aliasing the same module differently
  each match their own binding.
- extract() pops `local_alias` off every edge right after
  run_language_resolvers runs, and build_from_json drops it from edge
  attrs too -- the same two spots target_file is dropped at (#1814),
  except the extract()-side pop has to happen AFTER the resolver
  reads the field, not at the earlier point _disambiguate_colliding_
  node_ids already pops target_file: that function runs before
  run_language_resolvers, so popping local_alias there would strip it
  before the resolver ever sees it and silently undo the fix above.

Known limitation, left out of scope: two different aliases bound to
the same module in the same file only resolve the last one
registered, since the match is one alias slot keyed per (importing
file, target module) -- not a regression, since the parent resolved
neither.

Adds five regression tests in tests/test_extract.py: the issue's own
shape (`from pkg import gate as m_gate`), its try/except-guarded
variant (the issue's literal repro, confirming the drop is
independent of try: nesting), the adjacent `import mathlib as m` and
dotted `import pkg.gate as g_alias` forms, and the relative `from .
import gate as r_gate` form -- plus an assertion that `local_alias`
never survives into the returned edges. All fail on the parent commit
with the exact missing-edge symptom and pass after the fix. Full
suite: 3554 passed vs. 3549 on the unfixed parent, a delta of exactly
these five new tests; ruff clean. #2080's calls-edge-direction
regression tests (test_serve.py) still pass unchanged.
2026-07-22 15:32:08 +01:00
safishamsi 4c759b6dd6 fix(cli): deterministic tie-break for grouped explain tail (follow-up to #2094) 2026-07-22 15:31:56 +01:00
Yyunozor 0ef452c570 fix: group cut connections by file on explain for high-degree nodes
`graphify explain "<node>"` sorts a node's connections by neighbor
degree and shows only the top 20, appending a bare "... and N more"
for the rest. On a high-degree node (a logging/error helper called
from dozens of places is typical) that leaves the exact question
explain is meant to answer - "who calls this, what's the impact?" -
unanswered for 97% of the callers, with nothing pointing at where
they live (#2009).

The top-20 list and its ordering are unchanged (no behavior change
for nodes at or under the cutoff). Past it, the cut connections are
now grouped by (direction, source_file) with counts and printed
under a "Grouped by file:" section, sorted by count descending, so
the caller/callee distribution is visible without falling back to a
repo-wide grep. The aggregation itself is capped at 20 files with
its own "... and N more files" line for the pathological case where
the remainder is spread across more files than that.

Full per-caller detail behind a flag (--callers --all / --group-by=dir
as proposed in the issue) is left for a follow-up: it's a larger,
more opinionated surface (flag naming, pagination semantics) than
the literal complaint needs, and the default output no longer hides
the answer either way.

Four regression tests in tests/test_explain_cli.py: the pre-existing
truncation notice on a 30-connection node is unchanged, the grouped-
by-file output carries real counts (3 files, one at 4 and two at 3)
that sum back to exactly the cut total (no silent loss), a
byte-for-byte no-op check for nodes at/under the cutoff, and a
boundary check pinning the cutoff itself at exactly 20 connections
(no section) vs exactly 21 (one grouped entry) — the earlier tests
sit well clear of the edge and wouldn't catch a future off-by-one.
2026-07-22 15:27:07 +01:00
Yyunozor 4ace95182b fix: preserve calls edge direction in graphify query output
`graphify query` builds an undirected nx.Graph (so BFS/DFS can explore
both callers and callees of the seed node), but its text renderer
assumed the BFS/DFS visit order (u, v) was always the edge's
(source, target). On an undirected graph that assumption only holds
when the seed happens to be the caller: seeding on the callee makes
BFS/DFS visit the callee first, so a `caller --calls--> callee` edge
was rendered backwards as `callee --calls--> caller`. graph.json's
own source/target fields stay correct on disk; only the query
rendering was wrong.

`graphify path` and `graphify explain` don't have this problem
because they force directed=True on load (#849, #853), and the MCP
query_graph tool's _load_graph() does the same. Doing that for CLI
`query` too was tried and reverted: forcing a DiGraph makes
G.neighbors() return successors only, so a query seeded on a
leaf/sink node (no outgoing edges) found zero neighbors instead of
its callers — a recall regression, not just a display fix, and it
would make the CLI and MCP query tools diverge in what they discover
even though they'd render direction identically.

Fix instead mirrors the _src/_tgt pattern graphify/build.py already
uses for the same underlying problem (undirected storage loses
direction): the CLI now stashes each link's true source/target on
its edge data as _src/_tgt before constructing the (still undirected)
graph, and _subgraph_to_text renders EDGE lines from _src/_tgt when
present, falling back to (u, v) otherwise. Traversal itself is
unchanged, so recall is unaffected — verified against the unpatched
CLI, the node counts returned for the same seeds are identical before
and after this fix, only the printed edge direction changes.

Adds two regression tests in tests/test_query_cli.py seeding the same
`calls` edge from both endpoints; the callee-seeded case fails on the
prior code with the exact backwards-edge symptom above.
2026-07-21 21:45:53 +01:00
safishamsi deb2620be9 feat(serve): announce get_neighbors/get_community truncation at the top + tests (follow-up to #2069) 2026-07-21 21:39:32 +01:00
B fef9dbb5e7 feat(serve): honor a token_budget on get_neighbors/get_community
get_neighbors and get_community render every edge/member line unbounded — on a
god node or a large community that floods an MCP client's context window with
100KB+ of text in one tool result. query_graph already solves this with the
~3-chars/token cut in _subgraph_to_text; this applies the same budget rule to
the two line-list tools via a shared helper (_cut_lines_to_budget): cut at a
line boundary, report how many lines were dropped, and point at the narrowing
path (relation_filter / get_node). Default 2000 like query_graph; output under
budget is byte-identical to today.
2026-07-21 21:36:30 +01:00
HerenderKumar bb8f5bd675 docs(cli): surface --code-only in the extract usage and README (#2071)
The top-level `graphify --help` has listed --code-only since #1734, but the
`graphify extract` usage string and the README never mentioned it. A user
evaluating graphify on a "can this run with no network call" constraint sees
the extract usage / README first and can conclude the flag doesn't exist.

Add --code-only to the extract usage line, name it in the Privacy section and
the command reference, and add a test asserting the usage advertises it.
2026-07-21 21:33:12 +01:00
safishamsi 1fbc623271 fix(query): report real call-site lines + stop silent query truncation (benchmark)
Two correctness defects found in a head-to-head benchmark of 0.9.22.

Caller line numbers: explain/affected/get_neighbors/query printed the caller
node's def line for an incoming call, presented as a precise citation, so
click-through landed in the wrong place. The `calls` edge already carries the
true call-site line (engine.py sets it); every caller/relation listing now reads
the traversed edge's source_file:source_location, falling back to the node's own
line only when the edge lacks one.

Silent query truncation: rendered nodes were degree-ordered (a low-degree
definition node ranked last, cut first), the queried symbol wasn't guaranteed to
appear, and the truncation marker sat only at the end so silence read as absence.
Nodes are now ranked by hop distance from the seeds (deterministic), the seed the
question named is rendered first and never truncated, and a prominent TRUNCATED
notice at the top states shown/total counts and how to widen the budget. Also
rewires the seed-first ordering the renderer already supported — a branch merge
had silently dropped the `seeds=` argument, leaving it dead code.
2026-07-21 18:39:05 +01:00
safishamsi 10b710561a fix(extract): harden #2072 src-layout resolution (adversarial-review fixes)
Three issues found reviewing #2072:
- The import-edge repoint loop matched by target id regardless of the edge's
  language, so a non-Python dotted import (C# `using Pkg.Mod;`, Java/Go) whose
  dangling target coincided with a Python alias got repointed onto a Python file,
  fabricating a cross-language import. Gate the rewrite on the edge being
  Python-sourced.
- The resolver ancestor walk probed package dirs too, resolving an absolute
  `from helpers import x` to a sibling in the current package (Python-2 implicit-
  relative semantics) even when `helpers` is external. Only probe sys.path-root
  candidates (ancestors without their own __init__.py).
- Bound the __init__.py package-root chain walk by the path depth so a
  pathological `/__init__.py` can't loop.
Added a cross-language-guard regression test; fixed the tautological (or->and)
ambiguity assertion.
2026-07-21 14:22:50 +01:00
safishamsi bb467634c9 fix(extract): resolve Python imports regardless of scan root (src-layout) (#2072)
Python absolute imports were resolved only against the scan root, and file-node
ids are scan-root-relative, so a src-layout project (code under src/) lost most
of its imports/imports_from edges when scanned from the repo root — the dangling
edges were silently dropped, so the graph looked complete but wasn't. The chosen
scan root thus silently changed the graph.

Two fixes: (1) _resolve_python_module_path probes the scan root first, then walks
up from the importing file toward the root so a nested package root (src/pkg)
resolves (mirrors the Lua upward walk); (2) a Python post-pass detects each
file's package root via its __init__.py chain and repoints absolute-import edge
targets (dotted-module id -> real file-node id), guarded against shadowing an
existing id and against ambiguous aliases claimed by >1 file. Result: byte-
identical import edges whether scanned from the repo root or from src/.
2026-07-21 13:44:49 +01:00
safishamsi cdeba1dcb7 fix(build): ghost-merge keys on full source_file, not bare basename (#2068)
build_from_json's #1145 ghost-duplicate merge keyed on (Path(source_file).name,
label), discarding the directory, so unrelated nodes from different files sharing
a common basename (index.md, README.md, ...) and a generic label were silently
merged onto one survivor with their edges rewired — corrupting multi-corpus doc
graphs. The AST/LLM ghost twins the merge legitimately targets always share the
same source_file, so keying on the full normalized source_file preserves #1145
while making cross-directory false merges impossible. This subsumes the
#1753/#1257 cross-file ambiguity guard (now removed as dead code). Independent of
the #2032 label pass. Updated the #1257 test to the now-correct precise merge.
2026-07-21 13:03:34 +01:00
safishamsi 87c870495f fix(label): --no-label placeholders no longer permanently suppress real labels (#2073)
A `cluster-only --no-label` run wrote "Community N" placeholders into
.graphify_labels.json plus a matching .sig, and the reuse path treated them as
fresh, so real labels were never regenerated on later runs. Two fixes: (a) don't
persist the labels sidecar (or its .sig) on a placeholder-only run, so a later
run generates real labels; (b) treat a stored "Community {cid}" as absent in the
reuse path so an already-polluted sidecar self-heals via the hub labeler while
genuine labels are still reused with no LLM call. The watch/update rebuild had
the same placeholder-perpetuation twin — fixed alongside.
2026-07-21 12:59:10 +01:00
safishamsi b194301c05 fix(path): deterministic route + honest edge relation, not fabricated calls (#2074)
`graphify path` (and the MCP shortest_path tool) ran shortest_path over
G.to_undirected(as_view=True), whose neighbor iteration is a hash-seeded set
union, so among equal-length paths BFS returned a route that varied per process.
Build a sorted, materialized undirected graph so the chosen path is canonical.

The hop label also printed a relation read from an arbitrarily-collapsed parallel
edge, so it could show `calls` on a pair that only carries `references`. Force
multigraph on the cli path reload so parallel links survive, and render the
ACTUAL stored relation(s) via edge_datas, falling back to an honest "related"
when the edge has none. Serve's shared graph is left untouched (its degree feeds
query-seed tie-breaks); the fix is applied locally in both path readers.
2026-07-21 12:54:58 +01:00
safishamsi b96effd3aa fix(install): uninstall must not delete a user's ### graphify H3 section (#2062)
The uninstall strip used an unanchored regex `## graphify`, which matched inside
a user's `### graphify` heading and deleted hand-written content; the
`marker not in content` guard was a substring test that passed on the same
mention. Add a shared `_remove_marker_section` helper that matches the heading
only when a line is exactly the marker (mirroring the install-side #1688
hardening), running each section to the next same-level heading or EOF, and
returning None (leave the file untouched) when no exact heading exists. Replace
all six strip sites: GEMINI.md, copilot-instructions.md, AGENTS.md, CLAUDE.md
(_strip_graphify_md_section), CODEBUDDY.md, and the H1 skill-registration
(_remove_claude_skill_registration, which had the same bug with `# graphify`).
2026-07-21 12:40:49 +01:00
safishamsi 5b05f0d9ab fix(cli): disambiguate file labels on the --no-cluster extract path too (#2032)
The build_from_json label pass only covered the clustered/update paths; the raw
`extract --no-cluster` path writes the merged node list directly, so colliding
basenames stayed un-disambiguated there. Factor the logic into a shared
_file_label_reassignments core with a list-based variant
(disambiguate_file_labels_in_nodes) and apply it on the raw merged nodes.
Caught by the clean-venv edge-case battery.
2026-07-20 16:35:14 +01:00
safishamsi fecf9aa76b fix(build): disambiguate colliding file-node labels for discovery (#2032)
In directory-per-entrypoint repos (Supabase Edge Functions, Next.js page.tsx,
Rust mod.rs, Python __init__.py) many files share a basename, so basename-only
file-node labels collided and `explain`/free-text discovery couldn't resolve
them — exactly the highest-value files. build_from_json now runs a final pass
that gives colliding-basename file nodes the shortest unique directory-qualified
label (`process-order/index.ts`); unique basenames stay bare, and node ids/edges
are never touched. The pass runs after the alias-competition (which still needs
bare basenames), is idempotent (labels derive from source_file), and the
downstream file-node predicates (analyze god-nodes, tree_html, serve lookup)
recognize the qualified form via a shared _is_file_node_label helper.
2026-07-20 16:25:11 +01:00
safishamsi f1405dd7c1 fix(extract): nested types get a contains edge from the enclosing type (#2040)
In _extract_generic's class branch the `contains` edge was hard-coded to source
from the file node, so a nested class/object/trait attached to the file instead
of its enclosing type across ~19 languages (only C# even flagged the node with
is_nested_type, and still emitted no edge to the parent). The edge now sources
from parent_class_nid when set, else the file node — keeping the containment
tree connected (file -> Outer -> Inner). A `!= class_nid` guard avoids a
self-loop when same-name nesting collides ids (class ids omit the enclosing
name). The C# is_nested_type flag is retained (load-bearing for cross-file
resolution). Methods were already parent-sourced and are unaffected.
2026-07-20 15:40:32 +01:00
safishamsi 1f4e3b2fe1 fix(cli): wire god-nodes subcommand + accept --output alias on extract (#2004)
Part 2: `god_nodes` was an analyzer, an MCP tool, and a README-advertised
capability, but `graphify god_nodes` errored with "unknown command". Add a
read-only `god-nodes`/`god_nodes` subcommand mirroring `affected` (--graph,
--top, --json), routing labels through sanitize_label.

Part 3: `--output DIR` on `extract` was silently dropped (output fell back to
the default dir). It is now an alias of `--out` (both space and =forms), matching
what `graphify tree` already documents. Help/usage text updated.

Part 1 (affected/reverse-dep import-id mismatch) is deferred — a build-time
id-resolution change, tracked separately.
2026-07-20 15:35:40 +01:00
safishamsi db410d0d09 fix(detect): stop silent data loss from env-dir pruning and absolute-path sidecars (#2058, #2059)
#2058: `_is_noise_dir` treated any directory named `env`/`.env`/`*_env` as a
Python virtualenv and pruned it during the walk — before `.graphifyignore`
negation, with zero trace in any returned bucket. Real source dirs with those
names (common in UVM/ASIC verification trees) were silently lost. The venv
heuristic for those names is now gated on actual markers (`pyvenv.cfg`,
`bin`/`Scripts/activate`, `lib/python*`, `conda-meta/`); `venv`/`.venv`/`*_venv`
stay name-only. Pruned-as-noise dirs are recorded in a new `pruned_noise_dirs`
bucket for traceability, and extract.py's walk call sites pass the parent so
genuine venvs are still marker-checked and pruned.

#2059: Office and Google-Workspace sidecars were named with a hash of the
resolved ABSOLUTE source path, so the same tracked file in two clones/worktrees
produced two differently-named byte-identical sidecars — unbounded duplicates
when graphify-out/ is committed, each ingested as a distinct source doc. The
hash is now over the scan-root-relative (NFC-normalized) path, stable across
checkouts while still disambiguating same-stem files; out-of-root sources fall
back to the old absolute form. Also fixes the same bug in google_workspace's
`_sidecar_path` (which additionally never had the #1226 NFC fix).
2026-07-20 15:28:59 +01:00
김재현 b890ca6590 fix(serve): reorder except clauses so the corrupted-graph message is reachable
json.JSONDecodeError subclasses ValueError, so the broader
`except (ValueError, FileNotFoundError)` clause always matched first,
making the intended "graph.json is corrupted (...). Re-run /graphify
to rebuild." recovery hint dead code — users with a truncated graph.json
got the bare json.JSONDecodeError message instead, contradicting the
behavior SECURITY.md documents for this exact threat.

Move the json.JSONDecodeError clause first so it actually catches.
Audited the rest of serve.py's exception handlers for the same
narrower-after-broader ordering bug; found no other instance.

Fixes #2005.
2026-07-20 15:02:54 +01:00
safishamsi f6974eae7c fix(watch): don't evict remote-source nodes after URL slash-collapse (#2051 follow-up)
The #2051 disk-absence sweep guarded remote/virtual sources with a literal
`"://"` check. But the write-side path normalization (Path.as_posix) collapses
the double slash, so a stored `gdoc://x` reads back as `gdoc:/x` on the next
update; the literal guard then missed it and the node fell into the disk-absence
branch (`Path('gdoc:/x').exists()` is False), evicting it on the second
`graphify update`. Match the scheme with a regex tolerant of the collapse, with
a 2+ char scheme so a Windows drive letter (C:/) is not misread as remote.

Regression test runs three consecutive updates and asserts the remote node
survives every one.
2026-07-20 13:57:12 +01:00
safishamsi 9c2e0fcfa2 fix(skillgen): stamp only produced-output files in the update runbook (#2015)
The --update runbook's Step 9 stamped the entire detected corpus into the
manifest, so a semantic file (doc/paper/image) whose chunk failed or was omitted
was recorded as done and never re-queued on the next update — losing its content
permanently. The runbook now builds the manifest the way the library extract
path does: cli._stamped_manifest_files stamps only files that actually produced
nodes/edges/hyperedges, dispatched-but-empty files have their stale hash cleared,
and scan_corpus drops newly-excluded in-root rows.

Applied to the Claude, Aider, and Devin skill bodies and the shared update
reference; all 134 artifacts regenerated. gen.py gains a sanctioned monolith-
diff predicate for the new stamping lines.
2026-07-20 13:30:26 +01:00
safishamsi 6a56763051 fix(build): form-insensitive prune + scan-root marker (#2012)
build_merge now prunes a deleted file's nodes, edges, and hyperedges regardless
of whether their stored source_file is absolute or relative. When the caller
passed no root (the --update runbook), a node that kept an absolute path slipped
past the relative prune set and the deleted file's graph survived silently.
Matching is now form-insensitive (raw, normalized-relative, then an absolute-
identity fallback); a re-extracted file is still never pruned (#1796 preserved).

`graphify extract` also writes the .graphify_root marker after every graph write
so a later build_merge relativizes deleted-file paths correctly even under a
custom --out (its grandparent-of-graph.json fallback pointed at the wrong dir).

Regression tests in tests/test_build_merge_hyperedges_and_prune.py.
2026-07-20 13:29:54 +01:00
safishamsi 94189bac3c fix(watch): correct semantic-node eviction/preservation on update
Three silent-data-loss fixes in the update/reconcile path:

- #2051: a full `graphify update` now evicts semantic nodes whose non-AST
  source (a .txt/.pdf/.png with no code extractor) was deleted from disk.
  The corpus sweep only checked re-extractable files, so deleted docs'/images'
  LLM nodes survived as authoritative forever. Disk absence is now the deletion
  signal; remote/virtual sources (`://`) are left untouched.
- #2056: an incremental rebuild whose change set names a present-but-
  unextractable file no longer treats it as a deletion (which evicted its
  semantic nodes and disabled the shrink guard). The guard now falls through to
  per-source accounting instead of a wholesale bypass on any deletion.
- #2014: code-typed nodes the semantic pass surfaces from within a document now
  count as that doc's semantic layer, so a rebuild doesn't re-scan and drop them.

Regression tests for each in tests/test_watch.py.
2026-07-20 13:29:44 +01:00
safishamsiandClaude Opus 4.8 e7ad693b5b fix(extract): don't guess an ambiguous barrel re-export (follow-up to #2034)
The barrel-chain resolver keyed (source, symbol) -> target as last-write-wins, so
a barrel re-exporting the same local name from two modules collapsed an importer's
edge onto whichever was learned last — a fabricated wrong edge. Learn into a set
per key and refuse to resolve when a name maps to more than one target; the edge
falls to the dangling-canonical fallback (dropped at build) instead. Adds a
regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:41:44 +01:00
safishamsiandClaude Opus 4.8 868f75de38 fix(llm): OLLAMA_HOST fallback in detect_backend + default port (follow-up to #2019)
The PR resolved OLLAMA_HOST for the client base_url but not in detect_backend(),
so the headline #1940 case (OLLAMA_HOST set, no --backend) still errored with
"no LLM API key found". detect_backend() now uses _resolve_ollama_base_url (empty
default stays falsy, so ollama remains opt-in and never shadows a paid key). Also
default the port to 11434 when OLLAMA_HOST omits it (a bare host would otherwise
resolve to port 80), and handle bare-port / :port forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:41:43 +01:00
HerenderKumar fab28ab7bc fix(extract): resolve alias re-exports and imports through barrels to the defining symbol (#1983) 2026-07-20 11:31:58 +01:00
oleksii-tumanov c57d5711ca fix(extract): honor persisted excludes on re-extract 2026-07-20 11:31:36 +01:00
oleksii-tumanov b35898aafc fix(extract): initialize detection for pathless Postgres 2026-07-20 11:31:36 +01:00
김재현 220a675265 fix(llm): fall back to OLLAMA_HOST when OLLAMA_BASE_URL is unset
Ollama's own server has no OLLAMA_BASE_URL concept — it reads
OLLAMA_HOST (https://docs.ollama.com/faq#how-do-i-configure-ollama-server).
graphify only ever read OLLAMA_BASE_URL, so anyone who configured Ollama
the way its own docs describe (OLLAMA_HOST) had graphify silently ignore
it and fall back to the localhost:11434 default.

Add _resolve_ollama_base_url(default): OLLAMA_BASE_URL still wins when
set (unchanged behavior, graphify's existing convention for OpenAI-
compatible base_url overrides across backends). Otherwise falls back to
OLLAMA_HOST, normalized into an OpenAI-compatible URL (adds a scheme if
missing, ensures a trailing /v1). Wired into the BACKENDS dict's ollama
entry and the two ollama_url call sites that read the env var directly.

Fixes #1940.
2026-07-20 11:29:22 +01:00
Endogen c105633b56 fix: drop collapsed import self-loops 2026-07-20 11:29:22 +01:00
shazeb 08166306ba fix(cache): anchor semantic cache writes to cache_root so --out round-trips (#1990, #1991)
With `graphify extract --out <dir>`, the semantic cache write and read
sides disagreed on both location and key anchoring, breaking the cache
round-trip in two ways:

- Checkpoints (#1990): `_checkpoint_chunk` called `save_semantic_cache`
  with only `root=target`, so per-chunk recovery checkpoints were written
  under `<corpus>/graphify-out/` while the reader consulted
  `<out>/graphify-out/` — creating an unwanted graphify-out/ inside the
  analyzed source tree and making every interrupted run re-extract (and
  re-bill) completed chunks.

- Final save (#1991): cli.py passed `root=out_root`, so corpus-relative
  `source_file` paths resolved against the --out directory, failed
  `p.is_file()`, and every result group was silently skipped — the cache
  the reader would consult was never populated at all, with no warning.

Fix, following the split the AST cache already uses (#1774):

- `save_semantic_cache` and `check_semantic_cache` gain a `cache_root`
  parameter mirroring `load_cached`/`save_cached`: `root` stays the
  source-key anchor (content-hash keys, source_file resolution and
  relativization), `cache_root` selects where cache files live. Omitting
  it keeps `root` for both, so existing callers are unchanged.
- `extract_corpus_parallel` plumbs `cache_root` into `_checkpoint_chunk`.
- cli.py extract passes `root=target, cache_root=out_root` at the cache
  read, the checkpoint path, and the final save, and re-anchors the
  prune sweep's live hashes to `target` (keys anchored to out_root would
  mismatch every entry and sweep the fresh cache as orphaned).
- `save_semantic_cache` now warns loudly when every result group is
  dropped because its source_file does not resolve to a real file — the
  silent-0-writes failure mode #1991 asked to surface.

Regression tests cover: checkpoint written under cache_root (not the
corpus, no corpus graphify-out/ created), recovery read finds the
checkpoint via the same root/cache_root split, the final-save call shape
writes entries where the reader looks, the all-groups-dropped warning,
and backward compatibility when cache_root is omitted.

Fixes #1990
Fixes #1991
2026-07-18 19:09:53 +01:00
shazeb 0224bcaea4 fix(install): fire the search nudge on Claude Code's Grep tool, not just Bash (#1986)
ee1df22 narrowed the Claude Code search-guard matcher from "Glob|Grep" to
"Bash" on the premise that dedicated search tools were removed and searches
go through Bash. Current Claude Code routes content search through its
first-class Grep tool (its Bash tool description actively steers away from
shell grep), so the graphify-first nudge never fired on the agent's primary
exploration path and the graph was silently bypassed.

Three-part fix, per the issue's analysis:

- Matcher: "Bash" -> "Bash|Grep" in _claude_pretooluse_hooks. Glob already
  fires the read nudge via "Read|Glob", so Grep was the only orphaned tool.
- Guard body: the hook-guard search branch only inspected tool_input.command,
  which a Grep call doesn't carry (it has pattern/path/glob). A Grep-shaped
  input (pattern present, no command) is now treated as a search — it IS one
  by definition — and nudges whenever a fresh graph exists. The Bash
  token-matching path is unchanged, and a command-carrying input never
  triggers the Grep shape, so non-search Bash calls stay silent.
- Idempotency: "Bash|Grep" added to the four install/uninstall dedup filters
  (claude + codebuddy), so upgrading replaces the stale "Bash" hook in place
  instead of appending a duplicate — verified against a pre-fix settings.json.

Tests: new regression tests feed Grep-shaped tool_input through
hook-guard search and assert the nudge (with graph), silence (without),
valid PreToolUse JSON, and no blocking; plus a guard that a non-search Bash
command with a stray pattern key does not nudge. Existing matcher assertions
updated across test_search_hook/test_install/test_claude_md/test_codebuddy/
test_hook_strict. Hook+install suites: 397 passed. Full suite: 3224 passed;
the 13 failures are pre-existing on clean v8 in this environment.

Fixes #1986
2026-07-18 19:07:16 +01:00
varuntej07 faa0ac2984 fix(install): emit hook exe paths with forward slashes so Git Bash does not strip them
Claude Code runs command-type PreToolUse hooks through Git Bash by default on
Windows. The resolved exe was emitted as a raw backslash path and quoted only
when it contained a space, so a space-free path like C:\Users\me\graphify.EXE
reached settings.json unquoted. Git Bash treats the unquoted backslashes as
escapes and strips them, producing 'C:Usersmegraphify.EXE: command not found',
so every graph guard silently fails. Normalize the path to forward slashes in
_resolve_graphify_exe (a no-op on POSIX), fixing the Claude, Gemini, and Codex
hook emitters at once.
2026-07-18 19:06:51 +01:00
oleksii-tumanov e5a4b51d87 fix(extract): rewrite alias re-export targets 2026-07-18 18:53:28 +01:00