Skip to content

[pull] v8 from Graphify-Labs:v8 - #112

Open
pull[bot] wants to merge 306 commits into
miqdigital:v8from
Graphify-Labs:v8
Open

[pull] v8 from Graphify-Labs:v8#112
pull[bot] wants to merge 306 commits into
miqdigital:v8from
Graphify-Labs:v8

Conversation

@pull

@pull pull Bot commented Jul 6, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

@pull pull Bot locked and limited conversation to collaborators Jul 6, 2026
@pull pull Bot added ⤵️ pull merge-conflict Resolve conflicts manually labels Jul 6, 2026
@safishamsi
safishamsi force-pushed the v8 branch 3 times, most recently from f4a15b4 to d2d1f68 Compare July 9, 2026 00:06
Cekaru and others added 20 commits July 9, 2026 13:22
…ge split (#1721)

The extract_terraform move #1721 proposed already landed on v8 via the #1737
decomposition (extractors/terraform.py exists, extract.py re-exports it, and
extractors/LANGUAGE_EXTRACTORS registers it), so the code move is a no-op now.
But the regression test @Cekaru added with it had no equivalent on v8. Salvage
and generalize it: sweep every LANGUAGE_EXTRACTORS entry and assert graphify.
extract re-exports the SAME object (facade identity) and the registry maps to
it (registry identity), plus the concrete terraform anchor from the PR. This
institutionalizes the re-export-identity guarantee the split relies on, so a
future move that forgets a facade re-export fails loudly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1753)

