1076 Commits

Author SHA1 Message Date
CJNA 591da764a1 fix(watch): require deletion evidence before evicting a missing source (#1795)
_reconcile_existing_graph treated "source identity absent from the collected
corpus" as deletion and evicted its nodes/edges/hyperedges. But corpus absence
is ambiguous: it's also what you see when a file still exists and merely stopped
being collected (ignore rules or filters changed). Upgrading into the merged-
.gitignore scan semantics (#1363) mass-evicted 655 nodes from a deliberately-
built, .gitignore'd docs dir whose files were present the whole time — reported
as a successful rebuild.

Fail-closed: before evicting a corpus-absent identity, require Path(identity)
.exists() is False (identity is an absolute path). Alive-but-excluded sources
are preserved (nodes, edges, hyperedges) and a loud line reports how many were
kept and why. True deletions and renames still evict (old path gone from disk);
a full extract --force still purges deliberate exclusions via the AST ownership
rule. Existence is memoized (one stat per file that left the corpus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:37:09 +01:00
safishamsi 0efb2a443c docs: point all website links at graphify.com
Switch every website URL from graphifylabs.ai to graphify.com — the hero logo,
the Penpax section, and the waitlist link — across the main README and the
translated READMEs. The contact email stays on graphifylabs.ai (mail is hosted
there); no mailto links were changed. graphify.com is the official graphify
product site; graphifylabs.ai remains the company site (org profile + email).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:07:57 +01:00
erichkusuki 091f6095b4 fix(build): never prune a re-extracted source in build_merge (#1796)
build_merge already drops a re-extracted file's stale base nodes before merging
(replace-per-source), so on current code an EDITED file passed only in
new_chunks is handled correctly. But the prune step still removed every node
whose source_file was in prune_sources, with no guard for re-extracted files —
so a caller following the old edit-workflow (pass the changed file in BOTH
new_chunks and prune_sources) had its freshly-built nodes deleted after the
merge, silently losing a concept whose label survived the edit.

Exclude new_sources (files present in new_chunks) from prune_set: a re-extracted
file is being replaced, never deleted, so "replace" wins over a contradictory
"delete" of the same source. Genuine deletions (in prune_sources but not
new_chunks) still prune. Regression tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:22:25 +01:00
safishamsi 0d0e5780c8 test(cli): graph.json node ids are portable across checkout paths (#1789)
The absolute-path-in-node-ids leak reported on 0.8.19 is already fixed on v8:
detect() returns paths relative to the scan root, so the CLI-produced graph.json
uses relative structural node ids (portable, no username/home leak). Lock it with
a regression test that extracts the same corpus from two different absolute
checkout dirs and asserts identical, leak-free node ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:31:10 +01:00
CJNA b688a26f71 fix(path): prefer full-token label matches when resolving path endpoints (#1785)
`graphify path` committed each endpoint to _score_nodes()[0]. The full-query
bonus tier only fires when the query equals/prefixes a label, so a query that is
a token subset of the intended label ("Reject-everything judge" vs "Degenerate
Reject-Everything Judge") got no bonus and a node prefix-matching one rare token
("Rejection Summary") could out-score it on IDF alone — anchoring the path on an
unrelated, often disconnected node and returning a false "No path found".

_pick_scored_endpoint() scans the score-ordered list and takes the first
candidate whose label contains EVERY query token, falling back to scored[0] when
none does — so when the head already full-matches (the common case) resolution
is unchanged. Wired into both the `path` CLI and the MCP _tool_shortest_path.
The close-runner-up ambiguity warning now fires only when the picked endpoint is
the raw score head (a full-token override was chosen on coverage, not score, so
the head's margin is irrelevant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:56:38 +01:00
balloon72 15a86536c4 fix(analyze): exclude rationale nodes from suggested-question gap count (#1768)
suggest_questions()'s "isolated/weakly-connected nodes" filter was missing the
`file_type != "rationale"` exclusion that report.py's Knowledge Gaps section
already applies, so the same GRAPH_REPORT.md reported two different counts for
the same concept (757 vs 245 on a real graph) — an internal inconsistency that
made a healthy graph look like a documentation problem. Add the same filter so
both computations agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:52:12 +01:00
balloon72 a646d66a67 fix(bash): link executed scripts across files (#1756)
extract_bash only created a cross-file edge for `source x.sh` / `. x.sh`. The
two most common ways one script runs another — `bash x.sh` and `./x.sh` —
produced no edge, so in any repo where scripts invoke each other by execution
the call topology was missing (each script left an isolated file+entry pair).

Emit a `calls` edge (context `script_invocation`) from the caller's entry (or
enclosing function) to the invoked script's entry node, for script-runner
commands (bash/sh/zsh/ksh/dash <path>) and bare `./x.sh`, but only when the
target resolves to a real .sh file on disk — so no phantom edges to missing or
function-shadowed names. Verified end-to-end: the edges land on real target
nodes (no dangling drop at build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:48:10 +01:00
krishnateja7 5777fec8c1 fix(extract): route .rake files to the Ruby extractor + resolution (#1784)
.rake files are plain Ruby (Rake's task DSL is ordinary method calls), but the
extension was gated out everywhere, so rake tasks were classified as
unsupported, skipped, and their calls invisible. Add `.rake` to all seven `.rb`
gates the reporter mapped:
  - detect.CODE_EXTENSIONS (classification)
  - extract._DISPATCH (extractor dispatch)
  - extract._LANG_FAMILY_BY_EXT-adjacent language-name map (.rake -> ruby)
  - the ruby_member_calls LanguageResolver suffix set
  - both `.rb`-suffix filters in ruby_resolution.py (raw-call gather + class-def index)
  - analyze language-stats map
  - build repo-tag map

The extractor already parsed the content; this is purely extension routing.
Regression test: a `.rake` task's `Widget.tally` resolves cross-file to the
`.rb` definition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:31:47 +01:00
safishamsi df74ab4481 test(csharp): chained call off new X(...) resolves (#1770)
The reported bug — a method chained directly onto a `new X(...)` expression
(no intermediate variable) producing no calls edge — is already fixed on v8:
`new Merger(ctx).Combine(...)` emits calls -> Merger.Combine. Add a regression
test so the fluent new-expression receiver stays covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:57:16 +01:00
EmilNyg 3c3b6554e7 fix(extract): rewire cross-module function references to their definition (#1781)
_rewire_unique_stub_nodes gated merge targets through _is_type_like_definition,
which rejects any label ending in `)`. So a function referenced from another
module (passed by name, e.g. FastAPI's Depends(get_db)) left its reference edge
dangling on a sourceless name-only stub while the real def had zero incoming
edges — "who references this function" returned nothing. Class/type symbols were
fine; only functions/methods suffered.

Top-level function defs (label `name()`, not `.name()` methods or `Class.m()`
qualifiers) are now eligible rewire targets, but only when:
  - the label key matches exactly one such function corpus-wide (existing
    unique-candidate guard — two same-named functions stay unresolved), AND
  - the candidate shares a language family with the stub's referrers, so a
    Python `get_db` reference can't bind to a unique Go `get_db()` (#1718/#1749
    interop guard), AND
  - the stub is not used as a supertype (inherits/implements/extends) — you
    don't inherit from a function.

Types are unchanged. Regression tests: cross-module function ref binds to def;
cross-language, ambiguous, and supertype cases all correctly left unresolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:44:31 +01:00
safishamsi ce5af6fbc5 test: guard to_html/sanitize_label against null source_file/label (#1775)
The TypeError reported on 0.1.14 is already fixed on v8: sanitize_label coerces
None ('if text is None: return ""') and the source_file call site guards with
str(data.get("source_file") or ""). Add regression tests (unit + to_html
integration with null label/source_file) so it can't silently regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:24:25 +01:00
safishamsi 4c075f90ab docs(readme): unlink YC S26 badge until the public company page is live
The badge's href pointed at www.ycombinator.com/companies/graphify, which 404s
— the public S26 company page isn't published yet. Show the badge without a
link rather than ship a dead click; re-add the href once YC publishes the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:52:56 +01:00
rithyKabir 35665a76ba fix(pg_introspect): read FKs from pg_constraint so read-only roles get references edges (#1746)
The live-introspection FK query joined information_schema.referential_
constraints, which Postgres only exposes for constraints where the current user
has WRITE access to the referencing table. A read-only introspection role
therefore got zero FK rows — while tables/views/routines still appeared (SELECT
is enough for those views) — so the graph silently lost every `references`
edge, contradicting the documented FK-mapping behavior.

Switch to pg_catalog.pg_constraint (world-readable, not privilege-filtered),
keyed by constraint oid rather than name — which also fixes a latent bug where
same-named constraints on sibling tables could cross-match in the old
name-based key_column_usage joins. Composite-FK column order is preserved with
UNNEST(conkey/confkey) WITH ORDINALITY. Mock test asserts the query targets
pg_constraint and not the privilege-filtered view, plus composite-FK ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.12
2026-07-10 11:12:28 +01:00
oleksii-tumanov d68bf2819c fix(extract): create json_config reference target nodes (#1764)
extract_json emitted `imports` edges for package.json dependencies and
`extends`/`$ref` edges for tsconfig.json to target ids (`_make_id("ref", ...)`
/ `_make_id(key)`) that it never created as nodes. build_from_json drops edges
to unknown node ids silently (that case is filtered out of real_errors), so
dependency and extends structure vanished from the graph on two of the most
common files in any JS/TS repo, surfaced only by diagnose_extraction after the
fact.

The extractor now adds the referenced target as a `concept` node (external ref,
not a corpus file) before emitting each edge, so the edges survive build.
Regression test asserts no dangling endpoints, the concept nodes exist, and the
import/extends edges land on real targets with no self-loops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:53:58 +01:00
oleksii-tumanov 51b15a9ff1 fix(update): preserve surviving hyperedges on AST-only rebuild (#1755)
`graphify update` (the watch._rebuild_code / _reconcile_existing_graph path)
evicted every hyperedge whose source_file is in the corpus, because on a full
update every corpus file counts as "rebuilt" and hyperedge eviction reused the
node/edge eviction set. But the AST pass never emits hyperedges, so nothing
replaced them — doc-sourced hyperedges (what semantic extraction produces) were
permanently lost on the first update after a full build, even on a no-op run.

Split out a hyperedge_evicted_source_identities set scoped to genuinely deleted
(and symlink-target-outside) sources only, not merely-rebuilt ones. Replacement-
by-id (new_hyperedge_ids) and dangling-member cleanup are unchanged, so a real
semantic re-extraction still replaces its own hyperedges and orphaned ones are
still dropped. Parametrized regression test (full + incremental doc update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:48:57 +01:00
oleksii-tumanov dae602ccd1 fix(extract): resolve Java member calls by receiver type (#1696)
Java member calls (`gw.charge()`) resolved by bare method name, so a call bound
to any same-named method in the corpus — e.g. `PaymentGateway.charge` and
`AuditLog.charge` were indistinguishable, producing phantom cross-class edges
and a false god node.

The extractor now preserves the receiver and its static type, and
_resolve_java_member_calls binds the call against the receiver's declared type:
explicit-type receivers and `this` are exact; current-class fields, method
parameters, and explicitly-typed locals resolve via a method-scoped type table;
a missing/ambiguous/inherited/chained receiver is skipped rather than falling
back to a bare name match (same single-owner god-node guard as the C#/Swift/Ruby
resolvers). Fully-qualified and nested-type receivers are deferred since they
need package- and nesting-aware type identity.

Verified: `gw.ping()/gw.charge()` (gw: PaymentGateway) bind to PaymentGateway,
the three charge() calls dedup to one edge, and no edge targets the same-named
AuditLog methods. Applies cleanly to the post-#1737 layout (extract.py +
extractors/engine.py). 13 new tests; full suite 3135 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:11:36 +01:00
bbqboogiedwonsen c5db9ffb06 fix(cli): keep outputs/cache with --out / --graph, not the corpus or CWD (#1747)
Case 1 — `extract <corpus> --out <dir>`: the graph went to <dir> (cache_root is
already passed to the AST extractor), but detect()'s word-count/stat-index cache
uses the scan root, so a stray graphify-out/cache/ was created inside the corpus
(and left behind even when the run aborted at the no-LLM-key gate). Thread an
optional cache_root through detect() -> cached_word_count() -> _ensure_stat_index()
and pass out_root from the extract CLI, so the stat index lives under --out. Entry
keys are absolute paths, so relocating the index file is safe.

Case 2 — `cluster-only --graph <elsewhere>/graphify-out/graph.json`: outputs
(GRAPH_REPORT.md, re-clustered graph.json, labels, analysis, html) were written to
the CWD's graphify-out/, ignoring where --graph lives. They now write beside the
input graph when it sits in a graphify-out/ dir (another project/tenant's output),
while still falling back to the CWD for an arbitrary archived backup/graph.json —
the restore-into-place workflow #934 pins.

Regression tests for both cases; #934 still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:06:26 +01:00
philberndt bdc6e531a6 fix(build): don't bind imports/references across a language boundary (#1749)
The extraction spec forbids cross-language `calls` edges, and build already
dropped cross-language INFERRED `calls`. But `imports`/`references` had no such
guard: an unresolved Python `import time` resolved by bare stem (the #1504
old-stem alias) onto a `src/time.ts` file node, welding a polyglot repo's two
language halves together. In the reporter's repo three such edges were the only
bridge between 2409 Python and 1403 TS nodes, so every backend<->frontend
shortest path routed through time.ts, inflating its betweenness ~90x and making
it the #1 reported god node.

Hoist the interop-family map to a module constant and extend the edge-loop
guard to `imports`/`imports_from`/`references`. For these relations the edge is
dropped only when BOTH endpoints are known code languages of different families,
so a config/manifest -> code reference (unknown ext) is never mistaken for a
phantom. `calls` behavior is unchanged (still INFERRED-only, still drops when
either family differs). Regression tests: py->ts import dropped, ts->ts import
kept, config->code reference kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 00:20:55 +01:00
rithyKabir 8b7ffc5b15 fix(extract): warn when files skip extraction for a missing optional dep (#1745)
When the [sql] extra is absent, .sql files are counted as code and scanned but
extract_sql returns an error result and zero nodes — and the graph builds
"successfully" with the entire SQL corpus missing. Neither existing warning
catches it: #1666's zero-node warning skips results carrying an "error", and
#1689 only covers files with NO extractor at all (.sql HAS a dispatch entry).

extract() now scans per-file results for a "not installed" error, groups the
affected files by extension, and prints a warning naming the extra that
restores the language (pip install "graphifyy[sql]"), via a small
_EXTRA_FOR_EXTENSION map (sql, terraform, dm). The map is only consulted after
an extractor actually reports the dependency missing, so it can't mislabel a
language that has a working fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:43:47 +01:00
erasmust-dotcom 29a6954f91 fix(build): make ghost-node merge deterministic across process runs (#1753)
build_from_json's ghost-node merge iterated set(G.nodes()), so when two nodes
shared a (basename, label) key the "canonical" survivor was chosen by CPython's
per-process string-hash order — rebuilding the same extraction JSON in a fresh
process could pick a different survivor, silently changing which node id
represents a concept. That breaks any workflow persisting ids across a rebuild;
concretely it broke the cluster->relabel step (community membership referenced
an id that the second build merged away -> KeyError in report generation).

Two changes:
- Pass 1 and Pass 2 now iterate sorted(node_set), not set(node_set), the same
  deterministic-order fix the edge loop below already uses on purpose.
- The #1257 ambiguity guard is extended to the case it did not cover: two
  NON-AST nodes sharing a key but from DIFFERENT source files are distinct
  concepts, not an AST ghost/canonical twin, so the key is marked ambiguous and
  both survive rather than one arbitrarily merging away (data loss). A genuine
  same-file duplicate (identical source_file) is not flagged and still
  collapses to one node.

Reported with a precise root-cause, minimal repro, and real-world impact by
@erasmust-dotcom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:32:54 +01:00
Cekaru 9c27a52448 test(extractors): guard facade + registry identity for the per-language split (#1721)
The extract_terraform move #1721 proposed already landed on v8 via the #1737
decomposition (extractors/terraform.py exists, extract.py re-exports it, and
extractors/LANGUAGE_EXTRACTORS registers it), so the code move is a no-op now.
But the regression test @Cekaru added with it had no equivalent on v8. Salvage
and generalize it: sweep every LANGUAGE_EXTRACTORS entry and assert graphify.
extract re-exports the SAME object (facade identity) and the registry maps to
it (registry identity), plus the concrete terraform anchor from the PR. This
institutionalizes the re-export-identity guarantee the split relies on, so a
future move that forgets a facade re-export fails loudly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:22:36 +01:00
safishamsi ee1ff3d691 fix(extract): resolve Java cross-module type references by import (#1744)
Two classes with the same simple name in different Maven modules
(FinancialEntryValidator in payment/ and core/) already survive as distinct
path-scoped nodes on v8 -- the "node silently disappears" report from 0.9.9 is
fixed. But a cross-module field/type `references` edge was still left dangling
on a sourceless phantom stub: _resolve_java_type_references (#1318) re-pointed
implements/inherits/extends/imports edges to the real definition using the
importing file's `import` statement, but its REPOINT_RELATIONS omitted
`references`, so bare-name resolution's shadow stub survived for field types.
A query about the referenced class could then miss it.

Add `references` to the Java resolver's REPOINT_RELATIONS. The C# sibling
already covers references; this brings Java to parity. The reference now
resolves to the imported package's class (falling back to same-package), and
the orphaned phantom is dropped. Regression test covers the ambiguous
two-module case: both reals present, no phantom, reference lands on the
imported class.

Reported with a precise root-cause and repro by @aviciot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:44:50 +01:00
safishamsi d2d1f68ff9 fix: surface silently-skipped dirs in enumeration + dedup Pascal edges
Two correctness fixes found while analysing the reported 'graphify update
occasionally writes a partial graph.json' bug.

Enumeration (P0): detect()'s os.walk had no onerror handler, so any os.scandir
failure -- a transient PermissionError, or a directory created/deleted mid-walk
by concurrent writes (e.g. benchmarking racing the scan) -- was silently
swallowed and that entire subtree dropped out of the file list with no log, no
error. Downstream that becomes a silently partial graph.json. The walk now
records each skipped directory (surfaced as walk_errors in detect()'s result)
and warns to stderr, while still enumerating the rest of the tree. This stays
visible even when a --force/GRAPHIFY_FORCE rebuild bypasses the shrink guards.
Relatedly, to_json's #479 anti-shrink guard was fail-OPEN: a non-empty but
unreadable existing graph.json (corrupt or mid-write) proceeded with the
overwrite. It now fails SAFE -- refuse and point at force=True -- while an
empty/whitespace existing file (no nodes to lose) still proceeds. The size-cap
check keeps running before any read, so an oversized existing file is not
loaded into memory.

Pascal edges (P1): a class method declared in the interface section and defined
in the implementation section each emitted a "method" edge to the same node id,
and the edge helpers (unlike the node helpers) did not dedup, so ~half of a
Pascal/Delphi graph's method edges were doubled -- inflating degree/centrality
and tripping the #1739 cross-file resolver's single-owner god-node guard. Both
extractors now dedup edges on (source, target, relation).

Adds regression tests for all three behaviours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.11
2026-07-09 01:06:02 +01:00
richtext d89efbf9ef fix(extract): scope Pascal/Delphi call resolution + resolve inherited calls across files (#1739)
Both Pascal extractors resolved every call via a single file-wide
{method_name: node_id} dict, so two unrelated classes declaring a same-named
method (property accessors, generated COM/TLB wrapper classes) collapsed onto
whichever declaration was inserted last, producing wrong cross-class `calls`
edges. Resolution is now scoped: own class -> ancestor chain (inherits) ->
file-level free function -> unambiguous file-wide match; ambiguous at every
level emits no edge rather than guessing (same god-node guard as the Ruby
resolver).

Adds graphify/pascal_resolution.py, a corpus-wide post-extraction resolver
(registered via resolver_registry) that walks the inherits chain across file
boundaries, so a call from a manual descendant to a method it inherits from a
base class in a separate unit (the generated-base/manual-descendant split)
resolves. Also stops both extractors from emitting a duplicate base-class stub
carrying the referencing file's source_file, which collided with the real node
under cross-file id disambiguation. cache.py gives the new raw_calls bucket the
same portable-path treatment as nodes/edges so it round-trips.

Re-applied to the post-#1737 module layout (extractor hunks land in
graphify/extractors/pascal.py; registration stays in extract.py). Added one
adaptation the original PR predated: the cross-file resolver's god-node guard
now counts DISTINCT method nids, because the tree-sitter extractor emits a
method edge for both the interface declaration and the implementation, so the
same method_nid arrives twice -- without deduping, every inherited call looked
ambiguous and resolved to nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:32:21 +01:00
fkhawajagh 2f9deae8d5 fix(serve): scale per-term exact/prefix score tiers by squared term coverage (#1602)
In a multi-term query, a lone generic term that exactly equals a short leaf
label (`home` vs a `home()` action, `list` vs a `list()` function) received
the full exact-tier bonus and outscored nodes matching several of the query's
terms by ~10x, so `_pick_seeds`' gap cutoff discarded the genuinely relevant
candidates and `path`-style consumers of `scored[0]` had no backstop.

`_score_nodes` now scales the per-term exact/prefix tier contributions by the
squared fraction of query terms the node's label matches
(`tiered * (matched / n_terms) ** 2`). Squared because the exact tier is 10x
the prefix tier; label hits only (source-path hits score but don't count as
coverage, so a colliding leaf in the target's own directory can't win its
exact tier back via path fragments); query tokens deduped order-preserving so
a repeated word can't quadratically restore the collision. Single-term and
full-coverage queries are unchanged (coverage == 1), keeping identifier
exact-match dominance. Guarded against zero-term division.

Dropped the unrelated README badge changes from the PR; kept the scoring fix
and its two regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:56:28 +01:00
safishamsi e2d47535c3 Changelog: fold #1700 (Kotlin enums) and #1735 (uv probe) into 0.9.11
Both fixes landed after the initial 0.9.11 section was written but before
0.9.11 was published, so they belong in that release's notes rather than a
new version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:41:01 +01:00
mohammedMsgm ea8b804b7f Fix uv interpreter detection falling back to graphify-less python (#1735)
Step 1's POSIX interpreter probe ran `uv tool run graphifyy python -c ...`,
but the graphifyy package exposes its executable as `graphify`, so uv treated
`python` as a missing `graphifyy` command. The probe failed, and `2>/dev/null`
swallowed uv's "use --from" hint, leaving PYTHON on a system interpreter that
does not have graphify installed. The probe now runs
`uv tool run --from graphifyy python -c ...`.

Applied to the skillgen source fragments (shell/posix.md and the aider/devin
monoliths) and regenerated all skill*.md; added a sanctioned monolith-diff
predicate and re-blessed the expected/ snapshots. The PowerShell path was
already correct (it probes the venv python.exe directly).

Reported and originally patched by @mohammedMsgm in #1736.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:08:05 +01:00
ivanzhilovich 00dd978d17 fix(extract): emit Kotlin enum entries as nodes with case_of edges (#1700 Kotlin half, #1738)
Kotlin enum entries weren't extracted: the walker never descended into the enum
body (`enum_class_body` wasn't in _KOTLIN_CONFIG.body_fallback_child_types, so
_find_body returned None). Add `enum_class_body` to the fallback body types and a
`_kotlin_extra_walk` (dispatched for tree_sitter_kotlin, mirroring the Java/Swift
handling) that emits each enum_entry as a node with a `case_of` edge to the enum.

Re-applied from PR #1738 (@ivanzhl) onto the post-#1737 module layout: the walk
engine now lives in graphify/extractors/engine.py while _KOTLIN_CONFIG stays in
extract.py. Closes the Kotlin half of #1700 (Java was #1719).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:49:01 +01:00
safishamsi 87b330c44f Release 0.9.11
Decompose extract.py / __main__.py / export.py into focused modules (#1737,
verbatim, no behavior change). Plus fixes since 0.9.10: merge-graphs distinct
repo tags (#1729), uninstall cleans Claude .local files (#1731), and
extract --code-only for keyless code-only indexing of a mixed repo (#1734).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:21:41 +01:00
tpateeq 247b2ae603 refactor(main): move the command dispatch into graphify/cli.py
main() was a 2,760-line function that was almost entirely a 2,538-line
if/elif chain dispatching every non-install subcommand (query, path, explain,
diagnose, affected, reflect, save-result, extract, update, cluster-only, label,
build, global, merge-graphs, merge-driver, watch, tree, export, benchmark,
clone, prs, provider, hook*, check-update, and the `graphify <path>` redirect).

Move that chain, plus its 5 chain-only helpers (_StageTimer, _clone_repo,
_default_graph_path, _enforce_graph_size_cap_or_exit, _run_hook_guard) and 4
chain-only constants (_SEARCH_NUDGE, _READ_NUDGE, _HOOK_SOURCE_EXTS,
_GEMINI_NUDGE_TEXT), into graphify/cli.py as dispatch_command(cmd). main() now
does its setup (encoding, stale-skill check, version/help) then
`if dispatch_install_cli(cmd): return` else `dispatch_command(cmd)`.

The chain ends in its own unknown-command exit, so it's called as a statement —
no return-value plumbing. The one cli->__main__ coupling, the path-redirect's
recursive main() call, is handled by a lazy import (_reenter_main), so import
direction stays __main__ -> cli with no cycle. __main__ re-exports every moved
symbol, so importers/tests are unchanged.

__main__.py 3,388 -> 662 LOC (5,368 at branch start, -88%). Verified end-to-end:
version/help/unknown-command, the `graphify <path>` redirect (rebuilds a graph),
and query/path/explain. Full suite unchanged: 3036 passed, 29 skipped; skillgen OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:13:04 +05:30
tpateeq 563bb599fa refactor(extract): migrate pascal, objc, and julia extractors to extractors/
Now that the resolution passes and the tree-sitter engine live in their own
modules, these three bespoke extractors are self-contained and move cleanly
(verbatim), pulling only the specific engine/resolution/base helpers they need:

- extractors/pascal.py: extract_pascal + regex helpers + _PAS_* constants
- extractors/objc.py: extract_objc + Objective-C member-call resolution
- extractors/julia.py: extract_julia

Re-exported from extract.py and registered in extractors/__init__.py (27 langs).
The remaining in-file extractors (js/ts config family + vue/svelte/astro/xaml)
stay because they share _JS_CONFIG/_TS_CONFIG and the xaml dispatch caches with
extract_js/extract_csharp and the dispatcher.

extract.py 5,947 -> 4,740 LOC (17,054 at branch start, -72%). Full suite
unchanged: 3036 passed, 29 skipped; skillgen --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:03:52 +05:30
tpateeq 5015212fc1 refactor(extract): extract the _extract_generic tree-sitter engine into extractors/engine.py
Moves the config-driven extraction engine — _extract_generic (~2.2k LOC) and its
67-function closure of per-language tree-sitter walkers/type-collectors
(_js_*, _ts_*, _python_*, _java_*, _csharp_*, _cpp_*, _swift_*, _kotlin_*,
_php_*, _ruby_*, _scala_*) plus 10 engine-private constants (language builtin/type
tables) — into graphify/extractors/engine.py, verbatim.

AST analysis proved the engine closure references neither `extract` nor
`_DISPATCH`, so the dispatcher facade (extract, _DISPATCH, _get_extractor,
collect_files, the thin config-driven extractor wrappers) stays in extract.py and
imports the engine. Import direction: extract.py -> engine -> {resolution, models,
base}; no shared constant crosses the boundary (verified), and engine pulls a
single name (_resolve_js_import_target) from resolution, so there is no cycle.

extract.py re-exports every moved symbol, so importers and tests reaching into
engine internals are unchanged.

extract.py 10,270 -> 5,947 LOC (17,054 at branch start, -65%). Full suite
unchanged: 3036 passed, 29 skipped; skillgen --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:01:27 +05:30
tpateeq 9b4ffdad4c build: package the new graphify.exporters subpackage
setuptools uses an explicit package list, so the new graphify/exporters/ package
(base, html, graphdb — split out of export.py) was absent from the built wheel,
which would break `import graphify.exporters.*` for installed users. Add it
alongside graphify and graphify.extractors. No version change.

Verified: fresh-venv install of the rebuilt wheel imports every new module,
resolves all backward-compat re-exports, builds a multi-language graph, and runs
query/path/explain and install/uninstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:46:02 +05:30
tpateeq 97a289c531 refactor(main): move install/platform CLI dispatch into install.dispatch_install_cli
main() carried a 256-line if/elif chain routing every install-family command
(install, uninstall, and the 22 per-platform targets: claude, codebuddy, gemini,
cursor, vscode, copilot, kilo, kiro, devin, pi, amp, agents/skills,
aider/codex/opencode/claw/droid/trae/trae-cn/hermes, antigravity). That block
only ever reads sys.argv and calls functions that already live in install.py, so
move it there verbatim as a guarded dispatch_install_cli(cmd) -> bool; main() now
does `if dispatch_install_cli(cmd): return`.

The command set is derived from the block's own conditions (so none is missed),
and the branch's early `return` becomes `return True` so the help path still
short-circuits. Verified end-to-end: `graphify claude install/uninstall`, unknown
command, and the install test suite all behave identically.

__main__.py 3,641 -> 3,388 LOC (5,368 at branch start, -37%). Full suite
unchanged: 3036 passed, 29 skipped; skillgen --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:42:12 +05:30
tpateeq 4561da2088 refactor(extract): extract symbol-resolution subsystem into extractors/
Moves the cross-file symbol-resolution / import-resolution / decl-def-merge
passes — the largest remaining self-contained subsystem in extract.py — into
two new modules, verbatim:

- extractors/models.py: the shared data types (LanguageConfig, the
  _Symbol*Fact / _SymbolResolutionFacts dataclasses) and two shared caches
  (_WORKSPACE_PACKAGE_CACHE, _JS_CACHE_BYPASS_SUFFIXES). Homed here so both
  extract.py and resolution.py import them without a cycle; the mutable
  workspace cache keeps a single shared object identity (only ever .clear()'d
  in place), verified in the smoke test.
- extractors/resolution.py: 60 resolution functions (_resolve_*, _collect_*,
  _apply_symbol_resolution_facts, _disambiguate_colliding_node_ids,
  _merge_decl_def_classes, the JS/TS/Python import walkers, tsconfig/workspace
  resolution) and their 12 private constants.

extract.py re-exports every moved name, so importers (__main__, watch, tests
that reach into _JS_RESOLVE_EXTS / _resolve_cross_file_imports / the fact
dataclasses) are unchanged. Import direction is strictly extract.py ->
resolution -> {models, base}; AST analysis confirmed no moved non-entry symbol
is referenced from outside its new module.

extract.py 12,632 -> 10,270 LOC (17,054 at the start of this branch, -40%).
Full suite unchanged: 3036 passed, 29 skipped; skillgen --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:36:20 +05:30
tpateeq e8a22893c9 refactor(extract): migrate verilog and markdown extractors to extractors/
Continues the extract.py -> extractors/ split. Both are self-contained bespoke
extractors (verified: closure-private, byte-identical verbatim move, facade
identity + _DISPATCH resolution intact):

- extractors/verilog.py: extract_verilog + SystemVerilog helpers (_sv_*,
  _augment_systemverilog_semantics) and _SV_* constants.
- extractors/markdown.py: extract_markdown + _resolve_markdown_link and _MD_* regexes.

extract.py 13,121 -> 12,632 LOC. Full suite unchanged: 3036 passed, 29 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:31:17 +05:30
tpateeq 4859f9883c refactor(export): split HTML and graph-DB exporters into graphify/exporters/
export.py mixed nine independent export targets in one 1,671-LOC module. Move
the two largest self-contained ones into a new graphify/exporters/ package,
verbatim, re-exported from export.py so every importer (from graphify.export
import to_html / push_to_neo4j) is unchanged:

- exporters/html.py: to_html + its private helpers (_html_script, _html_styles,
  _hyperedge_script, _viz_node_limit) and MAX_NODES_FOR_VIZ.
- exporters/graphdb.py: push_to_neo4j, push_to_falkordb.
- exporters/base.py: COMMUNITY_COLORS (shared by the HTML/SVG/Obsidian
  exporters), homed here so per-format modules and export.py both import it
  without a cycle.

Verified: AST closure-privacy analysis, facade object identity, ruff clean.
export.py drops 1,671 -> 962 LOC. Full suite unchanged: 3036 passed, 29 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:20:49 +05:30
tpateeq 9fc30e680d refactor(extract): migrate 18 bespoke language extractors to extractors/ package
Continues the graphify/extract.py -> graphify/extractors/ split (MIGRATION.md,
upstream #1212), which already moved blade/zig/elixir/razor. Moves the
independent bespoke extractors whose closures are fully private (no shared
_extract_generic core, no shared mutable caches), verbatim, following the
documented invariants:

  dart, rust, go, powershell (+psd1 manifest), fortran, sql,
  dm (dm/dmm/dmi/dmf), bash, apex, terraform, sln,
  pascal_forms (delphi .dfm + lazarus .lfm), json_config

Each language's private helper funcs and constants move with it; only the
`extract_<lang>` entry points (plus fortran's _cpp_preprocess, which has a
direct unit test) are re-exported from extract.py's facade block, so every
existing importer (__main__.py, watch.py, tests) is unchanged and object
identity is preserved. Registry (extractors/__init__.py) grows 4 -> 22 langs.

Verified: AST closure-privacy analysis (no symbol referenced from outside its
moved set except via the facade); byte-identity of every moved span;
extract._DISPATCH still resolves every extension; ruff clean; skillgen --check
OK. extract.py drops 17,054 -> 13,121 LOC. Full suite unchanged: 3036 passed,
29 skipped (excluding env-only openai tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:11:37 +05:30
tpateeq cc2d3c13a6 refactor(main): extract install/uninstall subsystem into graphify/install.py
__main__.py was 5,368 LOC, more than half of it per-platform install/uninstall
machinery interleaved with the CLI dispatcher. Move that subsystem — 68
functions (all *_install/*_uninstall, _copy_skill_file, _platform_skill_destination,
_always_on, etc.) plus 21 module constants (_PLATFORM_CONFIG and the platform
skill/hook payload constants) — into a new graphify/install.py, extracted verbatim.

Behavior-preserving:
- install.py lives in the same package dir as __main__ so packaged-asset lookups
  via Path(__file__).parent ("always_on"/"skills") resolve unchanged.
- __main__ re-exports all 89 moved names, so `from graphify.__main__ import
  claude_install` (and every other import, incl. private helpers used by tests)
  keeps working, with object identity preserved.
- The install cluster was verified (AST) to have zero back-references into
  __main__, so no circular import; __main__ imports _PLATFORM_CONFIG et al. one-way.

__main__.py drops 5,368 -> 3,642 LOC. Full suite unchanged: 3036 passed, 29
skipped (excluding the env-only openai tests). ruff check clean; skillgen --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:20:48 +05:30
safishamsi 20bfdf60ac feat(extract): add --code-only to index code without an LLM key on a mixed repo (#1734)
`graphify extract` on a repo containing docs/papers/images hard-failed when no
LLM backend was configured — even for a user who only wants the code graph. The
only workaround was hand-building a .graphifyignore of everything non-code, which
is onerous (the "not code" set is far larger than the code set).

`--code-only` skips the semantic (doc/paper/image) pass entirely: it indexes the
code via pure local AST (no key required) and reports what it skipped ("skipping
N non-code file(s) ...") rather than silently dropping it. The no-key error on a
mixed repo now also points users at the flag. Code-only was always keyless; this
just makes a *mixed* repo usable without a key instead of failing outright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:07:31 +01:00
tpateeq 0d206cbe11 fix(uninstall): remove graphify hook/section from Claude local-only files (#1731)
`graphify uninstall` (and `graphify claude uninstall`) only looked at
`.claude/settings.json` and root `CLAUDE.md`. A user who relocates the
PreToolUse hook into `.claude/settings.local.json` and the `## graphify`
instructions into `CLAUDE.local.md` / `.claude/CLAUDE.local.md` - so they
are not committed to a shared repo - was left with both behind after
uninstall, which reported "nothing to do".

- `_uninstall_claude_hook` now strips the hook from both `settings.json`
  and `settings.local.json` (factored into `_strip_graphify_hook`).
- `claude_uninstall` now strips the `## graphify` section from `CLAUDE.md`,
  root `CLAUDE.local.md`, and `.claude/CLAUDE.local.md` (factored into
  `_strip_graphify_md_section`), cleaning every present file rather than the
  first, and always runs the hook cleanup.
- `_strip_graphify_md_section` reads the two newly-covered files defensively
  so a non-UTF-8 or otherwise unreadable file can't abort uninstall.

Unrelated local-only files (no graphify content) are left byte-for-byte
untouched. The `--local` install flag suggested in the issue is left out of
scope; this change makes uninstall symmetric with wherever the config lives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:38:35 +01:00
safishamsi 4be0f7b3bf fix(merge-graphs): give each input a distinct repo tag so same-stem nodes don't collapse (#1729)
merge-graphs prefixed each graph's node ids with `<repo>::` where repo was
`gp.parent.parent.name` — the graphify-out parent dir name. That tag is not
unique across inputs: `src/graphify-out` and `frontend/src/graphify-out` both
yield `src`, so a bare `app` node from a backend `src/app.js` and a frontend
`App.jsx` both became `src::app` and nx.compose silently merged them into one
node (one graph's label/source_file, both graphs' edges) — inventing false
cross-runtime `path` results with no warning.

New `distinct_repo_tags` guarantees a unique prefix per graph: colliding tags
are widened with their own parent dir (`frontend_src`), then an index suffix
backstops any residual duplicate. The handler prints a note when it disambiguates.
Verified end to end: the two `app` nodes now survive as `..._src::app` and
`frontend_src::app` instead of collapsing. Regression tests cover the merge case
and the tag uniquifier (pass-through, widening, and triple-collision fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:45:08 +01:00
safishamsi 5c6f7272cd test(extract): cover builtin static-call shape (Date.now()) for #1726
Adds a regression test for the Date.now()/static-call shape (credit PR #1727 /
@2loch-ness6, who independently found the same fix): a capitalized builtin
receiver must not bind cross-file to a same-spelled user const/class. The same
guard added in 67f4f83 already covers it (verified); this locks the static-call
shape in, while allowing the legitimate same-file const reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:09:40 +01:00
safishamsi 781d1cd8ec Release 0.9.10
Correctness batch since 0.9.9: TS/JS builtin-typed receiver no longer collapses
onto a same-named user symbol (#1726); no cross-language calls edges (#1718);
build_merge ambiguous-alias no longer merges unrelated files (#1713); base-class
stubs tagged with origin_file (#1707); Java enum constants as nodes (#1719);
rebuild recovers from a deleted hook cwd (#1703); per-chunk semantic-cache
checkpoint (#1715); SECURITY.md http-transport doc + GRAPHIFY_MAX_GRAPH_BYTES
tests (#1714, #1722). Nine merged PRs plus #1726.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.10
2026-07-08 11:39:39 +01:00
safishamsi 67f4f835f7 fix(extract): skip builtin-global receiver types in TS/JS member-call resolution (#1726)
`_resolve_typescript_member_calls` resolves a member call's receiver to a type
definition by casefolded label. For a builtin-typed receiver (`x: Date;
x.getTime()`), `_key("Date")` == "date" matched a same-named user `class DATE` /
`const DATE` in another file, binding the caller to it as a phantom
`references[call]` edge. In a real 3,368-file repo one module-local `const DATE`
accumulated 469 false cross-file edges (degree 472, betweenness 0.435) — a false
god node distorting path/god-node results.

Skip the resolution when the receiver type is an ECMAScript/Python builtin
global (Date, Promise, Map, ...), mirroring the guard the cross-file CALL
resolver already applies (#726). The guard is a strict no-op for genuine user
types, so legitimate constructor-injection member-call resolution (#1316) is
unaffected. Verified across new Date().method(), return-type, and var-decl-type
shapes: zero phantom edges, user DATE degree back to 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:25:28 +01:00
mallyskies 96db75c8c2 fix(build): recognize a salted file node as its own alias claimant
Follow-up to the previous commit on this branch. That fix's ambiguity check
missed a case: it detects "is this node the file itself" by checking
whether the node's id starts with the file's plain new_stem, but a
same-directory .h/.cpp pair that collides on their shared pre-extension id
gets salted apart by _disambiguate_colliding_node_ids into ids like
"tools_aolserver_utility_h_tools_aolserver_utility" -- no longer a clean
new_stem prefix.

That salted header silently failed to compute an empty suffix, so it never
entered the bare "utility" alias race at all, leaving an unrelated
wwwapi.masque.com/pages/utility.php as the lone (wrong) "unambiguous"
winner -- reproduced exactly against the real depot's
Tools/aolserver/utility.h and .cpp.

Detect "this node IS the file" by label instead: every file node's label is
its own basename regardless of what its id looks like after salting. That
keeps a salted file node in the alias competition, so the real collision
between the C header and the PHP file is correctly caught as ambiguous.
2026-07-08 01:24:25 +01:00
mallyskies 3b2ca2e55f fix(build): don't let an ambiguous legacy-stem alias silently merge files
build_from_json's "pre-migration alias index" (#1504) registers each real
file's OLD-style bare-stem id (extension dropped) as an alias so a stale
cached fragment referencing that old form still resolves after an id-scheme
migration. It never checked for collisions: two unrelated real files easily
compute the same bare alias (e.g. "ping.h" and "ping.php" in different
directories both alias to "ping"), and dict.setdefault let whichever file
happened to iterate first in a Python set win, arbitrarily.

A dangling edge with a bare, deliberately-unscoped fallback target (e.g. the
C/C++ extractor's last-resort id for an #include it couldn't resolve to a
real path) would ride that alias onto whatever unrelated file won the
collision -- silently wiring, say, a C++ server file to an unrelated PHP
script, purely because both files happen to share a common basename
somewhere in the corpus.

Now every candidate for an alias is collected before any of them are
committed to norm_to_id, and the alias is only trusted when exactly one real
file claims it. Ambiguous aliases are dropped entirely, so the dangling edge
correctly stays dangling (and gets discarded) instead of merging two
unrelated files -- same "don't guess through ambiguity" principle already
applied to stub-node disambiguation and cross-file call resolution
elsewhere in this codebase.

Found via graphify-practice round 2: a `path` query between two unrelated
symbols routed through exactly this kind of bogus edge, traced back to
Dev/Poker/TDServer/server.cpp's unresolved `#include "ping.h"` /
`#include "utility.h"` landing on unrelated www.masque.com PHP scripts of
the same bare name.
2026-07-08 01:24:25 +01:00
mallyskies 9557bf6733 fix(extract): tag inline base-class stubs with origin_file
ensure_named_node() tags the sourceless stub it creates for an
unresolved reference with origin_file, so _disambiguate_colliding_node_ids
can tell one file's unresolved reference apart from another's instead of
merging every file's same-named reference onto one shared bare id
(which can then collide with an unrelated same-named real definition
anywhere else in the corpus, since ids are case-normalized and global).

Five call sites duplicated that stub-creation logic inline instead of
calling ensure_named_node() -- Ruby's `Class.new(Super)` and
`class Foo < Base` inheritance, Python inheritance, Kotlin delegation
-specifier inheritance/conformance, and C++ base_class_clause
inheritance -- and none of them were updated when origin_file was added,
so all five still produce the un-disambiguated bare-id stub the fix was
meant to eliminate.

Four of the five sit directly inside _extract_generic, where
ensure_named_node() is already in scope as a closure, so they're
switched to call it directly. The fifth (Ruby's `Class.new(Super)`) is
handled by a separate helper, _ruby_extra_walk(), which doesn't have
that closure in scope; that one gets the same origin_file tag added
directly to its own inline stub dict, matching what ensure_named_node()
already does, without changing the helper's signature.

(A sixth occurrence of the same inline pattern exists in extract_apex(),
a fully separate regex-based extractor with no ensure_named_node()
equivalent of its own -- left out of this fix, which is scoped to the
shared _extract_generic path and its one directly-affiliated helper.)

Added a regression test: two different C++ files each inheriting from
the same undefined base class must produce two distinct stub nodes, not
one shared one. Fails on main (one shared 'base' id for both files),
passes with this fix.
2026-07-08 01:21:55 +01:00
edinaldoof bf7fa50f38 fix(extract): never bind a cross-file call to a definition in another language family
The cross-file call resolver matches raw-call callees against a repo-wide
label index with no language check. In a repo that mixes a web app with a
native Android app, a TSX callback passed by name (register(refreshHeading))
resolved to a same-named Kotlin method and shipped as an INFERRED
indirect_call edge — a phantom the extraction spec itself forbids ('calls
edges MUST stay within one language'). Direct calls from non-JS/TS callers
had the same hole with no gate at all: a bare Python call bound to the lone
same-named Kotlin fun. Found on a production Next.js + Android codebase,
where the phantom edge also inflated the Kotlin node's betweenness enough
to surface it as a top suggested question in GRAPH_REPORT.md.

Resolution candidates are now filtered by language interop family before
the single-candidate/import-evidence logic runs. Families are grouped by
REAL interop so legitimate cross-language resolution keeps working:
Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA share
headers (Swift bridges to ObjC), and JS/TS variants plus Vue/Svelte/Astro
SFCs compile into one module graph. Candidates whose family is unknown
(no source_file, non-code nodes) are never filtered, preserving the
previous permissive behavior, and callers with an unmapped extension skip
the guard entirely.
2026-07-08 01:16:58 +01:00
safishamsi 27b523e769 test(cache): cover save_semantic_cache merge_existing union + default overwrite (#1715)
The per-chunk checkpoint feature added merge_existing without test coverage.
Add tests asserting merge_existing=True unions a file's slices across chunks and
the default still overwrites (the authoritative final write in extract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:14:12 +01:00