#4262 reported that a type defined exactly once never bound across modules,
leaving hundreds of per-file stubs. It no longer reproduces on v8: the
reference binds through the re-export chain (and, absent any import, through the
unique-stub rewire). Add regression coverage matching the reporter's node
layout (config/settings/_models.py -> config_settings_models_settings) plus an
import-free inheritance control, so the resolved behavior cannot silently
regress. No production change required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An external type the corpus references but never declares (`List<T>`,
`System.Type`, Unity's `VisualElement`) got a separate sourceless placeholder
in every file that mentioned it, with the file's name baked into the id
(`a_cs_list`, `b_cs_list`). Queries returned a dozen identical nodes and the
files sharing the type stayed unconnected.
`_resolve_csharp_type_references` minted/kept a per-file stub because the arbiter
short-circuited on whichever placeholder each edge already pointed at. It now
routes every undeclared same-name reference to a single canonical, name-keyed
stub (mirroring Go's external-type treatment), still shadowing no real or
differently-labelled node. The now-unreferenced per-file placeholders are pruned
by the existing sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`def read_body(response: httpx.Response)` names an external type, but a private
`class _Response` in a test file absorbed the reference, producing a fabricated
EXTRACTED-confidence edge from production code to test code.
The stub-rewire key (`_node_label_key`) stripped ALL non-alphanumeric
characters, including underscores, so `_Response` and `Response` collapsed to
the same key and the lone local `_Response` was chosen as the unique match.
Underscores are significant identifier characters, so the key now preserves
them (only `()`, `<>`, `.`, and whitespace are dropped); `Foo()` still matches
`Foo`, but `_Response` no longer matches `Response`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-package directory with a file named like a stdlib module (e.g.
`scripts/logging.py`) wrongly captured a bare `import logging` from a
sibling, binding the import — and member calls like `logging.getLogger()` —
to the local file instead of the stdlib module.
The false binding arose at three independent layers, all now guarded by a
vendored, deterministic `_PYTHON_STDLIB_MODULE_NAMES` frozenset (union of
3.10 and 3.13 `sys.stdlib_module_names`, NOT the host's live set):
- `_resolve_python_module_path`: an absolute import whose top-level name is
a stdlib module no longer resolves to a loose same-named `.py` file or
namespace dir. A deliberate package shadow (`logging/__init__.py`) still
resolves — only loose modules/namespaces are refused.
- `_repoint_python_sibling_imports`: stdlib-named loose siblings are no
longer indexed as repoint targets.
- build.py #1504 pre-migration stem-alias index: a stdlib-named Python file
no longer claims the collapsed bare-stem alias ("logging"), which would
otherwise re-capture the external edge. The directory-scoped alias
("scripts_logging") is still registered, so genuine stale-id references to
the file keep healing.
Adds a stdlib-shadow regression plus a non-stdlib positive control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`graphify opencode install` wrote a plugin with only a named export, the
OpenCode 1 shape. OpenCode 2 rejects it ("Plugin must export a default
definition with an id and an effect or setup function") and reads the bare
path registered in opencode.json as an npm package name, so it fails twice
on every start.
The plugin is now one default export with `id`, `setup()` for OpenCode 2
and `server()` for OpenCode 1 (1.3.4 and later). The installer no longer
writes the opencode.json entry, because both runtimes discover
.opencode/plugins/ on their own, and it removes the entry older releases
wrote.
OpenCode loads .opencode/plugins/ from the project and from every directory
above it, and OpenCode 2 fails a second plugin with the same id ("Duplicate
plugin ID"). An install in ~ and one in a project are both normal, so the id
carries the file's location and the copies share one record of who was
reminded: both load, and a command still gets a single reminder.
Upgrading graphify does not touch a plugin already on disk. So the CLI now
rewrites an old-shape plugin in the working directory or in the home
directory on its next run, and drops the stale entry there, next to the
stale-skill refresh from #1805. It does so only when the `opencode` on PATH
reports 1.3.4 or later, because older OpenCode 1 releases cannot load the
new plugin and the one they have still works. The rewrite takes the same
per-directory lock as the skill refresh and replaces both files atomically,
because OpenCode watches them. GRAPHIFY_NO_AUTO_REFRESH=1 turns it off.
(cherry picked from commit 0e539e1f7a)
A file-scoped path endpoint that matched no symbol in that file fell through to
bag-of-words scoring, which then answered from a same-named symbol in another
file. Refuse like explain does (listing the files that do define the symbol)
instead of guessing across files; fixes the CLI path and the MCP shortest_path
tool via the shared resolver. Native Rust/C++ :: labels resolve via the exact
tier and are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A partially-symbolic backtick name (`%foo%`, S3 `[.cls`, dotted `my.op`)
normalized to the same _make_id suffix as an ordinary identifier, so the second
node was silently dropped by the seen_ids merge. Route every backtick-quoted
name through the reserved __operator__<hex> namespace, not only empty-suffix
operators; #4171's existing ids are unchanged. (The #4253 hang itself was
already fixed by #4171's resolve_local visited guard.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
File-scope begin/process/end/dynamicparam/clean parse as pseudo-commands
(command_name 'begin'), not named_block, so the command branch returned before
the child walk and any function/class defined inside was dropped. Descend into
those five block keywords' children; ids stay name-based and stable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unqualified call fell through to a file-wide last-writer-wins label map, so
it bound to the last-declared same-named method. Walk the caller's class chain
(own class + in-file bases nearest-first, then enclosing classes) and bind a
single hit; fail-closed on a tie; free functions/constructors still resolve.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve a.b.C(), xs[i].M(), and calls inside constructor bodies: a fail-closed
chain/element-type resolver (one declared type per hop, collection element only
for T[]/List<T>, bail on ambiguity) plus a ctor-body walk attributing calls to
the type node. Reuses the existing C# scoping; avoids same-pair method/calls
duplicates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exclude case_of targets from the per-file label map and apply the cross-file
_member_nids exclusion to all C# calls (not only new X()), so a bare call like
Delegate(t) no longer binds to a same-named enum case. Methods are method/
contains targets, not defines, so real call targets survive; fail-closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the type-parameter arity into the C# type node id (Effect`1), so Effect
and Effect<T> no longer collapse to one node and Effect<T> : Effect stops
producing an inherits self-loop. Arity 0 keeps the current id (back-compat);
backtick is collision-free and deterministic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refuse a read-only destination in _atomic_replace on Windows (nt-only guard;
os.replace there raises PermissionError and the fallback would silently clobber
it), plus test-only guards: utf-8 encoding kwargs, posix-shape path assertions,
FIFO/AF_UNIX/symlink skips, and a replace-retry for AV/indexer locks. No POSIX
behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read existing CLI build-config options in fresh and incremental skill
scan fragments, keeping corpus boundaries consistent without changing
standalone detection API defaults. Regenerate all host artifacts and
snapshots. Add rendered-snippet regressions and explicit-override tests.
Fixes#4240.
AI-Assisted-By: AI coding assistant
(cherry picked from commit 3f68952d80)
The default graph keeps one undirected edge per node pair. A factory
that returns new Report() also has a calls edge back to Report, and
that call was the one that survived. Report then had no method edge
to Fault, so anything that walks a type's members missed it.
A method or contains edge now wins over a calls edge in the opposite
direction. A directed graph still keeps both.
Fixes#4248
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 95546d6784)
A node without a label is drawn under its id (`G.nodes[n].get("label", n)`),
a third path from text to graph.svg. The previous commit cleaned the labels
and left that fallback as it was, so an unlabelled node whose id holds a C0
control character still produced a graph.svg that a strict parser rejects.
Run the fallback through the same strip: one expression,
`_strip_xml_illegal(str(G.nodes[n].get("label", n)))`, now covers the label
and the id. A clean id is drawn as before.
Tests: for each control character the module already lists, an unlabelled
node whose id carries it; the file must parse and still show the cleaned id.
Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp)
(cherry picked from commit 2e7e28ab32)
extract_rust bound every in-file call through one bare-name index (last one wins),
so `Matcher::new()` reached `Printer::new()` when the file defined both, `Arc::new(x)`
reached the file's own `fn new`, `self.go()` another type's `go`, `drop(x)` the
`impl Drop` method and `x.total()` a free `fn total`, all EXTRACTED.
`Type::f()` / `Self::f()` / `self.f()` now bind only to that type's method in the file
(type = last path segment without generics, on the call and on the impl; `i32` and
other primitives count as types). With none, `Enum::Variant(x)` still gets its
constructor reference and `self.f()` falls through to the cross-file self pass. A bare
`f()` binds only to a free function or a type, `x.f()` only to a method.
Against rust-analyzer: wrong edges ripgrep 1232 -> 532, bat 138 -> 36, fd 29 -> 9;
correct edges 1852 -> 2236, 602 -> 655, 148 -> 154.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit de9b728ac6)
Print a precise, interactive-only CTA after `graphify extract` succeeds:
faster indexing, fewer tokens, cross-repo search, PR review, and an
always-current SDLC map at app.graphify.com. Shown once per project (marker
under graphify-out/), suppressed when stdout is not a TTY (CI, pipes, the
AI-assistant pipeline) and via GRAPHIFY_NO_TIPS/GRAPHIFY_NO_CTA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-file pass walks only bases declared in the same file, so `Flask.__init__` ->
`App.__init__` (flask/app.py -> flask/sansio/app.py) and every other inherited call
across modules had no calls edge.
_resolve_python_member_calls now walks the caller's class up a single-inheritance
chain and binds the first class that owns the method, as the MRO does. It stops with
no edge at multiple inheritance, a base the inherits edges cannot show (marked
`python_opaque_bases` by the extractor: `mod.Base`, `Generic[T]`), a base outside
the corpus or a self-loop, a caller base its file neither defines nor imports, and
`super(Cls, obj)` with arguments. On a changed-files rebuild an unchanged base is
still a name stub, so the walk rewires copies of the inherits edges the way the full
build does; the opaque-base marker is forwarded with the other context metadata.
AST cache schema 6 -> 7.
flask: 66.7% -> 71.6% of jedi's call pairs found; 16 new edges on httpx/click/flask,
16 confirmed by jedi, 0 lost. Changed-files rebuilds match full builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit b241c3bc79)
The typed-receiver arm of _resolve_python_member_calls checks that the receiver's
class is defined in or imported into the caller's file. _file_node returned the
caller's first contains/method parent, which is the file for a top-level function or
method but the enclosing function for a nested def and the outer class for a nested
class's method, so those calls (click's `def callback(ctx: Context, ...): ctx.abort()`)
never got an edge. Climb the parents to the top, which is always the file node.
0.9.80 vs this change: click +10, rich +4 call edges, all 14 confirmed by jedi at the
call site, 0 lost; httpx, flask and typer unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 56634ac22d)
One-line definitions (`def a(x), do: b(x)`) have no do_block node: the
body is the value of the `do:` pair in the arguments keywords node. The
extractor only registered do_block bodies for the call-walk pass, so every
call inside a keyword-form body was silently dropped from the graph. When
there is no do_block, take the `do:` pair value as the body and walk it
the same way (Fixes#4207).
AI assistance: implementation drafted with Muse (Anthropic), reviewed and
verified by the author.
Signed-off-by: Raphael Malikian <rtmalikian@gmail.com>
(cherry picked from commit b00d3704db)
A directory-qualified stem fallback could resolve an absolute import
(from werkzeug.wrappers import Request) onto a local wrappers.py, rewiring a
class to inherit from itself. Exclude the importing file's own stems; a
same-named module in another package still resolves by exact/suffix/bare stem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maintainers are seeing more AI-assisted pull requests. The rules for
them already exist across this guide (branch off v8, one concern per
PR, regression tests that fail before the fix, never hand-edit
generated skills, Co-Authored-By disclosure), but an agent or a new
contributor has to piece them together.
This adds a short "Working with AI Coding Agents" section under Pull
Requests that collects those existing rules in one place. It adds no
new policy. It also mentions ContribOS as an optional tool that reads
these rules and checks a branch against them; the maintainer agreed
to a PR for it on X.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d47b311588)
_resolve_typescript_member_calls required a receiver's type name to be unique in the
corpus before applying the #2553 origin gate, so hono's `c: Context` (a class plus two
interfaces and a type named Context) never resolved, though the caller's
`import type { Context } from '../../context'` names the exact one. Apply the origin
gate to the candidates first and emit only when exactly one is visible; one definition
behaves as before, an undecided import still emits nothing.
_key() also folds `#name` into `name`, so `c.newResponse()` could bind to a private
`#newResponse()`. Skip a match whose leading `#` disagrees with the call.
Against the TypeScript compiler, 0.9.80 vs this change: hono obj.method() 20.3% -> 47.3%
(+23 edges), NestJS +5, TypeORM +3, all 31 confirmed at the call site, 0 lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 7935593eca)
The TS receiver table behind `this.repo.findById()` held constructor parameter
properties, `new` locals and typed parameters, but no class fields, which is how most
NestJS, Angular and TypeORM classes hold their collaborators (`protected repo: Repo`,
`private logger = new Logger()`, `#root: Node`). Those calls got no edge.
_ts_receiver_type_table now records non-static class fields: `name: T`, `name?: T`,
`T | undefined` / `T | null`, an untyped `new T()` initializer, and `#private` names
keyed as the extractor records the receiver. Arrays, generics and two-class unions stay
untyped; the #2553 origin gate and first-binding-wins are unchanged. JS/TS files bypass
the AST cache, so no schema change.
Against the TypeScript compiler, 0.9.80 vs this change: NestJS this.x.method() 47.9% ->
77.0% (+102 edges), TypeORM 64.5% -> 79.6% (+91), all 193 confirmed at the call site,
0 lost; changed-files rebuild == full build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit e5fb5e3b95)
AMBIGUOUS-tagged edges (confidence ≈ 0.35, often `conceptually_related_to`
from LLM semantic extraction) could glue otherwise-independent clusters
together through Leiden/Louvain modularity, and the resulting bridge
questions crowded `Suggested Questions` with low-signal entries. The
`AMBIGUOUS` flag was already correct — only downstream consumers
treated it identically to EXTRACTED/INFERRED.
Changes:
- `cluster()` gains `ambiguous_scale: float = 0.5` (overridable via
`GRAPHIFY_AMBIGUOUS_SCALE`). Multiplier is applied to AMBIGUOUS edge
`weight` in a graph copy before partitioning so the raw graph (and
`/graphify path`/`explain` output) stays untouched. 1.0 restores
legacy behavior.
- `suggest_questions()` tags each ambiguous_edge entry with
`low_confidence=True`. Interleave + top_n contract from #3849 is
preserved; the tag is purely metadata.
- `report.py` splits tagged entries into a new "Low-confidence Hints"
section below the main Suggested Questions.
Tests:
- `test_cluster_ambiguous_scale.py` (new, 7 tests): default scale
demotes AMBIGUOUS weight, scale=0.0 drops it entirely, scale=1.0
is no-op, env override + malformed env fail-soft, raw graph not
mutated, no copy when no AMBIGUOUS edges present.
- `test_suggest_questions_ambiguous.py` (new, 4 tests): tag present
on every ambiguous_edge entry, never on other types.
- `test_analyze.py::test_suggest_questions_diversity_preserves_limit_and_candidates`
relaxed to allow the optional `low_confidence` key.
Fixes#4199
---
한국어 요약: AMBIGUOUS edge (LLM 추론, 신뢰도 ~0.35) 가 Leiden 모듈러리티로 독립
클러스터를 묶거나 Suggested Questions top-N 을 잠식하던 문제 수정. cluster() 에
ambiguous_scale 파라미터 (default 0.5, env override) 추가, 원본 그래프는 복사 후
수정하여 /graphify path/explain 영향 없음. suggest_questions 는 low_confidence
플래그만 추가하고 interleave contract (#3849) 유지. report.py 가 분리 렌더링.
regression test 11 개 신규 + 기존 1 개 relax.
(cherry picked from commit ff52a29266)
Seven bugs of one shape (#4152, #4154, #4156, #4164, #4180, #4182, #4188) were each
found by diffing two builds of the same tree by hand: a checkout path leaking into an
id, a rebuild writing a different graph than the first build, CRLF ids differing from
LF, an edge to a node that is never declared. Each fix got its own narrow test, so the
next extractor or write-path change can bring the same class back unseen.
tests/test_graph_invariance.py builds the whole tests/fixtures corpus (every extractor)
through the real CLI, --no-cluster and clustered, and checks: two folders give the same
graph, the checkout path appears nowhere in graph.json, a rebuild of the unchanged tree
changes nothing, LF and CRLF give the same graph, and every raw edge endpoint is declared.
On 0.9.79 the three checks those fixes cover fail; on 0.9.80 all 12 pass in about 20 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 4293a638d4)
Resolves a call on a local or parameter whose class is known from an annotation
(c: Client), an Optional/union/string annotation, or a constructor/with binding,
to the owning class's method - fail-closed to a single in-file-or-imported class
that owns the method. Complements the self.<attr> arm from #4176.
Rebased onto the landed #4176: reuses its _emit_call(inferred=True) INFERRED/0.85
path instead of the PR's own confidence= parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
extract()'s id remap called Path.resolve() on the same file paths once
per input, once per target_file-stamped edge and once per node: ~24k
calls for ~1k distinct paths on graphify's own repo. Resolve each
distinct path once per extract() call through a local memo; a hit
returns the same Path the call would, and a path that raises is never
stored.
_path_proximity_winner (the #1553 god-node tie-break) re-parsed every
candidate's parent directory with PurePosixPath on every ambiguous call
site (~227k Path objects per update on graphify's repo), although the
same candidate files come back for every site of a common name. Memoize
the parent-directory parts per path string; equal tuples are exactly
equal PurePosixPath parents, so every tier decides as before.
graphify's own repo (18,830 nodes), `graphify update --force`,
alternated: cold 28.1 -> 26.3 s, no-change update 24.8 -> 22.5 s and
20.9 -> 18.0 s. graph.json identical (all attributes) cold and warm;
old vs new tie-break identical on 200,000 random cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d5966eda8a)
0.23/0.24 cap at ABI 14 while pinned grammars ship ABI 15, so a resolver could
pair a 0.23 runtime with ABI-15 grammars and fail every parse. Floor at 0.25,
the oldest runtime that loads them (uv.lock already resolves there). Lock
regenerated on 0.9.80.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The raw --no-cluster writers skip build_from_json. `update --no-cluster`
only minted the #2873 import stubs, and `extract --no-cluster` did not
even do that, so graph.json kept edges to undeclared nodes. The common
case is a markdown link to a missing document: extract_markdown keeps
it dangling on purpose and builds the target id from the absolute
resolved path, so the checkout folder and OS username reached
graph.json.
finalize_raw_graph_endpoints() now runs on both raw writers: mint the
import stubs, drop links that still lack two declared endpoints (after
the same normalization remap build_from_json does), and drop stubs no
link references any more, so a stub minted on the extract path cannot
outlive its import.
On graphify's own repo: 186 dangling edges -> 0 (149 carried the
checkout path), node set unchanged, and every pair the clustered build
links is still linked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 97027ccf04)
extract() returns extracted_sources (#3411) and failed_sources (#2543)
next to nodes/edges. They steer the current run and hold the absolute
paths of its inputs. The raw --no-cluster writers dumped the extraction
as graph.json, so the first `graphify update --no-cluster` and every
`graphify extract --no-cluster` wrote the checkout path and OS username
into the graph. A rebuild dropped them again, and also added a
`hyperedges` key the first build lacked, so the first build and a
rebuild of the same tree wrote different files.
Name the run-only keys once (extract.RUN_ONLY_EXTRACTION_KEYS) and leave
them out of both raw writers; the update writer always writes
`hyperedges`, as the extract writer and the clustered path already do.
Nothing reads these keys back from graph.json: build_merge and
merge_raw_extraction take them from the in-memory extraction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 76cfcef556)
External import stubs (#2873) are minted without an _origin stamp. On
the next update the #2334 tier backfill stamped them by shape, and a
stub has no L<line> location, so it became `semantic`. Reconcile keeps
semantic nodes, so once the last file importing a module stopped
importing it, the stub stayed in graph.json forever with no edge. It
also made a rebuild of an unchanged tree differ from the first build.
A stub is derived from its import edges and minted again on every
write, so it has no tier. The backfill (now one helper shared by
_load_existing_graph and the reconcile path) skips stubs and removes a
stamp an earlier run gave one; every tier decision reads an unstamped
stub exactly as before. Reconcile drops a preserved stub that no fresh
or preserved edge references.
On graphify's own repo a warm rebuild now equals the cold build (125
stub nodes differed), a long-lived graph heals to the cold node set
(8 ghost stubs removed), and the changed-files path equals the full
rebuild for edits and additions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d3fbf6458f)