Skip to content

fix(extract): resolve bash calls into sourced-file functions (#2141)#2157

Closed
HerenderKumar wants to merge 1 commit into
Graphify-Labs:v8from
HerenderKumar:fix/2141-bash-sourced-call-edges
Closed

fix(extract): resolve bash calls into sourced-file functions (#2141)#2157
HerenderKumar wants to merge 1 commit into
Graphify-Labs:v8from
HerenderKumar:fix/2141-bash-sourced-call-edges

Conversation

@HerenderKumar

Copy link
Copy Markdown
Contributor

Closes #2141.

Problem

extract_bash only records a call edge when the callee is defined in the same file (name in defined_functions). A call to a function from a sourced file is silently dropped, so shell scripts look disconnected from the libraries they use — graphify callers b_func and graphify path main b_func both come up empty even though both functions are indexed.

The resolver for exactly this — resolve_bash_source_edges (graphify/symbol_resolution.py) — already exists with unit tests, but had no call site anywhere in graphify/, and no raw_calls / bash_sources to work from.

Fix

  • extractors/bash.py now emits the two buckets cross-file resolution needs:
    • bash_sources — the files this script sources (gated on existence, like the existing imports_from edge, so crafted traversal paths never enter the resolver's data).
    • raw_calls — calls whose callee isn't defined locally (candidate calls into a sourced library), stamped language: "bash".
  • extract.py runs resolve_bash_source_edges after the id-remap passes (so caller/function node ids are final) and de-dupes the source edge the extractor already emitted via existing_edges.
  • Bash raw_calls are excluded from the generic global-name resolver. That resolver matches by name across the whole corpus and would emit an INFERRED phantom edge from a bash call to any same-named function in an unsourced file — including calls to external commands that merely share a name with some function elsewhere. Resolution is therefore scoped strictly to the source relationship.

Acceptance

Testing

Four tests added in tests/test_extract.py (unit: extractor emits raw_calls/bash_sources; integration: the call edge resolves end-to-end; guards: no duplicate source edge, and an external/unsourced same-named command stays unlinked). Red-green verified (the two core tests fail without the production change). Full suite: 3383 passed, with only pre-existing missing-dependency failures unrelated to this change. Also confirmed resolution survives the incremental-cache path.

…y-Labs#2141)

extract_bash only linked calls whose callee was defined in the same file, so a
call to a function from a `source`d library was silently dropped and shell
scripts looked disconnected from the libraries they use. The resolver for exactly
this, resolve_bash_source_edges, already existed with tests but had no call site
and no raw_calls/bash_sources to work from.

extract_bash now emits `bash_sources` (the files it `source`s) and `raw_calls`
(calls whose callee isn't defined locally), and the pipeline runs them through
resolve_bash_source_edges after the id-remap passes, so caller and function node
ids are final and the already-emitted source edge is de-duped. Resolution is
scoped to the source relationship: a call to an external command never binds to a
same-named function in an unsourced file, and bash raw_calls are excluded from the
generic global-name resolver for the same reason.
@safishamsi

Copy link
Copy Markdown
Collaborator

Thanks @HerenderKumar. Shipped in v0.9.26 (cherry-picked to v8 as bdcae25 to keep your authorship). Closed-unmerged here, but it is in the release: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.26

@safishamsi safishamsi closed this Jul 24, 2026
safishamsi pushed a commit that referenced this pull request Jul 26, 2026
Two coverage gaps left by #2141 / #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.
Rogue05 added a commit to Rogue05/graphify that referenced this pull request Jul 26, 2026
commit f5a3592882ad54e5394c5cd5391786a589110bd1
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 13:01:17 2026 +0100

    chore(release): 0.9.27

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

commit b4dd6d7b25bb703383cb739c680642e407988fa6
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 13:00:06 2026 +0100

    docs: changelog for the Track A / node-identity / C# batch (0.9.27)

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

commit 0858954db94c39e6431f8c3ec504d0e135fd3561
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 12:43:17 2026 +0100

    feat(csharp): namespace-aware member-call resolution + shadow poisoning (#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>

commit 8adb261d16d6c801066578fff72ec281608dadc2
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 11:52:20 2026 +0100

    fix(build): fold legacy node/edge aliases and re-key absolute-derived 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>

commit 334cff6172ab8684eb45327ec76bc8df70e0cbd3
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 11:52:20 2026 +0100

    fix(cache): portable stat-index keys + normalized semantic source_file (#2199, #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>

commit e395ff9b433a441da3848a18bd4c82c0c9b888fc
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 11:52:20 2026 +0100

    fix(dedup): merge cross-file concept nodes with identical normalized labels (#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>

commit d16510ed4a869f9396403da754922dfdd1031187
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 11:52:20 2026 +0100

    fix(extract): canonicalize regex-rescue import target ids (#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 #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>

commit 9eb76ace3068dc2eb5b75e67705644954281ad9e
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 11:18:13 2026 +0100

    fix: review follow-ups for the bash/SQL contributor PRs (#2171/#2172/#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) (#2171)
    - bash: un-join a comment accidentally merged onto the _BASH_SCRIPT_RUNNERS
      line during #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' (#2180)

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

commit 911d58178f46b4f448548f55904325d7f6864ebd
Author: Souptik Chakraborty <souptikc80@gmail.com>
Date:   Sat Jul 25 20:10:13 2026 +0530

    fix: document the Codex PreToolUse hook as an intentional no-op (#2165)

    `graphify codex install` registers `graphify hook-check` in .codex/hooks.json,
    and #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 #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.

commit ffa2a2471a12188e443bde1c8dea885343cf796c
Author: Souptik Chakraborty <souptikc80@gmail.com>
Date:   Sat Jul 25 20:55:13 2026 +0530

    fix: recover every declared SQL routine from unparseable PL/pgSQL (#2180)

    tree-sitter-sql cannot parse PL/pgSQL-only statements, and #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 #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).

commit cfe15f91612028ab5f129c7934463cabec9e91d5
Author: Souptik Chakraborty <souptikc80@gmail.com>
Date:   Sat Jul 25 20:24:15 2026 +0530

    fix: pin interpreters whose path contains a space (#2166)

    `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=''`.

commit 44241dd10c4cb7347670a0e7cb2518502a5d8bf1
Author: Souptik Chakraborty <souptikc80@gmail.com>
Date:   Sat Jul 25 23:23:42 2026 +0530

    fix: resolve ${VAR} bash sources against the variable's real base (#2172)
    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.

commit 4710b864eadbac83342132faaf7e8f7690940804
Author: Souptik Chakraborty <souptikc80@gmail.com>
Date:   Sat Jul 25 23:17:29 2026 +0530

    fix: resolve bash sources for extensionless libs and bare names (#2171)

    Two coverage gaps left by #2141 / #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.

commit 556e615cc6c0fd643f2ecc49350234dec9761104
Author: Souptik Chakraborty <souptikc80@gmail.com>
Date:   Sat Jul 25 20:39:47 2026 +0530

    fix: skip the process pool when only one worker is available (#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 #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.

commit 4191b7dee3ac444bd5a0ba35c8be6736dcc93df2
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sun Jul 26 11:06:51 2026 +0100

    chore: sync uv.lock to 0.9.27

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

commit 52e1ed4050aa79bcb44dcc0de05b4ca277e61326
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 23:16:52 2026 +0100

    test(resolution): assert canonical import-target ids in jsconfig tests (follow-up to #2169)
    id (the same id the target file gets as a node) instead of the old
    absolute-path form. The #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>

commit a8da0d38210bdb2e33b6441772e23ae09ae0ac10
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 22:54:48 2026 +0100

    chore: bump to 0.9.27; changelog for #2167/#2169/#2154/#2153/#2147/#2168

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

commit fbc24c7d3f8a748838b1a08452d1ff63cd92066b
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 22:53:43 2026 +0100

    fix(extract): suppress builtin/stdlib Python decorators from reference edges (follow-up to #2154)

    The decorator reference edges added in #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>

commit 137dcf23fe0aa68954f299aa61c3559994d67603
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 22:53:43 2026 +0100

    fix(extract): incremental --no-cluster merges instead of overwriting the graph (#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>

commit 05ee5689693997284f3fe3358dec301542e2bcc3
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 22:53:43 2026 +0100

    fix(install): never clobber an unparseable settings file; back up before write (#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 #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>

commit c18ec817414bf91cd9f670e65fa430b3936f1652
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 18:02:33 2026 +0100

    test: sandbox HOME for the whole suite so installers can't touch real config (#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 #2057 (thanks
    @erlandl4g for surfacing it).

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

commit 33aa89c722cb71709eac01a57a8d5610d1b36e19
Author: MasterFede5 <151699201+MasterFede5@users.noreply.github.com>
Date:   Thu Jul 23 23:27:05 2026 -0600

    fix(extract,analyze): filter Swift/Foundation/SwiftUI builtins from resolution and god-node ranking (#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 #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 (#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>

commit 6107f14545cf8651408588b0a574f90fa810002e
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Sat Jul 25 06:33:08 2026 +0530

    test(resolution): cover jsconfig/baseUrl module resolution (#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.

commit 9be345951c2966d9e195798a5c48ff46cebc21f6
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Sat Jul 25 06:33:07 2026 +0530

    fix(resolution): honor jsconfig.json and compilerOptions.baseUrl (#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 (#1269, #927, #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.

commit 8d8005b83541295a850bb344ac727e9bc5a93072
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Sat Jul 25 06:11:34 2026 +0530

    test(extract): cover Python decorator reference edges (#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 #1050
    @property class-qualification regression guard, and an absence control.

commit 794cf9ebb6a348c477de2e7cc0ca7c98a182fc95
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Sat Jul 25 06:11:34 2026 +0530

    fix(extract): emit references edges for Python decorators (#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 (#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`.

commit 66d8110a534b52df3d660b5fda5aa5461a6b667a
Author: safishamsi <safishamsi98@gmail.com>
Date:   Sat Jul 25 00:09:40 2026 +0100

    chore(release): 0.9.26

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

commit a4fb87d1bd5003dcb2173fcc447817757afa1f3d
Author: safishamsi <safishamsi98@gmail.com>
Date:   Fri Jul 24 23:48:21 2026 +0100

    chore: bump to 0.9.26; changelog for #2137/#2126/#2148/#2141/#2079/#2163

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

commit eebf030f5bd95b8e809f07a3c1b325e736d41144
Author: safishamsi <safishamsi98@gmail.com>
Date:   Fri Jul 24 23:47:25 2026 +0100

    test(extract): cover calls into a ${VAR}-sourced bash lib (follow-up to #2139)

    The #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>

commit 7e955d223b28e6537c4e5ce6ad14ade135c9f0a3
Author: safishamsi <safishamsi98@gmail.com>
Date:   Fri Jul 24 23:47:25 2026 +0100

    fix(detect): honor ignore files saved with a UTF-8 BOM (#2163)

    .gitignore/.graphifyignore/info-exclude read with encoding=utf-8 kept a
    leading BOM (U+FEFF) on the first line, so the first pattern (e.g. *.log
    or .fable-wt/) silently matched nothing and a BOM'd full-line comment
    became a bogus pattern. git strips a single leading BOM; switching the
    two ignore read sites to utf-8-sig matches git exactly (strips at most
    one, file-start only).

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

commit 0019fc4d902f4ba8d1df441ca16f51ba293aba93
Author: HerenderKumar <herender121112@gmail.com>
Date:   Thu Jul 23 11:38:58 2026 +0530

    fix(extract): resolve bash source edges built from ${VAR} paths (#2079)

    `source "${BENCH_DIR}/lib/x.sh"` (the `dirname "${BASH_SOURCE[0]}"` idiom)
    took the bare-name branch, which baked the unexpanded `${BENCH_DIR}` text
    into the target id via `_make_id`. That id matches no node, so the edge was
    flagged dangling and dropped at export — shared shell libraries looked
    orphaned and were split into separate communities.

    Detect a `$`-expansion in the source argument, strip the leading
    expansion segment(s), and resolve the literal suffix against the script's
    own directory (which is what the canonical idiom makes the variable). Emit
    `imports_from` as INFERRED only when it resolves to a real file on disk;
    skip otherwise instead of emitting a dead id. Bare-name sources keep their
    existing behavior.

commit bdcae25a261e741f506e5880167de0caf66fc2f3
Author: HerenderKumar <herender121112@gmail.com>
Date:   Fri Jul 24 20:19:06 2026 +0530

    fix(extract): resolve bash calls into sourced-file functions (#2141)

    extract_bash only linked calls whose callee was defined in the same file, so a
    call to a function from a `source`d library was silently dropped and shell
    scripts looked disconnected from the libraries they use. The resolver for exactly
    this, resolve_bash_source_edges, already existed with tests but had no call site
    and no raw_calls/bash_sources to work from.

    extract_bash now emits `bash_sources` (the files it `source`s) and `raw_calls`
    (calls whose callee isn't defined locally), and the pipeline runs them through
    resolve_bash_source_edges after the id-remap passes, so caller and function node
    ids are final and the already-emitted source edge is de-duped. Resolution is
    scoped to the source relationship: a call to an external command never binds to a
    same-named function in an unsourced file, and bash raw_calls are excluded from the
    generic global-name resolver for the same reason.

commit 0da6929e57eeea47ce4dfe737a65fa83bf3ee638
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Sat Jul 25 02:07:43 2026 +0530

    fix(hooks): match the post-checkout log prefix in the timeout fallback

    The post-checkout body logs with [graphify] throughout, but the new
    non-SIGALRM fallback used [graphify hook], so the same timeout read
    differently depending on the platform. The post-commit body does use
    [graphify hook] everywhere, so only the checkout copy was wrong.

    Assert each body uses a single log prefix so this cannot drift again.

commit ca3113ac7b01df380db503f4ef429d0c0986e5ce
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Sat Jul 25 01:55:16 2026 +0530

    fix(hooks): arm the rebuild timeout without SIGALRM on Windows

    The GRAPHIFY_REBUILD_TIMEOUT watchdog in both embedded rebuild bodies was
    guarded by hasattr(signal, 'SIGALRM') with no else branch, so on Windows it
    never armed and a hung rebuild survived indefinitely -- the exact tail case
    the #791 timeout was added to catch.

    Fall back to a daemon threading.Timer that prints the same message and calls
    os._exit(1). The hard exit is deliberate: the process is already stuck, so a
    clean shutdown may itself be blocked, and _rebuild_lock degrades to a no-op
    yield on platforms without fcntl, so there is no lock to leave stale.

commit ce9ea7d7b46fa92e93305aafd2ee12d6d39224af
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Thu Jul 23 22:30:48 2026 +0530

    test(hooks): surface bash failures in _shell_verdict helper

    Assert returncode == 0 so a malformed case snippet fails fast with
    stderr instead of silently returning an empty string. Addresses
    Copilot review feedback on #2133.

commit e1e041b09b3ec58f94cc66480c9f3fbac1a2b0f8
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Thu Jul 23 20:11:01 2026 +0530

    fix(hooks): accept Windows backslash paths in interpreter allowlist (#2126)

    The post-commit/post-checkout hook's interpreter-detection allowlist
    silently rejected valid Windows paths (C:\...\python.exe) on Git-Bash.
    bash treats a lone backslash inside [...] as an escape that consumes
    itself, so the emitted glob never matched a real backslash at runtime,
    even though the pattern looked correct in Python's install-time re dialect.

    Fix both allowlists (.graphify_python file path and shebang-parsed
    launcher path) to emit [!a-zA-Z0-9/_.@:\\-], a doubled-backslash form
    verified against bash and dash: it accepts Windows paths and still
    rejects ; ` $ injection. The shebang allowlist additionally lacked :
    and backslash entirely. Install-time _pinned_python() re is left as-is.

    Add shell-runtime tests that execute the emitted case/esac glob directly
    against Windows paths and shell metacharacters.

commit 26cac4192d843f312404c0d1043b514471689391
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Fri Jul 24 01:21:22 2026 +0530

    test(extract): cover cross-file class-ref indirect_call suppression (#2137)

    Copilot flagged that the #2137 regression test only exercised the
    intra-file suppression path; a regression in the cross-file resolver
    guard in extract.py would still pass. Add a cross-file test: class and
    function imported from another module, asserting the imported class is
    never an indirect_call target while a genuine imported callback still
    emits its edge.

commit f6742bb6f00879063ef4e7b0731fea7e5839cd7c
Author: Rishet Mehra <rishetmehra11@gmail.com>
Date:   Fri Jul 24 00:00:46 2026 +0530

    fix(extract): exclude class refs from indirect_call edges (#2137)

    Classes are callable via their constructor but are frequently referenced as
    descriptive values, not invoked: ORM args (select(Model), db.get(Model, id)),
    exception tuples (except (ErrorA, ErrorB)), and string-literal getattr resolving
    to a same-named class. The indirect_call guard treated any callable-def target
    identically, so these produced false edges (~41% of indirect_call edges in the
    reported sample targeted classes), inflating centrality and traversals.

    Track class defs in a callable_class_nids set parallel to callable_def_nids,
    mark class nodes with a _callable_class attribute, and exclude class targets
    from indirect_call emission in both the intra-file (_emit_indirect_by_name) and
    cross-file resolver paths. Marker is stripped before output like _callable.

    Covers all languages: both class-node creation sites (the generic
    config.class_types branch and the Ruby Struct.new/Class.new/Data.define
    synthesis) register into the new set.

    Tradeoff: suppression is context-blind, so a genuine higher-order class
    callback (e.g. map(Point, coords)) also loses its indirect_call edge. This is
    far rarer than the false-positive noise removed and matches the issue framing.

    Verified before/after on the same input: 4 class-targeted indirect_call edges
    -> 0, function callbacks preserved.

commit 2fa6cd3d5548577f8c5f591b713f0bf80c1af183
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 23:56:15 2026 +0100

    chore(release): 0.9.25

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

commit ba7f9ea9d8c2c7c7807957010bec34543c20e20a
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 23:50:47 2026 +0100

    chore: relicense from MIT to Apache-2.0

    Apache 2.0 adds an explicit patent grant, a patent-retaliation clause,
    and explicit inbound-contribution terms. MIT's sublicense right permits
    relicensing the combined work, so this needs no per-contributor consent;
    prior contributions were made under MIT and remain available under those
    terms. The original MIT text is retained in LICENSE-MIT and referenced
    from NOTICE.

    - LICENSE: verbatim Apache License 2.0
    - LICENSE-MIT: preserved MIT text for prior contributions
    - NOTICE: attribution + pointer to LICENSE-MIT
    - pyproject: license = "Apache-2.0" (PEP 639 SPDX), license-files, setuptools>=77

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

commit 363f036e5eb26c9506fda14637d3f2e7998c095f
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 22:30:22 2026 +0100

    docs(readme): drop the broken star-history chart

    The inline api.star-history.com SVG rate-limits on a repo this large and
    serves a 503 instead of a chart, so the image was permanently broken.

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

commit 0d1f25c22139fcfe869cb1293c2f678aa06e75b7
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 20:35:54 2026 +0100

    refactor(detect): remove dead .graphifyinclude handling (#2112)

    The .graphifyinclude loader and its two matcher helpers had no consumers:
    commit df40e4d (#873, index dot dirs) removed the blanket dot-prefix
    exclusion and with it the only call sites, leaving detect() parsing the
    file on every run and then discarding the result. A .graphifyinclude was
    silently a no-op.

    Delete _load_graphifyinclude, _is_included, _could_contain_included_path
    and the orphaned assignment; add .graphifyinclude to _SKIP_FILES so a
    leftover file no longer lands in unclassified; and print a one-time
    stderr note when one is present at the scan root, pointing to ! negation
    patterns in .graphifyignore. Bump to 0.9.25.

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

commit ad53f751f9cfc47c14e25775420d8ce531e7cf85
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 19:46:09 2026 +0100

    chore(release): 0.9.24

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

commit 9b1ac65de5d79db06a2c29602e2db42de8117915
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 19:31:42 2026 +0100

    docs: changelog for the XAML .cs-scan hang fix (0.9.24)

commit 2da192db560144bf411835883ab057ea0eb4cfab
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 19:31:19 2026 +0100

    fix(extract): bound the XAML code-behind .cs scan so it can't hang

    _xaml_csharp_class_nodes did `sorted(root.rglob("*.cs"))` where `root` comes from
    _xaml_project_root walking up for a .csproj/.sln marker. On a standalone
    extract_xaml call (no corpus boundary), a .xaml under a large/shared parent (a
    temp dir, or a big monorepo) could resolve `root` to a broad ancestor, and the
    rglob then recursively scanned the entire tree — effectively hanging (it stalled
    the test suite intermittently once the shared temp dir grew). Replace the rglob
    with an os.walk that prunes noise/hidden dirs (node_modules/.venv/.git/...) during
    traversal and caps directories visited, so a real project scans fully while a
    runaway root degrades to a fast, partial scan instead of a hang. Regression test
    asserts a decoy .cs in node_modules is pruned and the real ViewModel still links.

commit 8658cc31059111dd914d247301e3d999b4935cf0
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 19:01:53 2026 +0100

    fix(skillgen): commit rendered skill artifacts for the #2106 traceability line (missed in 65b81bf)

commit a3e283e1093020d331932b76294c1296c564f349
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 18:41:21 2026 +0100

    docs: changelog for #2106 (0.9.24)

commit 65b81bfa8076e9e87a7f2163dc788ce2c2101079
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 18:40:19 2026 +0100

    fix(skillgen): skill flow lists skipped-sensitive filenames, not just a count (#2106)

commit 36b5e770eb565c22c76483065a259bd2bbcb2279
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 18:40:11 2026 +0100

    fix(detect): stop the sensitive-file filter from dropping topic docs and real source (#2106)

    The heuristic over-matched and silently dropped legitimate files:
    - prose `.md`/`.rst` whose topic slug ends in a keyword (privacy-tokens.md,
      token-economics.md) — only code was exempt, not prose;
    - the unbounded Stage-2 `service.account` substring (regex `.` wildcard) matched
      real source (google/oauth2/service_account.py) and prose slugs.
    It also MISSED real secrets (.npmrc, .pypirc, secring, .git-credentials, and
    case variants on case-insensitive filesystems), which were being indexed.

    Fix: move service_account/aws_credentials to the boundary-checked keyword path
    (so real source is spared, downloaded key files still drop), add a prose-note
    carve-out (multi-word slugs indexed, bare `secrets.md`/`token.md` still dropped),
    tighten id_rsa with a left boundary, add the missed secret dotfiles + secring,
    lowercase the dir/segment comparisons, and count multi-dot slugs as multi-word.
    Net effect is stricter on real secrets and stops the false-positive data loss.

    Traceability: `graphify extract` now names the files skipped as sensitive (not
    just a count), so a wrongly-flagged file is visible.

commit 82c46e5358d5b3185b7d66573b52f01e59bd2d06
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 16:19:26 2026 +0100

    docs: changelog for #2093/#2102/#2095/#2094 (0.9.24)

commit 866d503b4808b07e57ba11a1670a2866fc92ac29
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 16:11:15 2026 +0100

    fix(dedup): order-independent gap-fill, source_file gate, _origin/None handling (follow-up to #2102)

commit 5a4b207ddf8b25c7a511e93f1529646c75a3a07e
Author: Synvoya <16019863+Synvoya@users.noreply.github.com>
Date:   Wed Jul 22 21:54:15 2026 +1000

    fix(dedup): preserve same-source node attributes

commit 16315f1956afe7b3e728629bfe287644e55a26ba
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 15:57:54 2026 +0100

    fix(llm): parse claude-cli structured_output, not the prose result field (follow-up to #2095)

commit 7116a6f5f5b643860752a9023dd6d10f5247e515
Author: Yyunozor <yyunozor@icloud.com>
Date:   Wed Jul 22 01:54:05 2026 +0200

    fix(llm): pin claude-cli output to a JSON schema when supported (#2076)

    The claude-cli backend delivers the extraction schema in the user turn and
    trusts the model to emit raw JSON. Newer Claude Code releases treat that
    prompt as an agentic task and report the result in prose instead ("Knowledge
    graph extracted — 21 nodes, 20 edges…"), so the graph parses empty, reads as
    truncation, and adaptive-retry bisects without ever converging.

    Pass --json-schema (structured output) when the CLI advertises it — probed
    once via `claude --help` and cached — so the object shape is constrained
    regardless of prompt framing. Older CLIs that predate the flag keep the
    user-turn prompt as a fallback. The `result` envelope still carries the JSON
    string, so the parse path is unchanged.

commit 8c5f58558765140e831bdbb815523df14cccaa36
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 15:40:00 2026 +0100

    test(extract): external-alias negative + warm-cache coverage for #2082 (follow-up to #2093)

commit cc70085f3b523fa0f98dca9184f41f8a6d7dce17
Author: Yyunozor <yyunozor@icloud.com>
Date:   Wed Jul 22 00:42:45 2026 +0200

    fix(extract): resolve calls edges through an aliased Python import (#2082)

    `from pkg import mod as alias` correctly emits the file-level
    `imports_from` edge, but every downstream `alias.func()` call was
    dropped: the module arm of the cross-file member-call resolver
    (#1883, _resolve_python_member_calls in extract.py) matches a call
    receiver against the imported module's own file stem, with no
    awareness that the local binding in the importing file can be a
    different name. `from pkg import mod` / `mod.func()` resolves because
    the receiver ("mod") equals the stem; aliasing breaks the match
    because the receiver ("alias") never does, and the calls edge
    silently disappears while imports_from stays present -- the graph
    looks connected, only the symbol-level reverse query comes back
    empty. `import pkg.mod as alias` regresses the same way through the
    same resolver.

    Root cause is a missing propagation, not a missing feature: the local
    alias is already parsed correctly in two places (_python_imported_names
    in extractors/resolution.py, and the aliased_import branch of
    _import_python in extract.py) but discarded before it reaches the
    edges the module arm reads.

    Fix threads the alias through as a `local_alias` field on the
    `imports`/`imports_from` edge (mirroring the existing `target_file`
    transient-hint pattern, #1814, including its pop-once-consumed
    hygiene so the hint never reaches graph.json):
    - _import_python now splits the alias off `import pkg.mod as alias`
      and stamps it on the edge instead of only using it to compute the
      bare module_name.
    - _SymbolResolutionFacts.module_imports gains a 4th `local_name` slot
      so the `from pkg import submod [as alias]` submodule path (#1146)
      carries the binding through to _apply_symbol_resolution_facts,
      which now stamps `local_alias` on the edge whenever it differs from
      the submodule's own stem.
    - The module arm's receiver match now accepts the tracked alias in
      addition to the module's real stem, keyed per (importing file,
      target module) so two files aliasing the same module differently
      each match their own binding.
    - extract() pops `local_alias` off every edge right after
      run_language_resolvers runs, and build_from_json drops it from edge
      attrs too -- the same two spots target_file is dropped at (#1814),
      except the extract()-side pop has to happen AFTER the resolver
      reads the field, not at the earlier point _disambiguate_colliding_
      node_ids already pops target_file: that function runs before
      run_language_resolvers, so popping local_alias there would strip it
      before the resolver ever sees it and silently undo the fix above.

    Known limitation, left out of scope: two different aliases bound to
    the same module in the same file only resolve the last one
    registered, since the match is one alias slot keyed per (importing
    file, target module) -- not a regression, since the parent resolved
    neither.

    Adds five regression tests in tests/test_extract.py: the issue's own
    shape (`from pkg import gate as m_gate`), its try/except-guarded
    variant (the issue's literal repro, confirming the drop is
    independent of try: nesting), the adjacent `import mathlib as m` and
    dotted `import pkg.gate as g_alias` forms, and the relative `from .
    import gate as r_gate` form -- plus an assertion that `local_alias`
    never survives into the returned edges. All fail on the parent commit
    with the exact missing-edge symptom and pass after the fix. Full
    suite: 3554 passed vs. 3549 on the unfixed parent, a delta of exactly
    these five new tests; ruff clean. #2080's calls-edge-direction
    regression tests (test_serve.py) still pass unchanged.

commit 4c759b6dd6aa3d9a3f5ebffbdfda4260781f437b
Author: safishamsi <safishamsi98@gmail.com>
Date:   Wed Jul 22 15:31:56 2026 +0100

    fix(cli): deterministic tie-break for grouped explain tail (follow-up to #2094)

commit 0ef452c5703e25877cc292a065c8e0e8b4df9d65
Author: Yyunozor <yyunozor@icloud.com>
Date:   Wed Jul 22 01:46:40 2026 +0200

    fix: group cut connections by file on explain for high-degree nodes

    `graphify explain "<node>"` sorts a node's connections by neighbor
    degree and shows only the top 20, appending a bare "... and N more"
    for the rest. On a high-degree node (a logging/error helper called
    from dozens of places is typical) that leaves the exact question
    explain is meant to answer - "who calls this, what's the impact?" -
    unanswered for 97% of the callers, with nothing pointing at where
    they live (#2009).

    The top-20 list and its ordering are unchanged (no behavior change
    for nodes at or under the cutoff). Past it, the cut connections are
    now grouped by (direction, source_file) with counts and printed
    under a "Grouped by file:" section, sorted by count descending, so
    the caller/callee distribution is visible without falling back to a
    repo-wide grep. The aggregation itself is capped at 20 files with
    its own "... and N more files" line for the pathological case where
    the remainder is spread across more files than that.

    Full per-caller detail behind a flag (--callers --all / --group-by=dir
    as proposed in the issue) is left for a follow-up: it's a larger,
    more opinionated surface (flag naming, pagination semantics) than
    the literal complaint needs, and the default output no longer hides
    the answer either way.

    Four regression tests in tests/test_explain_cli.py: the pre-existing
    truncation notice on a 30-connection node is unchanged, the grouped-
    by-file output carries real counts (3 files, one at 4 and two at 3)
    that sum back to exactly the cut total (no silent loss), a
    byte-for-byte no-op check for nodes at/under the cutoff, and a
    boundary check pinning the cutoff itself at exactly 20 connections
    (no section) vs exactly 21 (one grouped entry) — the earlier tests
    sit well clear of the edge and wouldn't catch a future off-by-one.

commit e32c9f431c5ed7ed8c980f6d34dc3fe6c99a389b
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 21:47:51 2026 +0100

    chore: bump to 0.9.24; changelog for #2080/#2069/#2075/#2085

commit 53d0f88e7bc493fad7f53ec91493cebb1c16cf3a
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 21:46:42 2026 +0100

    test(serve): guard + direction tests for dangling _src/_tgt (follow-up to #2080)

commit 4ace95182bdecd65961df876643bf81e57a24cc2
Author: Yyunozor <yyunozor@icloud.com>
Date:   Tue Jul 21 04:35:38 2026 +0200

    fix: preserve calls edge direction in graphify query output

    `graphify query` builds an undirected nx.Graph (so BFS/DFS can explore
    both callers and callees of the seed node), but its text renderer
    assumed the BFS/DFS visit order (u, v) was always the edge's
    (source, target). On an undirected graph that assumption only holds
    when the seed happens to be the caller: seeding on the callee makes
    BFS/DFS visit the callee first, so a `caller --calls--> callee` edge
    was rendered backwards as `callee --calls--> caller`. graph.json's
    own source/target fields stay correct on disk; only the query
    rendering was wrong.

    `graphify path` and `graphify explain` don't have this problem
    because they force directed=True on load (#849, #853), and the MCP
    query_graph tool's _load_graph() does the same. Doing that for CLI
    `query` too was tried and reverted: forcing a DiGraph makes
    G.neighbors() return successors only, so a query seeded on a
    leaf/sink node (no outgoing edges) found zero neighbors instead of
    its callers — a recall regression, not just a display fix, and it
    would make the CLI and MCP query tools diverge in what they discover
    even though they'd render direction identically.

    Fix instead mirrors the _src/_tgt pattern graphify/build.py already
    uses for the same underlying problem (undirected storage loses
    direction): the CLI now stashes each link's true source/target on
    its edge data as _src/_tgt before constructing the (still undirected)
    graph, and _subgraph_to_text renders EDGE lines from _src/_tgt when
    present, falling back to (u, v) otherwise. Traversal itself is
    unchanged, so recall is unaffected — verified against the unpatched
    CLI, the node counts returned for the same seeds are identical before
    and after this fix, only the printed edge direction changes.

    Adds two regression tests in tests/test_query_cli.py seeding the same
    `calls` edge from both endpoints; the callee-seeded case fails on the
    prior code with the exact backwards-edge symptom above.

commit deb2620be953ac8fe53f58d885df666eec52cf49
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 21:39:32 2026 +0100

    feat(serve): announce get_neighbors/get_community truncation at the top + tests (follow-up to #2069)

commit fef9dbb5e74580371326050cca7d2b649b556ff4
Author: B <8027448+ojmucianski@users.noreply.github.com>
Date:   Tue Jul 21 01:18:31 2026 +0100

    feat(serve): honor a token_budget on get_neighbors/get_community

    get_neighbors and get_community render every edge/member line unbounded — on a
    god node or a large community that floods an MCP client's context window with
    100KB+ of text in one tool result. query_graph already solves this with the
    ~3-chars/token cut in _subgraph_to_text; this applies the same budget rule to
    the two line-list tools via a shared helper (_cut_lines_to_budget): cut at a
    line boundary, report how many lines were dropped, and point at the narrowing
    path (relation_filter / get_node). Default 2000 like query_graph; output under
    budget is byte-identical to today.

commit a59abaf99d20c472eb59401717424b64c767fed0
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 21:34:47 2026 +0100

    docs: --code-only is an extract flag, not a skill flag (follow-up to #2075)

commit bb8f5bd6754880edb39bc057bae2b86414b9d979
Author: HerenderKumar <herender121112@gmail.com>
Date:   Tue Jul 21 10:14:56 2026 +0530

    docs(cli): surface --code-only in the extract usage and README (#2071)

    The top-level `graphify --help` has listed --code-only since #1734, but the
    `graphify extract` usage string and the README never mentioned it. A user
    evaluating graphify on a "can this run with no network call" constraint sees
    the extract usage / README first and can conclude the flag doesn't exist.

    Add --code-only to the extract usage line, name it in the Privacy section and
    the command reference, and add a test asserting the usage advertises it.

commit abb85ccb038165522bce0c9f8d648bf6bd428453
Author: HerenderKumar <herender121112@gmail.com>
Date:   Tue Jul 21 10:47:47 2026 +0530

    docs(readme): troubleshoot an older graphifyy shadowing `uv run` (#1540)

    `uv run --with graphifyy python -m graphify` runs the system interpreter, so an
    older `graphifyy` in system site-packages loads before the uv-managed one — with
    no error. Env overrides like OPENAI_BASE_URL are then ignored and requests 401
    against the default endpoint. The existing `skill is from graphify X, package is
    Y` warning is the real fingerprint, but users dismiss it as a stale skill.

    Add a troubleshooting entry next to the uvx-resolution one: name the symptom,
    show how to confirm which module actually loaded, and point at
    `uvx --from graphifyy` / uninstalling the stale copy.

commit c83085cfef922fd03b62db0a10ccbf7d5eadf4b6
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 18:58:17 2026 +0100

    docs: date 0.9.23 for release

commit 1fbc623271937d7753ea3b6b80e559bd3a473668
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 18:39:05 2026 +0100

    fix(query): report real call-site lines + stop silent query truncation (benchmark)

    Two correctness defects found in a head-to-head benchmark of 0.9.22.

    Caller line numbers: explain/affected/get_neighbors/query printed the caller
    node's def line for an incoming call, presented as a precise citation, so
    click-through landed in the wrong place. The `calls` edge already carries the
    true call-site line (engine.py sets it); every caller/relation listing now reads
    the traversed edge's source_file:source_location, falling back to the node's own
    line only when the edge lacks one.

    Silent query truncation: rendered nodes were degree-ordered (a low-degree
    definition node ranked last, cut first), the queried symbol wasn't guaranteed to
    appear, and the truncation marker sat only at the end so silence read as absence.
    Nodes are now ranked by hop distance from the seeds (deterministic), the seed the
    question named is rendered first and never truncated, and a prominent TRUNCATED
    notice at the top states shown/total counts and how to widen the budget. Also
    rewires the seed-first ordering the renderer already supported — a branch merge
    had silently dropped the `seeds=` argument, leaving it dead code.

commit 10b710561afeb172c043dd129450c861979da79e
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 14:22:50 2026 +0100

    fix(extract): harden #2072 src-layout resolution (adversarial-review fixes)

    Three issues found reviewing #2072:
    - The import-edge repoint loop matched by target id regardless of the edge's
      language, so a non-Python dotted import (C# `using Pkg.Mod;`, Java/Go) whose
      dangling target coincided with a Python alias got repointed onto a Python file,
      fabricating a cross-language import. Gate the rewrite on the edge being
      Python-sourced.
    - The resolver ancestor walk probed package dirs too, resolving an absolute
      `from helpers import x` to a sibling in the current package (Python-2 implicit-
      relative semantics) even when `helpers` is external. Only probe sys.path-root
      candidates (ancestors without their own __init__.py).
    - Bound the __init__.py package-root chain walk by the path depth so a
      pathological `/__init__.py` can't loop.
    Added a cross-language-guard regression test; fixed the tautological (or->and)
    ambiguity assertion.

commit afa7c0d9d3fdcb3ea70160e1b22f9d9e5e2b4e19
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 13:45:49 2026 +0100

    chore: bump to 0.9.23; changelog for #2062/#2074/#2073/#2068/#2072; date 0.9.22

commit bb467634c9bd7c595b7c61ec1fe43e632bf9e28a
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 13:44:49 2026 +0100

    fix(extract): resolve Python imports regardless of scan root (src-layout) (#2072)

    Python absolute imports were resolved only against the scan root, and file-node
    ids are scan-root-relative, so a src-layout project (code under src/) lost most
    of its imports/imports_from edges when scanned from the repo root — the dangling
    edges were silently dropped, so the graph looked complete but wasn't. The chosen
    scan root thus silently changed the graph.

    Two fixes: (1) _resolve_python_module_path probes the scan root first, then walks
    up from the importing file toward the root so a nested package root (src/pkg)
    resolves (mirrors the Lua upward walk); (2) a Python post-pass detects each
    file's package root via its __init__.py chain and repoints absolute-import edge
    targets (dotted-module id -> real file-node id), guarded against shadowing an
    existing id and against ambiguous aliases claimed by >1 file. Result: byte-
    identical import edges whether scanned from the repo root or from src/.

commit cdeba1dcb7837a7478fc891a185ae073acc15daf
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 13:03:34 2026 +0100

    fix(build): ghost-merge keys on full source_file, not bare basename (#2068)

    build_from_json's #1145 ghost-duplicate merge keyed on (Path(source_file).name,
    label), discarding the directory, so unrelated nodes from different files sharing
    a common basename (index.md, README.md, ...) and a generic label were silently
    merged onto one survivor with their edges rewired — corrupting multi-corpus doc
    graphs. The AST/LLM ghost twins the merge legitimately targets always share the
    same source_file, so keying on the full normalized source_file preserves #1145
    while making cross-directory false merges impossible. This subsumes the
    the #2032 label pass. Updated the #1257 test to the now-correct precise merge.

commit 87c870495fad4743484349de78d3de52ef9dd49b
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 12:59:10 2026 +0100

    fix(label): --no-label placeholders no longer permanently suppress real labels (#2073)

    A `cluster-only --no-label` run wrote "Community N" placeholders into
    .graphify_labels.json plus a matching .sig, and the reuse path treated them as
    fresh, so real labels were never regenerated on later runs. Two fixes: (a) don't
    persist the labels sidecar (or its .sig) on a placeholder-only run, so a later
    run generates real labels; (b) treat a stored "Community {cid}" as absent in the
    reuse path so an already-polluted sidecar self-heals via the hub labeler while
    genuine labels are still reused with no LLM call. The watch/update rebuild had
    the same placeholder-perpetuation twin — fixed alongside.

commit b194301c05f4528aa0ba11140deb59d55546b482
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 12:54:58 2026 +0100

    fix(path): deterministic route + honest edge relation, not fabricated calls (#2074)

    `graphify path` (and the MCP shortest_path tool) ran shortest_path over
    G.to_undirected(as_view=True), whose neighbor iteration is a hash-seeded set
    union, so among equal-length paths BFS returned a route that varied per process.
    Build a sorted, materialized undirected graph so the chosen path is canonical.

    The hop label also printed a relation read from an arbitrarily-collapsed parallel
    edge, so it could show `calls` on a pair that only carries `references`. Force
    multigraph on the cli path reload so parallel links survive, and render the
    ACTUAL stored relation(s) via edge_datas, falling back to an honest "related"
    when the edge has none. Serve's shared graph is left untouched (its degree feeds
    query-seed tie-breaks); the fix is applied locally in both path readers.

commit b96effd3aa2bada960df77cc0e659e0276e84211
Author: safishamsi <safishamsi98@gmail.com>
Date:   Tue Jul 21 12:40:49 2026 +0100

    fix(install): uninstall must not delete a user's ### graphify H3 section (#2062)

    The uninstall strip used an unanchored regex `## graphify`, which matched inside
    a user's `### graphify` heading and deleted hand-written content; the
    `marker not in content` guard was a substring test that passed on the same
    mention. Add a shared `_remove_marker_section` helper that matches the heading
    only when a line is exactly the marker (mirroring the install-side #1688
    hardening), running each section to the next same-level heading or EOF, and
    returning None (leave the file untouched) when no exact heading exists. Replace
    all six strip sites: GEMINI.md, copilot-instructions.md, AGENTS.md, CLAUDE.md
    (_strip_graphify_md_section), CODEBUDDY.md, and the H1 skill-registration
    (_remove_claude_skill_registration, which had the same bug with `# graphify`).

commit abff1b1ca4052fcf9d955c5f6a034088723f4536
Author: safishamsi <safishamsi98@gmail.com>
Date:   Mon Jul 20 17:08:15 2026 +0100

    docs(readme): surface the graphify.com waitlist above the fold

    GitHub is the top organic source of waitlist signups, but the only path to the
    site was the logo and a CTA at the very bottom of the README. Add a short,
    plain-voice waitlist line right after the value proposition, and change the
    Enterprise-section CTA from "Learn more" to an explicit "Join the waitlist".

commit 5b05f0d9abbfbf0eba52a109d9cc512aca9f31de
Author: safishamsi <safishamsi98@gmail.com>
Date:   Mon Jul 20 16:35:14 2026 +0100

    fix(cli): disambiguate file labels on the --no-cluster extract path too (#2032)

    The build_from_json label pass only covered the clustered/update paths; the raw
    `extract --no-cluster` path writes the merged node list directly, so colliding
    basenames stayed un-disambiguated there. Factor the logic into a shared
    _file_label_reassignments core with a list-based variant
    (disambiguate_file_labels_in_nodes) and apply it on the raw merged nodes.
    Caught by the clean-venv edge-case battery.

commit 8ce9f6b906f8bd676b524f272a39c6eda6be6f76
Author: safishamsi <safishamsi98@gmail.com>
Date:   Mon Jul 20 16:25:11 2026 +0100

    docs: changelog for the 0.9.22 data-integrity/correctness batch

commit fecf9aa76b3d0f3967ea56e3d5c44f061203dd5a
Author: safishamsi <safishamsi98@gmail.com>
Date:   Mon Jul 20 16:25:11 2026 +0100

    fix(build): disambiguate colliding file-node labels for discovery (#2032)

    In directory-per-entrypoint repos (Supabase Edge Functions, Next.js page.tsx,
    Rust mod.rs, Python __init__.py) many files share a basename, so basename-only
    file-node labels collided and `explain`/free-text discovery couldn't resolve
    them — exactly the highest-value files. build_from_json now runs a final pass
    that gives colliding-basename file nodes the shortest unique directory-qualified
    label (`process-order/index.ts`); unique basenames stay bare, and node ids/edges
    are never touched. The pass runs after the alias-competition (which still needs
    bare basenames), is idempotent (labels derive from source_file), and the
    downstream file-node predicates (analyze god-nodes, tree_html, serve lookup)
    recognize the qualified form via a shared _is_file_node_label helper.

commit f1405dd7c113c452c0ca16c6bf1debda33a967aa
Author: safishamsi <safishamsi98@gmail.com>
Date:   Mon Jul 20 15:40:32 2026 +0100

    fix(extract): nested types get a contains edge from the enclosing type (#2040)

…
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.

Bash: calls to functions defined in a sourced file never get call edges

2 participants