Skip to content

fix(export): build Obsidian colorGroup queries with the note-tag sanitizer#2219

Open
ddy4633 wants to merge 1274 commits into
Graphify-Labs:mainfrom
ddy4633:fix-2204-obsidian-colorgroup-sanitizer
Open

fix(export): build Obsidian colorGroup queries with the note-tag sanitizer#2219
ddy4633 wants to merge 1274 commits into
Graphify-Labs:mainfrom
ddy4633:fix-2204-obsidian-colorgroup-sanitizer

Conversation

@ddy4633

@ddy4633 ddy4633 commented Jul 26, 2026

Copy link
Copy Markdown

Closes #2204.

The bug

graphify/export.py writes note tags through _obsidian_tag() (:548) but builds the graph-view colorGroup query with a bare label.replace(' ', '_') (:735). The two paths diverge for any community label containing a character Obsidian rejects in a tag — &, parentheses, a diacritic — which are ordinary in LLM-generated labels (Product & Vale Lookup, Auth (v2)).

The colorGroup then matches no note, so the community renders uncolored in graph view with no warning.

The Dataview queries at :689 and :693 in the same module already use _obsidian_tag(). :735 was the only site that missed it.

The fix

One line, reusing the sanitizer that is already the module's single source of truth, plus a comment explaining why the two must stay in step.

How to test

uv run pytest tests/test_export.py -q      # 51 passed

The new test — test_to_obsidian_color_groups_match_the_tags_written_into_notes — deliberately does not assert exact strings. It asserts that every tag queried by a colorGroup in .obsidian/graph.json actually appears in an exported note. That keeps it valid if _obsidian_tag()'s rules ever change, and it catches drift in either direction.

Regression proof — source change reverted, test kept:

tests/test_export.py:570: in test_to_obsidian_color_groups_match_the_tags_written_into_notes
    assert queried <= written, f"dead colorGroups: {sorted(queried - written)}"
E   AssertionError: dead colorGroups: ['community/Auth_(v2)', 'community/Product_&_Vale_Lookup']

Full suite: 3746 passed, 20 failed, 3 skipped — the 20 failures are identical to the baseline set on a clean tree and are environmental, not related to this change:

  • test_skillgen.py ×13 — shallow clone lacks the origin/v8 ref (CI uses fetch-depth: 0)
  • test_security.py ×7 — sandbox DNS resolves example.com to 198.18.0.132, tripping validate_url's private-range guard
  • test_ollama.py ×2 — an OPENAI_API_KEY present in the environment makes detect_backend() return openai

ruff check clean.

Deliberately out of scope

The issue's closing note about Créditos → Crditos is a separate defect in _obsidian_tag() itself — Obsidian does accept Unicode letters, so the character class is arguably too strict. Fixing that changes the tag values already written into users' existing notes, which is a second logical change and deserves its own PR. Happy to open one if you want it.

Two pre-existing edge cases this PR neither introduces nor resolves:

  • Two labels can collapse to the same tag (Auth & Session and Auth Session both → Auth__Session), producing duplicate colorGroup queries. True before and after; the note-side tags already collapse.
  • A label that sanitizes to empty ("&&&") yields tag:#community/. After this change it at least matches the note side consistently, but a real fix needs a fallback such as Community {cid}.

Verification limit

I verified that the two code paths now agree and that the queried tags exist in the exported notes. I did not open the export in a real Obsidian vault to confirm the groups render colored — that part rests on the reporter's observation.

mallyskies and others added 30 commits July 8, 2026 01:24
Follow-up to the previous commit on this branch. That fix's ambiguity check
missed a case: it detects "is this node the file itself" by checking
whether the node's id starts with the file's plain new_stem, but a
same-directory .h/.cpp pair that collides on their shared pre-extension id
gets salted apart by _disambiguate_colliding_node_ids into ids like
"tools_aolserver_utility_h_tools_aolserver_utility" -- no longer a clean
new_stem prefix.

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

