1187 Commits

Author SHA1 Message Date
safishamsi fe66389083 release: 0.9.61 — fix Python 3.12/3.13 breakage from 0.9.60
- chinese extra: pin jieba-py from 3.12 (was 3.14); jieba's invalid escapes are
  a hard error on 3.12+ and broke graphify.serve import.
- serve.py: suppress the tokenizer SyntaxWarning by category so -W error can't
  escalate it; catch SyntaxError as a fallback.
- hooks.py: harden the #3492 rebuild-root guard for 3.13, whose resolve() no
  longer raises on a symlink loop — require a real directory.
Verified full suite green on both 3.10 (5628) and 3.13 (5627).
2026-09-12 22:23:00 +01:00
taazbro 200f3a15b5 fix: address Python 3.14 review findings
(cherry picked from commit c49db38935)
2026-09-12 21:35:25 +01:00
taazbro b42e5bc5c2 fix: support Python 3.14 optional dependencies
(cherry picked from commit 76bb841de0)
2026-09-12 21:33:43 +01:00
dajiaohuang 47ddc5c47b fix(imports): pick workspace exports by importer platform, runtime before types
The workspace `exports` resolver reduced a condition object to a single target,
so a package that splits its runtime source from its declarations never
produced a cross-package edge. Reported in #3487; the exports map itself is
present and works, the defect is in how one is chosen.

`_EXPORT_CONDITION_PRIORITY` listed `types` ahead of `require` and `default`.
`types` is declaration-only and normally points into `dist/`, which is build
output and not part of the corpus, so the resolver returned a path that is not
on disk and stopped there — `_package_entry_candidates` returned `[candidate]`
as soon as any condition matched, so the `default` source beside it was never
tried and the import became a dangling `ref_*` external.

That is not the order the map was added with. #1308 specifies
`source -> import -> module -> default -> require -> types`, and the commit
that implemented it says `default` is consulted LAST so a matching
`import`/`module`/`svelte` condition wins; the tuple shipped with `types` in
the middle instead.

Three changes, all on the workspace `exports` path:

- `types` moves to the end of `_EXPORT_CONDITION_PRIORITY`, restoring the
  specified order. `require`/`default` keep their relative order, so the
  behaviour pinned by `test_workspace_subpath_export_default_consulted_last`
  is unchanged.
- `_resolve_export_targets` keeps every condition target in preference order
  instead of returning the first, so a target that is not on disk falls
  through to the next condition rather than taking the import down with it.
- `react-native` is honoured only for an importer that is itself native, and
  each target is tried alongside its platform-suffixed siblings
  (`Icon.tsx` -> `Icon.web.tsx` / `Icon.native.tsx`), which is how a bundler
  reaches a platform split whose `exports` target names the unsuffixed path.
  Importer platform comes from whole path segments, so `apps/mobile/src` is
  native while `packages/web-utils` is not web.

`_resolve_export_target` keeps its signature and its semantics for the
`package.json` `imports` path, and platform expansion applies only to the
workspace `exports` path, so relative imports elsewhere are untouched.

Behaviour note for review: when every `exports` target escapes the package
directory, resolution still falls through to the bare-path fallback exactly as
before. The first revision of this change swallowed that fallback, which the
containment guard made reachable, and a test now pins it. The `"."` branch
additionally gains the containment guard its subpath sibling already had; that
is the only change here beyond the reported defect, and such a package
previously resolved outside its own directory.

Verified: same 29 pre-existing failures before and after, byte-identical
failure sets (`diff` clean; unrelated to this path — non-regular-file, watch
and uninstall-scope tests on Windows). Passing goes 5500 -> 5507, the seven
tests added here. The fixture from #3487 resolves its four previously-dropped
imports and leaves the three control cases unchanged; focused run 109 passed;
`ruff check` clean; all five `tools.skillgen` CI-parity checks OK.

