Commit Graph

467 Commits

Author SHA1 Message Date
ivanzhilovich cf36d10292 fix(extract): emit Java enum constants as nodes with case_of edges 2026-07-08 00:42:56 +01:00
safishamsi 733ad08852 fix(extract): don't force-parse MATLAB .m through the Objective-C grammar (#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 (#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>
2026-07-07 11:46:51 +01:00
safishamsi 23457d11ff feat(detect): surface unclassified files instead of dropping them silently (#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>
2026-07-07 11:19:18 +01:00
safishamsi f84a0af1bf fix(report): don't emit dangling [[_COMMUNITY_*]] wikilinks by default (#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 #1444/#1465 portability
fix that was applied to `export wiki`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:57:52 +01:00
safishamsi f5d50adbd0 fix(extract): keep AST progress denominator consistent to the end (#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>
2026-07-07 00:12:36 +01:00
safishamsi 377dc7f384 fix(extract): warn when code files have no AST extractor instead of silently dropping them (#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 #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>
2026-07-07 00:06:49 +01:00
safishamsi d1d1f412b2 fix(serve): match a punctuated label against norm_label symmetrically in _find_node (#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>
2026-07-06 23:28:32 +01:00
safishamsi 737a0b8454 test: rigorous edge cases for the hook-guard subcommand (#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>
2026-07-06 16:49:31 +01:00
safishamsi 85f9fac7f7 Fix: port the Gemini CLI BeforeTool graph-nudge hook to be shell-agnostic too (#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>
2026-07-06 16:43:21 +01:00
safishamsi f7911fd1b3 Fix: make Claude Code / Codebuddy PreToolUse graph-nudge hooks work on Windows (#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>
2026-07-06 16:38:42 +01:00
safishamsi 5d0137388e Feat: opt-in GRAPHIFY_DISABLE_THINKING; correct deepseek thinking default (#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>
2026-07-06 16:02:45 +01:00
safishamsi 912d832a60 Fix: stop silently dropping source files during discovery via two over-broad filters (#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>
2026-07-06 16:00:31 +01:00
safishamsi 21b851b3d2 Fix: salvage truncated labeling replies and account for labeling token cost (#1690, #1694)
Two related fixes in the community-labeling path:

#1690 (thanks @vdgbcrypto): a truncated or slightly malformed reply no
longer discards the whole batch with "Expecting value: line 1 column 6".
`_parse_label_response` now salvages the complete `"id": "name"` pairs from
a reply that failed a strict `json.loads` (e.g. one truncated mid-object),
raising only when no pairs can be recovered. The per-batch token budget was
also raised (256 + 48*n, was 64 + 24*n) so models that prepend a short
preamble have headroom to finish the JSON. The exact provider truncation
could not be reproduced without a live key; the parser and budget address
the mechanism.

#1694 (thanks @sub4biz): cluster-only mode reported a hardcoded
`0 input * 0 output` token cost because the labeling LLM calls were never
accounted for. `_call_llm` now accumulates per-response usage into an
optional accumulator threaded through the labeling path and surfaced in
GRAPH_REPORT.md. Backends that do not return usage (the Claude Code CLI)
contribute nothing, which is honest rather than estimated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:46:12 +01:00
safishamsi b78248f22a Fix: cap Ollama client-side retries so a hung request cannot multiply the stall (#1686)
A stalled local model wedged for `timeout * (max_retries + 1)`, which with
the default 6 retries turned one long stall into a ~21-minute block with no
progress. `_call_openai_compat` now defaults the Ollama backend to zero
client-side retries (a local model that stalls will not un-stall on retry);
set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged.

This bounds the wait; the underlying stall is driven by the model server
and is non-deterministic, so it is not eliminated. Thanks @Kyzcreig for the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:44:47 +01:00
safishamsi 0ff584f070 Fix: tolerate tiktoken special-token text in token estimation (#1685)
`_TOKENIZER.encode(content)` raises ValueError by default when the text
contains a special token such as `<|endoftext|>`, so a doc or corpus that
merely mentions these strings crashed the entire semantic pass. Both
`encode` sites in `_estimate_file_tokens` now pass `disallowed_special=()`
so such text is tokenized as ordinary bytes. Thanks @Kyzcreig for the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:42:39 +01:00
safishamsi 97a1371478 Fix: exact-match section heading in _replace_or_append_section to prevent CLAUDE.md data loss (#1688)
The updater located its managed block by substring (`marker in content`
and `next(... if marker in line)`), so a heading that merely appeared as
a substring of another line, or a duplicate heading, matched the wrong
offset and the rewrite could truncate or drop unrelated content in
CLAUDE.md / AGENTS.md.

It now matches the section heading exactly (`line.strip() == marker`),
appends when the section is absent, and prefers the last exact match
when several exist. Thanks @bdfinst for the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:36:12 +01:00
NydiaChung 92edf78005 feat(java): suppress java stdlib types from references edges (#1603)
Adds a _JAVA_BUILTIN_TYPES skip list so ubiquitous java.lang/util/io/time/etc.
type names (String, List, Map, Optional, ...) are not emitted as references
edges (they never resolve to a project node). Mirrors _GO_PREDECLARED_TYPES /
_PYTHON_ANNOTATION_NOISE. Nested user-type generic args still resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 01:01:53 +01:00
Nilton Moura 6d3a6f1c6b feat: extract rationale comments + ADR/RFC doc references from JS/TS
Parity with _extract_python_rationale: Python files get rationale nodes
from docstrings and '# NOTE:'-style comments, but JS/TS comments were
discarded entirely. This adds a post-pass to extract_js that:

1. extracts rationale comments ('// NOTE:', '// WHY:', block-comment
   '* NOTE:' variants) as rationale nodes with rationale_for edges,
   matching the Python behavior;
2. first-classes architecture-decision references (ADR-NNNN, RFC NNNN)
   found in comments as doc_ref nodes with 'cites' edges from the file.

The doc_ref pass is the natural join point between code and design docs
in mixed corpora: teams conventionally cite ADR ids in file headers, but
today those citations produce no edges, so code<->ADR connections never
form even when the discipline exists. Spellings are normalized
(ADR-11 / ADR 0011 -> ADR-0011) so references to the same document
collapse to one node, and string literals are excluded (comment-shaped
lines only).

Tested on a real mixed corpus (Flutter/Supabase monorepo): router.ts
alone yields 10 ADR citations that previously produced zero edges.
2026-07-06 00:45:51 +01:00
stasnamco 2ab086742a fix(extract): route extensionless shebang scripts to their AST extractor
detect.classify_file already labels extensionless files with a bash/python/
node/... shebang as CODE via _shebang_interpreter, but _get_extractor
dispatched purely on path.suffix — so a CLI entry point like `devctl` or
`manage` was detected as code and then silently contributed zero nodes to
the graph (its doc-referenced symbols stayed dangling stubs).

Resolve extensionless files through the same _shebang_interpreter and a
new _SHEBANG_DISPATCH map. Only interpreters with a real extractor are
mapped (python/bash-family/node/ruby/lua/php/julia); detect's wider set
(perl, fish, tcsh, Rscript) stays unmapped and skipped rather than being
mis-parsed by a wrong grammar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:09:11 +01:00
safishamsi 6631af7936 feat(ruby/affected): mixes_in edges for include/extend/prepend + method-bound callers in affected (#1668, #1669)
#1668: Ruby `include`/`extend`/`prepend <Const>` in a class/module body now emits
a `mixes_in` edge to the module. The mixin is captured during the node walk and
resolved cross-file by resolve_ruby_member_calls (single-owner guard, reusing
the #1640 module nodes as targets). The shared call pass skips these markers so
they are not mislabeled as `calls`. `extend self` and non-constant args are
skipped; ambiguous/undefined modules produce no edge. Rails concern composition
is now visible to affected/explain.

#1669: affected <Class> seeds the reverse walk with the root's own member nodes
(one method/contains hop) so callers that bind at method granularity (e.g.
Service.call -> the def self.call node, #1634) are reachable from the class.
method/contains stay out of the general relation-filtered walk (no forward
noise), and the seeded member nodes are not reported as hits.

Full suite: 2924 passed, 3 skipped. Verified end-to-end (Rails-shaped repros)
plus edge cases: extend self / undefined / ambiguous mixins emit nothing, mixins
are not emitted as calls, member methods aren't reported, class-level callers
still resolve, and one-hop seeding does not pull in downstream classes' methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:41:18 +01:00
raman118 aa1bbdab23 Fix case-sensitive file suffix filtering silently skipping capitalized/mixed-case extensions (#1671) 2026-07-05 10:42:09 +01:00
Synvoya 94392de640 fix(extract): don't report deferred import() as a file cycle (#1241)
`_dynamic_import_js` emitted a deferred `import('./x')` as a plain
`imports_from` edge, so `find_import_cycles` counted it as a static import.
A file that statically imports another which dynamically imports it back was
reported as a phantom circular dependency.

Keep the edge as `imports_from` (the dependency stays visible in the graph)
but mark it `deferred`, and skip deferred edges in `find_import_cycles`.

Closes #1241
2026-07-05 10:42:09 +01:00
raman118 5ffa921e27 Fix invalid virtual postgres source_file URI backslashes on Windows (#1672) 2026-07-05 10:42:09 +01:00
safishamsi 1288a557e3 fix(extract): don't cache zero-node results; warn on empty source files (#1666)
krishnateja7 reported that on a full-repo run a stable subset of Ruby files
yields zero nodes (not even a file node), each fine in isolation, drop set
byte-stable across runs. Root cause is a transient batch/parallel extraction
that produces an empty result, which then gets cached and persists.

Every extractable file yields at least a file node, so a zero-node result is
anomalous. Both extraction paths (parallel worker and sequential fallback) now
skip the cache write when a non-error result has no nodes, so a rerun re-extracts
and self-heals instead of loading the stale empty. extract() also warns, listing
the files that landed in the graph with zero nodes, so the previously-silent
blindness in affected/explain is visible.

This addresses the persistence and the silent blindness. The underlying trigger
(why a valid file occasionally extracts empty when co-processed with certain
others) was not reproducible with synthetic corpora; the warning now surfaces it
for a concrete report if it recurs.

Full suite: 2912 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:33:07 +01:00
safishamsi 54825b6a1c fix: windows skill name, opencode plugin separator, doc-corpus report noise (#1635, #1646, #1657)
#1635: the windows skill variant declared `name: graphify-windows`, but
`graphify install --platform windows` writes it to ~/.claude/skills/graphify/
SKILL.md and Claude Code requires the folder name to equal the frontmatter
`name` — the suffix broke discovery. platforms.toml now sets name = "graphify"
(regenerated + re-blessed).

#1646: the OpenCode (and Kilo) plugin prepended its reminder with `&&`, which
Windows PowerShell 5.1 rejects as a statement separator, breaking the first
bash command of every session. Switched to `;` (valid in PowerShell 5.1, Bash,
POSIX).

#1657: the GRAPH_REPORT.md "Import Cycles" section printed "None detected" on
documents-only corpora where imports don't exist — now gated on code nodes /
import edges being present. The other two items in that issue (mojibake in
manifest/report, stdout encoding) are already handled on current v8: both files
are written UTF-8 and main() reconfigures stdout/stderr to UTF-8.

Full suite: 2909 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:43:31 +01:00
safishamsi f9174943a2 fix(detect): incremental correctness for Office sources + long paths, cache word counts (#1649, #1655, #1656)
#1649: detect_incremental tracks the converted markdown sidecar, and
convert_office_file early-returned whenever the sidecar existed — so a .docx/
.xlsx edited after its first conversion never updated its sidecar and was
reported "unchanged" forever, freezing the graph. It now re-converts when the
source is newer than the sidecar (bumping the sidecar so the hash check catches
it); an unchanged source still skips the rewrite (#1226).

#1655: _md5_file/save_manifest/count_words used plain open()/stat(), which the
Windows file APIs reject for absolute paths over 260 chars unless prefixed with
`\\?\`. Deeply-nested files never hashed, their manifest entry never stabilized,
and detect_incremental re-flagged them as changed every run. A new _os_path adds
the extended-length prefix on win32 for change-detection I/O (mirror of
cache._normalize_path, which strips it for keys). No-op elsewhere.

#1656: detect() re-parsed every PDF/docx/text file to size the corpus on each
run. Word counts are now memoized in the existing content-hash stat index (keyed
by size + mtime_ns), so an unchanged file is parsed once. file_hash's fastpath is
guarded so a word-count-only entry (no hash) can't KeyError, and both writers
augment a co-located entry in place instead of clobbering the other's field.

Full suite: 2906 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:12:47 +01:00
safishamsi 62b8eb1416 fix(extract): gate JS/TS cross-file calls on import evidence to kill phantom cross-package edges (#1659)
When a callee had exactly one same-named definition repo-wide, the cross-file
resolver emitted a `calls` edge at INFERRED/0.8 even with no import path between
caller and callee. On a monorepo this fabricated dependencies: a 14-package repo
showed `platform`/`sidecar` depending on `registry-protocol` purely because it
exported generically-named symbols that unresolved calls collapsed onto.

JS/TS modules have no implicit cross-module scope, so a cross-file call is real
only if the caller imported it. Direct JS/TS cross-file `calls` attribution is
now gated on import evidence and left unresolved otherwise. Scoped to direct
calls: other languages keep the #1553 single-candidate resolution (C/C++
headers, Ruby autoload, same-package implicit scope), and the indirect_call path
(already INFERRED + callable-gated) is untouched.

Also hardens caller/candidate -> file mapping to resolve via the node's
`source_file` string (identifying the file node by its basename label) instead
of `relative_to(root.resolve())`, which threw on a path-resolution/symlink
mismatch and fell back to a non-matching absolute id — spuriously failing import
evidence. This both makes the new gate safe and fixes legitimate cross-file
calls being mislabeled INFERRED instead of EXTRACTED.

Full suite: 2898 passed, 3 skipped. Verified via CLI on the reporter's repro
(phantom dropped) and a control (imported call resolves EXTRACTED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:22:39 +01:00
safishamsi 13e2bddf4d fix(ruby): extract module/Struct/Class.new containers and resolve constant-receiver calls (#1640, #1634)
#1640 (node extraction): the extractor only created nodes for `class Foo`, so
plain `module Foo`, `Foo = Struct.new(...) do ... end`, `Foo = Class.new(Super)`
and `Result = Data.define(...)` produced no container node — their methods hung
off the file via `contains` with dot-less labels and no edge could target them.
`module` is now a container type (methods attach via `method`, nested modules
included), and a constant assignment whose RHS is Struct.new/Class.new/Data.define
synthesizes a class node named after the constant, attaches block-defined methods
to it, and emits an `inherits` edge for `Class.new(Super)`. Plain constant
assignments (MAX = 100, X = Foo.new) are untouched.

#1634 (resolution): constant-receiver singleton calls (`Service.call`,
`Model.where`, `SomeJob.perform_async`) emitted no edge, so a Zeitwerk-autoloaded
Rails app (no requires) had near-zero cross-file edges. resolve_ruby_member_calls
now handles a capitalized receiver with any callee: bind to the class's owned
singleton/instance method (`def self.call`) when present, else to the class node
itself so inherited/dynamic class methods (ActiveRecord where/find_by) still give
blast-radius. Namespaced receivers resolve by bare class name. The
single-owning-class god-node guard is kept — ambiguous receivers resolve to
nothing, never a wrong edge.

The two compound: PaymentProcessor#process -> TaxCalculator.rate_for needs the
module node (#1640) AND the resolver (#1634); both now land.

Full suite: 2893 passed, 3 skipped. Adversarial smoke confirms no false class
nodes from plain/multiple assignments and no self-loops on self-class calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:20:07 +01:00
Synvoya 9b040224e1 fix(kotlin): emit implements edge for interface delegation (by)
class Foo : Bar by baz produced no edge because the delegation_specifier loop only handled constructor_invocation and bare user_type children; the by form wraps user_type in an explicit_delegation node. Add that branch so the implements edge (and generic-arg recovery) fires.
2026-07-04 11:26:55 +01:00
Synvoya 53c769d23d fix(apex): emit extends edges for interface multiple inheritance
interface X extends A, B captured the parent list in iface_re group 2 but the handler only read group 1, so no inheritance edge was emitted. Split the parent list and emit one extends edge per parent (mirroring the class branch).
2026-07-04 11:26:54 +01:00
safishamsi e2ef4ef3d1 fix: harden semantic extraction and kill phantom import edges (#1631, #1638, #1632)
#1631: a malformed LLM chunk (a stray non-dict entry in edges/nodes/hyperedges)
crashed the AST+semantic merge and the semantic-cache write with
`AttributeError: 'list' object has no attribute 'get'`, discarding every
successful chunk and writing no graph.json. `_parse_llm_json` now sanitizes each
fragment at the single parse chokepoint (dict entries only; non-list values
coerced to []), protecting the cache writer, the adaptive-retry merge, and the
CLI merge in one place.

#1638: an unresolved bare npm import (`import colors from "tailwindcss/colors"`)
emitted an imports_from edge to the bare id `colors`, which build.py's
pre-migration alias index then remapped onto an unrelated local file of that
stem (backend/utils/colors.py) - a confident EXTRACTED cross-language phantom
edge, one per importing file. The external-import fallback now namespaces its
target with the `ref` prefix (the J-4 convention), so it can never collapse to a
local node id; the ref target has no node, so build drops it as an external
reference.

#1632: with a parallel LLM backend, extract_corpus_parallel merged chunk results
in completion order, so which network call returned first reordered nodes/edges
run-to-run even when the model returned identical content - churning graph.json.
Chunks are now merged in deterministic submission order after the pool drains
(matching the serial path); the progress callback still fires in completion
order. The model's own content variance is unchanged (irreducible).

Full suite: 2882 passed, 3 skipped. Validated end-to-end via a local wheel build
on a mixed TS+Python corpus: `explain colors.py` shows only the real importer,
and graph.json is byte-identical across repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:12:23 +01:00
safishamsi 2ba07e84e1 fix(export): guard to_canvas against dangling community members (#1236 follow-up)
The #1236 fix guarded to_obsidian's member loop but not to_canvas, so
`graphify export obsidian` (which also writes graph.canvas) still crashed with
KeyError on a community member id absent from G — after the notes exported,
leaving a partial mirror. Reported on 0.9.5 by @swells808.

Apply the same `m in G and m in node_filenames` filter in both to_canvas loops:
the box-sizing loop (so the group box matches the cards actually laid out) and
the card-layout loop (so the sort/label deref and the node_filenames fallback
never touch a dangling id). Regression test added alongside the to_obsidian one.
Full suite 2872.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:34:41 +01:00
safishamsi 4744dfefca feat(extract): TS/JS member calls on local new-binding + typed-param receivers (#1630)
The #1316 resolver handled `this.injectedField.method()`, but a receiver whose
type comes from a local `const x = new Foo()` binding (Pattern A) or a
type-annotated parameter — including inside a returned closure (Pattern B) —
produced no calls edge, so `affected <method>` silently under-reported.

- _ts_receiver_type_table: augment the per-file type table with local
  `new` bindings (name -> constructor type) and bare-typed parameters
  (`(svc: Svc)` -> svc: Svc), merged after the constructor-injection entries
  (which win on a name clash). Only a bare type_identifier is recorded — an
  array/union/generic/qualified/predefined type is skipped (precision).
- walk_calls now descends into an inline/returned JS/TS closure that is not
  separately tracked in function_bodies (e.g. `return () => svc.doThing()`),
  attributing its calls to the enclosing function, instead of stopping at the
  arrow boundary. A tracked-body-id set prevents double-walking const-assigned
  arrows.

The existing _resolve_typescript_member_calls then resolves both via the
receiver type with its single-definition guard. Verified on the real-CLI shape
(absolute paths + graphify-out cache): both patterns resolve, ambiguity binds
to the right class (Svc not Cache), untyped/array-typed receivers emit nothing.
5 tests, full suite 2871.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:03:18 +01:00
oleksii-tumanov 8d8d2b8c0a fix(update): reconcile removed and renamed sources 2026-07-03 10:30:27 +01:00
Mark Butterworth d56ee8384d fix: guarantee per-term BFS seed diversity in query (fixes #1445)
_pick_seeds' gap_ratio cutoff discards any candidate scoring below 20%
of the top score. On a multi-term natural-language query, one term's
incidental EXACT label match on a node that is otherwise unrelated to
the query's intent (e.g. a common word also used as a field name or
identifier elsewhere in the corpus) scores ~1000x higher than any
SUBSTRING match on the query's other, actually-relevant terms
(_EXACT_MATCH_BONUS vs _SUBSTRING_MATCH_BONUS). The cutoff then
silently discards every one of those substring-tier candidates as BFS
seeds, so the traversal only ever explores the neighborhood of the one
unrelated exact match, and `query` returns confidently-wrong results
with no signal that anything went wrong.

This matches #1445's reproduction exactly: a vague query that doesn't
name a target symbol seeds from unrelated "concept-dense" nodes
instead, even though the target node is present in the graph.

_pick_seeds now optionally accepts the graph and the tokenized query
terms; when supplied, it guarantees at least one seed per distinct
term that has any match at all, so one term's collision cannot starve
out the others. Ties within a term are broken by node degree, so an
isolated incidental match doesn't out-rank a real, well-connected hub
for that term. The parameters default to None and existing callers
that don't pass them see byte-identical behavior (see
test_pick_seeds_without_diversity_args_is_unchanged).

Adds a regression test reproducing the exact failure shape from
#1445 and confirms the previously-starved target node is recovered
as a seed once G/terms are supplied.

Full test suite (74 tests) and ruff both pass.
2026-07-03 10:14:57 +01:00
safishamsi cf4b4ef85a fix(build): don't crash when a node's source_file is the scan root (#1618)
A node whose source_file equals the absolute scan root (e.g. a project-level
semantic concept the LLM attributed to the whole repo) relativized to Path('.'),
and _semantic_id_remap fed that into _file_stem, whose path.with_suffix("")
raises `ValueError: '.' has an empty name`. The crash landed in final graph
assembly — AFTER all LLM extraction cost was spent — writing no graph.json at
all, and leaving `cluster-only` to then report "no graph found".

Two guards: _file_stem returns "" for a name-less path (protects every caller,
not just this one), and both _semantic_id_remap passes skip a root-equal
source_file explicitly (it has no per-file identity to remap — id left
untouched). Reported with a minimal LLM-free repro by @sub4biz.

Not a 0.9.5 regression: _semantic_id_remap/_file_stem are byte-identical to
0.9.4; the latent path was only hit when dedup produced a root-source_file node.
4 regression tests (dot-path stem, remap no-crash, build_from_json with a
root-level concept node, normal remap unaffected). Full suite 2849.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:32:24 +01:00
safishamsi eebc406773 feat(extract): C# receiver-typed member-call resolution (#1609)
C# had no member-call resolver (unlike Swift/Python/Ruby/TS/C++/ObjC), so
`recv.Method()` fell back to a bare method-name match against label_to_nid —
which, under ambiguity, silently mis-bound `_server.Save()` to an unrelated
`Cache.Save()`. That's a WRONG edge, not just a missing one, and it left
delegation-heavy C# call graphs (wrappers, service layers) blind across typed
member/param boundaries.

Mirrors the C++ #1547 pattern:
- capture the member_access_expression receiver (simple identifier or `this`)
  into member_receiver and set is_member_call in the C# invocation branch;
- defer ALL C# member calls with a receiver to the resolver (tgt_nid = None) so
  the bare in-file match can't fire, and emit a raw_call tagged lang="csharp";
- _csharp_member_type_table: file-wide name -> Type from fields, properties,
  parameters, and locals (incl. `var v = new T()`), first-binding-wins;
- _resolve_csharp_member_calls: `this` -> enclosing class (EXTRACTED),
  capitalized -> the named type (EXTRACTED), else the receiver's table type
  (INFERRED), each gated by the single-definition guard; no method on the type
  -> no edge. Registered for .cs.

Verified: the ambiguous `_server.Save()` now resolves to Server.Save and NOT
Cache.Save; field/param/local/this/Type.static/cross-file all resolve; dynamic
receiver and absent-method emit nothing; unqualified calls unregressed. 8 new
tests, full suite 2841.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:25:54 +01:00
Paulo Pinto 35404167d9 fix(extract): emit reference edges for TS/JS decorators
`@Component`, `@Injectable`, `@Input`, `@Inject`, `@Entity`, … produced no
edge — the `decorator` node kind was never walked. This is framework-critical
(Angular, NestJS, Vue class components, TypeORM): the decorators are the
primary signal of what a class is and does.

Decorators occur only on classes, class members, and parameters, so one pass
over each class declaration covers them. `_ts_emit_decorator_edges` emits a
`references` edge (context="decorator") from the decorated entity to the
decorator symbol:

  - class decorators -> the class. Handles both `@Deco class C` (decorator is
    a child of the class) and `@Deco export class C` (decorator sits on the
    wrapping export_statement), plus stacked decorators.
  - method decorators -> the method node. They are siblings preceding the
    `method_definition`; stacked decorators are skipped past to find it.
  - field / accessor decorators -> the class (the field is not a graph node).
  - parameter decorators (`@Inject(T)`) -> the enclosing method/constructor.

The symbol is the head identifier: `@Injectable`, the `function` of
`@Component({...})`, or the `property` of `@ns.Component()`. Targets go
through `ensure_named_node`, so a decorator defined outside the corpus
becomes a sourceless stub, consistent with type references — one per
referencing file, matching the cross-file stub disambiguation introduced
with full-path node IDs in 0.9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:38:29 +01:00
Paulo Pinto 869aaf7502 fix(extract): emit a node for TS namespace / module containers
`namespace Foo {}` parses as `internal_module` and `module Bar {}` (and
ambient `declare module "pkg" {}`) as a named `module` node. Neither kind
was in `class_types`/`function_types` nor handled by an extra-walk, so the
container produced no node — its members were still reached by the default
recurse, but the namespace/module itself was invisible to the graph and its
members lost their namespace context.

Add `_ts_extra_walk`, dispatched for TypeScript after `_js_extra_walk`,
mirroring `_csharp_extra_walk`: it emits a container node + a file→container
`contains` edge and recurses the body, leaving members file-contained as
before. `internal_module` exposes `name`/`body` fields; `module` exposes
none, so name (identifier / nested_identifier / quote-stripped string) and
body (`statement_block`) are found positionally. The `is_named` guard skips
the anonymous `module` keyword token, which shares the `module` type string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:38:29 +01:00
Paulo Pinto 09aeb979c8 fix(extract): extract TS/JS generator functions as nodes
Generator functions were invisible to the graph. The declaration form
`function* g()` parses as `generator_function_declaration`, which was
absent from the JS/TS `function_types`, so it produced no node; the
expression form `const h = function*(){}` parses as `generator_function`,
which was absent from the JS function-value types, so it was never captured
when assigned to a module-level const. Generator *methods* (`*gen()` in a
class) were already covered — they parse as `method_definition`.

Add `generator_function_declaration` to the JS and TS `function_types` (so
it emits a node and its body is walked) and to `function_boundary_types`
(so its calls are scoped to it, parity with `function_declaration`); add
`generator_function` to `_JS_FUNCTION_VALUE_TYPES` (so the const-assigned
expression form is captured like `function_expression`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:38:28 +01:00
Tok6Flow0 009a98b6dd Contain symlinked extraction inputs 2026-07-02 22:29:32 +01:00
Paulo Pinto 9811def1b3 fix(extract): capture the TS import-equals form (import x = require(...))
`import x = require("./m")` produced no edge at all: tree-sitter parses it
as an `import_statement` whose module string sits inside an
`import_require_clause`, not as a direct child of the statement, so the
direct-child string scan in `_import_js` never found it. The file-level
dependency was silently dropped while the equivalent ESM form
(`import * as x from "./m"`) was captured — an invisible hole in the
import graph of TS codebases that interop with CommonJS modules.

Restructure the scan to first locate the module string — a direct `string`
child for ESM imports/re-exports, or the `string` nested inside an
`import_require_clause` for the import-equals form — then emit the
`imports_from` edge from the single shared path. Relative paths, tsconfig
aliases, and bare modules all resolve through the same
`_resolve_js_import_target` as ESM, giving the import-equals form exact
parity with a namespace import: one file-level `imports_from` edge.

Plain JS is unaffected (the grammar has no `import_require_clause`), and
the pure namespace alias form (`import A = B.C`) is out of scope — it has
no module string and models an intra-code alias, not an import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:29:32 +01:00
Ashmit 1226c34731 Treat .mts/.cts (TypeScript module extensions) as TypeScript
`.mts` (ESM) and `.cts` (CommonJS) are the TypeScript counterparts of the
already-supported `.mjs`/`.cjs`, but graphify handled them in neither the
code-extension detection nor the TypeScript grammar path — so they were either
skipped entirely (classified as non-code) or, once detected, parsed with the plain
JavaScript grammar, which silently drops every `type`/`interface`/type-alias
declaration.

Detection — add `.mts`/`.cts` alongside their siblings in:
- detect.CODE_EXTENSIONS            (the code / non-code gate)
- analyze._LANG_FAMILY              (cross-language edge family → "js")
- extract._JS_RESOLVE_EXTS          (import target resolution)
- extract._JS_CACHE_BYPASS_SUFFIXES
- build.py per-node language map    (→ "js")

TypeScript grammar — route `.mts`/`.cts` to the plain TypeScript grammar (like
`.ts`; neither is JSX), so their TS-only syntax is captured:
- extract_js()                      (grammar selector: .mts/.cts → _TS_CONFIG)
- extract._DISPATCH                 (.mts/.cts → extract_js)
- the use_ts tree-sitter selector
- the typescript_member_calls resolver frozenset

Result: a `.mts`/`.cts` file now extracts identically to the equivalent `.ts`.
Verified on a real 47 KB `.mts` source — 36 nodes for `.mts`/`.cts`, matching `.ts`
exactly (was 22 with the JS grammar; the 14 dropped nodes were TS type/interface
declarations). No new dependency — same tree-sitter-typescript grammar as `.ts`.

Tests: tests/test_typescript_module_extensions.py — membership regression locks for
the extension sets, dispatch routing, and end-to-end parity asserting `.mts`/`.cts`
capture the TS `type`/`interface` nodes and match the `.ts` node set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:29:31 +01:00
Jeisson 32ff6d6fb3 fix(claude-cli): deliver extraction instructions in the user turn
The claude-cli backend passed the extraction schema via --system-prompt with
only the raw file dump in the user turn, assuming a replacement system prompt
is the model's sole authority. Claude Code >= ~2.1 (verified on 2.1.197) does
not honour that: it still layers in the local coding-agent context
(CLAUDE.md/AGENTS.md in cwd, skills, MCP) and, given a user turn that is just a
file with no request, replies conversationally ("I see the file, but there's no
actual request attached"). That prose parses to zero nodes/edges, so
_response_is_hollow flags it as truncation and the adaptive-retry path bisects
the chunk indefinitely (94 -> 47 -> 23 -> ...), never converging and never
writing graph.json.

Move the full extraction schema plus an explicit imperative into the user turn
and drop --system-prompt, so the CLI emits the JSON object directly. The
<untrusted_source> prompt-injection guardrails are carried verbatim; model
override, --add-dir image handling, timeout, and token accounting are untouched.
2026-07-02 22:29:30 +01:00
sanmaxdev 62f49bad73 fix: persist cluster-only analysis sidecar 2026-07-02 22:29:29 +01:00
Joan F Garcia 9e7fbcbd6e feat(serve): optional project_path for multi-project MCP serving
Add an optional project_path to every MCP tool so one server process can
answer against many projects. Omitted -> the server's default graph (fully
backward-compatible); an absolute project_path -> that project's
<GRAPHIFY_OUT>/graph.json, routed per call.

Implementation (all in _build_server):
- _ctx_cache + _load_ctx(): per-graph context cache with mtime+size
  hot-reload, unified across the default graph and every project graph.
  Unlike _load_graph it raises instead of sys.exit on a bad file.
- _resolve_graph_path()/_select_graph(): map project_path to a graph.json
  and rebind G/communities/active_graph_path per call. No hot-path lock:
  select+handler run in one synchronous span of the call_tool coroutine.
- Tolerant startup: serve with no default graph (pure multi-project mode)
  instead of exiting; a bad project_path is a tool error, not a crash.
- project_path injected as an optional (non-required) field on all tools.

Tests: 3 new HTTP-transport tests (optional-on-every-tool, routing,
bad-path-no-crash). Full serve suite green (91 passed).
2026-07-02 12:11:51 +01:00
Paul Young 6e97088493 fix(serve): drop question/filler stopwords from query terms
`graphify query "<question>"` tokenised the whole question and seeded BFS on
every word, so natural-language scaffolding dominated retrieval. "how does the
frontier cache work" seeded on "how"/"the"/"work" — which prefix-match prose
labels like "Working Principles" at the 100x prefix tier — instead of on
"frontier"/"cache", landing in the wrong part of the graph.

Filter a set of English question/filler words from the query terms so content
words drive seeding, with a fallback to the unfiltered terms when a query is all
stopwords ("how does it work"). Applied to query terms only — node text is never
filtered, so a symbol literally named `work` stays findable via explain/path.

Updates the one test that pinned "what" as a kept term and adds coverage for the
new drop + all-stopword fallback. Full suite green (2745 passed, 28 skipped).
2026-07-02 11:50:17 +01:00
Jim 1256d65214 perf(hooks): eliminate multi-second foreground stalls before the detached launch
Three foreground costs ran on every commit before the detached rebuild
launcher even started:

1. Interpreter probes imported the full package. Each probe executed
   'import graphify' wholesale — measured 13s per probe cold on a Windows 11
   dev box with AV-scanned site-packages — and up to four probes could run
   synchronously. Probes now use importlib.util.find_spec, which locates the
   package without executing it (interpreter startup cost only). A broken
   install under the selected interpreter still fails loudly in the rebuild
   log, as before.

2. The shebang probe read a binary. Git for Windows' command -v can return
   the launcher path WITHOUT its .exe suffix, so the '*.exe)' guard missed
   and head -1 read a PE binary: the shell warned 'ignored null byte in
   input' on every commit and the garbage always fell through to the slow
   python3/python fallbacks. The Windows pip layout is now resolved directly
   (Scripts/graphify -> sibling ../python.exe, or ./python.exe for venvs),
   and the remaining POSIX shebang read strips NULs first.

3. GIT_DIR was re-derived. git exports GIT_DIR to hooks; the unconditional
   rev-parse added ~1.3s more on machines where every git exec is scanned.
   Now reused from the environment with rev-parse as the manual-run fallback.

Measured on the affected machine: hook foreground drops from 26s+ (cold) to
~1.4s, warnings gone. Behavior is unchanged on healthy POSIX setups — probe
order and fallback semantics are preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:50:12 +01:00
safishamsi 44c0a5e33c fix(swift): resolve calls on a singleton cached into a local var (#1604)
`let manager = NetworkManager.shared` followed by `manager.fetchData()` on a
later line produced zero call edges. Two gaps: (1) local let/var bindings inside
method bodies were never typed (only class-level properties and function params
populated the per-file type table), and (2) a static-member initializer
(`Type.shared`, a navigation_expression) wasn't recognized as typing the local
even in the class-property path — only constructor calls (`Type()`) were.

Add _swift_local_var_types (mirrors _cpp_local_var_types): walk each function
body and type a local from a constructor OR a `Type.staticProp` access whose head
is upper-cased. `x.method()` then resolves to the receiver type through the
existing single-definition god-node guard. The class-property path also learns
the Type.shared shape. Reported on a 23k-file iOS corpus where this idiom is the
median call pattern.

Verified: the repro resolves (loadIfNeeded -> fetchData/isLoading); constructor
locals still resolve; a lowercase-head init (`other.child`) does NOT falsely
type; and an ambiguous method name resolves to the receiver-typed class only.
Swift + full suite 2789 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:46:22 +01:00
safishamsi b70a6d7126 fix: allow python@ shebang in skill detection (#1586) + merge-graphs mixed types (#1606)
#1586: the skill's interpreter-detection allowlist rejected any shebath path
with a char outside [a-zA-Z0-9/_.-], so Homebrew's versioned python@3.13 path
was skipped and detection fell through to a bare python3 without graphify.
Allow @ in the skillgen source fragments (core/devin/aider/posix), regenerate
all skill artifacts, and register the change as a sanctioned monolith diff.
Injection chars (; $ ( etc.) are still rejected.

#1606: graphify merge-graphs crashed with an unhandled NetworkXError when
inputs disagreed on directed/multigraph. _to_simple only converted MultiGraph,
leaving a DiGraph to fail compose. Normalize every input to a plain undirected
Graph (the merged cross-repo view is undirected anyway).

Also confirmed on v8: `graphify kilo install` (#1605) works (reporter was on an
older version), and `pip3 install graphifyy` failing on macOS (#1585) is a
broken Homebrew Python 3.14 / pyexpat env, not a graphify issue — pip crashes
in its own vendored distlib before graphify is touched.

skillgen --check / --monolith-roundtrip / --schema-singleton all green; shell
smoke accepts python@3.13 and still rejects `; rm -rf /` and `$(evil)`. Full
suite 2789.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:25:51 +01:00