diff --git a/CHANGELOG.md b/CHANGELOG.md index 791142ef2..148fd60e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,14 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) -## 0.9.32 (unreleased) +## 0.9.33 (unreleased) + +- Fix: the C# `partial class` merge (#2332) no longer conflates two same-named classes that live in different assemblies (#2411, thanks @JensD-git). The merge now keys on assembly (nearest ancestor directory containing a `.csproj`/`.fsproj`/`.vbproj`) in addition to namespace and name, so genuine partial halves within one project still merge while same-name types in separate projects stay distinct. A corpus with no project file keeps merging by namespace and name as before. +- Fix: `graphify update` no longer drops member-call and `indirect_call` edges from a changed file into an unchanged target (#2437, #2438, thanks @aryanbonigala). Incremental re-resolution now sees the unchanged corpus (its nodes, `contains`/`method` edges, and the `_callable` markers, which now persist to `graph.json` like `_origin`), so cross-file calls survive an incremental rebuild while edges to a genuinely removed target are still evicted. +- Fix: `graphify extract` no longer silently substitutes an empty result when a worker crashes (#2444, #2445, thanks @Baziar). A `BrokenProcessPool` now triggers the sequential fallback instead of being swallowed per future, a failed worker file is retried sequentially rather than merged as empty, and a whole-pass AST failure on a fresh build exits non-zero instead of writing a zero-node graph (use `--allow-partial` to opt into a best-effort partial graph). +- `graphify install` now prints a one-time pointer to the hosted platform (early access is open free before the public v1 launch) after the setup summary. + +## 0.9.32 (2026-08-01) - Fix: incremental extraction and `_rebuild_code` no longer drop a file's other tier (#2333, #2334, #2336). Node/edge ownership was keyed on `source_file` alone, so a semantic re-extract deleted a doc's AST headings and a full rebuild deleted document AST nodes. Merge is now tier-aware (an AST re-extract replaces only AST nodes and keeps the semantic layer, and vice versa), the `_origin` provenance marker is backfilled on load so old graphs self-heal, and the full-rebuild drop is scoped to sources actually regenerated. - Fix: `graphify update` preserves the graph's `directed` flag instead of rebuilding it undirected (#2342, thanks @Rishet11), so God-node / path ranking keeps its direction on both the clustered and `--no-cluster` rebuild paths. diff --git a/graphify/cli.py b/graphify/cli.py index 56c32140a..c7d9a87ea 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3091,11 +3091,100 @@ def dispatch_command(cmd: str) -> None: ast_kwargs: dict = {"cache_root": out_root, "root": target} if cli_max_workers is not None: ast_kwargs["max_workers"] = cli_max_workers + # #2437/#2438 (the `graphify update` twin of watch's #2406 fix): an + # incremental re-scan extracts only the changed code files, so the + # cross-file resolvers cannot see a callee living in an unchanged + # file and every changed->unchanged call edge silently vanished on + # merge. Hand extract() read-only resolution context from the + # persisted graph: its AST-tier nodes (with their `_callable`/ + # `_callable_class` markers, #2438) plus the contains/method edges + # the member-call resolvers walk (#2437), scoped to the UNCHANGED + # live corpus — never a re-extracted, deleted, or excluded file, so + # stale symbols cannot resurrect. Fails open (changed-batch-only + # resolution, the pre-fix behavior) on an unreadable graph. + if incremental_mode and existing_graph_path.exists(): + _ctx_nodes: list[dict] = [] + _ctx_edges: list[dict] = [] + try: + from graphify.build import _is_ast_tier as _ctx_is_ast_tier + from graphify.security import ( + check_graph_file_size_cap as _ctx_size_cap, + ) + _ctx_size_cap(existing_graph_path) + _ctx_graph = json.loads( + existing_graph_path.read_text(encoding="utf-8") + ) + _ctx_root = Path(os.path.abspath(target)) + + def _ctx_identity(source_file) -> str | None: + # graph.json source_file values are relative to the + # scanned root (`root=target` above); detect's + # unchanged_files keep their scan-time form. Compare + # both as absolute posix paths. + if not source_file: + return None + _p = Path(str(source_file)) + if not _p.is_absolute(): + _p = _ctx_root / _p + return Path(os.path.abspath(_p)).as_posix() + + _ctx_live = { + _ctx_identity(f) + for _flist in detection.get("unchanged_files", {}).values() + for f in _flist + } + _ctx_live.discard(None) + for _node in _ctx_graph.get("nodes", []): + if not _node.get("id") or not _ctx_is_ast_tier(_node): + continue + _sf = _node.get("source_file") + if not _sf or _ctx_identity(_sf) not in _ctx_live: + continue + _ctx_node = { + "id": _node["id"], + "label": _node.get("label"), + "source_file": _sf, + "file_type": _node.get("file_type"), + "type": _node.get("type"), + } + for _marker in ("_callable", "_callable_class"): + if _node.get(_marker): + _ctx_node[_marker] = _node[_marker] + _ctx_nodes.append(_ctx_node) + for _edge in _ctx_graph.get( + "links", _ctx_graph.get("edges", []) + ): + if _edge.get("relation") not in ("contains", "method"): + continue + if not _ctx_is_ast_tier(_edge): + continue + _sf = _edge.get("source_file") + if not _sf or _ctx_identity(_sf) not in _ctx_live: + continue + _ctx_edges.append({ + "source": _edge.get("source"), + "target": _edge.get("target"), + "relation": _edge.get("relation"), + "source_file": _sf, + }) + except Exception: + _ctx_nodes, _ctx_edges = [], [] + if _ctx_nodes: + ast_kwargs["resolution_context_nodes"] = _ctx_nodes + if _ctx_edges: + ast_kwargs["resolution_context_edges"] = _ctx_edges print(f"[graphify extract] AST extraction on {len(code_files)} code files...") try: ast_result = _ast_extract(code_files, **ast_kwargs) except Exception as exc: print(f"[graphify extract] AST extraction failed: {exc}", file=sys.stderr) + # #2445: losing the whole AST pass is fatal by default. The + # empty stand-in only reaches the shrink guard when an existing + # graph is larger — on a fresh build it used to be written as a + # 0-node graph with exit 0, indistinguishable from success. + # --allow-partial opts back into the best-effort continuation. + if not cli_allow_partial: + sys.exit(1) ast_result = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} _extraction_incomplete = True # the whole AST pass was lost stages.mark("AST extract") diff --git a/graphify/extract.py b/graphify/extract.py index 30f31d329..dc7540d5f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2086,21 +2086,31 @@ def _merge_csharp_partial_class_nodes( per_file: list[dict], all_nodes: list[dict], all_edges: list[dict], + paths: list[Path], + root: Path, ) -> None: """Collapse C# `partial class Foo` halves split across files into ONE node - (#2332). + (#2332), without crossing assembly boundaries (#2411). The per-file extractor mints class ids with the file stem, so each file declaring `partial class Foo` produces its own `Foo` node: members split across the halves and cross-half calls don't resolve (two candidate types make every receiver-typed lookup bail as ambiguous). Group partial-stamped - type nodes by (namespace, label) — same-named types in different namespaces - are distinct types, non-partial same-named types are separate declarations, - and nested partials are excluded (their ids omit the enclosing type, so a - same-named nested pair under different outers would falsely merge). The - canonical node is the sorted-first half by (source_file, source_location, - id); every edge endpoint and raw-call caller is remapped onto it. Member - node ids are left untouched — only the class-level nodes collapse. + type nodes by (assembly, namespace, label) — same-named types in different + namespaces are distinct types, non-partial same-named types are separate + declarations, and nested partials are excluded (their ids omit the + enclosing type, so a same-named nested pair under different outers would + falsely merge). The `partial` keyword only fuses declarations compiled into + the SAME assembly, so the key also carries the nearest ancestor directory + holding a `*.csproj`/`*.fsproj`/`*.vbproj` — same-named halves under + different project dirs are genuinely distinct types and stay apart. Halves + with NO project file on any ancestor (up to the scan root) all key to "" + and still merge together, so single-project/snippet corpora behave exactly + as before; the probe runs only for groups that are otherwise ambiguous. + The canonical node is the sorted-first half by (source_file, + source_location, id); every edge endpoint and raw-call caller is remapped + onto it. Member node ids are left untouched — only the class-level nodes + collapse. Must run BEFORE _disambiguate_colliding_node_ids / _rewire_unique_stub_nodes / _resolve_csharp_type_references and the resolver registry, so every later @@ -2120,19 +2130,90 @@ def _merge_csharp_partial_class_nodes( continue groups.setdefault((str(md.get("namespace", "")), str(label)), []).append(n) + if not any(len(members) >= 2 for members in groups.values()): + return + + # Assembly probe (#2411). A node's `source_file` can be a bare filename at + # this point (ambiguous across project dirs), so map nid -> scanned path + # via per_file, which aligns 1:1 with `paths`. + nid_to_path: dict[str, Path] = {} + for result, path in zip(per_file, paths): + for pn in result.get("nodes") or []: + nid_to_path.setdefault(pn["id"], path) + + proj_exts = (".csproj", ".fsproj", ".vbproj") + project_dirs: set[Path] = set() + for p in paths: + if p.suffix.lower() in proj_exts: + try: + project_dirs.add(p.resolve().parent) + except OSError: + pass + try: + stop = root.resolve() + except OSError: + stop = root + dir_assembly: dict[Path, str] = {} + + def _assembly_of_dir(d: Path) -> str: + """Nearest ancestor dir (self included) holding a project file, "" if + none up to the scan root; memoized along the walked chain.""" + chain: list[Path] = [] + key = "" + while True: + cached = dir_assembly.get(d) + if cached is not None: + key = cached + break + chain.append(d) + if d in project_dirs: + key = str(d) + break + try: + has_project = any( + c.suffix.lower() in proj_exts for c in d.iterdir() + ) + except OSError: + has_project = False + if has_project: + key = str(d) + break + if d == stop or d.parent == d: + break + d = d.parent + for c in chain: + dir_assembly[c] = key + return key + + def _assembly_of_node(nid: str) -> str: + path = nid_to_path.get(nid) + if path is None: + return "" + try: + d = path.resolve().parent + except OSError: + return "" + return _assembly_of_dir(d) + remap: dict[str, str] = {} for members in groups.values(): if len(members) < 2: continue - members.sort(key=lambda n: ( - str(n.get("source_file", "")), - str(n.get("source_location", "")), - str(n.get("id", "")), - )) - canonical_nid = members[0]["id"] - for other in members[1:]: - if other["id"] != canonical_nid: - remap[other["id"]] = canonical_nid + by_assembly: dict[str, list[dict]] = {} + for n in members: + by_assembly.setdefault(_assembly_of_node(n["id"]), []).append(n) + for halves in by_assembly.values(): + if len(halves) < 2: + continue + halves.sort(key=lambda n: ( + str(n.get("source_file", "")), + str(n.get("source_location", "")), + str(n.get("id", "")), + )) + canonical_nid = halves[0]["id"] + for other in halves[1:]: + if other["id"] != canonical_nid: + remap[other["id"]] = canonical_nid if not remap: return @@ -4491,6 +4572,7 @@ def _extract_parallel( work_items = [(idx, str(path), root_str, cache_loc_str) for idx, path in uncached_work] done_count = 0 + failed: list[int] = [] # positions into uncached_work whose future failed _PROGRESS_INTERVAL = 100 try: with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as pool: @@ -4502,12 +4584,21 @@ def _extract_parallel( try: idx, result = future.result() per_file[idx] = result + except concurrent.futures.process.BrokenProcessPool: + # #2444: a pool that dies while results are being consumed + # raises BrokenProcessPool from every pending future. It + # must reach the pool-level handler below (which returns + # False so the caller falls back to sequential), not be + # swallowed here per-future — that left the remaining + # per_file slots empty and silently dropped the files. + raise except Exception as exc: pos = futures[future] print( f" warning: worker failed for {work_items[pos][1]}: {exc}", file=sys.stderr, flush=True, ) + failed.append(pos) done_count += 1 if ( total_files >= _PROGRESS_INTERVAL @@ -4532,6 +4623,16 @@ def _extract_parallel( flush=True, ) return False + if failed: + # #2445: retry per-future failures once, in-process, instead of leaving + # their per_file slots None (which the defensive fill downstream turned + # into well-formed empties — silent data loss). This is bounded, not a + # loop: _extract_sequential goes through _safe_extract, which converts + # a second failure into an error-carrying result. + _extract_sequential( + [uncached_work[pos] for pos in failed], + per_file, root, total_files, cache_location, + ) if total_files >= _PROGRESS_INTERVAL: # Report the same denominator the intermediate lines used (uncached files # actually processed this run), not total_files — switching to the full @@ -4591,6 +4692,8 @@ def extract( root: Path | None = None, parallel: bool = True, max_workers: int | None = None, + resolution_context_nodes: list[dict] | None = None, + resolution_context_edges: list[dict] | None = None, ) -> dict: """Extract AST nodes and edges from a list of code files. @@ -4613,6 +4716,24 @@ def extract( use ProcessPoolExecutor for multi-core extraction. max_workers: max subprocess count. Defaults to cpu_count (or the value of GRAPHIFY_MAX_WORKERS if set), bounded by len(uncached_work). + resolution_context_nodes: read-only AST nodes from files that are NOT + being extracted this run (an incremental rebuild's unchanged + corpus, #2406). They extend the cross-file resolution indexes — + the shared direct-call pass's label/file indexes, the + indirect_call callable guard (via the persisted `_callable` / + `_callable_class` markers, #2438), and the member-call resolvers + run by `run_language_resolvers` (#2437) — so a changed caller can + still bind `foo()`, `obj.method()`, or `submit(handler)` to an + unchanged callee. They are never parsed, mutated, or returned; + raw_calls come only from `paths`, so only edges sourced by the + re-extracted files are emitted. + resolution_context_edges: the `contains`/`method` edges of the same + unchanged corpus (#2437). The member-call resolvers walk these to + map a receiver type to the single class owning the called method; + without them an unchanged callee's class never passes the + single-definition guard. Read-only, same contract as + resolution_context_nodes: they widen the resolvers' view but only + fresh results are appended to the returned nodes/edges. """ paths = [Path(p) for p in paths] anchor_root = Path(root) if root is not None else None @@ -4682,12 +4803,24 @@ def extract( uncached_work, per_file, root, max_workers, total, cache_location ) if not ran_parallel: - _extract_sequential(uncached_work, per_file, root, total, cache_location) + # #2444: only re-extract what the pool didn't finish. A pool that + # breaks mid-run has already filled some per_file slots; redoing + # the whole batch would throw that work away. + _extract_sequential( + [(i, p) for (i, p) in uncached_work if per_file[i] is None], + per_file, root, total, cache_location, + ) - # Fill any remaining None slots (shouldn't happen, but defensive) + # Fill any remaining None slots. With the #2444/#2445 handling above this + # is unreachable; the error marker keeps any regression loud (and out of + # the caches/#1666 paths) instead of letting a dropped file masquerade as + # a legitimately-empty one. for i in range(total): if per_file[i] is None: - per_file[i] = {"nodes": [], "edges": []} + per_file[i] = { + "nodes": [], "edges": [], + "error": "internal: no extraction result produced", + } # #1666: surface any source file an extractor accepted but that produced zero # nodes (not even a file node). Such a file is silently absent from the graph, @@ -5175,7 +5308,7 @@ def extract( # graph is identical regardless of scan root (#2072). _repoint_python_package_imports(paths, all_nodes, all_edges, root) _merge_swift_extensions(per_file, all_nodes, all_edges) - _merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges) + _merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges, paths, root) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) _canonicalize_csharp_namespace_nodes(all_nodes, all_edges) # PHP namespace/use disambiguation must run BEFORE the unique-stub rewire: @@ -5296,7 +5429,28 @@ def extract( # identifiers, and they were polluting matches for short names — #563). global_label_to_nids: dict[str, list[str]] = {} # exact-case (all languages) global_label_to_nids_ci: dict[str, list[str]] = {} # case-INSENSITIVE-language nodes - for n in all_nodes: + # #2406: on an incremental rebuild only the CHANGED files are parsed, so + # `all_nodes` alone cannot see a callee that lives in an unchanged file and + # every changed->unchanged DIRECT call silently vanished (while the file-level + # `imports` edge survived, because the JS/Python symbol-resolution pass + # reads the import TARGET off disk instead of off the node list). Extend the + # resolution indexes — and ONLY the indexes — with the caller-supplied + # unchanged-corpus nodes. Fresh nodes win on id collision, nothing is + # appended to `all_nodes`, and raw_calls still come solely from `paths`, so + # the emitted edges remain sourced by the re-extracted files. + # + # Scope: this list feeds the shared direct-call loop below, the + # indirect_call callable guard (#2438, via the persisted `_callable` / + # `_callable_class` markers), and — together with resolution_context_edges — + # the member-call resolvers run by run_language_resolvers (#2437). + resolution_nodes = all_nodes + if resolution_context_nodes: + _fresh_ids = {n["id"] for n in all_nodes} + resolution_nodes = all_nodes + [ + n for n in resolution_context_nodes + if n.get("id") and n["id"] not in _fresh_ids + ] + for n in resolution_nodes: if n.get("file_type") == "rationale" or n.get("type") == "namespace": continue raw = n.get("label", "") @@ -5313,12 +5467,15 @@ def extract( # Callable-def ids for the indirect_call callable guard, read from the `_callable` # marker on the FINAL (post-remap) nodes — so a callback resolves only to a real # function/method/class, never a same-named data symbol, and the guard never goes - # stale when node ids were relativized/disambiguated above (#1566). - callable_nids = {n["id"] for n in all_nodes if n.get("_callable")} + # stale when node ids were relativized/disambiguated above (#1566). Read from + # `resolution_nodes`, not `all_nodes` (#2438): an unchanged callee's context node + # carries the marker persisted in graph.json, so an incremental rebuild keeps + # resolving callbacks into unchanged files while data symbols stay excluded. + callable_nids = {n["id"] for n in resolution_nodes if n.get("_callable")} # Class defs are callable only via their constructor; they are frequently passed # as descriptive values (`select(Model)`, exception tuples), not invoked. Exclude # them from the indirect_call guard below to avoid false edges (#2137). - class_nids = {n["id"] for n in all_nodes if n.get("_callable_class")} + class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")} # Build evidence index from import edges so cross-file calls backed by an # explicit import statement can be promoted from INFERRED to EXTRACTED. @@ -5344,7 +5501,7 @@ def extract( # absolute-derived id — which would spuriously fail import evidence and (with # the #1659 JS/TS gate below) drop a legitimately-imported call. sf_to_file_nid: dict[str, str] = {} - for n in all_nodes: + for n in resolution_nodes: sf = n.get("source_file") if sf and n.get("label") == Path(str(sf)).name: sf_to_file_nid.setdefault(str(sf), n["id"]) @@ -5353,7 +5510,7 @@ def extract( # (test/non-test classification + path proximity). Kept separate from the # file-node-id map because tie-breaking compares the actual file paths. nid_to_source_file: dict[str, str] = {} - for n in all_nodes: + for n in resolution_nodes: sf = n.get("source_file") if not sf: continue @@ -5567,7 +5724,24 @@ def extract( # receiver-typed/qualified calls the shared pass skipped) with its own # single-definition god-node guard. Registered in graphify.resolver_registry so # a new language plugs in without editing this body (#1356 Swift, #1446 Python). - run_language_resolvers(paths, per_file, all_nodes, all_edges) + # + # #2437: on an incremental rebuild the resolvers must also see the unchanged + # corpus — its nodes (types/methods, from resolution_nodes above) and its + # persisted contains/method edges (resolution_context_edges) — or the + # single-definition guards bail on every changed->unchanged member call. Run + # them over SCRATCH lists that include the context, then keep only the fresh + # results: raw_calls come solely from `paths`, so nothing sourced by an + # unchanged file is ever emitted, and the ambiguity guards count the same + # candidates a full build would (the context is the whole unchanged corpus). + if resolution_context_nodes or resolution_context_edges: + _rl_nodes = list(resolution_nodes) + _rl_edges = all_edges + list(resolution_context_edges or []) + _n0, _e0 = len(_rl_nodes), len(_rl_edges) + run_language_resolvers(paths, per_file, _rl_nodes, _rl_edges) + all_nodes.extend(_rl_nodes[_n0:]) + all_edges.extend(_rl_edges[_e0:]) + else: + run_language_resolvers(paths, per_file, all_nodes, all_edges) # Relativize source_file fields so paths are portable across machines (#555). # When the node's id was itself minted from the absolute path, remap it to a @@ -5695,8 +5869,14 @@ def extract( # cache keeps its own copy, which is what the colliding-id pass reads on a cache hit. for n in all_nodes: n.pop("origin_file", None) - n.pop("_callable", None) # internal indirect_call marker — never ships to graph.json - n.pop("_callable_class", None) # internal #2137 marker — never ships to graph.json + # `_callable` / `_callable_class` are deliberately NOT popped (#2438): they + # persist into graph.json — the same underscore-provenance precedent as + # `_origin` below — so an incremental rebuild can hand them back as + # resolution context and the indirect_call callable guard keeps working for + # targets in unchanged files. Callability is never inferred from a persisted + # label (that would reintroduce the #1566/#2137 data-symbol false positives); + # a graph written before the markers existed simply fails closed until its + # files are re-extracted. # local_alias is a transient import-resolution hint (#2082), same shape as # target_file (#1814): it exists only so the module arm of diff --git a/graphify/watch.py b/graphify/watch.py index a1adb64bb..862997a68 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1157,8 +1157,96 @@ def _rebuild_code( # AST heading layer intact alongside the semantic layer. extract_targets = [p for p in code_files if p not in semantic_doc_files] + # #2406: an incremental rebuild parses only the changed files, so the + # cross-file resolvers could not see a callee living in an unchanged + # file and every changed->unchanged `calls` edge disappeared (reconcile + # evicts the old one as AST-tier output of a re-extracted source, and + # nothing regenerates it). Hand extract() read-only resolution context: + # the persisted AST nodes of files this run is NOT re-extracting — + # including their `_callable`/`_callable_class` markers, so the + # indirect_call guard keeps working (#2438) — plus their contains/method + # edges, which the member-call resolvers walk (#2437). + # + # Scoping rules, in order of importance: + # * AST-tier only — semantic/LLM nodes are not symbol definitions. + # * never a file in extract_targets (its fresh nodes are authoritative) + # nor a deleted one (its persisted symbols are gone). + # * only sources still in the scanned corpus, so a renamed/removed file + # cannot stay a resolver target. + # extract() uses these purely to widen the resolvers' indexes; nothing + # is parsed, mutated, or emitted from them (see extract()'s docstring). + resolution_context_nodes: list[dict] = [] + resolution_context_edges: list[dict] = [] + if changed_paths is not None and existing_graph.exists(): + try: + check_graph_file_size_cap(existing_graph) + ctx_graph = json.loads(existing_graph.read_text(encoding="utf-8")) + ctx_paths = _StoredSourcePaths( + ctx_graph, + out=out, + project_root=project_root, + watch_root=watch_root, + normalize_source=_nsf, + ) + ctx_live = { + ctx_paths.absolute_identity(str(p), project_root) for p in code_files + } + ctx_live -= { + ctx_paths.absolute_identity(str(p), project_root) for p in extract_targets + } + ctx_live -= deleted_source_identities + ctx_live.discard(None) + for node in ctx_graph.get("nodes", []): + if not node.get("id") or not _is_ast_tier(node): + continue + source_file = node.get("source_file") + if not source_file or ctx_paths.identity(source_file) not in ctx_live: + continue + ctx_node = { + "id": node["id"], + "label": node.get("label"), + "source_file": source_file, + "file_type": node.get("file_type"), + "type": node.get("type"), + } + # #2438: the persisted callability markers are the only + # thing that lets an unchanged target pass the + # indirect_call guard — never re-derived from the label. + for marker in ("_callable", "_callable_class"): + if node.get(marker): + ctx_node[marker] = node[marker] + resolution_context_nodes.append(ctx_node) + # #2437: the member-call resolvers map receiver type -> owning + # class -> method through contains/method edges; hand over the + # unchanged corpus's, scoped exactly like the nodes above so a + # deleted/re-extracted file's edges can never resurrect. + for edge in ctx_graph.get("links", ctx_graph.get("edges", [])): + if edge.get("relation") not in ("contains", "method"): + continue + if not _is_ast_tier(edge): + continue + source_file = edge.get("source_file") + if not source_file or ctx_paths.identity(source_file) not in ctx_live: + continue + resolution_context_edges.append({ + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": source_file, + }) + except Exception: + # Unreadable/oversized graph: resolve with the changed batch only + # (pre-#2406 behavior). Reconcile below still fails closed on it. + resolution_context_nodes = [] + resolution_context_edges = [] + commit = _git_head(cwd=watch_root) - result = extract(extract_targets, cache_root=watch_root) if extract_targets else { + result = extract( + extract_targets, + cache_root=watch_root, + resolution_context_nodes=resolution_context_nodes or None, + resolution_context_edges=resolution_context_edges or None, + ) if extract_targets else { "nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0, } diff --git a/pyproject.toml b/pyproject.toml index c9b550983..5168ebd3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.32" +version = "0.9.33" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_csharp_partial_classes.py b/tests/test_csharp_partial_classes.py index 39a2ba9c3..60ae8128e 100644 --- a/tests/test_csharp_partial_classes.py +++ b/tests/test_csharp_partial_classes.py @@ -4,8 +4,11 @@ carries the per-file stem), so the type's members split across the halves and every receiver-typed lookup on `Foo` bailed as ambiguous — cross-half calls never resolved. `_merge_csharp_partial_class_nodes` collapses the halves onto -one canonical node, keyed by (namespace, label); same-named types in other -namespaces, non-partial declarations, and nested partial types are left alone. +one canonical node, keyed by (assembly, namespace, label); same-named types in +other namespaces, non-partial declarations, and nested partial types are left +alone. The assembly key is the nearest ancestor dir holding a `*.csproj`/ +`*.fsproj`/`*.vbproj` (#2411: `partial` never fuses across assemblies), with +"" — halves under no project at all, which still merge — as the sentinel. """ from __future__ import annotations @@ -149,3 +152,107 @@ def test_nested_partial_not_merged(tmp_path): assert len(outers) == 1, "top-level partial halves still merge" assert len(inners) == 2, \ f"nested partial types must NOT merge (id has no outer qualifier): {inners}" + + +_TWO_ASSEMBLIES = { + "src/AsmOne/AsmOne.csproj": "\n", + "src/AsmOne/Widget.cs": ( + "namespace Shared {\n" + " public partial class Widget {\n" + " public void OnlyInAssemblyOne() {}\n" + " }\n" + "}\n" + ), + "src/AsmOne/Widget.Part2.cs": ( + "namespace Shared {\n" + " public partial class Widget {\n" + " public void AlsoInAssemblyOne() {}\n" + " }\n" + "}\n" + ), + "src/AsmTwo/AsmTwo.csproj": "\n", + "src/AsmTwo/Widget.cs": ( + "namespace Shared {\n" + " public partial class Widget {\n" + " public void OnlyInAssemblyTwo() {}\n" + " }\n" + "}\n" + ), +} + + +def _widget_methods_by_assembly(r): + """Map each Widget class node -> set of member-method labels hanging off it.""" + widgets = _nodes_labeled(r, "Widget") + label_of = {n["id"]: n["label"] for n in r["nodes"]} + return widgets, { + w["id"]: { + label_of[e["target"]] for e in r["edges"] + if e["relation"] == "method" and e["source"] == w["id"] + } + for w in widgets + } + + +def test_same_namespace_partials_in_different_assemblies_not_merged(tmp_path): + """#2411: same fully-qualified name under TWO .csproj projects is two + genuinely distinct types — never one node with phantom cross-assembly edges.""" + calls, r = _extract(tmp_path, _TWO_ASSEMBLIES) + widgets, methods = _widget_methods_by_assembly(r) + assert len(widgets) == 2, \ + f"partials in different assemblies must stay distinct: {widgets}" + asm_one = next(w["id"] for w in widgets if "asmone" in w["id"].lower()) + asm_two = next(w["id"] for w in widgets if "asmtwo" in w["id"].lower()) + assert methods[asm_one] == {".OnlyInAssemblyOne()", ".AlsoInAssemblyOne()"}, \ + f"AsmOne's Widget owns exactly its own two members: {methods[asm_one]}" + assert methods[asm_two] == {".OnlyInAssemblyTwo()"}, \ + f"AsmTwo's Widget owns exactly its own member: {methods[asm_two]}" + phantom = [ + e for e in r["edges"] + if e["relation"] in ("contains", "method") + and e["target"] == asm_one and "asmtwo" in str(e["source"]).lower() + ] + assert not phantom, f"no AsmTwo-derived edge may reach AsmOne's Widget: {phantom}" + + +def test_partial_halves_within_one_csproj_still_merge(tmp_path): + """Adding a .csproj must not break the #2332 merge within one project.""" + calls, r = _extract(tmp_path, { + "src/AsmOne/AsmOne.csproj": "\n", + "src/AsmOne/Widget.cs": ( + "namespace Shared {\n" + " public partial class Widget {\n" + " public void Alpha() {}\n" + " }\n" + "}\n" + ), + "src/AsmOne/Widget.Part2.cs": ( + "namespace Shared {\n" + " public partial class Widget {\n" + " public void Beta() { Alpha(); }\n" + " }\n" + "}\n" + ), + }) + widgets, methods = _widget_methods_by_assembly(r) + assert len(widgets) == 1, \ + f"same-project halves must still collapse to ONE node: {widgets}" + assert methods[widgets[0]["id"]] == {".Alpha()", ".Beta()"}, \ + f"canonical Widget must own members from BOTH halves: {methods}" + alpha = _find(r, ".Alpha()", "widget") + beta = _find(r, ".Beta()", "widget") + assert (beta, alpha) in calls, "cross-half in-class call must still resolve" + + +def test_assembly_probe_without_scanned_csproj(tmp_path): + """The .csproj files exist on disk but are NOT in the scanned paths — the + assembly key comes from the ancestor-dir walk, not the paths seed.""" + for name, body in _TWO_ASSEMBLIES.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + cs_only = {n: b for n, b in _TWO_ASSEMBLIES.items() if n.endswith(".cs")} + calls, r = _extract(tmp_path, cs_only) + widgets = _nodes_labeled(r, "Widget") + assert len(widgets) == 2, \ + f"on-disk (unscanned) project files must still split assemblies: {widgets}" diff --git a/tests/test_extract.py b/tests/test_extract.py index da7a615f8..28c97b272 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1650,6 +1650,255 @@ def test_extract_parallel_still_spawns_pool_for_multiple_workers(tmp_path, monke assert spawned["count"] == 1, "multi-worker runs must still use the pool" +def test_extract_falls_back_when_worker_future_breaks_pool( + tmp_path, monkeypatch, capsys +): + """#2444: a BrokenProcessPool raised from future.result() (pool died while + results were being consumed) must trigger the sequential fallback, not be + swallowed per-future leaving empty per_file slots.""" + from concurrent.futures.process import BrokenProcessPool + import concurrent.futures + from graphify import extract as extract_mod + + class BrokenFuture: + def result(self): + raise BrokenProcessPool("simulated worker termination") + + class FakePool: + def __init__(self, *a, **kw): pass + def __enter__(self): return self + def __exit__(self, *a): return False + def submit(self, *a, **kw): + return BrokenFuture() + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + monkeypatch.setattr( + concurrent.futures, "as_completed", lambda futures: iter(futures) + ) + # A 1-CPU runner resolves max_workers to 1 and never enters the pool (#2173). + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "2") + + sequential_calls = 0 + real_sequential = extract_mod._extract_sequential + + def wrapped_sequential(*args, **kwargs): + nonlocal sequential_calls + sequential_calls += 1 + return real_sequential(*args, **kwargs) + + monkeypatch.setattr(extract_mod, "_extract_sequential", wrapped_sequential) + + files = [FIXTURES / "sample.py"] * 25 # >= _PARALLEL_THRESHOLD + result = extract_mod.extract(files, cache_root=tmp_path / "cache") + + assert sequential_calls == 1, "sequential fallback should have run exactly once" + assert result["nodes"], "sequential fallback must recover AST nodes" + assert "BrokenProcessPool" in capsys.readouterr().out + + +def test_extract_bpp_fallback_skips_already_completed_files(tmp_path, monkeypatch): + """#2444: when the pool breaks mid-run, the sequential fallback must + re-extract only the files whose futures never completed.""" + from concurrent.futures.process import BrokenProcessPool + import concurrent.futures + from graphify import extract as extract_mod + + completed_before_break = 5 + + class GoodFuture: + def __init__(self, value): self._value = value + def result(self): return self._value + + class BrokenFuture: + def result(self): + raise BrokenProcessPool("simulated worker termination") + + class FakePool: + def __init__(self, *a, **kw): + self._submitted = 0 + def __enter__(self): return self + def __exit__(self, *a): return False + def submit(self, fn, item): + self._submitted += 1 + if self._submitted <= completed_before_break: + return GoodFuture(fn(item)) # extract in-process, eagerly + return BrokenFuture() + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + monkeypatch.setattr( + concurrent.futures, "as_completed", lambda futures: iter(futures) + ) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "2") + + retried: list[list[int]] = [] + real_sequential = extract_mod._extract_sequential + + def wrapped_sequential(uncached_work, *args, **kwargs): + retried.append([idx for idx, _ in uncached_work]) + return real_sequential(uncached_work, *args, **kwargs) + + monkeypatch.setattr(extract_mod, "_extract_sequential", wrapped_sequential) + + files = [FIXTURES / "sample.py"] * 25 + result = extract_mod.extract(files, cache_root=tmp_path / "cache") + + assert len(retried) == 1, "sequential fallback should have run exactly once" + assert sorted(retried[0]) == list(range(completed_before_break, 25)), ( + "files whose futures completed before the pool broke must not be re-extracted" + ) + assert result["nodes"] + + +def test_extract_parallel_retries_failed_future_sequentially( + tmp_path, monkeypatch, capsys +): + """#2445: a non-BPP per-future failure must be surfaced and retried + in-process, not silently replaced by a well-formed empty result.""" + import concurrent.futures + from graphify import extract as extract_mod + + class GoodFuture: + def __init__(self, value): self._value = value + def result(self): return self._value + + class FailingFuture: + def result(self): + raise RuntimeError("simulated worker crash") + + class FakePool: + def __init__(self, *a, **kw): + self._submitted = 0 + def __enter__(self): return self + def __exit__(self, *a): return False + def submit(self, fn, item): + self._submitted += 1 + if self._submitted == 1: + return FailingFuture() + return GoodFuture(fn(item)) + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + monkeypatch.setattr( + concurrent.futures, "as_completed", lambda futures: iter(futures) + ) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "2") + + retried: list[list[int]] = [] + real_sequential = extract_mod._extract_sequential + + def wrapped_sequential(uncached_work, *args, **kwargs): + retried.append([idx for idx, _ in uncached_work]) + return real_sequential(uncached_work, *args, **kwargs) + + monkeypatch.setattr(extract_mod, "_extract_sequential", wrapped_sequential) + + files = [FIXTURES / "sample.py"] * 25 + result = extract_mod.extract(files, cache_root=tmp_path / "cache") + + assert retried == [[0]], "only the failed file may be retried, exactly once" + assert result["nodes"] + err = capsys.readouterr().err + assert "worker failed" in err + assert "zero nodes" not in err, ( + "a retried-and-recovered file must not trip the #1666 empty warning" + ) + + +def test_extract_twice_failing_file_carries_error_marker(tmp_path, monkeypatch): + """#2445: a file that fails in the pool AND on the sequential retry must + end up with an error-carrying result (via _safe_extract), not loop and not + masquerade as legitimately empty. Other files still complete.""" + import concurrent.futures + from graphify import extract as extract_mod + + bad_file = tmp_path / "boom.go" + bad_file.write_text("package main\n") + + def _boom_extractor(path): + raise RuntimeError("extractor always crashes") + + monkeypatch.setitem(extract_mod._DISPATCH, ".go", _boom_extractor) + + class GoodFuture: + def __init__(self, value): self._value = value + def result(self): return self._value + + class FailingFuture: + def result(self): + raise RuntimeError("simulated worker crash") + + class FakePool: + def __init__(self, *a, **kw): + self._submitted = 0 + def __enter__(self): return self + def __exit__(self, *a): return False + def submit(self, fn, item): + self._submitted += 1 + if self._submitted == 1: # boom.go is first in the batch + return FailingFuture() + return GoodFuture(fn(item)) + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + monkeypatch.setattr( + concurrent.futures, "as_completed", lambda futures: iter(futures) + ) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "2") + + captured: dict = {"calls": 0} + real_sequential = extract_mod._extract_sequential + + def wrapped_sequential(uncached_work, per_file, *args, **kwargs): + captured["calls"] += 1 + captured["retry_indices"] = [idx for idx, _ in uncached_work] + real_sequential(uncached_work, per_file, *args, **kwargs) + captured["per_file"] = list(per_file) + + monkeypatch.setattr(extract_mod, "_extract_sequential", wrapped_sequential) + + files = [bad_file] + [FIXTURES / "sample.py"] * 24 + result = extract_mod.extract(files, cache_root=tmp_path / "cache") + + assert captured["calls"] == 1, "the retry must be bounded: one pass, no loop" + assert captured["retry_indices"] == [0] + assert "error" in captured["per_file"][0], ( + "a twice-failing file must carry an error marker, not a clean empty" + ) + assert result["nodes"], "the other files must still complete" + + +def test_extract_legitimately_empty_result_keeps_no_error_marker( + tmp_path, monkeypatch, capsys +): + """Guard for the #2445 error-marked None-fill: a file whose extractor + genuinely returns zero nodes gets a real (marker-free) result and still + trips the #1666 zero-nodes warning — behavior unchanged.""" + from graphify import extract as extract_mod + + empty_file = tmp_path / "empty.go" + empty_file.write_text("package main\n") + + monkeypatch.setitem( + extract_mod._DISPATCH, ".go", lambda path: {"nodes": [], "edges": []} + ) + + captured: dict = {} + real_sequential = extract_mod._extract_sequential + + def wrapped_sequential(uncached_work, per_file, *args, **kwargs): + real_sequential(uncached_work, per_file, *args, **kwargs) + captured["per_file"] = list(per_file) + + monkeypatch.setattr(extract_mod, "_extract_sequential", wrapped_sequential) + + extract_mod.extract([empty_file], cache_root=tmp_path / "cache") + + assert "error" not in captured["per_file"][0], ( + "a legitimately-empty extraction must not be error-marked" + ) + assert "zero nodes" in capsys.readouterr().err, ( + "the #1666 zero-nodes warning must still fire for a genuine empty" + ) + + # --------------------------------------------------------------------------- # Bash extractor tests (#866) # --------------------------------------------------------------------------- diff --git a/tests/test_extract_cli.py b/tests/test_extract_cli.py index b1c369a5e..bac501f52 100644 --- a/tests/test_extract_cli.py +++ b/tests/test_extract_cli.py @@ -19,6 +19,96 @@ def _make_corpus(tmp_path): return tmp_path +def test_extract_exits_nonzero_when_ast_extraction_raises( + monkeypatch, tmp_path, capsys +): + """#2445: an AST-pass failure on a fresh build must not be presented as a + successful empty corpus (exit 0 + 0-node graph.json).""" + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "main.go").write_text("package main\nfunc main() {}\n") + out_dir = tmp_path / "out" + + import graphify.extract as extractmod + + def _ast_failed(paths, **kwargs): + raise RuntimeError("worker pool failed") + + monkeypatch.setattr(extractmod, "extract", _ast_failed) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "extract", str(corpus), "--code-only", + "--out", str(out_dir)], + ) + + with pytest.raises(SystemExit) as exc_info: + mainmod.main() + + assert exc_info.value.code == 1 + assert ( + "[graphify extract] AST extraction failed: worker pool failed" + in capsys.readouterr().err + ) + assert not (out_dir / "graphify-out" / "graph.json").exists(), ( + "graph.json must not be written when the whole AST pass is lost" + ) + + +def test_extract_allow_partial_continues_past_ast_failure( + monkeypatch, tmp_path, capsys +): + """#2445 complement: --allow-partial opts back into the best-effort path — + the run continues, and a graph built from the surviving (semantic) pass is + written with exit 0.""" + corpus = _make_corpus(tmp_path) # main.go + README.md + out_dir = tmp_path / "out" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key") + + import graphify.extract as extractmod + + def _ast_failed(paths, **kwargs): + raise RuntimeError("worker pool failed") + + monkeypatch.setattr(extractmod, "extract", _ast_failed) + + def _one_chunk_succeeded(paths, **kwargs): + chunk = { + "nodes": [ + {"id": "concept_main_entry", "label": "main entry point", + "type": "concept", "source_file": "README.md"}, + ], + "edges": [], + "hyperedges": [], + } + on_chunk = kwargs.get("on_chunk_done") + if on_chunk: + on_chunk(0, 1, chunk) + return {**chunk, "input_tokens": 100, "output_tokens": 50} + + monkeypatch.setattr( + "graphify.llm.extract_corpus_parallel", _one_chunk_succeeded + ) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "extract", str(corpus), "--backend", "claude", + "--allow-partial", "--out", str(out_dir)], + ) + + try: + mainmod.main() + except SystemExit as exc: + assert exc.code in (None, 0), f"unexpected exit code {exc.code}" + + assert "AST extraction failed" in capsys.readouterr().err + assert (out_dir / "graphify-out" / "graph.json").exists(), ( + "--allow-partial must still write the best-effort graph" + ) + + def test_extract_exits_nonzero_when_all_semantic_chunks_fail( monkeypatch, tmp_path, capsys ): diff --git a/tests/test_indirect_dispatch.py b/tests/test_indirect_dispatch.py index a1ceaed22..1fd54d89d 100644 --- a/tests/test_indirect_dispatch.py +++ b/tests/test_indirect_dispatch.py @@ -224,8 +224,9 @@ def test_cross_file_indirect_survives_id_relativization(tmp_path): os.chdir(old) nid = {n["label"].rstrip("()"): n["id"] for n in r["nodes"]} assert (nid["schedule"], nid["on_event"]) in _rels(r, "indirect_call") - # the internal callable marker must never ship to graph.json - assert not any("_callable" in n for n in r["nodes"]) + # the internal callable marker now persists to graph.json (#2438), so an + # incremental rebuild can resolve indirect_call edges into unchanged targets. + assert any(n.get("_callable") for n in r["nodes"]) def test_cross_file_imported_callback_emits_indirect_call(tmp_path): diff --git a/tests/test_watch.py b/tests/test_watch.py index c3029fe13..4854a6773 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -2602,3 +2602,554 @@ def test_rebuild_code_fresh_build_defaults_undirected(tmp_path): graph_path = corpus / "graphify-out" / "graph.json" data = json.loads(graph_path.read_text(encoding="utf-8")) assert data.get("directed", False) is False + + +def test_incremental_rebuild_preserves_call_to_unchanged_typescript_target(tmp_path): + """#2406: rebuilding a changed caller retains its SHARED DIRECT calls into unchanged files. + + Scope here is the shared direct-call pass (a plain `shared()`); member calls + (#2437) and indirect_call (#2438) are covered by the tests at the end of + this file. + + A later edit that removes the call must still remove the edge, preventing a + stale-edge-preservation workaround. + """ + import json + + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + + target = corpus / "B.ts" + caller = corpus / "A.ts" + + target.write_text( + """ +export function shared(): number { + return 1; +} +""".lstrip(), + encoding="utf-8", + ) + caller.write_text( + """ +import { shared } from "./B"; + +export function run(): number { + return shared(); +} +""".lstrip(), + encoding="utf-8", + ) + + graph_path = corpus / "graphify-out" / "graph.json" + + def load_graph(): + return json.loads(graph_path.read_text(encoding="utf-8")) + + def node_id(graph, label, source_file): + return next( + node["id"] + for node in graph.get("nodes", []) + if node.get("label") == label + and node.get("source_file") == source_file + ) + + def has_call(graph): + run_id = node_id(graph, "run()", "A.ts") + shared_id = node_id(graph, "shared()", "B.ts") + return any( + edge.get("relation") == "calls" + and edge.get("source") == run_id + and edge.get("target") == shared_id + for edge in graph.get("links", graph.get("edges", [])) + ) + + # Full-corpus baseline resolves A.run() -> B.shared(). + assert _rebuild_code( + corpus, + no_cluster=True, + acquire_lock=False, + ) is True + assert has_call(load_graph()), "full rebuild must create the cross-file call edge" + + # Change only the caller while retaining the same call. The unchanged target + # must remain available to the cross-file resolver. + caller.write_text( + """ +import { shared } from "./B"; + +export function run(): number { + return shared() + 1; +} +""".lstrip(), + encoding="utf-8", + ) + assert _rebuild_code( + corpus, + changed_paths=[caller], + no_cluster=True, + acquire_lock=False, + ) is True + assert has_call( + load_graph() + ), "incremental rebuild dropped the call edge to an unchanged target" + + # Removing the call must remove the edge; do not merely preserve old outgoing + # edges from changed files. + caller.write_text( + """ +export function run(): number { + return 1; +} +""".lstrip(), + encoding="utf-8", + ) + assert _rebuild_code( + corpus, + changed_paths=[caller], + no_cluster=True, + acquire_lock=False, + ) is True + + final_graph = load_graph() + run_id = node_id(final_graph, "run()", "A.ts") + assert not any( + edge.get("relation") == "calls" + and edge.get("source") == run_id + for edge in final_graph.get("links", final_graph.get("edges", [])) + ), "removed call must not survive as a stale edge" + + +# --- #2406 incremental shared-direct-call resolution helpers ----------------- + +def _2406_graph(corpus): + import json + return json.loads( + (corpus / "graphify-out" / "graph.json").read_text(encoding="utf-8") + ) + + +def _2406_nid(graph, label, source_file): + return next( + ( + node["id"] + for node in graph.get("nodes", []) + if node.get("label") == label and node.get("source_file") == source_file + ), + None, + ) + + +def _2406_calls(graph): + """(source_id, target_id) of every `calls` edge.""" + return [ + (edge.get("source"), edge.get("target")) + for edge in graph.get("links", graph.get("edges", [])) + if edge.get("relation") == "calls" + ] + + +def _2406_seed(tmp_path, caller_src, target_src="export function shared(): number {\n return 1;\n}\n"): + """Build a two-file TS corpus and do the initial full rebuild.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir(parents=True) + (corpus / "B.ts").write_text(target_src, encoding="utf-8") + (corpus / "A.ts").write_text(caller_src, encoding="utf-8") + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + return corpus + + +_2406_CALLER = ( + 'import { shared } from "./B";\n' + "\n" + "export function run(): number {\n" + " return shared();\n" + "}\n" +) + + +def test_incremental_rebuild_drops_call_when_import_is_removed(tmp_path): + """#2406: no import evidence => the persisted target must not be resolved.""" + from graphify.watch import _rebuild_code + + corpus = _2406_seed(tmp_path, _2406_CALLER) + caller = corpus / "A.ts" + # `shared` is now a local no-op call with no import backing it. + caller.write_text( + "declare function shared(): number;\n" + "\n" + "export function run(): number {\n" + " return shared();\n" + "}\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + + graph = _2406_graph(corpus) + shared_id = _2406_nid(graph, "shared()", "B.ts") + run_id = _2406_nid(graph, "run()", "A.ts") + assert (run_id, shared_id) not in _2406_calls(graph) + + +def test_incremental_rebuild_uses_fresh_nodes_when_target_also_changed(tmp_path): + """#2406: a changed target's persisted symbols must never win over fresh ones.""" + from graphify.watch import _rebuild_code + + corpus = _2406_seed(tmp_path, _2406_CALLER) + caller, target = corpus / "A.ts", corpus / "B.ts" + target.write_text( + "export function renamed(): number {\n return 2;\n}\n", encoding="utf-8" + ) + caller.write_text( + 'import { renamed } from "./B";\n' + "\n" + "export function run(): number {\n" + " return renamed();\n" + "}\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, + changed_paths=[caller, target], + no_cluster=True, + acquire_lock=False, + ) is True + + graph = _2406_graph(corpus) + calls = _2406_calls(graph) + run_id = _2406_nid(graph, "run()", "A.ts") + assert (run_id, _2406_nid(graph, "renamed()", "B.ts")) in calls + # The stale `shared()` node is gone entirely, so nothing can point at it. + assert _2406_nid(graph, "shared()", "B.ts") is None + + +def test_incremental_rebuild_context_excludes_deleted_target(tmp_path): + """#2406: a deleted file cannot remain a resolver target.""" + from graphify.watch import _rebuild_code + + corpus = _2406_seed(tmp_path, _2406_CALLER) + caller, target = corpus / "A.ts", corpus / "B.ts" + target.unlink() + caller.write_text( + "export function run(): number {\n return shared();\n}\n", encoding="utf-8" + ) + assert _rebuild_code( + corpus, + changed_paths=[caller, target], + no_cluster=True, + acquire_lock=False, + ) is True + + graph = _2406_graph(corpus) + assert _2406_nid(graph, "shared()", "B.ts") is None + assert _2406_calls(graph) == [] + + +def test_incremental_rebuild_does_not_reparse_unchanged_targets(tmp_path, monkeypatch): + """#2406 keeps the incremental contract: only changed files are extracted.""" + import graphify.extract as extract_mod + from graphify.watch import _rebuild_code + + corpus = _2406_seed(tmp_path, _2406_CALLER) + caller = corpus / "A.ts" + + seen: list[list[str]] = [] + real_extract = extract_mod.extract + + def spy(paths, *args, **kwargs): + seen.append([Path(p).name for p in paths]) + return real_extract(paths, *args, **kwargs) + + monkeypatch.setattr(extract_mod, "extract", spy) + caller.write_text(_2406_CALLER.replace("shared();", "shared() + 1;"), encoding="utf-8") + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + + assert seen == [["A.ts"]] + + +def test_incremental_rebuild_matches_full_rebuild_and_does_not_duplicate(tmp_path): + """#2406: full/incremental parity for edges sourced by the changed file.""" + from graphify.watch import _rebuild_code + + corpus = _2406_seed(tmp_path, _2406_CALLER) + caller = corpus / "A.ts" + edited = _2406_CALLER.replace("shared();", "shared() + 1;") + caller.write_text(edited, encoding="utf-8") + + # Two incremental rebuilds in a row must be idempotent (no duplicate edges). + for _ in range(2): + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + incremental = _2406_calls(_2406_graph(corpus)) + assert len(incremental) == len(set(incremental)) + + # Same final corpus, built from scratch. + fresh = _2406_seed(tmp_path / "fresh", edited) + assert sorted(_2406_calls(_2406_graph(fresh))) == sorted(incremental) + + +def test_incremental_rebuild_preserves_python_call_to_unchanged_target(tmp_path): + """#2406 is language-agnostic: the shared DIRECT cross-file call pass carries it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "b.py").write_text("def shared():\n return 1\n", encoding="utf-8") + caller = corpus / "a.py" + caller.write_text( + "from b import shared\n\n\ndef run():\n return shared()\n", encoding="utf-8" + ) + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + full = _2406_calls(_2406_graph(corpus)) + assert full, "full rebuild must resolve the cross-file python call" + + caller.write_text( + "from b import shared\n\n\ndef run():\n return shared() + 1\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert sorted(_2406_calls(_2406_graph(corpus))) == sorted(full) + + +# --- #2437 / #2438: member + indirect calls into unchanged files ------------- +# The #2406 resolution context now also carries the unchanged corpus's +# contains/method edges (member-call resolvers, #2437) and the persisted +# `_callable`/`_callable_class` markers (indirect_call guard, #2438), so both +# edge families survive an incremental rebuild exactly like shared direct calls. + +_2437_TARGET = "export class Service {\n ping(): number { return 1; }\n}\n" +_2437_CALLER = ( + 'import { Service } from "./B";\n\n' + "export function run(): number {\n" + " const service = new Service();\n" + " return service.ping()%s;\n}\n" +) + + +def _2437_seed(tmp_path, caller_suffix=""): + """Build the member-call corpus (TS receiver-typed call) and full-rebuild it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir(parents=True) + (corpus / "B.ts").write_text(_2437_TARGET, encoding="utf-8") + (corpus / "A.ts").write_text(_2437_CALLER % caller_suffix, encoding="utf-8") + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + return corpus + + +_2438_CALLER = ( + "from b import handler\n\n\ndef run(pool):\n%s return pool.submit(handler)\n" +) + + +def _2438_seed(tmp_path, caller_prefix="", target_src="def handler():\n return 1\n"): + """Build the indirect-call corpus (py callback into b.py) and full-rebuild it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir(parents=True) + (corpus / "b.py").write_text(target_src, encoding="utf-8") + (corpus / "a.py").write_text(_2438_CALLER % caller_prefix, encoding="utf-8") + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + return corpus + + +def _2438_indirects(graph): + """(source_id, target_id) of every `indirect_call` edge.""" + return [ + (edge.get("source"), edge.get("target")) + for edge in graph.get("links", graph.get("edges", [])) + if edge.get("relation") == "indirect_call" + ] + + +def test_incremental_rebuild_preserves_member_call_to_unchanged_target(tmp_path): + """#2437: a changed caller keeps its `service.ping()` edge into an unchanged file.""" + from graphify.watch import _rebuild_code + + corpus = _2437_seed(tmp_path) + full = _2406_calls(_2406_graph(corpus)) + assert full, "full rebuild must resolve the cross-file member call" + + caller = corpus / "A.ts" + caller.write_text(_2437_CALLER % " + 1", encoding="utf-8") + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert sorted(_2406_calls(_2406_graph(corpus))) == sorted(full) + + +def test_incremental_rebuild_preserves_indirect_call_to_unchanged_target(tmp_path): + """#2438: the persisted `_callable` marker keeps `pool.submit(handler)` resolving.""" + from graphify.watch import _rebuild_code + + corpus = _2438_seed(tmp_path) + full = _2438_indirects(_2406_graph(corpus)) + assert full, "full rebuild must resolve the cross-file indirect call" + + caller = corpus / "a.py" + caller.write_text(_2438_CALLER % " x = 1\n", encoding="utf-8") + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert sorted(_2438_indirects(_2406_graph(corpus))) == sorted(full) + + +def test_incremental_rebuild_evicts_removed_member_call(tmp_path): + """#2437: removing the member call from the caller must remove the edge — + the fix regenerates edges from fresh raw_calls, it never preserves stale ones.""" + from graphify.watch import _rebuild_code + + corpus = _2437_seed(tmp_path) + assert _2406_calls(_2406_graph(corpus)), "member-call baseline missing" + + caller = corpus / "A.ts" + caller.write_text( + "export function run(): number {\n return 1;\n}\n", encoding="utf-8" + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert _2406_calls(_2406_graph(corpus)) == [] + + +def test_incremental_rebuild_evicts_member_call_when_target_deleted(tmp_path): + """#2437: a deleted callee file must not resurrect through the context edges.""" + from graphify.watch import _rebuild_code + + corpus = _2437_seed(tmp_path) + caller, target = corpus / "A.ts", corpus / "B.ts" + target.unlink() + assert _rebuild_code( + corpus, + changed_paths=[caller, target], + no_cluster=True, + acquire_lock=False, + ) is True + + graph = _2406_graph(corpus) + assert _2406_calls(graph) == [] + assert not any( + node.get("source_file") == "B.ts" for node in graph.get("nodes", []) + ), "deleted target's nodes must be evicted, not resurrected as context" + + +def test_incremental_rebuild_evicts_indirect_call_when_target_deleted(tmp_path): + """#2438: a deleted callback target must not resurrect through the context nodes.""" + from graphify.watch import _rebuild_code + + corpus = _2438_seed(tmp_path) + caller, target = corpus / "a.py", corpus / "b.py" + target.unlink() + assert _rebuild_code( + corpus, + changed_paths=[caller, target], + no_cluster=True, + acquire_lock=False, + ) is True + + graph = _2406_graph(corpus) + assert _2438_indirects(graph) == [] + assert not any( + node.get("source_file") == "b.py" for node in graph.get("nodes", []) + ), "deleted target's nodes must be evicted, not resurrected as context" + + +def test_incremental_rebuild_callable_guard_excludes_unchanged_data_symbol(tmp_path): + """#2438 keeps the #1566/#2137 guard: a same-named DATA symbol in an unchanged + file (`handler = 1`) is not `_callable`, so `pool.submit(handler)` must emit no + indirect_call on the full build or the incremental one.""" + from graphify.watch import _rebuild_code + + corpus = _2438_seed(tmp_path, target_src="handler = 1\n") + assert _2438_indirects(_2406_graph(corpus)) == [] + + caller = corpus / "a.py" + caller.write_text(_2438_CALLER % " x = 1\n", encoding="utf-8") + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert _2438_indirects(_2406_graph(corpus)) == [] + + +def test_incremental_rebuild_legacy_graph_without_callable_markers(tmp_path): + """#2438 degradation contract: a graph written before the `_callable` markers + persisted must not crash the incremental rebuild — the guard fails closed (no + indirect_call, pre-fix behavior) and the next full rebuild self-heals.""" + import json + + from graphify.watch import _rebuild_code + + corpus = _2438_seed(tmp_path) + graph_path = corpus / "graphify-out" / "graph.json" + assert _2438_indirects(_2406_graph(corpus)), "indirect-call baseline missing" + + # Simulate a pre-#2438 graph: strip the persisted callability markers. + legacy = json.loads(graph_path.read_text(encoding="utf-8")) + for node in legacy.get("nodes", []): + node.pop("_callable", None) + node.pop("_callable_class", None) + graph_path.write_text(json.dumps(legacy), encoding="utf-8") + + caller = corpus / "a.py" + caller.write_text(_2438_CALLER % " x = 1\n", encoding="utf-8") + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert _2438_indirects(_2406_graph(corpus)) == [] + + # A full rebuild re-extracts the target, restores the markers, and the edge. + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + assert _2438_indirects(_2406_graph(corpus)), "full rebuild must self-heal" + + +def test_incremental_member_call_parity_and_idempotency(tmp_path): + """#2437: repeated incremental rebuilds neither duplicate the member-call edge + nor diverge from a from-scratch build of the same corpus.""" + from graphify.watch import _rebuild_code + + corpus = _2437_seed(tmp_path) + caller = corpus / "A.ts" + caller.write_text(_2437_CALLER % " + 1", encoding="utf-8") + for _ in range(2): + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + incremental = _2406_calls(_2406_graph(corpus)) + assert incremental, "member call lost across repeated incremental rebuilds" + assert len(incremental) == len(set(incremental)) + + fresh = _2437_seed(tmp_path / "fresh", caller_suffix=" + 1") + assert sorted(_2406_calls(_2406_graph(fresh))) == sorted(incremental) + + +def test_incremental_indirect_call_parity_and_idempotency(tmp_path): + """#2438: repeated incremental rebuilds neither duplicate the indirect_call edge + nor diverge from a from-scratch build of the same corpus.""" + from graphify.watch import _rebuild_code + + corpus = _2438_seed(tmp_path) + caller = corpus / "a.py" + caller.write_text(_2438_CALLER % " x = 1\n", encoding="utf-8") + for _ in range(2): + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + incremental = _2438_indirects(_2406_graph(corpus)) + assert incremental, "indirect call lost across repeated incremental rebuilds" + assert len(incremental) == len(set(incremental)) + + fresh = _2438_seed(tmp_path / "fresh", caller_prefix=" x = 1\n") + assert sorted(_2438_indirects(_2406_graph(fresh))) == sorted(incremental)