(cherry picked from commit c704a5b2f2)
2026-09-12 21:31:35 +01:00
abhay-codes07 cd6e05df8a fix(js): resolve calls inside exported functions, incl. aliased imports (#3346)
_js_top_level_function_bodies scanned only direct program children, so a
top-level `export function f(){}` / `export const g = () => {}` — wrapped in
an export_statement — was skipped and the calls inside it never became `uses`
facts. Only the use-fact path consults the per-file import-alias table, so an
aliased-import call (`import { bar as baz }; baz()`) produced no calls edge:
the plain-name global resolver has no `baz` to match, and the alias mapping
that would have pointed it at `bar` was never exercised. Unwrapping a
non-re-export export_statement to its inner function/arrow declaration treats
exported and non-exported functions identically, so the alias table resolves
the call to the real definition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit 7d98266538)
2026-09-12 21:31:34 +01:00
ayushcodes10 600e4e7491 Exempt Office/Workspace sidecars from the ignore check
detect() converts Office files and Google Workspace shortcuts into
markdown sidecars under a converted subdirectory of the output dir,
then ran the same scan ignore check on the sidecar's own path. The
documented gitignore advice puts the whole output dir inside a
gitignored tree, so that check rejected the tool's own output for
the same reason a user would gitignore it, and the source document
vanished from the corpus with nothing recorded about why. The check
exists to keep user files out of the scan, not to filter output this
same pass just produced from an already admitted source, so a
sidecar under the converted directory is now exempt from it. Fixes
#3504.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 900b376d39)
2026-09-12 21:31:33 +01:00
Imanol Gutiérrez c32d30060d Add a newline char at EOF on C and C++ headers
(cherry picked from commit ff900a00d8)
2026-09-12 17:39:27 +01:00
ayushcodes10 cd8552242c Report unclassified files from graphify update too
graphify extract has printed this since #1692, but the update and
watch rebuild path never did, so a corpus with no extractor for its
language, Lean 4 in the reported case, rebuilt successfully with
those files completely absent and nothing said about it. Prints the
same wording from the extract command, and threads the count into
the local detection summary the rebuild path already builds for
GRAPH_REPORT.md, which previously dropped it. Fixes #3511.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 120405673f)
2026-09-12 17:36:10 +01:00
ayushcodes10 c9da36d87c Surface unclassified files in GRAPH_REPORT.md
detect() already returns every file it saw but could not classify, no
supported extension or shebang, but nothing read it: a corpus mostly
in an unsupported language got the same well covered verdict as one
that was actually extracted, silently. Adds an Unclassified line to
the Corpus Check section naming the count and the biggest offending
extensions. Toward #3511.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit f08e50534d)
2026-09-12 17:36:10 +01:00
ayushcodes10 0fd1f141d4 Guard os_replace_with_fallback against src equal to dst
Replacing a path with itself, if the initial os.replace call ever
fails for that call, crashed: the fallback backs dst up by renaming
it aside before landing the new content, and when src and dst are
the same path that rename moves src out from under itself, so the
final unlink of src then raised FileNotFoundError. Not reachable
through any current caller, every one passes a distinct temp file,
but cheap to guard against directly. Found by the graphify review
bot on PR 3509.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 5b108e1a1f)
2026-09-12 17:36:09 +01:00
ayushcodes10 4c27b61058 Restore the destination if the fallback swap fails
The fallback removed whatever sat at the destination and then
renamed the new content into place as two separate steps, so a
failure in the second step left nothing at that path at all, worse
than the original write it was trying to protect. Now renames the
existing destination aside as a backup instead of unlinking it, and
renames the backup straight back if the final rename fails, so a
mid swap failure restores the original rather than losing it. Found
by the graphify review bot on PR 3509.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit b56c77a107)
2026-09-12 17:36:09 +01:00
ayushcodes10 4489552b55 Preserve replace semantics for a symlinked destination
os.replace atomically swaps whatever sits at the destination, a
symlink included, by replacing the link itself. install.py relies on
exactly that for its managed skill symlinks, per #3286. The
fallback's plain shutil.copy2 does the opposite when the destination
is a symlink: opening it for writing follows the link and overwrites
whatever it points at instead of the link. Copies to a fresh temp
file beside the destination first, then swaps the destination out
from under it with an unlink plus rename, matching replace's own
behavior and, as a side effect, shrinking the window where a reader
could see a half written file down to two fast metadata operations
rather than however long the copy itself takes. Found by the graphify
review bot on PR 3509.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 39effa0c28)
2026-09-12 17:36:09 +01:00
ayushcodes10 784b1fb1bd Use the replace fallback for skill install writes
Fixes #3508's other reported failure: the Kiro skill install died on
the same WinError 17 while replacing SKILL.md. Covers the version
stamp write too, which shares the same pattern beside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit e6ac02a49d)
2026-09-12 17:36:09 +01:00
ayushcodes10 c9aff7fb06 Use the replace fallback for GraphML export
The GraphML writer replaced its temp file directly with no Windows
fallback of any kind, so a mid size export could hit the same class
of failure as the cache and install paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit ab2bde0956)
2026-09-12 17:36:09 +01:00
ayushcodes10 f71e75eb31 Use the replace fallback for AST cache writes
The AST extraction cache writes its own temp file and replace, a
separate code path from the shared atomic writer, and only caught
PermissionError. This was the exact write named in the report: worker
failed for a file with WinError 17 while replacing a cache entry
under the output directory's ast cache. Also covers the stat index
write, which had no Windows fallback at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 7638d47565)
2026-09-12 17:36:09 +01:00
ayushcodes10 df5a9042e3 Add a shared Windows replace fallback helper
os.replace can fail on Windows even when the temp file and the
destination are in the same directory on the same drive: WinError
17, "cannot move to a different disk drive", maps to a plain OSError
in Python rather than PermissionError, so the existing copy then
delete fallback for WinError 5 and 32 never caught it. Adds
os_replace_with_fallback, checking both PermissionError and winerror
17, and points the shared atomic writer at it instead of its own
inline PermissionError only version. Toward #3508.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 32cbf1f83e)
2026-09-12 17:36:09 +01:00
ayushcodes10 7a09d9ae57 Catch symlink loop RuntimeError in the root guard
Path.resolve() raises RuntimeError, not OSError, when a symlink chain
loops back on itself, so the #3265 in repo check let that propagate
up instead of falling back to the repo top like every other bad
marker value. Since a fork or PR can commit an actual symlink loop
alongside a crafted .graphify_root value, an untrusted checkout could
still turn the guard itself into a failure path. Both rebuild bodies
now catch RuntimeError alongside OSError. Found by the graphify
review bot on PR 3492.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 96d48c76b5)
2026-09-11 23:58:32 +01:00
ayushcodes10 9e734a1ad2 Keep the git hook rebuild root inside the repo
Team setup documents committing the graphify output directory, and
.graphify_root lives inside it, so its contents are checkout
controlled. Both generated git hooks read that file and pass its
value straight to _rebuild_code with no bound, so a value planted
there by a forked PR could point the rebuild, and therefore what it
reads and what it writes back into that same committed directory, at
a location outside the repository the hook runs in. Both rebuild
bodies now only adopt the saved root when it resolves inside the
working tree the hook is running from, falling back to the repo top
otherwise. Fixes #3265.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 31aa4d4e9d)
2026-09-11 23:58:32 +01:00
ayushcodes10 3cd785c65d Stop path::Symbol from shadowing a literal :: label
A native Rust or C++ label containing "::" could be hijacked by the
new path::Symbol resolution when the substring before "::" happens to
match some unrelated file elsewhere in the graph, for example an
extensionless file literally named the same as a Rust module. The
query would then resolve to a node in that unrelated file instead of
the node the literal label actually names. Path scoped resolution now
only runs when the raw, unsplit label does not already have a literal
exact match among real node labels, since a genuine label essentially
never equals a whole path plus symbol string verbatim. Found by the
graphify review bot on PR 3491.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 642f62a0e1)
2026-09-11 23:58:31 +01:00
ayushcodes10 6e19e0eb31 Point the ambiguity message at path::symbol
The prior retry suggestion, a bare repo relative path, resolves to the
file node rather than the symbol, so following it never actually
disambiguates anything. Now that path::symbol exists, point both the
explain CLI and the shared MCP resolver at it, using a generic
placeholder rather than one of the rival paths so the message stays
the same regardless of node iteration order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit c18f3d0a2c)
2026-09-11 23:58:31 +01:00
ayushcodes10 f9b6f21194 Add path::Symbol resolution for explain
A bare symbol name that exists in multiple files is ambiguous, and the
only suggested retry (a bare repo relative path) resolves to the file
node itself, never the symbol, so it could not actually disambiguate
anything. This adds a path::Symbol form that restricts the label match
to nodes defined in that file, wired into _find_node_tiers ahead of
the ordinary tiers so it is available to explain, get_node and
get_neighbors alike. Fixes #3485.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 84a2da2769)
2026-09-11 23:58:31 +01:00
safishamsi 047dd93187 fix(resolution): clear the #3500 path-resolution caches per extract() run
The memoized path/source-key lru_caches are keyed by (path, cwd) with no mtime
component; without a per-run clear they replay stale results across repeated
extract() calls in a long-lived watch/MCP process. Wire them into the existing
cache-clear block. (follow-up to #3500)
2026-09-11 22:47:27 +01:00
abhay-codes07 cd8d99b2f4 perf(resolution): memoize Path.resolve() across the symbol-resolution passes
The symbol-resolution and cross-file import passes call Path.resolve() once
per import/export/use fact and per node — ~44k calls over a few hundred
distinct corpus paths, each walking nt._getfinalpathname on Windows. A
per-(path, cwd) memo collapses that to one syscall per distinct path. On a
364-file self-corpus a sequential extract drops from ~27s to ~14s, graph
byte-identical. The cwd is part of the key so a relative path resolved after
a chdir misses rather than replaying a stale answer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit 5ca30dfd8a)
2026-09-11 22:46:36 +01:00
abhay-codes07 d7de19d47d perf(resolution): memoize source-key resolution in the id-disambiguation pass
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit 2adfa810c9)
2026-09-11 22:46:36 +01:00
abhay-codes07 f077217191 perf(resolution): parse each Python file once across both resolution passes
The symbol-resolution facts pass and the cross-file import pass each parsed
the whole .py corpus, back to back, in the main process — every file read
and tree-sitter-parsed twice for nothing. _parse_python_tree now memoizes on
(path, mtime, size), so the second pass reuses the first pass's tree while a
file changed between watch runs still re-parses. On a 364-file self-corpus a
sequential extract drops from ~27s to ~24s, graph byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit dcbab53879)
2026-09-11 22:46:27 +01:00
abhay-codes07 a572b88777 perf(resolution): walk the Python tree iteratively, not via recursive yield-from
_walk_python_tree drove the symbol-resolution passes through a recursive
generator: one suspended frame per tree level, every node re-propagated up
the whole chain — ~25M frame resumptions on a 364-file corpus for ~2.8M
nodes. An explicit stack yields the identical preorder (node identity and
order verified) in ~30% less time, and no longer risks RecursionError on a
deeply nested tree. Same rewrite _walk_js_tree already carries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit e1544e0ad4)
2026-09-11 22:46:26 +01:00
Curtis Muir 3f6ca655dd fix(extract): fall back to sequential when the process pool cannot start
_extract_parallel falls back to sequential on BrokenProcessPool, but an
OSError raised while constructing the ProcessPoolExecutor escaped and
killed the whole extraction (and `graphify update`).

