The Windows skill variant had a PowerShell Step 1 but its later steps came
from the shared core fragment as bash-only shell (cat-piped interpreter
invocations, rm -f, find -delete). The skillgen renderer now translates the
composed core to PowerShell for powershell-shell platforms (here-string
interpreter invocations, Remove-Item cleanup); POSIX skills are unchanged
and step / #2490 parity is enforced by a generator check. Not a regression
from 0.9.35 (skill files were unchanged between 0.9.34 and 0.9.35).
Thanks @tannermosher2015-debug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_collision_rank no longer falls through to raw lexical source_file order.
It now ranks by a lifecycle penalty (active over archived) computed on the
root-relative path, then a reversed-segment tie-break, so the winner of a
collision (e.g. plans/_done/x.md vs plans/in-progress/x.md) no longer
depends on ASCII filename order, absolute-vs-relative path form, or the
checkout directory name.
Active/archived idea from PR #2540 (thanks @michaelxer); the root-relative
and reversed-segment determinism is implemented here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
swift_extensions[].nid is now remapped in the in-process id/sym remap
passes (not just the cache path), so a cross-file extension merges onto
its base type before call resolution; static Foo.bar() and singleton
Foo.shared.bar() calls resolve again. The merge candidate set is gated by
language family + type-def predicate so it never absorbs a same-named
type from another language.
Folds in PR #2539 (thanks @pawelo446).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cluster-only warns when --backend/--model/--batch-size are ignored on the
label-reuse path; the community-label prompt key no longer collides with
the discard sentinel (an echoed key was silently dropped); tree --root
exits non-zero when it matches no source file instead of flattening the
tree; and cluster-only stamps built_at_commit from the analysed graph, not
the shell cwd. Also folds in the cluster-only refused-write guard from
PR #2522 (thanks @aniJani).
Thanks @elecnix for the report.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2508 (thanks @Tomaskobel): callflow loads the graph directed and recovers
direction from _src/_tgt markers (consistent with the path fix), so
caller/callee columns are correct; indirect calls are now counted.
#2507 (thanks @filipechagas): relational-intent verbs (calls/uses/extends/
...) are excluded from the per-term seed guarantee, so a decoy matching
only the verb no longer becomes a traversal root, while a verb that is a
real symbol name can still be seeded on merit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2495 (thanks @alisson-acioli): graphify update now evicts a file newly
added to .graphifyignore/--exclude even though it still exists on disk
(positive ignore-rule evidence), with .gitignore-driven eviction gated on
an explicit full update; unchanged in-corpus files and fail-closed #1795
cases are preserved.
#2504 (thanks @te7ina-honey): the Java type resolver runs before the
unique-label stub rewire and parks imported-but-external types on their
FQN, and cross-file import resolution checks the package, so a local
class and a same-named external annotation (e.g. Spring @Component) no
longer collapse into one node. In-corpus annotation resolution unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2497 (thanks @sortakool): the shrink guard read the post-replace node
count and was skipped under prune_sources, so a broken partial re-extract
could silently destroy nodes. It now diffs the on-disk baseline by node
identity and refuses any loss from a source neither re-extracted nor
pruned this run, and reports replaced-node counts.
#2446 (thanks @AI-invest): prune_sources given absolute paths under a
non-standard layout now derives the scan root by suffix-matching stored
source paths, reports the accurate matched count, and warns instead of
saying 'already clean' when nothing matched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2486 (thanks @adminwat): normalize dict-shaped hyperedge members to ids
(or drop with a warning) so a malformed hyperedge can't abort a completed
merge with a TypeError.
#2484 (thanks @sortakool; approach from @oleksii-tumanov's #1691):
merge-graphs relabels hyperedge member ids and ids with the repo prefix,
unions both inputs' hyperedges instead of clobbering, and writes both
persistence slots.
#2485 (thanks @sortakool): build_from_json reads hyperedges from the
top-level and nested slots; a full validation wipeout is reported loudly.
#2490 (thanks @PapiScholz): the skill Step-5 flow passes curated
community_labels to to_json, so graph.json ships community_name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
graphify path and the MCP shortest_path tool now build a digraph from the
true _src/_tgt directions and respect edge direction by default, so a
returned path no longer traverses edges backwards. --undirected (CLI) /
undirected=true (MCP) opt out; no directed path is reported plainly
instead of silently returning a reversed one.
Thanks @luliaz0601.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Track C# receiver types per lexical declaration scope (byte ranges) and
resolve by the call's position, instead of a method-wide flat table that
poisoned a name on any None-typed binding. A typed static local-function
parameter now keeps resolving even when an out var reuses the name in the
enclosing body. Fixes a regression from #2346; #2299 cross-method
independence and field-conflict poisoning are unchanged.
Thanks @JensD-git for the bisect and repro.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2411 (thanks @JensD-git): key the C# partial-class merge on assembly
(nearest ancestor .csproj/.fsproj/.vbproj) in addition to namespace and
name, so same-name partial classes in different assemblies stay distinct
while genuine partial halves within one project still merge. Fixes a
0.9.32 regression from #2332.
#2437/#2438 (thanks @aryanbonigala, builds on PR #2439): incremental
rebuilds no longer drop member-call and indirect_call edges from a
changed file into an unchanged target. Re-resolution now sees the
unchanged corpus (nodes, contains/method edges, and _callable markers,
which now persist to graph.json like _origin); edges to a genuinely
removed target are still evicted.
#2444/#2445 (thanks @Baziar, builds on PRs #2461/#2458): a
BrokenProcessPool triggers the sequential fallback instead of being
swallowed per future, a failed worker file is retried sequentially
rather than merged as empty, and a whole-pass AST failure on a fresh
build exits non-zero instead of writing a zero-node graph
(--allow-partial opts into a best-effort partial graph).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
module Foo::Bar and nested module Foo; module Bar are canonicalized to
one fully-qualified label, and include/extend/prepend keep the full
constant path, so include Foo::Bar resolves. Mixin resolution is scoped
and lexical: a qualified external name (extend ActiveSupport::Concern)
no longer binds to a local module named Concern, while a genuine
in-corpus include Foo::Concern still resolves. Nested-declared classes
keep a last-segment index so typed-receiver calls (Processor.new) keep
resolving.
Adapts the approach from #1394 (thanks @FolatheDuckofDuckingburg) to the
post-refactor extractors/engine.py, with an added last-segment guard for
nested-class receiver resolution.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- C# receivers declared inline via out-var / is / case / switch-arm patterns
are now typed into the per-method table, so their member calls resolve (#2346).
- partial class halves across files now merge to one class node (new
_merge_csharp_partial_class_nodes pass, mirroring the Swift-extension merge),
so cross-half member calls resolve instead of splitting the class (#2332).
- Kotlin anonymous-object (object : Foo {}) members now get nodes, contains/
implements edges, and their calls resolve (#2347).
All in-corpus only, never a wrong edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Node/edge ownership was keyed on source_file alone, but each file has an AST
tier and a semantic tier. A semantic-only re-extract deleted a doc's AST
headings, and a full _rebuild_code deleted document AST nodes. Add an
_is_ast_tier() predicate (with a source_location legacy fallback for the
unreliable _origin marker, #2334), backfill _origin on load so graphs
self-heal, make build_merge / merge_raw_extraction replace by (source_file,
tier) instead of source_file (coexist policy: an AST re-extract replaces only
AST nodes and keeps the semantic layer, and vice versa), and scope the
full-rebuild AST drop to sources actually regenerated (#2336) so a
semantic-backed doc keeps its AST layer. Deletion prune stays tier-blind.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The build_merge fix only covers the incremental path reached by
`graphify <path>`. `graphify update` rebuilds through _rebuild_code in
watch.py and never calls build_merge at all, so it still downgraded a
directed graph — which is the symptom named in the issue.
Two sites: the clustered path called build_from_json(result) with no
directed=, taking its directed=False default, and the --no-cluster path
built candidate_graph_data straight from the raw merged extraction, which
never carries a directed key, so the written file lost it entirely. Both
now inherit from existing_graph_data, defaulting to False when there is no
existing graph, matching build_merge's semantics.
_canonical_graph_for_compare normalizes a missing directed key to False so
an older graph.json without the key does not register as changed against a
candidate that now writes it explicitly.
build_merge took graph direction from its caller only, defaulting to
directed=False, and rebuilt with that default at build.py:1327 — discarding
the direction of the graph it had just loaded from graph_path. Since the
only call site (cli.py:3547) passes no directed=, every incremental
`graphify update` through the merge path silently returned an undirected
graph for a directed one.
_load_existing_graph now also returns the on-disk `directed` flag, parsed
from the JSON it already reads, and build_merge's parameter becomes
bool | None: None inherits that flag when a graph exists and falls back to
False when there is none, while an explicit True/False still wins.
"coverage" was an unconditional _SKIP_DIRS entry and _is_noise_dir matches
directory names at any depth, so a repo where coverage is a legitimate
package name lost the whole package from the graph — no warning, no
skipped_sensitive entry, nothing in the report. The failure is quiet in
the worst way: the package's dependents survive, so queries keep
returning plausible neighbours while the package itself has no nodes.
The entry's comment cites Vitest/Istanbul/nyc HTML reports (#870), but
the pruning is language-agnostic, so it also removes Python/Go/Rust
packages that happen to be called coverage.
detect.py has been fixed twice for this exact shape — #1666 gated a bare
snapshots/ on real .snap evidence, #2058 gated env/.env/*_env on real
virtualenv markers — so this applies the established pattern rather than
a new mechanism. _has_coverage_artifacts() mirrors _has_venv_markers():
same OSError guard, same "cannot verify, keep a possibly-real code dir"
contract when no parent is available. Evidence is a file a coverage tool
actually writes (lcov.info, coverage-final.json, clover.xml,
coverage.xml, cobertura-coverage.xml, jacoco.xml, .coverage, index.html)
or an lcov-report/ / html-report/ subtree, covering lcov, nyc/Istanbul,
coverage.py, JaCoCo and Cobertura.
lcov-report stays unconditional: it has no false-positive class, so
gating it would add filesystem probes for no benefit — the same split
#1666 made between the unambiguous __snapshots__ and the bare snapshots.
Seven tests, all red on 4fe1109 and green with the fix:
- location, parametrized over the clustered and --no-cluster write sites
- the unchanged-topology early return, the third write site; asserts the
"No code-graph topology changes detected" line so the test cannot
silently drift onto a different branch and keep passing
- the invoking project's own manifest survives an update of another
project (the data-loss half)
- a relative target stores relative keys
- the resulting manifest is actually consumable by detect_incremental
- built_at_commit matches the target repo's HEAD, not the caller's
`graphify query` returned the right nodes but dropped real edges between them.
Both traversals recorded an edge only when it discovered an unvisited
neighbour, so the result was a traversal tree rather than the induced subgraph
over the node set the query reports.
`_bfs` marks every seed visited before the loop starts, so an edge between two
seeds could never be recorded — the reported symptom, where both endpoints
render and the edge between them does not. It drops ordinary cross-edges and
hub-adjacent edges for the same reason.
`_dfs` needed the same completion for a narrower case. It appends on push
rather than on visit, so it already captured seed-to-seed and cross-edges; an
exhaustive search over every connected four- and five-node graph found no miss.
Its one gap is an edge between two non-seed hubs, where the hub guard means
neither endpoint is ever expanded and so neither records it.
The new `_complete_induced_edges` runs at the end of both. It scans only edges
incident to the visited set, so cost tracks the subgraph rather than the whole
graph. A visited hub is rescanned in full, which is unavoidable: a hub-to-hub
edge is exactly the case `_dfs` misses. Placing the pass inside the traversals
rather than at the call site binds it to the context-filtered `traversal_graph`
by construction, so a relation the user filtered out cannot reappear.
Dedup keys on the ordered pair for directed graphs and the unordered pair
otherwise: on a DiGraph `u->v` and `v->u` are distinct edges (mutual recursion,
circular imports) and collapsing them would drop a real one. Parallel edges on
a multigraph collapse to one entry, matching the renderer, which already shows
only the first. Self-loops are skipped, since no traversal ever recorded one
and surfacing recursion edges is a separate output change.
Traversal edges keep their discovery order; completions are appended after.
Closes#2323
A backend can emit {"id": 10} where the schema says {"id": "10"}. Every id
consumer downstream assumes str, so one numeric id aborted the whole run at
the final merge step, after the full extraction had already been paid for.
There are three crash sites, not the one in the report:
- dedup._pick_winner's _CHUNK_SUFFIX.search(n["id"]) raises
"expected string or bytes-like object, got 'int'" (the reported
traceback), but only when the node lands in a duplicate or fuzzy group
- build_from_json's sorted(node_set) raises "'<' not supported between
instances of 'str' and 'int'" for a lone node with nothing to dedup
- the same sort, reached via the fuzzy-group path
A str() cast at the reported line therefore does not fix the common case:
a single numeric-id node never reaches _pick_winner. Coerce at ingest
instead, in _coerce_non_string_ids, called from build() before dedup (which
keys on id) and from build_from_json (the direct entry used by cli.py,
watch.py and diagnostics.py, which never goes through build()). The nested
call on the build() path is an idempotent no-op.
Edge endpoints and hyperedge members are coerced alongside the nodes on
purpose: coercing node ids alone would renumber 10 to "10" and leave every
edge pointing at the vanished 10, trading a loud crash for a silently
disconnected graph. The legacy from/to aliases are included because dedup
reads them directly (#803).
Only bool-free numeric scalars are coerced. A None, list or dict id is left
for validate_extraction to report, since str(None) == "None" would fabricate
an id that no edge references.
Closes#2326
graphify path decided hop direction from the persisted source/target order,
so a link stored in flipped order (pre-#563 graphs, raw dumps, merge-driver
output) printed backwards; explain had the same defect, and the query/merge
load shims clobbered in-file _src/_tgt markers. All now honor _src/_tgt (the
build-side direction truth), falling back to arc order for markerless files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SQL references to a table defined in another file (e.g. prisma m2 -> m1) were
minted as node-less absolute-stem ids under the referencing file, leaking the
scan path and losing the FK. They now mint a sourceless bare-name stub (Go
#1402 pattern) that _rewire_unique_stub_nodes collapses onto the real table.
Also normalize identifiers (strip quotes, lowercase, bare-name alias) on both
table_nids writes and reference lookups, so a quoted definition ("public".
"users") resolves an unquoted reference (public.users) in-file instead of
stubbing — which also fixes pg_introspect's reconstructed-DDL views/FKs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The C# receiver-type table was per-FILE and poisoned a name on any
conflicting/untypable rebind anywhere in the file, so `var item = items[i]`
in one method silently deleted the true calls edge in another method where
the same name was a typed parameter (~2.3% of calls lost, per the reporter).
Ported C# to a per-method table mirroring the Java resolver (per-class field
scope + method params/locals, method-local poisoning only) and retired the
file-wide table. The namespace resolver, ambiguity bail, and inherits-chain
guards are unchanged, so the no-wrong-edge bar holds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_find_node` ranks matches but never reports that a tie was broken, so callers
taking `matches[0]` present one arbitrary file as the answer. Two workspaces
that each define `MetricsPort` put both nodes in the same `exact` tier,
separated only by `G.nodes()` iteration order — reorder the graph and the same
query answers with a different file, equally confidently.
`affected` already declines this via `resolve_seed()` returning None, and
`path` warns when the top two scores are within 10%. Only `explain` (and the
MCP `get_neighbors` tool, which shares the matcher) was silent.
Split `_find_node` into `_find_node_tiers` (logic unchanged, tiers exposed) plus
a flattening wrapper, and add `find_node_ambiguity()`, which returns rivals only
when the winning tier spans multiple source files. Matches within one file (a
file node plus its members) stay ordinary precedence and resolve as before.
`_disambiguate_file_node_labels` (#2032) already relabels colliding *file*
nodes; this covers the symbol case it does not reach.
Review feedback on #2296: the previous revision added Go's predeclared
names to _LANGUAGE_BUILTIN_GLOBALS, which ~11 languages consult through
engine.py and the cross-file pass, and whose check wraps the in-file
EXTRACTED branch as well as raw_calls. Two confirmed regressions:
* Rust normalizes 'Widget::new(3)' to the bare token 'new', so every
in-file 'Type::new()' edge disappeared. Rust keeps its own
_RUST_TRAIT_METHOD_BLOCKLIST, deliberately on the cross-file branch
only — this change follows that language-local pattern.
* Go 'h.append(v)' is a selector_expression call to a real method and
was dropped with it. On the 3x-ui corpus this cost a genuine
'systemMetrics.append(...)' -> '(*metricHistory).append' edge.
The filter now lives in extractors/go.py as _GO_PREDECLARED_FUNCS and
fires only when the callee node is a bare identifier, so selector calls
('h.append(v)', 'pkg.Delete(x)') and every other language are untouched.
Go raw_calls now carry language="go" and the shared pass gates on it,
mirroring the bash gate, as a backstop for Go raw_calls minted
elsewhere. The set is the Go spec's predeclared list in full: being
Go-local and bare-identifier-only makes 'len'/'max'/'min'/'print' safe
to include, and a spec boundary beats a hand-picked subset.
Remeasured on the same 466-file Go corpus:
upstream v8 16904 edges, 334 inbound to 'append'
previous rev 16571 edges, 1 inbound (332 phantom gone, but the
genuine selector call too)
this rev 16572 edges, 2 inbound (332 phantom gone, genuine
call restored)
Tests kept, plus three regressions: builtin 'append' must not bind
in-file (the branch a cross-file-only gate would miss), the Go selector
call must survive, and the in-file Rust 'Type::new()' edge must survive.
The last two fail on the previous revision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
graphify/extractors/go.py already consults _LANGUAGE_BUILTIN_GLOBALS
when resolving a callee, but the table covered JS/TS, Python and Swift
(#726, #2147) and never Go. Because the Go resolver looks the callee up
by bare name, an unexported method sharing a builtin's name absorbs
every builtin call in the repository.
Measured on a real 8.9k-node Go+TS codebase: a
'func (h *metricHistory) append(...)' method collected 330 phantom
inbound calls edges from the project's ordinary append(slice, x) calls,
which in turn invented twelve database-layer -> service-layer edges —
a layering violation absent from the source. After the fix, phantom
cross-file edges to builtin-named nodes drop from 325 to zero; the 8
that remain are all same-file (struct -> method, and real calls to that
method), and the user's method node is untouched.
Builtin types are deliberately not listed. Go conversions are
call-shaped too, but they produced no phantom edges on that corpus and
listing them would suppress genuine constructor-like calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_ANTIGRAVITY_WORKFLOW` is shared by the global and project-scoped installs,
which write SKILL.md to different locations (~/.gemini/config/skills vs
.agents/skills). The template named the global path unconditionally, so
`install --project --platform antigravity` (and `antigravity install
--project`) emitted a workflow pointing at a file that scope never writes -
a dangling reference on any machine without a prior global install.
Refer to the skill by name rather than by location, matching what every
block in always_on/ already does. Antigravity resolves it from the
`name: graphify` frontmatter in either scope, so no path interpolation is
needed and neither scope can dangle.
The existing tests only asserted the workflow file exists, which is why this
shipped; add coverage that the generated workflow names no skill path, in
both project and global scope.
botocore max_attempts counts the initial call, so GRAPHIFY_MAX_RETRIES must
map to _resolve_max_retries() + 1 (a value of 6 -> 7 total attempts; 0 ->
1, i.e. no retry). Also stub botocore.config in the #2288 reasoning-model
test, which broke once #2283 added the botocore.config import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_parse_js_tree parsed .tsx with language_typescript(), so JSX misparsed and
error-recovery floated nested handlers to top level; the symbol-resolution
pass then emitted calls edges whose SOURCE was an absolute-stem id for a
caller that owns no node — a leak the 0.9.29 backstop (learns only from
nodes) can't see. Fix: use language_tsx() for .tsx; never emit a calls
use-edge from an unowned source (reattribute to the file node); and teach
the backstop stem-form prefixes so any node-less absolute-derived endpoint
canonicalizes. No node id or edge endpoint now carries the scan-root slug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two bedrock-runtime clients (primary extraction in _call_bedrock and
the secondary dispatch path in _call_llm) were built with no botocore
config, so Converse used botocore's 60s default read timeout and ignored
GRAPHIFY_API_TIMEOUT / --api-timeout entirely. A long opus-class
generation then died with "Read timeout on endpoint URL" no matter how
high the timeout was set.
Both client constructions now pass a botocore.config.Config wiring
read_timeout to _resolve_api_timeout() (default 600s), a 10s
connect_timeout, and retries from _resolve_max_retries() in adaptive
mode. This mirrors the fixes that closed the same gap for the claude-cli
subprocess (#1112/#1111) and the secondary LLM dispatch path (#1442) --
bedrock was the last cloud backend still ignoring the knob.
Also updates the README env-var row, which listed the timeout as
applying to HTTP/claude-cli/Anthropic only, and the _fake_boto3 test
fixture to register botocore.config and capture the client config so the
new coverage can assert the timeout is wired.
Converse returns output.message.content as a list of blocks and does not
promise a text block is first. Reasoning-capable models emit a
reasoningContent block ahead of the answer, and toolUse or future block
types can precede it too, but both bedrock call sites indexed position 0:
content", [{}])[0].get("text", "{}")
For those models the default was returned on every call, so _parse_llm_json
saw an empty object, _response_is_hollow reported a hollow result,
finish_reason was rewritten to "length", and the adaptive retry bisected the
chunk. Splitting could not converge because the position assumption fails
identically at every chunk size, and raising GRAPHIFY_MAX_OUTPUT_TOKENS did
nothing because output length was never the constraint. stopReason on those
responses was end_turn, i.e. the model had answered correctly.
Selection now keys on the block's shape rather than its position, at both
_call_bedrock and the bedrock branch of _call_llm. A response whose first
block is already text -- every non-reasoning model today -- is unaffected.
On a 48-document corpus the hollow warnings and the bisection to the
recursion cap disappear, the 17 files previously reported as producing no
nodes are extracted, and output tokens drop from 217,538 to 53,274 as the
wasted retries stop.
Fixes#2287
Extractors mint node ids from the path STRING they are handed
(_make_id(str(path)), _file_node_id(path)), so an AST cache entry written
under root A embeds A's slug in every id and edge endpoint. save_cached
relativized only source_file, never the ids. Because extract()'s
id-remap / final-canonicalization passes key their rewrites off the
CURRENT run's paths, an A-derived id matches no key on a warm hit under
root B (a clone, a moved checkout, a second mount) and the stale
machine slug survives into graph.json. Distinct from #2231/#2243, which
fix producers on a cold run, and from #2199 (stat-index portability).
Entries are now stored root-anchored and re-anchored on read, the same
store-portable/re-anchor-on-load contract source_file (#777) and the
stat index (#2199) already use: _relativize_ids_in replaces the root's
contribution with a $graphify-root$ marker on write, _absolutize_ids_in
restores what the current run's extractor would mint on read. That is
the pre-remap form every downstream pass in extract() expects, so a
replay reproduces a cold run exactly and no other pass changed.
The anchor is derived per entry rather than assumed equal to the scan
root (normalize_id distributes over path joins), so a symlinked root or
relative inputs decompose exactly; only absolute root spellings may
anchor, or a relative root ("src") would rewrite an already-canonical
src_utils_foo into an absolute-derived id on the semantic path. The
walk covers the whole payload rather than a bucket list, since the id
form is self-identifying: that also reaches raw_calls[].caller_nid,
swift_extensions[].nid, edges[].target_file, bash_sources[].source_file
and *_type_table.path, the last three being resolution inputs that
would otherwise still point at root A. save_cached's deepcopy is now
unconditional; the old truthiness gate skipped it for a payload whose
only content lives outside nodes/edges, which would have let the
transform mutate the caller's dict and break cold-run remapping.
Pre-fix entries carry no marker and their content hash never changes,
so they cannot self-heal; they are swept when the release bumps the
version, since AST entries live under cache/ast/v{version}/.
Tests: extract a python/C/bash/markdown corpus under root A, copy the
tree and graphify-out to root B, extract under B on the warm cache, and
assert the run is genuinely warm (zero extractor calls), that no node id
or edge endpoint carries A's slug, that the on-disk entries hold neither
A's slug nor an absolute path, that a cold run still yields canonical
ids, and that warm and cold match exactly. The fixture avoids JS/TS on
purpose: those suffixes bypass the cache, which would make the warm
assertions vacuous.
Follow-ups on the cherry-picked #2242/#2232: an all-dots label ('...') no
longer produces an empty 'dot-' Obsidian stem (falls back to 'unnamed'),
and the .env.example carve-out gets the regression test it shipped without
(templates graphable, real .env still sensitive, secrets/.env.example still dropped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_reconcile_existing_graph loaded graph.json inside a swallowing try, so a
graph that was merely unreadable (over the size cap or unparseable) was
silently replaced by the code-only extraction, in both the clustered and
--no-cluster hook paths (force made it worse). It now loads through the
fail-closed build._load_existing_graph and _rebuild_code refuses the write
(prints and returns False) on a load failure, matching the CLI path; the
--no-cluster write is now atomic with a protected-graph backup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Absolute/machine-slug ids still leaked into edge endpoints from producers
the target_file-stamp loop didn't reach. Three fixes: apply id_remap to
raw_calls caller_nid so module-top-level indirect_call sources canonicalize
(#2231); a general backstop in the final relativization pass that learns
_make_id(abs source_file) -> canonical id for every node and rewrites all
node ids and edge endpoints (in-root -> _file_node_id, out-of-root -> ext_),
suffix-aware for __entry; and target_file stamps on bash source/entry edges
so they ride the same canonicalization. No node id or edge endpoint now
carries the scan-root slug for any file in the batch. Builds on #2250.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #1899. That fix taught the relativization pass to catch a NODE
whose id was minted from an absolute out-of-root path and give it a portable
"ext_"-namespaced id, by matching the node's own id against
_make_id(str(its source_file)). But several cross-file resolvers (Python
relative imports, C/C++/ObjC quoted #include) only ever emit an EDGE for an
import target, no node -- so when that target lives outside the scan root,
the belt-and-braces pass has nothing to learn the old->new id from, and the
edge keeps the raw _make_id(str(absolute_path)) slug forever. The scan path,
including the OS username, ends up in links[].source/target, and differs
between machines/checkouts even though the node id sets are identical.
_import_c also never stamped the transient `target_file` hint (#1814/#2169)
its Python/JS siblings already use for exactly this kind of cross-file
target canonicalization, so it could not benefit from that machinery either.
Fix, in the two places this root cause actually lives:
- _import_c now stamps target_file on a resolved #include, mirroring
_import_python/_import_js.
- The id_remap pass that already walks target_file-stamped edges to
canonicalize in-root-but-unscanned targets now also handles the
out-of-root branch it previously skipped ("leave its ids alone"): an
existing out-of-root target gets the same portable ext_-namespaced id an
out-of-root NODE already gets, so an edge with no node of its own is
covered too. A target that does not exist on disk still stays dangling,
unchanged from before.
_portable_out_of_root_sf moved next to id_remap so both the new edge-target
branch and the existing node-level pass share one implementation.
Four tests in tests/test_extract.py: the out-of-root #include gets a
portable id instead of the raw slug, and its transient target_file hint
never leaks into the returned edge; the same corpus built from two
differently-nested checkout paths produces a byte-identical target (the
reported non-determinism, made explicit); an in-root, same-batch include
still resolves to the real node's id (negative/regression guard); and the
equivalent out-of-root Python relative import is fixed too, since the gap
was in the shared remap path, not language-specific.
Known limitation: this covers every current target_file-stamping resolver
(Python relative imports, C/C++/ObjC #include, JS/TS/Svelte/Astro/Vue
rescued imports). A resolver that mints a path-derived edge target WITHOUT
stamping target_file at all -- none do today -- would still leak; the fix
closes the gap in the shared mechanism, not a per-language allowlist.
self_type (`self: Logging with Database =>`, `this: T =>`) was never
dispatched on anywhere in the Scala extractor, so a trait/class's
structural precondition on its enclosing type produced zero edges, in
any context. The type node sits at a fixed position among self_type's
unnamed-field children (binder identifier first, type second when
present), and _scala_collect_type_refs already handles every shape
that position can take (type_identifier, compound_type for `with`,
refinement bodies) -- reused unchanged, one new dispatch branch.
Also add the new `requires` relation to DEFAULT_AFFECTED_RELATIONS,
mirroring how `indirect_call` was wired into blast-radius traversal
when it was introduced, so `graphify affected` follows it like the
existing inherits/mixes_in/embeds structural relations.
Covers: single type, `with`-compound, structural refinement (base
type only, matching how refinement bodies are already unscanned
elsewhere), the binder-only `self =>` shape (no requires edge),
coexistence with an unrelated `extends`, and a plain class without a
self-type (no spurious edge).
walk_calls flattens an inline/untracked arrow or function-expression argument
(one not separately tracked in function_bodies) onto the enclosing named
function's caller_nid, so its calls resolve as if made directly by that
function (#1630). But the closure's own parameters and locals were never
folded into the shadow set used to guard argument-based indirect_call
resolution, so a call argument inside the closure that happened to share a
name with an unrelated callable elsewhere in the corpus produced a fabricated
indirect_call edge, confidence 0.8 — even though the identifier was, in fact,
a local binding one lexical scope down:
rows.map((r) => c.get(r)) // `r` is the arrow's own param, not a
// reference to some other same-named function
Single-letter names make this common, since they collide with same-named
symbols anywhere else in the repo (loop vars, test helpers).
Fix: thread an extra_locals set through walk_calls's recursion. Entering an
untracked closure folds that closure's own bindings (computed the same way as
a tracked function's, via _js_local_bound_names) into extra_locals for its
subtree only; deeper untracked closures compound the same way on their own
recursion. All six call sites that build the caller's shadow set now union in
extra_locals, so the fix applies uniformly to the argument, collection, and
assignment/return capture paths already sharing that guard, not just the
argument one that surfaced it. Tracked closures (const-assigned arrows,
methods) are unaffected — they already get their own caller_nid and their own
correctly-scoped shadow set.
Scope: this fixes the shadow-set gap for closures. A `for (const x of xs)`
loop variable not wrapped in a variable_declarator is a separate, pre-existing
gap in the same shadow computation, already addressed by #1985 — not
duplicated here.
_extract_python_rationale / _extract_js_rationale sliced the raw
docstring/comment text to 80 characters before collapsing whitespace,
so the cut could land mid-word, leave a run of literal spaces where a
newline + indentation used to be, and, when the cut landed on a ".",
produce an Obsidian export filename ending in "..md".
Both _add_rationale sites now share _shorten_rationale_label, which
normalizes whitespace first via textwrap.shorten (word-boundary safe,
adds a placeholder only when it actually truncates) and falls back to
a plain character truncation when shorten collapses to a bare
placeholder -- which it does when the first word alone is already
>= 80 chars (e.g. a comment opening with one long URL), a case that
would otherwise regress to a content-free label.
Adds the regression test PR #2224 shipped without: an NFD-keyed manifest
(portable and legacy-absolute) must match an NFC scan so --update is a
no-op instead of re-extracting everything.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude_uninstall/gemini_uninstall/codebuddy_uninstall accepted project_dir
but, with the default project=False, still deleted the user-global skill
tree, so a library/test caller passing a project_dir nuked ~/.claude et al
(the API trap behind #2168). They now take remove_user_skill and treat a
passed project_dir as authoritative; uninstall_all opts in explicitly to
preserve 'graphify uninstall' behavior. Also fixes a live CLI bug where
'uninstall --project' deleted the global codebuddy skill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2212: benchmark, the merge-driver graph load, and callflow_html crashed
or silently failed on a --no-cluster graph.json (edges stored under
'edges', not 'links'). A shared load_node_link_graph helper normalizes
links/edges before node_link_graph and is used at all three sites.
#2210: _stale_graph_sources compared graph source_file spellings to the
scan with a raw string test (no NFC), and pruned any non-match with no
liveness check, so alive files (macOS NFD paths, legacy basenames) were
pruned as 'deleted'. It now compares NFC-on-both-sides and is fail-closed:
a corpus-missing source whose file still exists is pruned only when the
exclusion is provable, else kept with a warning. Prune message corrected
to 'deleted or excluded'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #2169 incremental canonicalization only rewrites edge targets that
carry a target_file stamp. Python relative imports and markdown reference
links emitted absolute-path-derived target ids without one, so on an
incremental/subset extraction they dangled on an absolute id instead of
resolving to the canonical root-relative node (dropping md->md references
and leaving a dangling imports_from on --no-cluster). Both now stamp the
resolved target (existence-gated); the stamp is popped before graph.json
ships. Also register the unresolved target form in the remap loop so a
symlinked root (macOS /tmp) can't cause an id-form mismatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>