- 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).
The #3490 lock predated its own setuptools>=83 / pypdf / yt-dlp floor bumps, so
a frozen sync resolved setuptools 82.0.1 and the no-isolation wheel-build test
failed. Regenerate the lock so it honors the floors. Also update the windows
skill test: the troubleshooting item is now 'Skip Leiden' (graspologic ->
graspologic-native gating), which #3490 changed in the fragment but not the test.
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)
_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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
Executes the shipped snippet directly (extracted from the rebuild
body text) rather than a hand copy that could quietly drift from it,
covering both the rejected case (a marker pointing outside the repo)
and the case the guard must not break (a subdirectory scoped root).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 5976f7122a)
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)
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)
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)
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)
Under #3503 (#3477) existing nodes from untouched files are protected from
dedup collapse, so the carried-hyperedge remap test must deliver the duplicate
via an incoming re-extracted chunk to fold into the protected survivor.
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)
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)
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)
_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)
_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)
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)
#3475 added a PATH probe for a claude-cli labelling fallback; the no-backend
test must neutralize it so it stays deterministic on machines with claude
installed (follow-up to #3475).
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)
`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)
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)
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)
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)
`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)
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)