905 Commits

Author SHA1 Message Date
safishamsi 0c551ace36 release: 0.9.3
Cross-file member-call resolution for C++/ObjC (#1547/#1556) and
namespace-aware C# type resolution (#1562), the work-memory overlay
(#1441), test-mock call-graph fix (#1553), hyperedge member-key aliases
(#1561), plus the TS/JS/ObjC resolution fixes (#1316/#1544/#1552/#1475).
See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.3
2026-06-30 20:00:00 +01:00
TheFedaikin b9d8067d0a feat(csharp): namespace-aware cross-file type resolution (#1562)
Extends the C# type-reference resolver (#1466) to be namespace-aware,
advancing the #1318 shadow-node umbrella for C#:

- The namespace is folded into the C# node id (_make_id(stem, namespace,
  name)), so two same-named types in different namespaces in one file no
  longer collapse — replacing #1466's detect-and-skip workaround for
  multi-namespace files.
- Lexical per-block using-scope: a `using` applies only where it is in
  scope (file-level, or the enclosing namespace block via a scope chain),
  so sibling namespace blocks no longer share each other's usings.
- Qualified references (`Namespace.Type`) resolve via in-scope aliases
  (`using Q = X.Y`) then exact known namespaces; generics are stripped.

Preserves (and tightens) the refuse-rather-than-guess discipline: a bare
reference resolves only when exactly one in-scope namespace provides the
type; an ambiguous reference (e.g. `using A; using B;` both defining
`Widget`) resolves to nothing rather than fanning out. Verified: `using A`
-> A.Widget only; ambiguous -> no edge; qualified `B.Widget` -> B.Widget
regardless of usings; sibling-block using-scope isolated; no dangling
edges or fan-out.

Reconciled onto current v8 (the PR predated the C++/ObjC member-call
resolvers); full suite green, the C++/ObjC resolution coexists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:32:10 +01:00
safishamsi 49252d3cb7 feat(extract): cross-file member-call resolution for C++ and ObjC (#1547, #1556)
Connects paired classes across files: Main.cpp's `Foo f; f.bar()` now resolves
to Foo::bar, and ObjC `Foo *f = [[Foo alloc] init]; [f doThing]` to Foo's
doThing — the "connect with other classes" goal of #1547/#1556.

Design grounded in prior-art research (ctags qualified-name matching, Doxygen's
name-keyed false-edge failure modes, PAIGE's receiver-type approach, Clang USR):
resolve by RECEIVER TYPE, never bare name, and skip when the type can't be
inferred rather than guess (a false call edge / god-node is worse than a missing
one). Mirrors the existing Swift/Python/Ruby/TS member-call resolvers.

- C++ extractor now captures the member-call receiver (field_expression /
  qualified_identifier / pointer access) and builds a per-file type table from
  local declarations (`Foo f;`, `Foo* f;`, `Foo *f = ...;`); emits raw_calls.
- ObjC extractor emits raw_calls for message sends with the receiver + selector
  and a type table from `Foo *f = ...;` locals (existing in-file selector /
  alloc-init / dot-syntax / @selector matching preserved).
- New _resolve_cpp_member_calls / _resolve_objc_member_calls, registered for
  their suffixes. Receiver tiers: `Foo::bar()` / capitalized ObjC receiver and
  this/self/super (enclosing class) -> EXTRACTED; local-var-typed -> INFERRED.
  Single-definition god-node guard (skip unless exactly one type def matches);
  the just-shipped decl/def class merge makes a paired class one def so the
  guard resolves it. Verified: a.run() -> A::run only (not a same-named B::run);
  an uninferable receiver with run() in two classes emits zero edges (no
  fan-out); ObjC [f doThing] -> Foo only.
- build.py: the cross-language INFERRED-call prune treated .h/.cpp/.m as
  different families and dropped header/impl interop calls; unified the C family
  (.c .h .cc .cpp .hpp .cxx .hh .hxx .cu .cuh .metal .m .mm) so a .cpp/.m call to
  a .h-declared method survives.

Still open (tracked on #1547/#1556): the file-level `#include` edge can stay
uncanonicalized when the project root isn't symlink-resolved (the extract()
id-remap `continue`s on a /var-vs-/private/var mismatch) — the class connection
above is robust to it; include-reachability candidate narrowing and ObjC
dynamic-dispatch/id-typed receivers also deferred (expected low ObjC recall, per
the research).

Reported by @c0dezer019 (#1547) and @JabberYQ (#1556).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:30:05 +01:00
safishamsi 3bc3feed54 fix(extract): merge header/impl class fragmentation + C++/ObjC header routing (#1556, #1547)
A class declared in a header (Foo.h/@interface) and defined in its impl
(Foo.cpp/Foo.m/@implementation) fragmented into two nodes: _file_stem
drops the extension so Foo.h and Foo.cpp share a node id, which
_disambiguate_colliding_node_ids then split apart by path — and the two
"defs" tripped every resolver's single-definition god-node guard,
cascading into missing .h<->.m/.cpp linkage and cross-file/cross-language
edges.

- Routing: a `.h` using `#import` now routes to extract_objc (#1556 bridging
  headers — extract_c drops `#import` as a preproc_call), and a `.h` with
  C++-only signals (class/namespace/template/::/access-specifiers) routes
  to extract_cpp (#1547 — the C grammar has no class_specifier, so a C++
  header previously yielded a junk node and lost every method). ObjC sniff
  keeps priority; a plain C header still routes to extract_c.
- Merge: a new _merge_decl_def_classes post-pass collapses the header/impl
  id-collision onto the header (declaration) variant, modeled on
  _merge_swift_extensions, gated so it fires ONLY for a clean sibling
  header/impl pair (same dir, same base stem, exactly one header) — two
  same-named classes in different directories have different stems and
  never collide, so they are never merged (god-node guard verified). C++
  method definitions retain their `Foo::` qualifier so a `Foo::bar` def
  keys onto the header declaration (one method node, not two); free
  functions keep their bare-name ids.

Result: one canonical class node per .h/.m or .h/.cpp pair with methods
unified, which unblocks the existing member-call resolvers (verified
Swift->ObjC calls and Swift `extension` folding now resolve). Strict
improvement over v8 (which produced junk/fragmented nodes here, verified).
Still open as follow-ups: cross-file C++ #include edge resolution and a
C++/ObjC cross-file member-call resolver (a pre-existing gap, not a
regression).

Reported by @JabberYQ (#1556) and @c0dezer019 (#1547).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:23:46 +01:00
safishamsi bee3849810 fix(resolve): test mocks no longer erase the real cross-file call graph (#1553)
The cross-file call resolver bailed (#543/#1219 god-node guard) whenever a
bare callee name had 2+ definitions without unique import evidence — so a
single same-named test mock (or any same-named symbol) dropped the real
`calls` edge, erasing the call graph wherever a mock existed (the reporter
saw a 76-stub Pester suite wipe everything).

Replace the blunt bail with a smarter guard: when a name is ambiguous and
import evidence doesn't resolve it, apply tie-breakers — non-test
preference (a shared, segment-aware _is_test_path classifier) then path
proximity — and emit an INFERRED edge ONLY if exactly one candidate
survives, else keep bailing. A real def + a test mock resolves to the real
def; two genuine non-test defs still bail (god-node guard intact, no
fan-out). Wired into both the extract.py pass and the symbol_resolution.py
copy via the shared classifier.

Reported by @Schweinehund.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:59:16 +01:00
safishamsi bd885cc97d fix(hyperedge): accept members/node_ids alias keys for the member list (#1561)
A hyperedge's member list is canonically keyed `nodes`, but producers
(LLM/subagent drift, externally-supplied graph.json) sometimes emit
`members` or `node_ids` — graphify only read `nodes`, so those hyperedges
silently lost their members, and semantic_cleanup's prune dropped them
entirely. Normalize the member key to `nodes` at one ingest chokepoint in
build_from_json (and in semantic_cleanup, which runs pre-build), deduping
and warning, so every downstream consumer sees the canonical key. Mirrors
the existing from/to edge-endpoint aliasing.

Reported by @askalot-io.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:58:23 +01:00
safishamsi c865a3c9b0 fix(reflect): layout-ordered source resolution for overlay staleness (#1558)
Refines the staleness file resolution (00e00a0) by folding in the two
genuine merits of @TPAteeq's parallel fix (#1558), which independently
and correctly diagnosed the same root-mismatch bug:

- Layout-ordered candidates: try the layout-appropriate root FIRST (the
  graphify-out parent for the standard layout, graph.json's own dir for a
  flat layout) before the other. The prior order tried the grandparent
  first unconditionally, which in a flat layout (graph.json at the project
  root) could fingerprint a same-named file one directory up. Existence
  checking is kept on top, so a defeated name heuristic or a stale
  .graphify_root marker still falls through to the real file.
- Adds @TPAteeq's .graphify_root-marker-driven regression test, plus a
  flat-layout test that pins the ordering (editing the real file flips
  stale; editing the same-named decoy one dir up does not).

Co-Authored-By: tpateeq <mohammedateequddin399@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:57:33 +01:00
safishamsi 2cdc212b43 docs: README work-memory overlay note + Unreleased changelog
README: document that `reflect --graph` writes the .graphify_learning.json
overlay and that explain/query surface a Lesson hint (with the
code-changed staleness flag).

CHANGELOG: add an Unreleased section for the post-0.9.2 work — the
work-memory overlay (#1441/#1542), this.field.method() injected-field
resolution (#1316), TS wildcard path aliases (#1544), JS namespace
re-exports (#1552), and the ObjC dot-syntax/@selector edges (#1475/#1543).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:42:25 +01:00
safishamsi 00e00a0b5f fix(reflect): work-memory staleness false-positive on relative source paths
The overlay fingerprint resolved a node's source_file against
graph_path.parent (the graphify-out/ dir), but source_file is stored
relative to the PROJECT root — so graphify-out/auth.py never existed and
_is_stale flagged EVERY verdict "code changed since — re-verify" the
moment it was written. (The original staleness test used an absolute
source_file, which masked it.)

Fix: resolve the file by trying the likely roots in order (.graphify_root
marker, graphify-out's parent, graph.json's own dir, cwd) and use the
first that exists — the same search at write and read — and fingerprint
file CONTENT only (sha256 of bytes, no path mixed in) so the hash is
root-independent and a committed sidecar stays valid across checkouts.
Drops the brittle directory-name-based root guess.

Adds a regression test with a relative source_file under the graphify-out
layout (stale=False right after reflect, True after an edit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:40:20 +01:00
safishamsi 5779767fd3 feat(reflect): work-memory overlay — surface learned verdicts as a graph sidecar (#1441)
Projects the verdicts `graphify reflect` already distills (preferred /
tentative / contested, exponential time-decayed) into a derived
experiential layer the read surfaces consume, so accumulated agent
experience actually shows up where you look — without polluting the
structural graph.

Design (grounded in agent-memory + provenance literature; a redesign of
the #1542 approach):
- SIDECAR, not graph.json stamping. `reflect` writes `.graphify_learning.json`
  next to graph.json (an additional output, so the git hooks produce it
  automatically). graph.json stays purely structural; nothing leaks into
  GraphML; no graph.json churn. Mirrors the named-graph / event-sourcing
  separation of durable truth from a derived layer.
- Reuses the existing reflect aggregate (its `_decay` is the
  recency-weighted exponential model; `_finalize_sources` the
  classification) — no new scoring.
- PROVENANCE: each verdict carries the source questions/dates that produced
  it (cap 5, most-recent first).
- STALENESS: each verdict stores the node's file fingerprint; on read, a
  changed source file flags the verdict stale ("code changed since —
  re-verify") rather than presenting a confident lesson on rewritten code.
- CONTESTED surfaced distinctly (useful N / dead-end M), not averaged away.
- DEAD-ENDS stay QUERY-SCOPED — never a node-level status; they appear only
  in the report as question -> nodes.
- Read surfaces (explain / query+MCP / GRAPH_REPORT / graph.html) merge the
  overlay at read time, sanitized; un-annotated graphs are byte-identical.

Deferred (logged): letting verdicts influence query/seed traversal — the
recommender feedback-loop / Matthew-effect risk means that needs
propensity correction + exploration, not naive biasing.

Builds on the idea in #1441/#1542 (thanks @TPAteeq).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:19:54 +01:00
guy oron 0792b419fc feat(objc): dot-syntax property accesses and @selector() call edges (#1475, #1543)
`self.product.name` dot-syntax now emits an `accesses` edge and
`@selector(method)` emits a `calls` edge, both resolved only to an
unambiguous in-scope definition (a sibling method of the same class for
dot-syntax; exactly one method by exact selector name for @selector) so
no false-edge fan-out occurs when multiple classes share a name.

Hardened over the original PR: resolution now matches the method node id
EXACTLY (a method id is _make_id(container, name)) rather than by
`endswith` suffix. The substring match would mis-resolve `self.name` to a
sibling `-surname` (false positive) and, when a substring-colliding
sibling existed, suppress the correct edge (false negative); exact
matching fixes both. Adds substring-collision regression tests
(`-name`/`-surname`, `-doThing`/`-reallyDoThing`).

Completes the #1475 ObjC follow-ups (Bug 5 dot-syntax accesses, Bug 6b
@selector target-action).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:54:28 +01:00
guy oron 1801da0634 feat(ts): resolve this.field.method() calls via constructor-injection types (#1316)
A member call through a constructor-injected dependency
(`constructor(private db: Database)` ... `this.db.query()`) now produces
a calls edge to the field type's method. The field->type map is captured
from constructor parameter-properties, and resolution reuses the existing
single-definition god-node guard (like the Swift/Python/Ruby member-call
resolvers): the edge is emitted only when the field's type name resolves
to exactly one class definition that owns the method, so an ambiguous or
unknown/untyped field produces no edge — no global name-match fan-out.
Edges are EXTRACTED (the type is explicit from the annotation). TS/JS-only
and additive; scope is constructor parameter-property injection.

Adds the decisive regression tests the implementation needed: two classes
defining the same method name where the injected field is typed to one of
them (must resolve to that one only), and an ambiguous type-name case
(must emit no edge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:46:16 +01:00
oleksii-tumanov c8c604d08c feat(js): resolve namespace re-export bindings (export * as ns from) (#1552)
`export * as ns from './mod'` now creates a real symbol node for the
namespace binding `ns`, registers it as a named export (so a downstream
`import { ns }` resolves to it), and emits a file-level `re_exports` edge
to the target module. The binding is treated as a single opaque symbol —
`ns.member` accesses are deliberately NOT expanded into per-member
name-matching, avoiding the over-linking that would fan false edges.
Includes re-export cycle and deep-chain recursion guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:42:49 +01:00
oleksii-tumanov 57469641cd feat(js): resolve TypeScript wildcard path aliases (#1544)
Extends the tsconfig path-alias resolver (#1531) with single-`*` wildcard
capture and substitution: a pattern like `@app/*` or `@*/interfaces`
captures the matched segment and substitutes it into each target in
declared order, honoring baseUrl and tsc's longest-prefix / exact-wins
specificity rules, and preserving #1531's first-existing-target-wins
fallback (no false edge when nothing resolves). Builds on the
_resolve_tsconfig_alias helper rather than reintroducing inline loops;
multi-star patterns remain out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:41:50 +01:00
safishamsi 544f95efa6 release: 0.9.2
Ruby type-aware member-call resolution and workspace exports-map
resolution, the #1529 alias/workspace import-edge regression fix, tsconfig
paths fallbacks, semantic-cache pruning, three ObjC extractor fixes, Swift
static-call confidence, the secondary LLM timeout, GraphML null coercion,
host-generic install wording, and Dependabot dep bumps. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.2
2026-06-29 16:25:30 +01:00
safishamsi 1d8d278f9e docs: add Unreleased changelog section for today's fixes
Covers #1499 (Ruby type-aware resolution), #1308/#1541 (workspace
exports map), #1529 (alias/workspace import-edge regression), #1531
(tsconfig paths fallbacks), #1527 (semantic cache pruning), #1475 (three
ObjC fixes), #1533 (Swift static-call confidence), #1442 (secondary LLM
timeout), #1502 (GraphML null coercion + save-result --answer-file),
#1530 (host-generic skill wording), and the Dependabot dep bumps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:00:59 +01:00
safishamsi 51fc00a30f fix(swift): type-qualified static calls resolve as EXTRACTED, not INFERRED (#1533)
A type-qualified Swift call (`Type.staticMethod()`, `Singleton.shared.method()`)
names the receiver type explicitly in source, so the resolved edge is an exact
reference — now emitted as EXTRACTED (1.0), matching the Python
qualified-class-method pass (_resolve_python_member_calls). Instance calls whose
receiver type comes from local inference (`obj.method()`) stay INFERRED (0.8).
Resolution and the single-definition god-node guard are unchanged.

This addresses the actionable part of #1533's "static calls" report: the edge
was always produced (graphify models calls as method->method), it was just
under-confident. Updated the confidence test to assert the instance/type-qualified
split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:49:42 +01:00
safishamsi 1652dadd60 fix(objc): NS_ASSUME_NONNULL parse failure, dangling .m imports, alloc/init refs (#1475)
Three residual ObjC extractor bugs from the #1475 thread, each reproduced
against the real tree-sitter-objc grammar:

1. NS_ASSUME_NONNULL_BEGIN before @interface made the parser fail to emit
   a class_interface node at all (the whole interface was swallowed into
   ERROR nodes), so headers using the macro produced no class node. Blank
   the two argument-less annotation macros to equal-length spaces before
   parsing (offset-preserving; macro-free files are byte-identical). The
   reporter's "@class breaks it" hypothesis was wrong — only the macro does.

2. Quoted `#import "X.h"` edges dangled once a `.h`/`.m` pair existed: the
   target used the bare stem, which the post-pass canonicalizes and then
   _disambiguate_colliding_node_ids salts apart by path, so the import
   target no longer matched. Resolve the include to a real file (mirroring
   _import_c), and repoint imports/imports_from edges to the header variant
   in _disambiguate_colliding_node_ids — taking precedence over the
   same-source-file salt so a `.m` importing its own `.h` resolves to the
   header instead of self-looping. Also repairs the equivalent latent
   C-include dangling bug.

3. `[[Foo alloc] init]` produced no edge — walk_calls only reconstructed
   selectors and skipped the receiver. Emit a `references` edge from the
   allocating method to the class, resolved via the unique-class stub guard
   (ensure_named_node + _rewire_unique_stub_nodes) so unknown/ambiguous
   names produce no false edge. The calls-to-init edge is deliberately
   deferred (init selectors are ambiguous across classes).

Reported by JabberYQ with a precise repro and test repo. Adds regression
tests incl. a self-loop guard on the import edges. Still open on #1475:
dot-syntax property accesses (Bug 5) and @selector target-action (Bug 6b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:41:22 +01:00
guy oron e8dabadeb0 feat(imports): resolve workspace subpath exports via package.json exports map (#1308)
Workspace imports with subpath exports (e.g.
`import { x } from "@scope/pkg/browser"`) now resolve through the
package's `exports` map instead of falling back to a bare path. Supports
string values, condition objects, nested conditions, and single-`*`
wildcard patterns (`"./*": "./src/*.js"`), falling back to the existing
bare-path/index resolution when there is no exports map or no match.

Adapted from #1541, taking only the exports-map resolver and not that
PR's competing import-node-ID normalization (current v8 already resolves
the node-ID mismatch via the #1529 id-remap post-pass, and the PR's
_file_stem approach regressed the relative-input alias case). Two
hardening changes over the original:
- `default` is consulted LAST in the condition priority (it is Node's
  catch-all), so a matching `import`/`module`/`svelte` condition wins.
- Export targets that escape the package directory are rejected
  (`_contained_in_package`), so a malicious `exports` value like
  `"./x": "../../../etc/..."` cannot resolve to a file outside the package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:57:16 +01:00
safishamsi 7a9cda2452 fix(cache): prune orphan semantic-cache entries at end of extract (#1527)
The AST cache is version-swept but the semantic/LLM cache had no pruning,
so it grew unbounded: it is content-hash-keyed, so every content change
or file deletion leaves a permanent orphan entry (reporter saw 152
entries for 124 live docs). This matters for the committed-cache workflow
where the semantic cache is published for warm CI rebuilds.

Adds prune_semantic_cache(root, live_hashes) and wires it into the end of
the extract path, sweeping cache/semantic/*.json entries whose hash is not
in the live set. The live set is computed from the FULL detected document
set (not the incremental changed-subset, which would delete valid
entries), using the same file_hash recipe save_semantic_cache uses.
Best-effort (unlink guarded), only touches cache/semantic/ (.tmp and
cache/ast/** untouched), and keeps the semantic cache unversioned so
releases never re-bill LLM extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:14:37 +01:00
safishamsi 2133539930 fix(js): resolve alias/workspace import edges + honor tsconfig paths fallbacks (#1529, #1531)
#1529 (regression from the 0.9.0 full-repo-relative node-ID migration):
relative JS/TS imports resolve to repo-relative paths and ride the
extract() id-remap to canonical node IDs, but tsconfig path-alias and
workspace-package imports resolve to ABSOLUTE paths (their bases are
.resolve()'d), so the import-target ID baked in the on-disk prefix and
never matched the repo-relative definition node — the edge was dropped at
build (common on Next.js/SvelteKit `@/`-alias codebases). The id-remap
post-pass now also registers the absolute-resolved form of each input
path (file-level edges) and both the input-form and absolute-form symbol
prefixes (named-import edges), so alias/workspace import targets remap to
the canonical ID. Verified the built graph has no orphan nodes or
dangling edges.

#1531: tsconfig `paths` values are ordered fallback lists (tsc tries each
target until one resolves), but only targets[0] was kept. The alias map
now stores all targets in order, and a single _resolve_tsconfig_alias
helper (replacing six duplicated inline loops) returns the first target
whose candidate exists on disk, falling back to the first candidate when
none exist (no false edge). Wildcards, baseUrl, and array `extends` are
preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:14:36 +01:00
safishamsi 36b5e5cb29 chore(deps): drop unused safety dev dep (removes unfixable nltk HIGH)
`safety` was declared in the dev group but never invoked — the CI
security-scan job only runs bandit and pip-audit, and pip-audit already
provides the same dependency-CVE scanning. Its only practical effect was
pulling in nltk, which carries an unpatched HIGH path-traversal advisory
(GHSA-p4gq-832x-fm9v) with no fix available.

Removing safety drops nltk (and safety-schemas/typer/tenacity/tomlkit)
from the lockfile entirely, closing the alert with no loss of coverage.
Updated the stale CI comment that referenced safety. Full suite green
(2537 passed); pip-audit and bandit unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:29:45 +01:00
safishamsi 738c9cebcb chore(deps): bump msgpack 1.2.1 and pydantic-settings 2.14.2 (Dependabot)
Resolve two Dependabot alerts in transitive deps:

- msgpack 1.1.2 -> 1.2.1 (HIGH, GHSA-6v7p-g79w-8964): out-of-bounds
  read / crash on Unpacker reuse after a caught error. Pulled only via
  cachecontrol -> pip-audit (dev group), so not in the published wheel's
  closure, but a fix is available so we take it.
- pydantic-settings 2.14.1 -> 2.14.2 (MEDIUM, GHSA-4xgf-cpjx-pc3j):
  NestedSecretsSettingsSource follows symlinks outside secrets_dir.
  Pulled via mcp (the [mcp]/[all] extra); graphify does not use the
  affected secrets-dir source, but the fix is free.

Lockfile-only; both are transitive. Full suite green (2537 passed),
MCP/serve tests pass on the bumped versions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:26:29 +01:00
Ariadne Mitophane e3e4198038 fix(skillgen): host-generic /graphify install guidance (#1530)
Generated install/skill guidance told agents to invoke a literal `skill`
tool with `skill: "graphify"`, which is host-specific and not valid in
every environment. The always-on AGENTS fragment, packaged artifact,
expected snapshot, and _skill_registration() output now use host-generic
wording: "use the installed graphify skill or instructions". Also decodes
skillgen git blob reads as UTF-8 for Windows and replaces stale English
code-block examples in the translated READMEs.

The always-on roundtrip guard deliberately freezes the v8 baseline, so an
intentional wording change would otherwise fail it. Rather than only
patching the pytest mirror (which left the blocking CLI guard
--always-on-roundtrip red, as the original PR did), this adds an explicit,
reviewable ALWAYS_ON_SANCTIONED_EDITS registry: the guard applies the
approved old->new substitution to the baseline before the byte-for-byte
compare, so this exact sentence is allowed while any other drift still
fails. CLI guard and pytest test now agree and CI passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:01:06 +01:00
antonioscarinci 407a7f142d fix(export,cli): GraphML null-attr coercion + save-result --answer-file (#1502)
Two cross-platform fixes salvaged from #1502:

- to_graphml: nx.write_graphml raises ValueError on None attribute
  values, so a node/edge carrying a null field crashed the export.
  Coerce None -> "" for node and edge attributes before writing.

- save-result: add --answer-file as an alternative to --answer so long
  or multiline answers can be passed via a file instead of a fragile
  inline shell arg (notably Windows/PowerShell quoting). Exactly one of
  --answer / --answer-file is required.

The rest of #1502 (a version downgrade and a hand-edited generated
skill-windows.md that fails skillgen --check, plus duplicated
windows-scripts) is left for rework on the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:54:08 +01:00
DhruvTilva 4e4935a64a fix(llm): enforce API timeout in the secondary LLM dispatch path (#1442)
_call_llm (used by the dedup LLM tiebreaker) built its Anthropic and
OpenAI-compatible clients with max_retries but no timeout, so requests
on this path silently ignored GRAPHIFY_API_TIMEOUT — unlike the primary
extraction paths (_call_openai_compat / _call_claude) which already pass
both. Add timeout=_resolve_api_timeout() to both constructors.

The PR branch self-neutralized: a v8 merge resolved the conflict in
favor of the max_retries-bearing line and dropped the original one-line
fix, so it is re-applied here on top of current v8 with max_retries
preserved. Adds regression coverage for both _call_llm branches, which
were previously untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:45:03 +01:00
vamsi pavan mahesh gunturu 86ecb769b6 feat(ruby): type-aware member-call resolution via a resolver framework (#1499)
Resolve Ruby `obj.method()` calls by the inferred type of the receiver
instead of by globally-unique method name. `p = Processor.new; p.run`
now emits a `calls` edge to `Processor#run` and survives name collisions
with unrelated `Worker#run` definitions, where the old name-based match
either resolved by luck or dropped the edge as ambiguous.

Introduces graphify/resolver_registry.py, a behavior-identical
formalization of the existing tail-of-extract() language resolution
passes (Swift #1356, Python #1446 become registered entries), and
graphify/ruby_resolution.py, its first new consumer. Receiver type is
inferred only from unambiguous local `var = ClassName.new` bindings;
ambiguous or unknown receivers resolve to nothing (no false positives).

Note: Ruby member calls are now excluded from name-based cross-file
resolution and resolved by inferred type only. This is an intentional
precision-over-recall change scoped to Ruby: a cross-file `var.method`
whose receiver type cannot be proven from a local `X.new` binding no
longer resolves by name-luck (it produces no edge rather than a possibly
wrong one), matching the project's confidence model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:40:22 +01:00
safishamsi 12193a89f1 chore: untrack committed .DS_Store files
These were committed before .gitignore included the .DS_Store rule, so
gitignore never removed them from tracking. Untrack them (they remain
on local disk, just leave git) — the existing .gitignore rule keeps
them out going forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:24:02 +01:00
safishamsi 199061207f docs: add Discord badge to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:45:49 +01:00
safishamsi b7e256f779 release: 0.9.1
Patch over 0.9.0: completes the node-ID work (fully closes #1504 via injective
salt #1522), stops origin_file leaking into graph.json (#1516), extends cross-file
stub disambiguation to the six dedicated extractors (#1515), Java type-param skip
(#1518) + record component refs (#1519), prunes a deleted import's edge on update
(#1521), and retries rate-limited (429) requests instead of dropping chunks (#1523).
All non-breaking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.1
2026-06-28 20:15:44 +01:00
safishamsi 0ca73bd00b docs: document GRAPHIFY_MAX_RETRIES env var (#1523)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:13:55 +01:00
safishamsi 64c1f21070 fix(llm): retry rate-limited (429) requests instead of dropping the chunk (#1523)
On strict per-org concurrency/RPM caps (notably Moonshot/kimi), a parallel
`graphify extract --force` 429'd, and because the provider SDKs default to only
max_retries=2, the chunk gave up after two attempts, logged `chunk N failed`, and
was silently dropped — an incomplete graph plus the screen full of
rate_limit_reached_error spam in the report. The SDKs already back off
exponentially and honor Retry-After; they just need more attempts to outlast the
rate window.

The OpenAI-compatible, Azure, and Anthropic clients are now built with
max_retries=_resolve_max_retries() (default 6, override via GRAPHIFY_MAX_RETRIES;
0 disables). For very tight accounts, --max-concurrency 1 further cuts the
concurrency that trips org-level limits.

Reported by @bercedev (#1523). Tests: _resolve_max_retries default/env/invalid,
and the OpenAI-compatible client is constructed with retries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:11:18 +01:00
safishamsi 22a5afb251 docs(changelog): add #1521, #1522, #1519 entries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:57:33 +01:00
oleksii-tumanov 981e2b93cf fix(extract): emit Java record component references (#1519)
A Java record's component types produced no edges, so a record's data dependencies
were invisible. The record_declaration handler now emits `references` edges from
each component type (field context; generic_arg for type arguments), skipping
primitives and — via #1518's in-scope type-parameter filtering — the record's own
type parameters. Handles standard, generic, primitive, and varargs components.

Ported from PR #1520 by @oleksii-tumanov (clean 3-way onto v8; builds on #1518).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:55:51 +01:00
safishamsi 35fb437365 fix(ids): salt residual separator-collision node IDs injectively (#1522)
0.9.0 made the node-ID stem the full repo-relative path, but normalize_id collapses
every non-word run to "_", so the path separator is indistinguishable from inner
punctuation: foo/bar_baz.py and foo_bar/baz.py both normalized to foo_bar_baz and
still silently merged (the residual of #1504). The existing _disambiguate_colliding
_node_ids salt didn't help — it salted with _make_id(source_key, old_id), which
re-normalizes the path with the same lossy recipe, so the two colliders produced an
identical salted id.

When two distinct source paths' naive salts still collide, append a short stable
sha1(source_key)[:6] — injective over distinct paths — so they separate. Computed in
code from source_file (never trusted from the LLM), so AST<->semantic parity holds.

Blast radius: minimal/non-breaking — only the actual residual colliders get a hash
suffix. Non-colliding ids (the 99%, incl. the common #1504 case like two README.md
in different dirs) are byte-identical to 0.9.0 (verified: src/auth/session.py ->
src_auth_session, docs/v1/api/README.md -> docs_v1_api_readme unchanged). This is a
0.9.1 patch, not another migration.

Reported by @sub4biz (#1522). Regression tests cover both the collider-separation
and the non-collider-unchanged cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:54:48 +01:00
safishamsi 0080d8ac43 fix(update): prune edges owned by a re-extracted file (#1521)
`graphify update` preserved old edges keyed only on endpoint-node membership, so a
removed import's edge survived forever whenever both endpoint nodes still existed
(e.g. a.py no longer imports b, but both a and b are still present). The stale edge
drove phantom circular-dependency findings; `--force` didn't help (it only gates
the node-count shrink guard), only a clean rebuild did.

_rebuild_code (watch.py) now also drops preserved edges whose source_file was
re-extracted this run — an edge is owned by the file it was extracted from, and the
fresh extraction re-emits whichever ones still exist. Scoped to source_file
ownership (and the full-rebuild case, where evict_sources lists only deleted
files), so edges with no source_file or owned by a non-re-extracted file are kept —
cross-file edges that merely point at a re-extracted file (#1402 sourceless
stubs/rewire) are not over-pruned.

Reported by @UltronOfSpace (#1521). Regression test: a removed import's edge is
gone after update; verified no over-prune (still-present imports survive) and no
regression to deleted-file pruning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:29:51 +01:00
safishamsi faa9218907 chore: sync uv.lock to 0.9.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:06:28 +01:00
safishamsi 67d8c53ccc docs(changelog): add #1515 and #1518 entries to Unreleased
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:05:09 +01:00
oleksii-tumanov 8b9a99828a fix(extract): skip Java type-parameter references (#1518)
The generic-parent support (#1511) emitted a spurious references/generic_arg edge
(and a sourceless stub node) for a bare type parameter — the T in
`class Box<T> extends Container<T>` — because the Java extractor had no type-
parameter tracking. _java_collect_type_refs now collects the in-scope type-
parameter names (walking class/interface/record/method/constructor declarations,
including bounded and multiple params) and skips references to them, while keeping
every real type. Scoped to the declaring node, so a real class that shares a name
with a type parameter elsewhere is still referenced.

Ported from PR #1518 by @oleksii-tumanov. Closes the nit flagged when #1511 landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:05:09 +01:00
Mohammed Ateeq d177f04270 fix(extract): add origin_file to cross-file stubs in the six dedicated extractors (#1515)
The #1462 same-label cross-file stub disambiguation (the origin_file key) only
existed in the generic extractor, so the six dedicated extractors — Julia,
Fortran, Go, Rust, PowerShell, ObjC — still collapsed same-named imported-type
stubs from different files into one conflated bare-id node (a false cross-package
link). Each now sets origin_file on its sourceless stub, identical to the generic
extractor; the generic _node_disambiguation_source_key consumes it, so two files
importing the same type stay distinct while source_file stays empty (the #1402
rewire onto a real definition is unchanged).

Ported from PR #1515 by @TPAteeq. Must ship with #1516: this widens origin_file to
six more languages, and #1516 is what strips it from graph.json. Verified: a 2-file
Go corpus now yields 2 distinct Widget stubs AND graph.json carries no origin_file.
Resolved a test-insertion conflict with #1516 by keeping both tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:01:55 +01:00
Mohammed Ateeq afa4aded2e fix(extract): drop internal origin_file so it stops leaking into graph.json (#1516)
The origin_file disambiguation field (#1462) is internal — it's consumed by the
colliding-id pass and must not be persisted. In 0.9.0 it shipped into graph.json
as an absolute, machine-specific path (the same portability bug class as #555,
#932), breaking on clone/move. It's now popped at the single extract() output
chokepoint, AFTER _disambiguate_colliding_node_ids has consumed it, so all persist
paths (clustered, --no-cluster, cluster-only) emit clean output. _origin is kept
(the incremental watcher relies on it, #1116).

Ported from PR #1516 by @TPAteeq. Verified: fresh extract (clustered and
--no-cluster) produces graph.json with zero origin_file fields; the #1462 same-
label disambiguation still works (it runs before the strip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:54:58 +01:00
safishamsi 94e5bafc89 docs: document the --timing flag in the extract examples (#1490)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:28:31 +01:00
safishamsi 92e682f1de release: 0.9.0
BREAKING: node IDs now include the full repo-relative path (#1504, #1509) — fixes
silent data loss when same-named files live in different directories, and aligns
the AST and LLM id rules. Existing graphs migrate automatically on next build;
`graphify extract --force` to recover previously-collided nodes; Neo4j persisted
stores need a re-import. Also ships the --timing flag (#1490).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.0
2026-06-28 18:20:10 +01:00
safishamsi 73710d33ce fix(ids): legacy-id detector only inspects file-level nodes (no Go false-positive)
The graph_has_legacy_ids nudge flagged fresh v9 graphs that contain Go code,
because the Go extractor scopes a type node by package directory
(_make_id(pkg_dir, name) -> "sub_thing"), which coincides with the OLD one-parent
file-stem form of pkg/sub/thing.go even though it's a current, by-design
package-scoped id (methods on a type across files in a package share one node).

The detector now inspects only file-level nodes (source_location "L1"), whose id is
unambiguously the file stem and which the migration actually re-keys. This still
catches a genuine pre-#1504 graph (its file nodes carry the old stem) while leaving
package/dir-scoped symbol ids alone. Verified end-to-end: a fresh Go graph no longer
triggers the rebuild nudge; a real legacy graph still does.

The migration itself was already correct — file nodes and Python/AST symbols
migrate to full-path ids; Go's package-scoped type ids are intentional and
unaffected. This only fixes the over-eager warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:52:34 +01:00
safishamsi 3999dbc67e feat(ids): warn on legacy-id graphs + harden re-key source_file contract (#1504)
Two pre-0.9.0 safety to-dos from the migration spike:

1. Auto-detect-and-warn: graph_has_legacy_ids() samples a loaded graph's node ids
   and detects the pre-#1504 (parent-dir/filename) scheme. Read-only consumers that
   don't re-extract — `graphify query` and the MCP serve loader — now print a
   one-line nudge to rebuild with `extract --force` when they load an old graph.
   Fires only on legacy graphs (verified: canonical graphs stay silent).

2. Re-key source_file contract: pinned with tests that a relative source_file is
   migrated to the full-path id while an absolute/unrelativizable one is skipped
   (its on-disk path must never leak into a persisted id). The is_absolute() guard
   already enforced this; the tests make the contract explicit.

Full suite 2507 passed. Lands on v9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:29:57 +01:00
safishamsi 388d1b64db docs(changelog): clarify --force is needed to recover previously-collided nodes (#1504)
Rigorous testing surfaced the nuance: the ID-format migration is automatic, but
same-named files that already collapsed into one node under the old rule are only
recovered as distinct nodes by a fresh extract --force (migrating an already-
collided graph/cache can't resurrect already-dropped nodes; edges referencing a
shared old id are ambiguous). Fresh extraction yields distinct nodes as expected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:12:30 +01:00
safishamsi b46634ef7a fix(ids): node IDs include the full repo-relative path (#1504, #1509)
BREAKING node-ID format change. The stem that prefixes every node id was the
immediate parent dir + filename, so same-named files in different directories
collided into one last-writer-wins node and silently dropped graph content
(docs/v1/api/README.md and docs/v2/api/README.md both -> api_readme). The stem is
now the full repo-relative path (docs_v1_api_readme vs docs_v2_api_readme);
top-level files are unchanged (setup.py -> setup).

- extractors/base.py::_file_stem -> full path (as_posix; make_id collapses
  separators). The two hand-copied stems (symbol_resolution, mcp_ingest) now
  import the canonical one, so they can't drift again.
- llm.py system prompt + extraction-spec fragments aligned to the same rule,
  fixing the #1509 AST<->LLM divergence (prompt used zero parent dirs -> ghosts).
  Regenerated + blessed the per-host specs.
- build_from_json deterministically re-keys every non-AST node id from its
  source_file via the new stem (and registers old-stem aliases), so a cached or
  pre-migration semantic fragment carrying an old short id reconciles with the
  AST node instead of spawning a ghost. The semantic cache is unversioned, so
  this code-side re-key (not LLM prose) is what makes it survive the format change
  with no re-bill. AST nodes are already canonical and skipped.
- Migration: existing graphs migrate automatically on the next build/update (the
  re-key runs in build_from_json, including the whole graph fed back through
  build_merge); `graphify extract --force` for a clean rebuild. Neo4j persisted
  stores need a re-import; GraphML layouts go stale.

Full suite green (2505 passed); #1504 collision fixed and old-id fragments
re-keyed verified by smoke. Lands on v9 for review before a 0.9.0 release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:57:00 +01:00
safishamsi 21a9f1f0b5 chore: sync uv.lock to 0.8.51
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:49:56 +01:00
safishamsi f7f89d7d84 feat(cli): --timing flag for per-stage timings on extract and cluster-only (#1490)
Adds an opt-in --timing flag that prints per-stage wall-clock timings to stderr so
slow stages are visible on large corpora. extract reports detect / AST extract /
semantic extract / build / cluster / analyze / export (and write on --no-cluster);
cluster-only reports load / cluster / analyze / label / report / export; both end
with a total. A small _StageTimer helper uses monotonic perf_counter.

Off by default and stderr-only, so default output and machine-read stdout/graph.json
are byte-identical (the extract arg loop already swallows unknown flags). Added a
regression test asserting the lines appear with --timing and are absent without it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:17:21 +01:00
safishamsi 7592244386 docs: document --obsidian-dir and its existing-vault safety (#1506)
The --obsidian-dir flag was undocumented in the README. Add it to the command
list with a note that exporting into an existing vault never overwrites the user's
own notes or .obsidian config (the guard shipped in 0.8.51).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:33:36 +01:00