build_from_json's ghost-node merge iterated set(G.nodes()), so when two nodes
shared a (basename, label) key the "canonical" survivor was chosen by CPython's
per-process string-hash order — rebuilding the same extraction JSON in a fresh
process could pick a different survivor, silently changing which node id
represents a concept. That breaks any workflow persisting ids across a rebuild;
concretely it broke the cluster->relabel step (community membership referenced
an id that the second build merged away -> KeyError in report generation).

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dep (#1745)

When the [sql] extra is absent, .sql files are counted as code and scanned but
extract_sql returns an error result and zero nodes — and the graph builds
"successfully" with the entire SQL corpus missing. Neither existing warning
catches it: #1666's zero-node warning skips results carrying an "error", and
#1689 only covers files with NO extractor at all (.sql HAS a dispatch entry).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1749)

The extraction spec forbids cross-language `calls` edges, and build already
dropped cross-language INFERRED `calls`. But `imports`/`references` had no such
guard: an unresolved Python `import time` resolved by bare stem (the #1504
old-stem alias) onto a `src/time.ts` file node, welding a polyglot repo's two
language halves together. In the reporter's repo three such edges were the only
bridge between 2409 Python and 1403 TS nodes, so every backend<->frontend
shortest path routed through time.ts, inflating its betweenness ~90x and making
it the #1 reported god node.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…CWD (#1747)

Case 1 — `extract <corpus> --out <dir>`: the graph went to <dir> (cache_root is
already passed to the AST extractor), but detect()'s word-count/stat-index cache
uses the scan root, so a stray graphify-out/cache/ was created inside the corpus
(and left behind even when the run aborted at the no-LLM-key gate). Thread an
optional cache_root through detect() -> cached_word_count() -> _ensure_stat_index()
and pass out_root from the extract CLI, so the stat index lives under --out. Entry
keys are absolute paths, so relocating the index file is safe.

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

Regression tests for both cases; #934 still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Java member calls (`gw.charge()`) resolved by bare method name, so a call bound
to any same-named method in the corpus — e.g. `PaymentGateway.charge` and
`AuditLog.charge` were indistinguishable, producing phantom cross-class edges
and a false god node.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
extract_json emitted `imports` edges for package.json dependencies and
`extends`/`$ref` edges for tsconfig.json to target ids (`_make_id("ref", ...)`
/ `_make_id(key)`) that it never created as nodes. build_from_json drops edges
to unknown node ids silently (that case is filtered out of real_errors), so
dependency and extends structure vanished from the graph on two of the most
common files in any JS/TS repo, surfaced only by diagnose_extraction after the
fact.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t references edges (#1746)

The live-introspection FK query joined information_schema.referential_
constraints, which Postgres only exposes for constraints where the current user
has WRITE access to the referencing table. A read-only introspection role
therefore got zero FK rows — while tables/views/routines still appeared (SELECT
is enough for those views) — so the graph silently lost every `references`
edge, contradicting the documented FK-mapping behavior.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The badge's href pointed at www.ycombinator.com/companies/graphify, which 404s
— the public S26 company page isn't published yet. Show the badge without a
link rather than ship a dead click; re-add the href once YC publishes the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

The TypeError reported on 0.1.14 is already fixed on v8: sanitize_label coerces
None ('if text is None: return ""') and the source_file call site guards with
str(data.get("source_file") or ""). Add regression tests (unit + to_html
integration with null label/source_file) so it can't silently regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (#1781)

_rewire_unique_stub_nodes gated merge targets through _is_type_like_definition,
which rejects any label ending in `)`. So a function referenced from another
module (passed by name, e.g. FastAPI's Depends(get_db)) left its reference edge
dangling on a sourceless name-only stub while the real def had zero incoming
edges — "who references this function" returned nothing. Class/type symbols were
fine; only functions/methods suffered.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reported bug — a method chained directly onto a `new X(...)` expression
(no intermediate variable) producing no calls edge — is already fixed on v8:
`new Merger(ctx).Combine(...)` emits calls -> Merger.Combine. Add a regression
test so the fluent new-expression receiver stays covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1784)

.rake files are plain Ruby (Rake's task DSL is ordinary method calls), but the
extension was gated out everywhere, so rake tasks were classified as
unsupported, skipped, and their calls invisible. Add `.rake` to all seven `.rb`
gates the reporter mapped:
  - detect.CODE_EXTENSIONS (classification)
  - extract._DISPATCH (extractor dispatch)
  - extract._LANG_FAMILY_BY_EXT-adjacent language-name map (.rake -> ruby)
  - the ruby_member_calls LanguageResolver suffix set
  - both `.rb`-suffix filters in ruby_resolution.py (raw-call gather + class-def index)
  - analyze language-stats map
  - build repo-tag map

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt (#1768)

suggest_questions()'s "isolated/weakly-connected nodes" filter was missing the
`file_type != "rationale"` exclusion that report.py's Knowledge Gaps section
already applies, so the same GRAPH_REPORT.md reported two different counts for
the same concept (757 vs 245 on a real graph) — an internal inconsistency that
made a healthy graph look like a documentation problem. Add the same filter so
both computations agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts (#1785)

`graphify path` committed each endpoint to _score_nodes()[0]. The full-query
bonus tier only fires when the query equals/prefixes a label, so a query that is
a token subset of the intended label ("Reject-everything judge" vs "Degenerate
Reject-Everything Judge") got no bonus and a node prefix-matching one rare token
("Rejection Summary") could out-score it on IDF alone — anchoring the path on an
unrelated, often disconnected node and returning a false "No path found".

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

The absolute-path-in-node-ids leak reported on 0.8.19 is already fixed on v8:
detect() returns paths relative to the scan root, so the CLI-produced graph.json
uses relative structural node ids (portable, no username/home leak). Lock it with
a regression test that extracts the same corpus from two different absolute
checkout dirs and asserts identical, leak-free node ids.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CJNA and others added 3 commits July 12, 2026 00:37
#1795)

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… twin (#1799)

The semantic pass mints a document node <slug>_doc; the markdown quick-scan
(extract_markdown) mints the bare <slug>. After a semantic build, a `graphify
update` (AST path) re-runs the quick-scan and the graph ends up with BOTH — one
document as two disconnected nodes, the file's edges split between them (semantic
`references`/hyperedges on the _doc twin, quick-scan cross-links on the bare
one). path/query traversals dead-end on the wrong twin; degree and communities
split.

build_from_json now reconciles the pair: when <slug> and <slug>_doc both exist
with the same source_file and both are file_type=document, remap the bare node
into the semantic _doc node (canonical, richer edges) and repoint its edges and
hyperedges. Remap-induced self-loops are dropped; pre-existing ones are left
alone. Gated to document twins for the same file, so a code symbol `foo` and an
unrelated `foo_doc` never merge. Regression tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
querylog wrote every query/path/explain question + corpus path (and full
responses under GRAPHIFY_QUERY_LOG_RESPONSES) to a default-on, unbounded,
fail-silent plaintext file at ~/.cache/graphify-queries.log — outside any repo's
.gitignore/retention, and undocumented. A default-on plaintext record of
proprietary queries contradicts graphify's on-device / no-telemetry posture.

Flip to opt-in: _log_path() returns None unless GRAPHIFY_QUERY_LOG_ENABLE=1
(default path) or GRAPHIFY_QUERY_LOG=<path> is set; GRAPHIFY_QUERY_LOG_DISABLE=1
still forces it off (back-compat, wins). Document all four env vars in the
README (the old entries implied default-on). Regression tests cover
default-off, both enable paths, disable-wins, and that log_query writes nothing
without opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
safishamsi and others added 30 commits July 26, 2026 11:52
… semantic ids (#2194, #2197)

build_from_json now folds name->label, path->source_file, edge type->relation,
and confidence_score->confidence=INFERRED before validation, so alias-carrying
nodes stop entering the graph without label/source_file (invisible, unmergeable
ghosts); the same folds run before dedup. _semantic_id_remap now also learns the
absolute-path stem form, so a Windows absolute-derived semantic id re-keys to the
canonical root-relative id. The extraction warning now breaks errors down by cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng (#1609)

Adapted from #1620 by @TheFedaikin, reworked onto v8 as a focused change
(without the module split or the references-fallback behavior change).

Builds on the shipped #1609 resolver: instead of bailing when a receiver's
class name is ambiguous corpus-wide, the declared type is resolved with a
shared CsharpNameResolver (same-namespace, using-directive, and alias aware)
against the caller's namespace/scope, falling back to the unique bare match
only when scoping is non-decisive. Adds base./this.field receivers and
inherited-member lookup through the inherits chain (an out-of-corpus base
poisons the lookup, so no wrong edge). The per-file type table now poisons a
name on any conflicting rebinding, killing the wrong-edge class where a local
shadows a field of a different type. C#-gated; never emits a wrong edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
function_types only recognised func/init/deinit/subscript, so computed properties (var body: some View { ... }) and willSet/didSet observers produced no node and their bodies were never walked — erasing the whole SwiftUI view layer. Emit a function-like member node for them and defer the body to the call-walk via function_bodies; stored properties are unchanged. Adds tests.
…ath forms

macOS yields NFD paths from os.walk/getcwd while skill path literals are
often NFC. Raw string compare treated every file as deleted+new and forced
a full re-extract. Canonicalize at the manifest boundary (same idea as #1226).

Fixes #2221
Stamped keys can already be NFC while Path(root).resolve() is NFD, so
os.path.relpath treated in-root files as ../ and kept them absolute.
Normalize both operands inside _to_relative_for_storage (and the join in
_to_absolute_from_storage).
…bels

Community labels are saved keyed by community id, but re-clustering
reassigns those ids: after a rebuild that adds nodes, cid 30 can cover a
completely different community and its saved name is then simply wrong.
cluster-only already guards this — it validates each reused label against
the `.graphify_labels.json.sig` membership fingerprints and re-hubs any
community that changed (the case community_member_sigs() was written for).

_rebuild_code() skipped that check entirely: it reused every label whose
cid was present and hub-filled only the *missing* ones, so stale names
survived — and then wrote them back to labels.json, laundering them as
current. It also never refreshed the .sig sidecar, so the signatures kept
describing an older clustering and drifted out of step with the labels
they sit beside, leaving the cluster-only guard nothing accurate to check.

Adding ~3.5k nodes to a real graph re-clustered 463 -> 515 communities and
mislabeled 162 of them this way: a `domain.audit` namespace reading
"ACH / bank payments", a `domain.auth` namespace reading
"document-sensitivity.up.sql". Node and edge data stayed correct, so
nothing failed loudly — only the names lied.

Apply the same signature check in the incremental path, write the sidecar
in step with the labels, and print the same "run `graphify label`" notice
cluster-only emits so a drifted community set is visible rather than
silent.
Crossing MAX_NODES_FOR_VIZ left the project with no graph.html at all:
_rebuild_code unlinked the existing file and wrote nothing in its place. The
delete on its own is defensible — a kept graph.html would describe an older,
smaller graph — but the file is gone before the user reads the message, and
the next incremental rebuild silently removes it again, so a repo that grows
past the threshold just loses its visualization with no way to keep one.

The export path already solved this (#1019): over the cap it re-renders the
community-aggregation view rather than going without. Do the same here, so
the artifact is current AND present instead of current OR present.

GRAPHIFY_VIZ_NODE_LIMIT=0 still means "no HTML viz" (CI runners) rather than
"aggregate", and if the aggregated render also fails the old skip-and-remove
behaviour stands.
…o incremental targets canonicalize (#2211, #2213)

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>
#2212, #2210)

#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>
…ng the global skill (#2215)

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>
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>
/#2181/#2218

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#2206)

_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.
…l args (#2241)

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.
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).
safe_name left stems like .env intact, so the vault wrote .env.md which
Obsidian treats as a hidden file — invisible in the explorer and as
unresolved wikilinks. Prefix with dot- (shared _obsidian_safe_stem for
vault + canvas). True label stays in the note body.

Fixes #2205
Assert .env / .gitignore become dot-env / dot-gitignore on disk and in
the canvas file nodes, so Obsidian cannot hide them again (#2205).
…ge 2

Stage 2's .env regex treated .env.example / .sample / .template / .dist
like live secret files and dropped them from the graph. Carve out those
suffixes for .env / .envrc basenames only — real .env.local etc. stay blocked.

Fixes #2184
…2243)

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.
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>
…ook rebuild (#2251)

_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>
….example regression test (#2205, #2184)

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>
/#2184/#2205/#2052

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

⤵️ pull merge-conflict Resolve conflicts manually

Projects

None yet

Development

Successfully merging this pull request may close these issues.