fix: _git_head() resolves from the target repo, not the caller's CWD#2189
Open
geekypy wants to merge 1248 commits into
Open
fix: _git_head() resolves from the target repo, not the caller's CWD#2189geekypy wants to merge 1248 commits into
geekypy wants to merge 1248 commits into
Conversation
Adds the Hebrew translation under docs/translations/README.he-IL.md and links it from the language row (30 -> 31 languages). Merged from PR Graphify-Labs#1639 by @AdirBuskila; the language-row link was adapted to the current README structure by hand since the English README changed after the PR was opened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An animated SVG that mirrors the real `graphify path` output: a terminal types the query on the left while the answer draws itself hop by hop across the knowledge graph on the right, ending on "3 hops. Zero files opened." Pure SMIL inside a self-contained dark card, so it renders inline on both GitHub themes with no JS and no external fonts. Palette is brand-only: muted emerald (sampled from the logo) as the single accent, no neon. Generated by scripts/gen_demo_path.py (re-run to regenerate; STATIC=1 bakes a still frame for QA). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r-broad filters (Graphify-Labs#1666) @krishnateja7 root-caused this precisely: the files were never reaching extraction, so the 0.9.7 no-cache-on-empty mitigation could not surface them. Two discovery-layer filters were the cause: (a) A bare `snapshots/` directory was pruned as a Jest/Vitest artifact, which killed legitimate code namespaces like a Rails `app/services/snapshots/`. It is now pruned only when it actually contains `.snap` files or sits directly under a JS test root (`__tests__`/`__test__`). `__snapshots__` stays unconditionally pruned. (b) `_is_sensitive` dropped files on a bare name-keyword hit (device_token.rb, passwords_controller.rb) even when `classify_file` had already resolved them to source code. A genuine programming-language source file is now exempt from the weak keyword heuristic, while real secret stores in data/config formats (credentials.json, secrets.yaml, .env, .pem, ...) are still caught — those route through the CODE path for manifest parsing but are deliberately not exempted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ault (Graphify-Labs#1621) @sub4biz verified against the live DeepSeek API that deepseek-v4-flash (and v4-pro) have thinking ENABLED by default, contradicting the built-in config's stale "non-thinking" comment (now corrected). The naive fix (mirror the kimi branch and force thinking off) is the wrong call: @sub4biz's production testing on real corpora found that disabling thinking removes a rare reasoning-leak failure — which the adaptive extraction/labeling retry already recovers from — but trades it for far more frequent benign truncation AND measurably lower extraction quality and file coverage, confirmed by a blind second reviewer. So thinking stays ON by default (quality/coverage), with a documented opt-in `GRAPHIFY_DISABLE_THINKING=1` for users who prefer run-to-run stability. Applies to reasoning-capable OpenAI-compatible backends at both extra_body sites (extraction + labeling). An explicit providers.json extra_body still wins, and the moonshot/kimi branch is unchanged (it must disable thinking or content is empty). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n Windows (Graphify-Labs#522) The hooks were inline POSIX bash (case/esac, [ -f ], single-quoted echo), which Windows cmd.exe/PowerShell cannot parse. On Windows the hook failed silently, so the "run `graphify query` before grepping/reading raw files" nudge was never injected and users fell back to manual /graphify. The detection logic (grep-command match; source/doc extension match; skip if the target is under the output dir; require graph.json to exist) moved into a shell-agnostic `graphify hook-guard <search|read>` subcommand, invoked via the absolute exe path resolved by _resolve_graphify_exe() — the exact pattern the codex hook already uses. A single console-script invocation has no shell syntax, so it parses identically under sh, cmd.exe and PowerShell. Behavior on macOS/Linux is unchanged: the nudge payload is byte-identical (compact JSON, same additionalContext text), matchers stay "Bash"/"Read|Glob" so install/uninstall still find and replace old hooks, and the command still contains "graphify". The graph-exists check now honors GRAPHIFY_OUT instead of the hardcoded graphify-out/ path. Codex stays a no-op there (hook-check) because Codex Desktop rejects additionalContext. Detection is fully unit-tested (ported test_read_hook.py + new test_search_hook.py, byte-identical output on POSIX); Windows execution itself is not testable in CI here, but the mechanism is now shell-independent by construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stic too (Graphify-Labs#522) The Gemini hook was a `python -c "..."` one-liner that (a) depended on a bare `python` being on PATH (frequently `python`/`py` or absent on Windows) and (b) embedded backticks + escaped quotes that Windows PowerShell mangles. Same fix as the Claude/Codebuddy hooks: it now invokes `graphify hook-guard gemini` via the absolute exe path. The gemini mode always returns {"decision":"allow"} (never blocks a tool) and appends the graph nudge as additionalContext only when graph.json exists — the BeforeTool contract Gemini expects, byte-identical to the old payload. It takes no stdin, honors GRAPHIFY_OUT, and the matcher stays "read_file|list_directory" so install/uninstall find and replace old hooks unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s#522) Adds a wide matrix over _run_hook_guard covering the search/read detection boundaries and the gemini BeforeTool contract: - search: grep-family variants (grep/pgrep/egrep/fgrep/ripgrep), token matches (rg/find/fd/ack/ag with trailing space), piped commands; and silence for non-search commands, empty/missing/non-string command, 'find' without a trailing space, 'ag' mid-word, top-level vs nested tool_input, non-dict tool_input, and no-graph. - read: source/framework extensions, uppercase and multi-dot names, Windows backslash paths, glob patterns; and silence for .json (not .js), lockfiles, images, extensionless files, an extension on a directory segment, targets under the (default and custom-named) output dir, and no-graph. - fail-open: malformed/empty/binary stdin and a throwing graph-existence check never crash or block. - gemini: always returns {"decision":"allow"}, nudges only with a graph, and stays "allow" even if the check throws. - dispatch/exit/encoding via real subprocess: missing/unknown mode exits 0 silently, every mode exits 0 (never blocks), and the read nudge's em dash round-trips as valid UTF-8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes: Windows PreToolUse/BeforeTool hooks for Claude Code, Codebuddy and Gemini CLI (Graphify-Labs#522); CLAUDE.md/AGENTS.md section-write data loss (Graphify-Labs#1688); tiktoken special-token crash (Graphify-Labs#1685); Ollama hang retry-multiplication (Graphify-Labs#1686); truncated community-label reply salvage (Graphify-Labs#1690); cluster-only labeling token/cost accounting (Graphify-Labs#1694); discovery-layer file drops from snapshots/ and name-keyword filters (Graphify-Labs#1666); deepseek thinking default + GRAPHIFY_DISABLE_THINKING opt-in (Graphify-Labs#1621). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sec quickstart - Badges reranked for click-through and split: lean top strip (PyPI, Downloads, Discord, LinkedIn, YC S26) with trust signals leading and social/company links at the tail; a "Community and links" footer (Discord, X, Sponsor, Book); CI badge dropped, book also linked from "Learn more". - Hero leads with three differentiator bullets, corrected to match the codebase: code is tree-sitter AST (deterministic, no LLM, local), while docs/PDFs/images/ video use your assistant's model or a configured API key for a semantic pass. Removed the inaccurate "local embedder" claim (there is no embedder; dedup is MinHash/LSH and there is no vector store) and qualified "zero LLM tokens" to the code path. "Not a vector index" is accurate. - Added a 30-second quickstart (install + graphify install + /graphify .) above the fold, moved "See it in action" above "What it does" so differentiation is shown before it is enumerated, and compressed the "works in" list to one line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in _find_node (Graphify-Labs#1704) `_find_node` built its search term with `_search_tokens` (\w+ tokenization), so "blockStream.ts" became "blockstream ts" (space where the '.' was) while the node's stored `norm_label` keeps punctuation ("blockstream.ts"). The verbatim case is already rescued by the `term == label_tokens` tier (the node label tokenizes the same way), but that is a coincidence: if `label` and `norm_label` diverge, an exactly-typed punctuated label fails to resolve through `explain` even though `path`/`query` find it. Add a punctuation-preserving `norm_query` (`_strip_diacritics(label).lower()`) matched against `norm_label`/`bare_label` across the exact/prefix/substring tiers (and fed to the trigram prefilter so candidates are not missed). Purely additive, symmetric with how norm_label is stored. Regression tests cover the verbatim file-label case and the label/norm_label divergence case that only norm_query resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilently dropping them (Graphify-Labs#1689) Extensions like .r/.R (also .ejs, .ets) are in CODE_EXTENSIONS, so those files are classified as code and counted in the scan, but there is no entry for them in the extractor dispatch — so they produce zero nodes and are silently absent from the graph while the CLI still reports success. The Graphify-Labs#1666 zero-node warning deliberately skips them (it only fires when an extractor exists), so nothing surfaced the gap. extract() now emits a warning listing the offending extensions and counts ("N file(s) are classified as code but graphify has no AST extractor ...: .r (2)") so a primarily-R (or .ejs/.ets) corpus no longer looks fully mapped when it is not. Grouped by extension, fires only for files actually present with no extractor (today: .ejs, .ets, .r). Adding real grammars for these remains the follow-up; this removes the silent-data-loss now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aphify-Labs#1693) Intermediate progress lines count against len(uncached_work) ("X/Y uncached files"), but the final line switched to total_files ("Y/Y files"), which includes cached hits and files with no extractor that never entered uncached_work. On a large corpus with unsupported-language files, the total jumped upward right after 99% with no explanation. Both the parallel and sequential final lines now report the same uncached_work denominator, so the count no longer appears to change mid-run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Graphify-Labs#1712) GRAPH_REPORT.md rendered the Community Hubs section as Obsidian wikilinks, but the `_COMMUNITY_*.md` notes they target are only created by the opt-in `--obsidian` export — and the report is written at build time, before any export runs. So on a default run every link dangled: inside an Obsidian vault they spawned phantom `_COMMUNITY_*` nodes in the graph view, and outside Obsidian they rendered as literal brackets that navigate nowhere. `generate()` now takes `obsidian: bool = False`; by default the hubs render as a plain list, and the wikilink form is emitted only when a caller opts in. The Obsidian export's own community notes already cross-link each other, so the vault stays navigable without the report's links. Mirrors the Graphify-Labs#1444/Graphify-Labs#1465 portability fix that was applied to `export wiki`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ently (Graphify-Labs#1692) When classify_file() returned None — an extensionless, non-shebang file (Dockerfile, Gemfile, Makefile, Rakefile, LICENSE, ...) or an unsupported extension — the file left no trace at all: not counted, not listed, nothing. A user had no way to tell from graphify's output that those files were even considered. detect() now collects these into an "unclassified" list in its result, and `graphify extract` prints a one-line summary after the scan counts: "N file(s) not classified (no supported extension or shebang), skipped: Dockerfile, Makefile, ...". Real code/docs are unaffected. This is the visibility half of the issue; wiring up extractors/manifest handling for Dockerfile/Makefile-style files remains a separate feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…an vault by default (Graphify-Labs#1681) The Usage block's bare `/graphify` comment read "full pipeline on current directory -> Obsidian vault", contradicting Step 6 (HTML viz always; Obsidian vault only when --obsidian is explicitly given). The comment predated the opt-in change and told agents a bare /graphify produces a vault. It now reads "full pipeline on current directory (HTML viz; add --obsidian for a vault)". Fixed at the skillgen source (fragments/core/core.md + the aider monolith override), added a sanctioned-diff predicate so the monolith round-trip guard allows the change, and re-blessed the expected/ snapshots. Every generated skill-*.md now carries the corrected comment; the only content change across all rendered files is this one line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mmar (Graphify-Labs#1702) `.m` is shared by Objective-C implementation files and MATLAB, but the extractor dispatch routed every `.m` to extract_objc unconditionally. Feeding real MATLAB to the Objective-C tree-sitter grammar yields root.has_error and garbage nodes/edges — worse than skipping, because it pollutes the graph with wrong data. _get_extractor now content-sniffs `.m` the same way `.h` already is: a genuine Objective-C `.m` carries an ObjC directive (@implementation/@interface/@import/ #import) and still routes to extract_objc; a `.m` without one (MATLAB, Octave) gets no extractor, so it is surfaced by the no-AST-extractor warning (Graphify-Labs#1689) instead of mis-parsed. `.mm` is unambiguously Objective-C++ and is left untouched. This stops the wrong-by-omission garbage; wiring a real tree-sitter-matlab extractor (the issue's primary ask) remains a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes since 0.9.8: explain punctuated-label matching (Graphify-Labs#1704); surface code files with no AST extractor instead of dropping them silently (Graphify-Labs#1689); consistent AST-extraction progress denominator (Graphify-Labs#1693); no dangling Obsidian wikilinks in GRAPH_REPORT.md by default (Graphify-Labs#1712); MATLAB .m no longer force-parsed by the Objective-C grammar (Graphify-Labs#1702); corrected the /graphify usage comment in the skill files (Graphify-Labs#1681); surface unclassified files (Dockerfile/Makefile/...) instead of vanishing (Graphify-Labs#1692). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SECURITY.md said graphify never runs a network listener and only communicates over stdio. README.md documents an opt-in --transport http mode (with --host/--api-key flags) for sharing one server across a team, which does open a network listener. Update the claim to describe the default (stdio, no listener) and the documented opt-in exception.
…docstring _max_graph_file_bytes() resolves the graph-load memory-bomb cap and parses the GRAPHIFY_MAX_GRAPH_BYTES env var (plain bytes, MB/GB suffix, fallbacks), but had no behavioral tests — only the default constant was asserted. - Add tests for: unset/blank -> default, plain integer, MB/GB suffix (binary), case-insensitive and space-tolerant suffixes, unparseable -> default, and non-positive -> default. - Fix the docstring, which described the suffix as "decimal multipliers of 1024" (self-contradictory). MB/GB are treated as binary (MiB/GiB); the tests lock that behavior in. No behavior change.
What changed - Stabilize relative rebuild execution before graphify-out queue/lock setup. - Recover from deleted transient hook CWDs when GRAPHIFY_REPO_ROOT points at the repository root. - Fail cleanly when the current working directory is gone and no repo root fallback is available. - Add regression coverage for both fallback and clean-failure paths. Why - Detached post-commit/post-checkout rebuilds can inherit a transient CWD that is deleted before the background process starts. - _rebuild_code previously used relative graphify-out paths before Path.cwd()/watch_path resolution could be handled, causing FileNotFoundError: [Errno 2] No such file or directory. Validation - .venv/bin/pytest tests/test_watch.py -q: 54 passed, 2 skipped. - .venv/bin/ruff check graphify/watch.py tests/test_watch.py: passed. - .venv/bin/python -m py_compile graphify/watch.py tests/test_watch.py: passed. - env -u PYTHONPATH -u PYTHONHOME PYTHONHASHSEED=0 .venv/bin/pytest -q: 2904 passed, 30 skipped. Notes - Full suite without PYTHONHASHSEED=0 exposed an unrelated ordering-sensitive labeling test; with the deterministic hash seed used by graphify hooks, the suite passes.
Semantic extraction only wrote to the cache once, at the very end of the run (save_semantic_cache in __main__ after extract_corpus_parallel returns). A run interrupted partway — a crash, a kill, or a claude-cli/API run that exits when it hits a rate limit — therefore lost every completed chunk and restarted from scratch. On a large corpus with a slow local backend this can throw away many hours of work. Persist each chunk's results to the semantic cache as soon as it completes, in both the serial and threaded paths of extract_corpus_parallel. Add a merge_existing option to save_semantic_cache so a file split into slices across several chunks accumulates its slices instead of the later chunk overwriting the earlier one. The checkpoint is best-effort (a cache write error never aborts extraction) and can be disabled with GRAPHIFY_NO_INCREMENTAL_CACHE. Default behaviour of save_semantic_cache is unchanged (merge_existing defaults to False).
… overwrite (Graphify-Labs#1715) The per-chunk checkpoint feature added merge_existing without test coverage. Add tests asserting merge_existing=True unions a file's slices across chunks and the default still overwrites (the authoritative final write in extract). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… language family
The cross-file call resolver matches raw-call callees against a repo-wide
label index with no language check. In a repo that mixes a web app with a
native Android app, a TSX callback passed by name (register(refreshHeading))
resolved to a same-named Kotlin method and shipped as an INFERRED
indirect_call edge — a phantom the extraction spec itself forbids ('calls
edges MUST stay within one language'). Direct calls from non-JS/TS callers
had the same hole with no gate at all: a bare Python call bound to the lone
same-named Kotlin fun. Found on a production Next.js + Android codebase,
where the phantom edge also inflated the Kotlin node's betweenness enough
to surface it as a top suggested question in GRAPH_REPORT.md.
Resolution candidates are now filtered by language interop family before
the single-candidate/import-evidence logic runs. Families are grouped by
REAL interop so legitimate cross-language resolution keeps working:
Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA share
headers (Swift bridges to ObjC), and JS/TS variants plus Vue/Svelte/Astro
SFCs compile into one module graph. Candidates whose family is unknown
(no source_file, non-code nodes) are never filtered, preserving the
previous permissive behavior, and callers with an unmapped extension skip
the guard entirely.
ensure_named_node() tags the sourceless stub it creates for an unresolved reference with origin_file, so _disambiguate_colliding_node_ids can tell one file's unresolved reference apart from another's instead of merging every file's same-named reference onto one shared bare id (which can then collide with an unrelated same-named real definition anywhere else in the corpus, since ids are case-normalized and global). Five call sites duplicated that stub-creation logic inline instead of calling ensure_named_node() -- Ruby's `Class.new(Super)` and `class Foo < Base` inheritance, Python inheritance, Kotlin delegation -specifier inheritance/conformance, and C++ base_class_clause inheritance -- and none of them were updated when origin_file was added, so all five still produce the un-disambiguated bare-id stub the fix was meant to eliminate. Four of the five sit directly inside _extract_generic, where ensure_named_node() is already in scope as a closure, so they're switched to call it directly. The fifth (Ruby's `Class.new(Super)`) is handled by a separate helper, _ruby_extra_walk(), which doesn't have that closure in scope; that one gets the same origin_file tag added directly to its own inline stub dict, matching what ensure_named_node() already does, without changing the helper's signature. (A sixth occurrence of the same inline pattern exists in extract_apex(), a fully separate regex-based extractor with no ensure_named_node() equivalent of its own -- left out of this fix, which is scoped to the shared _extract_generic path and its one directly-affiliated helper.) Added a regression test: two different C++ files each inheriting from the same undefined base class must produce two distinct stub nodes, not one shared one. Fails on main (one shared 'base' id for both files), passes with this fix.
…iles build_from_json's "pre-migration alias index" (Graphify-Labs#1504) registers each real file's OLD-style bare-stem id (extension dropped) as an alias so a stale cached fragment referencing that old form still resolves after an id-scheme migration. It never checked for collisions: two unrelated real files easily compute the same bare alias (e.g. "ping.h" and "ping.php" in different directories both alias to "ping"), and dict.setdefault let whichever file happened to iterate first in a Python set win, arbitrarily. A dangling edge with a bare, deliberately-unscoped fallback target (e.g. the C/C++ extractor's last-resort id for an #include it couldn't resolve to a real path) would ride that alias onto whatever unrelated file won the collision -- silently wiring, say, a C++ server file to an unrelated PHP script, purely because both files happen to share a common basename somewhere in the corpus. Now every candidate for an alias is collected before any of them are committed to norm_to_id, and the alias is only trusted when exactly one real file claims it. Ambiguous aliases are dropped entirely, so the dangling edge correctly stays dangling (and gets discarded) instead of merging two unrelated files -- same "don't guess through ambiguity" principle already applied to stub-node disambiguation and cross-file call resolution elsewhere in this codebase. Found via graphify-practice round 2: a `path` query between two unrelated symbols routed through exactly this kind of bogus edge, traced back to Dev/Poker/TDServer/server.cpp's unresolved `#include "ping.h"` / `#include "utility.h"` landing on unrelated www.masque.com PHP scripts of the same bare name.
Follow-up to the previous commit on this branch. That fix's ambiguity check missed a case: it detects "is this node the file itself" by checking whether the node's id starts with the file's plain new_stem, but a same-directory .h/.cpp pair that collides on their shared pre-extension id gets salted apart by _disambiguate_colliding_node_ids into ids like "tools_aolserver_utility_h_tools_aolserver_utility" -- no longer a clean new_stem prefix. That salted header silently failed to compute an empty suffix, so it never entered the bare "utility" alias race at all, leaving an unrelated wwwapi.masque.com/pages/utility.php as the lone (wrong) "unambiguous" winner -- reproduced exactly against the real depot's Tools/aolserver/utility.h and .cpp. Detect "this node IS the file" by label instead: every file node's label is its own basename regardless of what its id looks like after salting. That keeps a salted file node in the alias competition, so the real collision between the C header and the PHP file is correctly caught as ambiguous.
… resolution (Graphify-Labs#1726) `_resolve_typescript_member_calls` resolves a member call's receiver to a type definition by casefolded label. For a builtin-typed receiver (`x: Date; x.getTime()`), `_key("Date")` == "date" matched a same-named user `class DATE` / `const DATE` in another file, binding the caller to it as a phantom `references[call]` edge. In a real 3,368-file repo one module-local `const DATE` accumulated 469 false cross-file edges (degree 472, betweenness 0.435) — a false god node distorting path/god-node results. Skip the resolution when the receiver type is an ECMAScript/Python builtin global (Date, Promise, Map, ...), mirroring the guard the cross-file CALL resolver already applies (Graphify-Labs#726). The guard is a strict no-op for genuine user types, so legitimate constructor-injection member-call resolution (Graphify-Labs#1316) is unaffected. Verified across new Date().method(), return-type, and var-decl-type shapes: zero phantom edges, user DATE degree back to 1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness batch since 0.9.9: TS/JS builtin-typed receiver no longer collapses onto a same-named user symbol (Graphify-Labs#1726); no cross-language calls edges (Graphify-Labs#1718); build_merge ambiguous-alias no longer merges unrelated files (Graphify-Labs#1713); base-class stubs tagged with origin_file (Graphify-Labs#1707); Java enum constants as nodes (Graphify-Labs#1719); rebuild recovers from a deleted hook cwd (Graphify-Labs#1703); per-chunk semantic-cache checkpoint (Graphify-Labs#1715); SECURITY.md http-transport doc + GRAPHIFY_MAX_GRAPH_BYTES tests (Graphify-Labs#1714, Graphify-Labs#1722). Nine merged PRs plus Graphify-Labs#1726. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ify-Labs#1726 Adds a regression test for the Date.now()/static-call shape (credit PR Graphify-Labs#1727 / @2loch-ness6, who independently found the same fix): a capitalized builtin receiver must not bind cross-file to a same-spelled user const/class. The same guard added in 67f4f83 already covers it (verified); this locks the static-call shape in, while allowing the legitimate same-file const reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hify-Labs#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.
…e handling (follow-up to Graphify-Labs#2102)
…and real source (Graphify-Labs#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.
_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.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s#2112) The .graphifyinclude loader and its two matcher helpers had no consumers: commit df40e4d (Graphify-Labs#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>
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>
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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…abs#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.
…raphify-Labs#2137) Copilot flagged that the Graphify-Labs#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.
…raphify-Labs#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.
Assert returncode == 0 so a malformed case snippet fails fast with stderr instead of silently returning an empty string. Addresses Copilot review feedback on Graphify-Labs#2133.
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 Graphify-Labs#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.
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.
…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.
…hify-Labs#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.
…#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>
Graphify-Labs#2139) The Graphify-Labs#2139 ${VAR} source handler now also records bash_sources so resolve_bash_source_edges binds calls into the sourced lib's functions, not just the source edge. Add the end-to-end oracle plus the previously untested _bash_source_suffix guards (mid-path $, whole-var, .. traversal fabricate nothing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#2126/Graphify-Labs#2148/Graphify-Labs#2141/Graphify-Labs#2079/Graphify-Labs#2163 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_git_head()` (export.py, twinned in watch.py) shells `git rev-parse HEAD` with
no cwd and no -C, so it resolves the CALLING PROCESS's directory rather than the
repository being graphed.
That value is written to graph.json as `built_at_commit` — the graph's
provenance. For a git hook, a watch daemon, or any wrapper invoked from
elsewhere, the CWD is routinely not the target project, so the graph ends up
asserting a commit that does not exist in the repo it describes. Consumers that
compare the stamp against the repo's HEAD then conclude the write failed.
Measured on a 10-repo fleet driven by a background job that runs from its own
working directory: 4 of 10 graphs carried a foreign commit, two of them sharing
the same sha (one run stamping both).
Reproduction — same command, same target, only CWD differs:
from inside <target> -> c2e08532 == target HEAD correct
from another repo -> 508cb720 == the OTHER repo's HEAD
`_git_head(root=None)` now accepts any path inside the target repository and
passes it via `git -C` (git walks up, so a subdirectory works). Omitting `root`
keeps the previous CWD-relative behaviour, so this is backwards compatible.
Callers updated to pass what they already have:
export.to_json -> Path(output_path).parent
watch -> project_root
Adds tests/test_git_head_root.py, parametrised over both copies: root wins over
the caller's CWD, a subdirectory resolves, omitting root preserves the old
behaviour, and a non-repo returns None. Reverting the fix fails 6 of the 8.
Existing export/watch suites: 129 passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
_git_head()(graphify/export.py, twinned ingraphify/watch.py) shellsgit rev-parse HEADwith nocwdand no-C, so it resolves the calling process's directory rather than the repository being graphed.That value is written into
graph.jsonasbuilt_at_commit— the graph's provenance. For a git hook, a watch daemon, or any wrapper invoked from elsewhere, the CWD is routinely not the target project. The graph then asserts a commit that does not exist in the repo it describes, and any consumer comparing the stamp against the repo's HEAD concludes the write failed.Reproduction
Same command, same target, only the CWD differs:
We hit this on a 10-repo fleet driven by a background job that runs from its own working directory: 4 of 10 graphs carried a foreign commit, two of them sharing the same sha — the fingerprint of a single run stamping both. It is silent; nothing errors, the graph is written, only the provenance is wrong.
The fix
_git_head(root=None)now accepts any path inside the target repository and passes it viagit -C(git walks up, so a subdirectory such asgraphify-out/works). Omittingrootkeeps the previous CWD-relative behaviour, so this is backwards compatible — no existing caller changes meaning.Callers updated to pass what they already have in scope:
export.to_jsonPath(output_path).parentwatchrebuildproject_root-Crather thancwd=deliberately: it needs no directory-existence handling and is unambiguous at the call site. A CLI that takes an explicit target arguably shouldn't derive repo state from wherever the process happens to be.Tests
Adds
tests/test_git_head_root.py, parametrised over both copies of the function:rootwins over the caller's CWD (the regression)rootpreserves the old behaviourNoneReverting the fix fails 6 of the 8. Existing
test_export*/test_watch*suites: 129 passed.Context
Found while auditing knowledge-graph provenance across a multi-repo setup. We've since worked around it downstream by passing
cwd=when we invoke the CLI, but the hook path can't be fixed that way from outside — hence this patch. Happy to adjust naming or the call-site choices if you'd prefer a different shape.