Detect "this node IS the file" by label instead: every file node's label is
its own basename regardless of what its id looks like after salting. That
keeps a salted file node in the alias competition, so the real collision
between the C header and the PHP file is correctly caught as ambiguous.
… resolution (Graphify-Labs#1726)

`_resolve_typescript_member_calls` resolves a member call's receiver to a type
definition by casefolded label. For a builtin-typed receiver (`x: Date;
x.getTime()`), `_key("Date")` == "date" matched a same-named user `class DATE` /
`const DATE` in another file, binding the caller to it as a phantom
`references[call]` edge. In a real 3,368-file repo one module-local `const DATE`
accumulated 469 false cross-file edges (degree 472, betweenness 0.435) — a false
god node distorting path/god-node results.

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

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

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

Adds a regression test for the Date.now()/static-call shape (credit PR Graphify-Labs#1727 /
@2loch-ness6, who independently found the same fix): a capitalized builtin
receiver must not bind cross-file to a same-spelled user const/class. The same
guard added in 67f4f83 already covers it (verified); this locks the static-call
shape in, while allowing the legitimate same-file const reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…odes don't collapse (Graphify-Labs#1729)

merge-graphs prefixed each graph's node ids with `<repo>::` where repo was
`gp.parent.parent.name` — the graphify-out parent dir name. That tag is not
unique across inputs: `src/graphify-out` and `frontend/src/graphify-out` both
yield `src`, so a bare `app` node from a backend `src/app.js` and a frontend
`App.jsx` both became `src::app` and nx.compose silently merged them into one
node (one graph's label/source_file, both graphs' edges) — inventing false
cross-runtime `path` results with no warning.

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

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

`graphify uninstall` (and `graphify claude uninstall`) only looked at
`.claude/settings.json` and root `CLAUDE.md`. A user who relocates the
PreToolUse hook into `.claude/settings.local.json` and the `## graphify`
instructions into `CLAUDE.local.md` / `.claude/CLAUDE.local.md` - so they
are not committed to a shared repo - was left with both behind after
uninstall, which reported "nothing to do".

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mixed repo (Graphify-Labs#1734)

`graphify extract` on a repo containing docs/papers/images hard-failed when no
LLM backend was configured — even for a user who only wants the code graph. The
only workaround was hand-building a .graphifyignore of everything non-code, which
is onerous (the "not code" set is far larger than the code set).

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

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

__main__.py was 5,368 LOC, more than half of it per-platform install/uninstall
machinery interleaved with the CLI dispatcher. Move that subsystem — 68
functions (all *_install/*_uninstall, _copy_skill_file, _platform_skill_destination,
_always_on, etc.) plus 21 module constants (_PLATFORM_CONFIG and the platform
skill/hook payload constants) — into a new graphify/install.py, extracted verbatim.

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

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

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

Continues the graphify/extract.py -> graphify/extractors/ split (MIGRATION.md,
upstream Graphify-Labs#1212), which already moved blade/zig/elixir/razor. Moves the
independent bespoke extractors whose closures are fully private (no shared
_extract_generic core, no shared mutable caches), verbatim, following the
documented invariants:

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

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

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

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

export.py mixed nine independent export targets in one 1,671-LOC module. Move
the two largest self-contained ones into a new graphify/exporters/ package,
verbatim, re-exported from export.py so every importer (from graphify.export
import to_html / push_to_neo4j) is unchanged:

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

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

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

Continues the extract.py -> extractors/ split. Both are self-contained bespoke
extractors (verified: closure-private, byte-identical verbatim move, facade
identity + _DISPATCH resolution intact):

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves the cross-file symbol-resolution / import-resolution / decl-def-merge
passes — the largest remaining self-contained subsystem in extract.py — into
two new modules, verbatim:

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

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

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

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

main() carried a 256-line if/elif chain routing every install-family command
(install, uninstall, and the 22 per-platform targets: claude, codebuddy, gemini,
cursor, vscode, copilot, kilo, kiro, devin, pi, amp, agents/skills,
aider/codex/opencode/claw/droid/trae/trae-cn/hermes, antigravity). That block
only ever reads sys.argv and calls functions that already live in install.py, so
move it there verbatim as a guarded dispatch_install_cli(cmd) -> bool; main() now
does `if dispatch_install_cli(cmd): return`.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to extractors/engine.py

Moves the config-driven extraction engine — _extract_generic (~2.2k LOC) and its
67-function closure of per-language tree-sitter walkers/type-collectors
(_js_*, _ts_*, _python_*, _java_*, _csharp_*, _cpp_*, _swift_*, _kotlin_*,
_php_*, _ruby_*, _scala_*) plus 10 engine-private constants (language builtin/type
tables) — into graphify/extractors/engine.py, verbatim.

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

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

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

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

Now that the resolution passes and the tree-sitter engine live in their own
modules, these three bespoke extractors are self-contained and move cleanly
(verbatim), pulling only the specific engine/resolution/base helpers they need:

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main() was a 2,760-line function that was almost entirely a 2,538-line
if/elif chain dispatching every non-install subcommand (query, path, explain,
diagnose, affected, reflect, save-result, extract, update, cluster-only, label,
build, global, merge-graphs, merge-driver, watch, tree, export, benchmark,
clone, prs, provider, hook*, check-update, and the `graphify <path>` redirect).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…raphify-Labs#1700 Kotlin half, Graphify-Labs#1738)

Kotlin enum entries weren't extracted: the walker never descended into the enum
body (`enum_class_body` wasn't in _KOTLIN_CONFIG.body_fallback_child_types, so
_find_body returned None). Add `enum_class_body` to the fallback body types and a
`_kotlin_extra_walk` (dispatched for tree_sitter_kotlin, mirroring the Java/Swift
handling) that emits each enum_entry as a node with a `case_of` edge to the enum.

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

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

Step 1's POSIX interpreter probe ran `uv tool run graphifyy python -c ...`,
but the graphifyy package exposes its executable as `graphify`, so uv treated
`python` as a missing `graphifyy` command. The probe failed, and `2>/dev/null`
swallowed uv's "use --from" hint, leaving PYTHON on a system interpreter that
does not have graphify installed. The probe now runs
`uv tool run --from graphifyy python -c ...`.

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

Reported and originally patched by @mohammedMsgm in Graphify-Labs#1736.

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

Both fixes landed after the initial 0.9.11 section was written but before
0.9.11 was published, so they belong in that release's notes rather than a
new version.

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

In a multi-term query, a lone generic term that exactly equals a short leaf
label (`home` vs a `home()` action, `list` vs a `list()` function) received
the full exact-tier bonus and outscored nodes matching several of the query's
terms by ~10x, so `_pick_seeds`' gap cutoff discarded the genuinely relevant
candidates and `path`-style consumers of `scored[0]` had no backstop.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… calls across files (Graphify-Labs#1739)

Both Pascal extractors resolved every call via a single file-wide
{method_name: node_id} dict, so two unrelated classes declaring a same-named
method (property accessors, generated COM/TLB wrapper classes) collapsed onto
whichever declaration was inserted last, producing wrong cross-class `calls`
edges. Resolution is now scoped: own class -> ancestor chain (inherits) ->
file-level free function -> unambiguous file-wide match; ambiguous at every
level emits no edge rather than guessing (same god-node guard as the Ruby
resolver).

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two correctness fixes found while analysing the reported 'graphify update
occasionally writes a partial graph.json' bug.

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

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

Adds regression tests for all three behaviours.

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

Two classes with the same simple name in different Maven modules
(FinancialEntryValidator in payment/ and core/) already survive as distinct
path-scoped nodes on v8 -- the "node silently disappears" report from 0.9.9 is
fixed. But a cross-module field/type `references` edge was still left dangling
on a sourceless phantom stub: _resolve_java_type_references (Graphify-Labs#1318) re-pointed
implements/inherits/extends/imports edges to the real definition using the
importing file's `import` statement, but its REPOINT_RELATIONS omitted
`references`, so bare-name resolution's shadow stub survived for field types.
A query about the referenced class could then miss it.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge split (Graphify-Labs#1721)

The extract_terraform move Graphify-Labs#1721 proposed already landed on v8 via the Graphify-Labs#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>
…raphify-Labs#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 Graphify-Labs#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 (Graphify-Labs#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: Graphify-Labs#1666's zero-node warning skips results carrying an "error", and
Graphify-Labs#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>
…raphify-Labs#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 Graphify-Labs#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 Graphify-Labs#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 (Graphify-Labs#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 Graphify-Labs#934 pins.

Regression tests for both cases; Graphify-Labs#934 still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
safishamsi and others added 30 commits July 24, 2026 23:47
Graphify-Labs#2139)

The Graphify-Labs#2139 ${VAR} source handler now also records bash_sources so
resolve_bash_source_edges binds calls into the sourced lib's functions,
not just the source edge. Add the end-to-end oracle plus the previously
untested _bash_source_suffix guards (mid-path $, whole-var, .. traversal
fabricate nothing).

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

Applying a Python decorator emitted no edge to the decorator symbol, so
`affected <decorator>` answered "No affected nodes found" for every
function it wraps — a silent false negative on reverse-impact queries.

TS/JS already emit these edges via `_ts_emit_decorator_edges`. The Python
`decorated_definition` branch walked its children only to propagate the
parent class id (Graphify-Labs#1050) and never looked at the `decorator` children.

Python now emits the same shape: a `references` edge with
context="decorator" from the decorated function/class to each decorator
symbol. Owner ids reuse the definition branches' own formulas, so the
edge lands on the node the walk creates. Targets go through
`ensure_named_node`, so an imported decorator becomes a sourceless stub
the corpus rewire collapses onto its real definition. Stacked, called
(`@retry(3)`) and attribute (`@app.route`) decorators are covered; the
attribute form targets the symbol, not the module alias, matching
`_ts_decorator_name`.
…2154)

Nine cases: the issue's imported-decorator repro, same-file resolution to
the local definition, called and attribute decorators, stacked
decorators, class-qualified method owners, a decorated class, the Graphify-Labs#1050
@Property class-qualification regression guard, and an absence control.
…phify-Labs#2153)

Non-relative imports produced no edge in a Rails/webpacker project, so
every module under `baseUrl` was orphaned and `affected` answered nothing.

Two defects. First, only `tsconfig.json` was probed, never
`jsconfig.json` — the plain-JS spelling of the same file, which
json_config.py already indexes, so the config's nodes appeared in the
graph while resolution ignored it entirely. Second, `baseUrl` was
consumed only as the base that `paths` targets resolve against, so a
config declaring `baseUrl` and NO `paths` produced an empty alias map and
every bare specifier died.

`_find_js_config` now probes both names, tsconfig winning within a
directory as tsc and editors do. `baseUrl` is exposed separately and used
as a resolution root of LAST RESORT, tried only when no declared alias
matches, so `paths` precedence (Graphify-Labs#1269, Graphify-Labs#927, Graphify-Labs#1531) is untouched. It is
deliberately not modelled as a synthesized `*` alias: that would score
(1, 0) in _match_tsconfig_alias and beat a declared non-wildcard
directory-prefix alias at (2, -len), silently shadowing it. The fallback
also returns a candidate only when it is a real file, so an external
package import is not fabricated into a <baseUrl>/<pkg> edge.

Threaded through the three regex-rescue dynamic-import paths (Svelte,
Astro, TS/TSX) as well as static resolution, since the issue reports both.
…Labs#2153)

Eleven cases: the webpacker repro (jsconfig + baseUrl, no paths) for
static, dynamic and extensionless specifiers; the same for tsconfig;
tsconfig winning over jsconfig in one directory; and four preservation
guards that pass before and after — declared paths and directory-prefix
aliases are not shadowed, relative imports are untouched, an absent
baseUrl changes nothing, and an external package is not fabricated.
…esolution and god-node ranking (Graphify-Labs#2147)

_LANGUAGE_BUILTIN_GLOBALS and _BUILTIN_NOISE_LABELS covered only JS/TS and
Python, so on Swift codebases framework symbols (Foundation, NSLock, View,
Data, Sendable, ...) ranked as god nodes, and the Swift member-call resolver
could bind a builtin-typed receiver (let d: Data) to a same-named user symbol
in another file — the same phantom-edge shape Graphify-Labs#1726 fixed for TypeScript.

- extractors/base.py: add Swift stdlib value types, conformance protocols,
  Foundation types, and SwiftUI View/Color/Font to _LANGUAGE_BUILTIN_GLOBALS
- analyze.py: add the same set plus framework module names (Foundation,
  SwiftUI, UIKit, AppKit, Combine) to _BUILTIN_NOISE_LABELS
- extract.py: _resolve_swift_member_calls now skips builtin receiver types,
  matching the guard the TS/Python member-call resolvers already have (Graphify-Labs#1726)
- tests: god_nodes exclusion (parametrized) + Swift builtin-receiver
  no-bind regression + user-type-still-resolves guard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… config (Graphify-Labs#2168)

Several test files call install/uninstall functions that operate on the
real user home (~/.claude, ~/.gemini, ~/.codebuddy, ~/.copilot), so
running the suite deleted/overwrote the developer's actual config. An
autouse conftest fixture now points HOME/USERPROFILE/LOCALAPPDATA at a
throwaway dir and clears CLAUDE_CONFIG_DIR/XDG_CONFIG_HOME for every
test. Supersedes the per-file sandbox proposed in Graphify-Labs#2057 (thanks
@erlandl4g for surfacing it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ore write (Graphify-Labs#2167)

The hook installers fell back to settings={} on any JSON parse error and
then overwrote the whole file, destroying the user's config (the likely
trigger is a UTF-8 BOM, same class as Graphify-Labs#2163). All four installers now
read utf-8-sig, refuse to modify a file that isn't a JSON object (naming
the path) instead of clobbering it, back up to <name>.graphify-bak before
any modifying write, skip the write when content is unchanged, and guard
the PreToolUse filter against non-dict entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the graph (Graphify-Labs#2169)

An incremental `extract --no-cluster` wrote only the changed files over
graph.json with no merge, dropping every node/edge owned by an unchanged
file; and the id-canonicalization pass only learned batch files, so the
changed file's cross-file edges kept absolute-path target ids and
dangled. The raw path now merges the existing graph forward with the same
replace/prune semantics as the clustered path (new merge_raw_extraction
helper in build.py, shared loader), refuses to overwrite a corrupt
existing graph, and the remap pass now also learns in-root edge
target_file paths (existence-gated) so cross-file targets canonicalize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e edges (follow-up to Graphify-Labs#2154)

The decorator reference edges added in Graphify-Labs#2154 fabricated sourceless stub
nodes for @property/@staticmethod/@dataclass/@functools.wraps and, via
the unique-function rewire, could stamp a false edge onto a corpus's own
def wraps(). Add _PYTHON_DECORATOR_NOISE (mirroring _PYTHON_ANNOTATION_NOISE)
and skip those names, same accepted tradeoff as patch/Mock in annotations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s (follow-up to Graphify-Labs#2169)

Graphify-Labs#2169 canonicalizes cross-file edge targets to the root-relative file-node
id (the same id the target file gets as a node) instead of the old
absolute-path form. The Graphify-Labs#2153 baseUrl tests asserted the absolute form;
update them to the canonical id via a _cid() helper. Resolution behavior
is unchanged — only the expected id form.

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

`_extract_parallel` spawned a ProcessPoolExecutor whenever there were at least
_PARALLEL_THRESHOLD (20) uncached files, even when the resolved worker count was
1. A one-worker pool buys no parallelism: it still pays a process spawn plus an
IPC round trip per file, and it is the one residual case where the parent's
rebuild watchdog (os._exit) can orphan a worker that is mid-task.

The Windows post-commit hook exports GRAPHIFY_MAX_WORKERS=1, so this was the
default there for any rebuild touching 20+ uncached files.

Gate the pool on the resolved worker count -- after the GRAPHIFY_MAX_WORKERS
override and the win32/floor clamps -- and return False when it is 1. That reuses
the existing contract: the caller already falls back to `_extract_sequential`
in-process when `_extract_parallel` returns False.

Tests: no pool is constructed with GRAPHIFY_MAX_WORKERS=1 and 25 uncached files,
and a multi-worker run still takes the pool path.

Only item 2 of Graphify-Labs#2173 is addressed here. Item 1 (the `graphify watch` rebuild
timeout) needs a maintainer decision first: `watch()` currently arms no timeout
at all on any platform -- there is no signal.SIGALRM branch in graphify/watch.py
to add an `else` to -- so applying the hook's shape means adding a watchdog that
os._exit(1)s a long-running foreground watcher on a slow-but-healthy rebuild.
That is a behaviour change rather than a Windows-compat fix, so it is left out of
this PR.
…hify-Labs#2171)

Two coverage gaps left by Graphify-Labs#2141 / Graphify-Labs#2157, both misses rather than fabrications.

1. Extensionless shebang scripts. _SHEBANG_DISPATCH already routes a
   `#!/usr/bin/env bash` file with no extension to extract_bash, so its functions
   are indexed, but the cross-file source-resolution pass picked participants by
   filename suffix (`p.suffix in (".sh", ".bash")`). A sourced extensionless lib
   was therefore excluded and calls into it never bound. Select by shape as well:
   the bash extractor tags every node it emits with metadata.language == "bash".
   The suffix check stays so an empty .sh file, which has no nodes to inspect,
   still participates.

2. Bare `source lib.sh` (no ./ prefix). Only the `raw.startswith((".", "/"))`
   branch recorded a bash_sources entry; a bare name fell through to the opaque
   `imports` fallback, so neither the source edge nor calls into the lib resolved
   even though the file usually sits beside the script. The else branch now binds
   a sibling of that name when one exists.

The existence gate from the ./-prefixed branch carries over: a bare name that
resolves to no sibling keeps the old `imports` edge and records no bash_sources
entry, so nothing is invented. is_file() is wrapped against OSError so a name
that is invalid for the platform degrades instead of raising.

Transitive sources (a->b->c) remain unresolved, as the issue notes.
…aphify-Labs#2172)

Graphify-Labs#2079 resolved `source "${VAR}/lib/x.sh"` by stripping the leading expansion and
resolving the literal suffix against the sourcing script's own directory. That is
correct for the canonical
`DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom, but wrong whenever
the variable points somewhere else. With

    ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
    source "${ROOT}/lib/utils.sh"

the real target is <root>/lib/utils.sh, yet a same-named decoy under the script
dir (<root>/scripts/lib/utils.sh) won: a wrong imports_from edge to a real node.

Track top-level variable assignments and use the assigned base for the leading
variable:

- the script-dir idiom, including any number of trailing `/..` hops (and the
  `$0` spelling), resolves to the script dir walked up that many times
- a literal value resolves as-is when absolute, or against the script dir when
  relative
- anything else -- a value built from other variables, or command substitution we
  do not model -- stays untracked, so the previous script-dir guess is kept

Only the base changes. The suffix guards are untouched: an expansion left in the
suffix, an empty suffix, and a `..` component in the suffix are all still
rejected, the edge is still gated on is_file() and still emitted as INFERRED.
`graphify hook install` emitted `_PINNED=''` for some Windows uv-tool installs,
so every interpreter probe failed, each commit printed "could not locate a
Python with graphify installed" and the graph never rebuilt.

Root cause is the install-time allowlist in `_pinned_python()`, not the uv
layout: it accepted `[a-zA-Z0-9/_.@:\-]` but not a plain space, so any
`sys.executable` under a profile whose name contains one -- `C:\Users\First
Last\AppData\Roaming\uv\tools\graphifyy\Scripts\python.exe`, or the equally
common `C:\Program Files\Python312\python.exe` -- was rejected wholesale and
nothing was recorded. A space-free Windows uv path pins correctly, which is why
this looks layout-specific.

A space is safe to allow because every consumer already quotes the value: the
hook scripts embed it as `_PINNED='<path>'` and dereference `"$_PINNED"`, so a
space can neither split a word nor start a command. Adding it to the allowlist
therefore fixes the pin without weakening the injection guard -- `$`, backtick,
`;`, `'` and `"` are all still rejected.

`_register_merge_driver` did interpolate the path unquoted into the
`merge.graphify.driver` command, which git runs through a shell; that would
split a spaced path into two words, so it is now double-quoted. Double quotes
are safe here precisely because the allowlist keeps `$` and backticks out.

Tests: spaced Windows/POSIX paths are pinned; metacharacter paths are still
rejected (including `'` and `"`); the merge driver quotes a spaced interpreter;
and the installed post-commit/post-checkout hooks carry the real path rather
than `_PINNED=''`.
…aphify-Labs#2180)

tree-sitter-sql cannot parse PL/pgSQL-only statements, and Graphify-Labs#1910's ERROR-node
name recovery only covered one of the shapes that produces. Two others dropped
the routine silently -- no node, no warning, exit code 0:

1. The statement is shredded into loose top-level tokens (keyword_create,
   keyword_function, object_reference, ..., keyword_begin) and the ERROR node
   holds only the offending body line, e.g. `PERFORM other_fn();` or `x := 1;`.
   No ERROR node contains any CREATE text, so scanning ERROR nodes finds
   nothing. This is what still dropped PERFORM and := after Graphify-Labs#1910.
2. The routine name is a quoted identifier -- CREATE OR REPLACE FUNCTION
   "public"."fn"(...) -- which the recovery's bare [\w$.]+ pattern cannot match,
   because it stops dead at the leading quote. Generated schema dumps quote
   every identifier, so whole files recovered nothing.

Verified on the reported repro: the same body that drops under a quoted name is
recovered fine under an unquoted one, which is why the drop looked like it
depended only on the body statement.

Fix mirrors the global REFERENCES fallback already in this extractor: after the
tree walk, scan the raw source for every CREATE [OR REPLACE] FUNCTION/PROCEDURE
and emit any routine the walk missed. Name parts accept bare or double-quoted
identifiers. _add_node dedupes by node id, so routines already recovered from
the tree are not emitted twice.

Adds tests/fixtures/sample_plpgsql_quoted.sql -- generated-style quoted DDL whose
bodies use RAISE, RAISE NOTICE, PERFORM, :=, IF..THEN and bare NULL; -- plus
tests that every routine is recovered and that the file stays clean (tables
before and after still extract, no duplicate ids or labels, no empty/ERROR
labels, and every routine keeps its contains edge from the file node).
…hify-Labs#2165)

`graphify codex install` registers `graphify hook-check` in .codex/hooks.json,
and Graphify-Labs#2165 reported that as a stale/unrecognized subcommand producing a silent
no-op. `hook-check` is in fact a real, deliberate no-op command: Codex Desktop
rejects hookSpecificOutput.additionalContext on PreToolUse, so the Codex hook
intentionally does nothing and AGENTS.md carries the always-on guidance
(cli.py dispatches `hook-check`; __main__.py lists it in _silent_cmds).
Repointing the installer at `hook-guard` would reintroduce the Graphify-Labs#522-class
breakage on Codex Desktop, so the behavior is left as is.

What actually misled the report was the documentation and the installer's own
output, which both describe the Codex hook as if it enforced graph usage:

- README: the Codex row claimed a PreToolUse hook that "fires before every Bash
  tool call, same always-on mechanism as Claude Code". It now states that the
  hook is a deliberate no-op, why (Codex Desktop rejects additionalContext), and
  that AGENTS.md is the always-on mechanism on this platform.
- `_install_codex_hook` printed "PreToolUse hook registered (... hook-check)"
  with no hint that the entry is inert. It now says so inline.

Also adds the regression guard the issue implicitly asks for: a test that reads
the command out of the generated .codex/hooks.json and asserts its subcommand is
one the CLI actually dispatches. A genuinely renamed/stale hook command now
fails the suite instead of shipping a permanently dead hook.

Note: contrary to the report, an unrecognized subcommand already exits non-zero
(`graphify totally-bogus-subcommand` -> "error: unknown command", exit 1), so no
change was needed there.
…s#2171/Graphify-Labs#2172/Graphify-Labs#2180)

- bash: mark the bare-name `source lib.sh` sibling binding INFERRED (it
  resolves via $PATH at runtime, so it's a heuristic, not EXTRACTED) (Graphify-Labs#2171)
- bash: un-join a comment accidentally merged onto the _BASH_SCRIPT_RUNNERS
  line during Graphify-Labs#2172
- sql: gate the global routine-recovery raw-text scan on root.has_error so a
  cleanly-parsing file can't fabricate routines from commented-out DDL,
  EXECUTE-string bodies, or MySQL 'CREATE FUNCTION IF NOT EXISTS' (Graphify-Labs#2180)

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

The Svelte/Astro/Vue regex-rescue import passes minted stub target nodes
with absolute-path ids (ghost nodes alongside the real file node, plus
dangling imports_from edges). They now resolve via _resolve_js_module_path
and stamp edge target_file so the Graphify-Labs#2169 canonicalization repoints them;
when the target is an in-root real code file, only the edge is emitted (no
duplicate stub). The final relativization pass also remaps ids in its
in-root branch, closing the residual class.

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

Pass 1 deferred cross-file exact matches to Pass 2, but Pass 2's candidate
filter keeps only the first node per normalized label, so identical-label
cross-file concept pairs could never merge (while fuzzy pairs did). Pass 1
now unions the cross-file residue of each label group, gated to concept
nodes with provenance and above the entropy floor, so code/rationale/
document/image/empty-source and cross-repo guards are all preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Graphify-Labs#2199, Graphify-Labs#2197)

stat-index.json keyed entries by absolute path and never pruned, so a
moved/cloned corpus got 0% cache hits and the index grew unbounded. Keys
are now stored root-relative and re-anchored on load (mirroring the
manifest.json portability fix), and dead-file entries are pruned on flush.
save_semantic_cache also normalizes source_file to root-relative before
persisting so an absolute/backslash fragment can't poison later updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… semantic ids (Graphify-Labs#2194, Graphify-Labs#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 (Graphify-Labs#1609)

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

Builds on the shipped Graphify-Labs#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>
…tizer

to_obsidian() wrote note tags through _obsidian_tag() but built the
graph-view colorGroup query with a bare label.replace(' ', '_'). The two
paths diverge for any community label containing a character Obsidian
rejects in a tag — `&`, parentheses, a diacritic — all of which are common
in LLM-generated labels ("Product & Vale Lookup", "Auth (v2)").

The result is a colorGroup that matches no note: the community renders
uncolored in graph view, with no warning.

The Dataview queries at :689 and :693 in the same module already use
_obsidian_tag(); :735 was the only site that missed it.

Closes Graphify-Labs#2204.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.