Seen on macOS: ProcessPoolExecutor -> multiprocessing SemLock -> sem_open
fails with OSError(ENOSPC), "No space left on device", once leaked POSIX
named semaphores exhaust kern.posix.sem.max — with the disk nowhere near
full. Sequential extraction needs no semaphore, so hand back False the
same way the BrokenProcessPool branch does, with a warning that names the
OS error rather than the Windows __main__ hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c154c01d50)
2026-09-11 22:46:26 +01:00
himanshupatro-334 8a89253fec Scope deduplication during incremental merges
(cherry picked from commit 9e65a71993)
2026-09-11 22:46:15 +01:00
L4XB ec12e5e341 test: isolate backend-detection tests from the developer's environment
Autouse conftest fixture clears every backend env var detect_backend() reads
(API keys, Azure/AWS/Ollama), with a self-guard test that the cleared list
covers every variable the detector reads. (#3484)
2026-09-10 23:04:40 +01:00
ktsang622 d9e094c2e2 fix: label via an installed claude CLI, and stop pinning a path that expires
Two independent failures, both silent, found while repairing 16 repositories whose
graphs had quietly stopped tracking their code.

1. `graphify label` downgraded graphs while reporting success

`detect_backend()` is key-based, and `claude-cli` is the one backend with no API key
to find — `_get_backend_api_key` can never return one for it, and the fallback loop
excludes it by name. Nothing else looked for it. So on a machine with the Claude Code
CLI installed and no API key, labelling announced "no LLM backend configured",
replaced every real community name with a `Community N` placeholder, and exited 0.
Run over an existing graph, that overwrites good names with worse ones and looks like
success; taking the exit code at face value commits the damage.

The fallback is placed in the labelling path, NOT in `detect_backend()`. Widening
detection itself was the first attempt and it broke
`test_mixed_repo_without_key_errors_and_points_at_code_only`: extraction deliberately
refuses to run without a configured backend and points at `--code-only`, and on any
machine with the CLI present that contract would have silently changed to "shell out
to the CLI instead". That contract is unchanged here, and a test now pins it.

2. The post-commit hook pinned a path with an expiry date

`_pinned_python()` wrote `sys.executable` verbatim. graphify installed from inside a
snap-confined editor lives under `~/snap/<app>/<revision>/`, and snap swaps that
revision on update and prunes the old tree. Every hook pinned to it then dies —
observed across 15 repositories at once when an editor snap moved past revision 259.
Each commit printed "could not locate a Python with graphify installed" and the graphs
stopped following the code, with nothing failing loudly enough to notice.

Two changes: `_pinned_python()` declines to pin a path under a rotating revision
directory, which its docstring already describes as safe degradation; and the
uv-tools probe — which already scans tool environments — additionally scans
snap-confined HOMEs by glob, since an install made inside a snap lands in that snap's
private HOME and the plain `$HOME` roots never see it once the hook runs from an
ordinary shell.

Verified: same 25 pre-existing failures before and after (unrelated: terraform
fixtures, a TS scaling test); passing goes 5110 -> 5119, the nine tests added here.
Each change was mutation-tested — removing it fails a test that the others still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c6065e24ea)
2026-09-10 22:44:40 +01:00
dajiaohuang 496112d6fc fix(install): do not abort the install when always-on registration is unwritable
`install()` copies the skill files first and writes the always-on
registration afterwards, so an unguarded write to `~/.claude/CLAUDE.md`
left a half-completed install plus a traceback whenever that file could
not be written. That is the normal shape for a declaratively managed
dotfile: nix/home-manager symlinks it into a read-only /nix/store, and
chezmoi or stow with read-only sources do the same.

Route the two registrations inside `install()` (CLAUDE.md and
CODEBUDDY.md, which had the identical unguarded pattern) through one
helper that reports the skip on stderr and lets the install finish, so
the skill files that were already laid down stay usable.

Refs #3474

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 47041eef5d)
2026-09-10 22:44:40 +01:00
himanshupatro-334 437c9629bd Fix same-file source path collision during merge
(cherry picked from commit fa1284a356)
2026-09-10 22:44:39 +01:00
L4XB ab80fa7413 fix(rust): extract module-level static and const declarations
The Rust extractor gated every non-function item on struct_item,
enum_item and trait_item, so tree-sitter-rust's static_item and
const_item never produced a node: a constant existed in the graph only
through the files that referenced it, never from the Rust that defines
it (0 of 783 names in a jdx/mise snapshot).

Add a branch for both node types: a module-level declaration gets a
node with a file-level contains edge, an associated const inside an
impl is attributed to the impl like a method, and the declared type is
referenced the way a struct field type is.

Fixes #3471

(cherry picked from commit 70ff464064)
2026-09-10 22:44:38 +01:00
L4XB d2abe4ed0c fix(sql): emit index nodes for CREATE [UNIQUE] INDEX statements
Adds an AST branch for create_index in the SQL extractor, emitting an index
node with an 'indexes' edge to its table (cross-file stub when the table is
defined elsewhere), mirroring the existing trigger handling. (#3470)
2026-09-10 14:51:54 +01:00
L4XB 355eefd391 fix(ts): keep star-export resolution off same-named interface methods
A workspace-barrel import (`import { evaluate } from '@scope/domain'`)
resolves through `export *` by probing each star target for a symbol
node with the imported name. The symbol map normalizes labels by
stripping the leading dot, so an interface/class METHOD `.evaluate()`
in the first star target satisfied the probe before the exported
function in the second one was reached. The import edge and the
cross-file call then bound to the method node, and the real function
lost its inbound `calls` edge (read as dead code).

Track member nodes separately and skip them in the star-export walk: a
module can only re-export top-level bindings. A member never shadows a
same-named top-level symbol in the map anymore either.

Fixes #3436

(cherry picked from commit 52e4a2b131)
2026-09-10 14:03:23 +01:00
xuxiongjian 76de060036 Never infer an empty repo tag for global add
`tag or source.parent.parent.name` is empty whenever the graph is not two levels
below a named directory: `graphify global add /tmp/graph.json`, or any relative
path. Every node is then prefixed `::`, the prune key is `""`, and the manifest
entry cannot be removed because the `remove` subcommand reads an empty tag as a
missing argument.

Infer through `distinct_repo_tags`, the helper `merge-graphs` already uses, so the
two entry points cannot diverge again and the degrade is `"repo"`; absolutize
first so a relative path names the repo dir rather than the cwd placeholder. An
omitted `remove` tag stays a usage error, while an explicitly empty one is now
addressable so stores written by earlier revisions can be cleaned up.

(cherry picked from commit 6a317e6ef2)
2026-09-10 14:03:22 +01:00
xuxiongjian dba63f7c9e Import Iterable in build.py, the annotations already use it
build.py annotates chunks and ast_sources with Iterable but never imports it, so
ruff reports four F821 on v8 and typing.get_type_hints() raises on
_tier_replacement_sources, merge_raw_extraction and build_merge. Nothing breaks at
import because `from __future__ import annotations` keeps the hints as strings.

The added test walks build.py's own functions rather than the package: nx under
if TYPE_CHECKING in cross_repo_calls.py, cross_repo_types.py and prs.py is
unresolvable on purpose, while build.py imports every annotated name at module scope.

(cherry picked from commit c315a15c24)
2026-09-10 14:03:22 +01:00
edwardselby 3dc3f4d78c fix(bash): resolve exec-position script paths built from a variable
`script_invocation` edges (#1756) only ever resolved a path that is a
bare literal -- `./helpers.sh`, or `bash ./helpers.sh`. Both paths into
that branch go through `literal()`, which rejects by design any token
containing `$`, so the single most common way a shell script calls a
sibling is invisible:

    script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    "$script_dir/helpers.sh" --flag

`cmd` is None for that command node, the `elif cmd and ...` guard short-
circuits, and no edge is emitted. On a 206-file shell tree that uses the
`script_dir` idiom throughout, `script_invocation` produced *zero* edges
-- the feature was effectively dead for that whole style of codebase.

The `source` branch already solved the same problem for
`source "$DIR/lib/x.sh"` (#2079): strip the leading `${VAR}/` segments
with `_bash_source_suffix` and resolve the literal remainder against the
sourcing file's own directory, because the canonical `script_dir` idiom
makes that variable equal to exactly that. This applies the identical
treatment to the exec position -- read the command-name node's raw text
rather than its `literal()`, strip the same way, and fall through to the
existing `resolved.is_file()` gate.

Nothing new is fabricated. `_bash_source_suffix` still returns None when
the remainder holds another expansion (`"$dir/$name.sh"`) or a `..`
segment, and the on-disk check still means a wrong script-dir guess
emits nothing at all. The three existing negative tests -- missing,
shadowed, and dynamic invocations -- pass unchanged.

Measured on the same 206-file tree: 0 -> 16 edges across 10 distinct
file pairs, every one hand-checked to a real invocation of a real file,
no false positives. Full suite: no change in outcomes (the 25 failures
here are pre-existing and unrelated -- terraform, ollama, skillgen).

Fixes #3416.

(cherry picked from commit 2e112c39fc)
2026-09-09 17:20:57 +01:00
abhay-codes07 34735fa762 fix(detect): probe every out subdirectory under an entry budget, not the first 20
Review follow-up: a wide TS outDir keeping compiled files only under a
late-sorted module dir slipped past the 20-subdirectory cap. The level-two
probe now covers every subdirectory, bounded by total entries scanned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit ff0a8f6d64)
2026-09-09 17:20:57 +01:00
abhay-codes07 ba9b3e3d6c fix(detect): gate the bare out dir on build-output evidence (#3347)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit eca5af36f1)
2026-09-09 17:20:57 +01:00
ayushcodes10 465270fba3 Prefer the alias for a PHP use import edge's target
An aliased use clause (use GuzzleHttp\Client as HttpClient) always
targeted the bare imported name, ignoring the alias entirely. Two
files importing the same external class under different local
aliases, a real pattern for disambiguating two same named classes
from different namespaces, produced two different stub targets for
one class, splitting its identity. The cross file resolver repoints
a stub through the file's own alias to FQN map, keyed by the alias
when the import has one, so a bare name derived stub could never be
found under that key and stayed stuck, unresolved. The alias, when
present, is also what the rest of the file actually references, so
preferring it here keeps this edge's target consistent with those
reference edges too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 73f9f1667b)
2026-09-09 17:20:56 +01:00
himanshupatro-334 5dd24de1c9 Fix loose sibling module import resolution
(cherry picked from commit d00d3aea14)
2026-09-09 17:20:56 +01:00
flaukowski d4531f3d25 fix(python): resolve submodule imports inside PEP 420 namespace packages
`from . import brain, ledger` in a directory with no __init__.py — a
namespace package, which `python -m pkg.mod` runs without complaint —
emitted nothing: _resolve_python_module_path returns None when there is
no module file to probe, and the #1146 submodule branch sits behind that
None. So every `brain.think()` / `ledger.write()` call in such a repo was
invisible to the #1883 module arm, and the most-called functions carried
in-degree 0 in the graph.

_resolve_python_namespace_dir mirrors the module-path walk (relative
base, scan root, then sys.path-root ancestors per #2072) and returns only
a directory that exists inside the root and has no __init__.py. The fact
collector uses it when the module path resolves to no file; the existing
submodule probe then emits the same imports_from edges a regular package
gets. A name that is not a module file on disk still emits nothing, and
a namespace package binds no symbols of its own.

Measured on a 275-node Python repo laid out this way, same graphify
version with and without the change: calls 201 -> 307, nodes unchanged;
think() 0 -> 15 callers, remember() 0 -> 10, ledger write() 0 -> 20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTNEBgonu6ASph6w8R2Wcp
(cherry picked from commit e2ec796bfd)
2026-09-09 17:20:55 +01:00
himanshupatro-334 c967797f62 Fix Python nested function resolution
(cherry picked from commit 416be1e9c7)
2026-09-09 17:20:54 +01:00
himanshupatro-334 32b209ae10 Fix incremental merge of cross-file project stubs
(cherry picked from commit 8286a51511)
2026-09-09 11:30:29 +01:00
abhay-codes07 5e6c2be092 fix(dedup): pick the richer duplicate as survivor and keep the losers' fields (#3372)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit a3ba93b859)
2026-09-09 11:30:29 +01:00
abhay-codes07 1dc0dc9423 fix(js): keep this.X = function members in every enclosing-function form (#3408)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit 08487e9a73)
2026-09-09 11:30:28 +01:00
abhay-codes07 5d09dce42c fix(csharp): resolve unqualified generic call sites, Get<int>(...) (#3406)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
(cherry picked from commit 6d8086d585)
2026-09-09 11:30:27 +01:00
ayushcodes10 3b9356bae6 Add source_location to Dart extraction
extractors/dart.py hardcoded source_location: None at every node and
edge construction site, so a Dart graph could cite zero lines no
matter how many declarations it had (0% across three real repos,
~25k unciteable nodes in one of them).

Threads a line number through add_node/add_edge from each match's own
offset, anchored on the capture group rather than the whole match: a
pattern like the class declaration's leading ^\s* otherwise reports
the line too early when blank lines precede the declaration.

The comment stripping pass also needed a fix first: it deleted a
comment token outright, so a multi-line comment shifted every later
offset (and therefore every later line number) up by the comment's
own height. Blanking it to the same number of newlines instead keeps
src_clean's line count in step with the original file.

Sourceless reference stub nodes (an inherited class, a mixin, an
import target, and similar names the file only references rather
than defines) keep source_location: None, same as before, since there
is no single line in THIS file for a symbol that lives elsewhere.
Every node this file actually defines, and every edge attributed to a
real declaration site, now carries its real line.

Fixes #3365.

(cherry picked from commit 0dbcefc5ba)
2026-09-07 20:21:35 +01:00
Sagexd08 82b25b9a96 fix(extract): make _TsRangeIndex a real type alias
The alias was written as a quoted string, which makes it a `str` *value*
rather than a type. Type checkers reject it: mypy reports "Variable
_TsRangeIndex is not valid as a type" at both annotation sites, plus two
cascading errors where the tuple loses its type and the unpacking/indexing
in _ts_ranges_containing and _normalize_ts_import_types become untyped.

`typing.get_type_hints` happened to resolve it at runtime because it
evaluates the annotation string and then the alias string again, so nothing
failed in practice -- but that is an accident of double evaluation, not a
working alias.

Unquote the right-hand side. The module already has
`from __future__ import annotations`, so the annotations themselves need no
quoting either. No behavior change.

(cherry picked from commit d902820ccf)
2026-09-07 20:21:35 +01:00