diff --git a/CHANGELOG.md b/CHANGELOG.md index 0006c4f46..70170db56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Feat: Ruby `include`/`extend`/`prepend ` now emits a `mixes_in` edge to the module (#1668, thanks @krishnateja7). Concerns/mixins are the composition mechanism in Rails, but they produced no edges, so the blast radius of editing a shared concern was invisible to `affected`. A constant-argument mixin inside a class or module body now resolves to the module node (reusing the #1634 candidate logic and the #1640 module nodes, under the single-owner guard) and emits `Class --mixes_in--> Module`, which `affected` already traverses. `extend self` and non-constant arguments are skipped; an ambiguous or undefined module produces no edge. +- Feat: `affected ` now reaches callers that bind to the class's method nodes (#1669, thanks @krishnateja7). Since #1634 binds `Service.call` precisely to the `def self.call` method node, a class-level `affected` query missed those callers because `method`/`contains` are (correctly) not general-traversal relations. The reverse walk now seeds from the root's own member nodes (one `method`/`contains` hop outward) so method-bound callers are reachable from the class, with no change to the general traversal (no forward noise) and the member nodes themselves are not reported as hits. +- Fix: capitalized/mixed-case file extensions are no longer silently skipped (#1671, thanks @raman118). `collect_files` and `_get_extractor` matched suffixes case-sensitively, so `App.PY`, `script.JS`, `Lib.Ts`, etc. fell through and were never extracted. Suffix matching now falls back to the lowercased form for both file discovery and extractor dispatch (including `.blade.php`); an unsupported extension like `.xyz` is still skipped. +- Fix: the virtual PostgreSQL `source_file` URI no longer gets backslash-mangled on Windows (#1672, thanks @raman118). `introspect_postgres` built the synthetic `postgresql://host/db` path with `Path`, which rewrites `/` to `\` on Windows; it now uses `PurePosixPath` so the URI stays forward-slashed on every platform. +- Fix: a deferred `import(...)` no longer manufactures a phantom file cycle (#1241, thanks @Synvoya). Dynamic imports are real dependencies but not static ones, so two files that reference each other via one static import plus one dynamic import were reported as a circular dependency. The dynamic-import edge stays in the graph (marked `deferred`) but is excluded from `find_import_cycles`. +- Fix: an extractable source file that produces zero nodes is no longer cached, and is surfaced with a warning (#1666, thanks @krishnateja7). Every supported file yields at least a file node, so a zero-node result is anomalous (a transient batch/parallel hiccup). Caching it made the empty byte-stable across runs and silently blinded `affected`/`explain` to and through the file. The cache write is now skipped for a zero-node result so a rerun self-heals, and `extract` warns when an accepted source file lands in the graph with no nodes. This addresses the persistence and the silent blindness; if the underlying zero-node extraction still reproduces on a specific corpus, the warning now makes it visible to report. - Fix: the Windows skill variant now declares `name: graphify` instead of `name: graphify-windows` (#1635, thanks @ray8875). `graphify install --platform windows` writes the variant to `~/.claude/skills/graphify/SKILL.md`, but Claude Code requires the skill folder name to equal the frontmatter `name`, so the `-windows` suffix broke discovery/validation. The variant suffix is a packaging detail, not part of the skill's identity. - Fix: the OpenCode plugin joins its reminder to the user's command with `;` instead of `&&` (#1646, thanks @gonaik). Windows PowerShell 5.1 rejects `&&` as a statement separator (`not a valid statement separator`), so the first bash command of every OpenCode session on Windows failed. `;` works in PowerShell 5.1, Bash, and POSIX shells. (Both the OpenCode and Kilo plugin templates are fixed.) - Fix: the `GRAPH_REPORT.md` "Import Cycles" section is now emitted only when the graph contains code (#1657, thanks @Ns2384-star). On a documents-only corpus there are no imports, so the section was pure noise ("None detected") on every run; it is now conditioned on code nodes or import edges being present. (The same report also confirms the mojibake and stdout-encoding items in that issue are already addressed on the current branch: manifest.json and `GRAPH_REPORT.md` are written UTF-8, and the CLI reconfigures stdout/stderr to UTF-8 with `errors="replace"`.) diff --git a/README.md b/README.md index ac8ce34f9..febe20e9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Graphify + Graphify

diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 000000000..95ad01b3b Binary files /dev/null and b/docs/logo.png differ diff --git a/graphify/affected.py b/graphify/affected.py index dbb532b42..deacc39c8 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -149,6 +149,27 @@ def affected_nodes( queue: deque[tuple[str, int]] = deque([(seed, 0)]) hits: list[AffectedHit] = [] + # #1669: seed the reverse walk with the root's own member nodes (one outward + # `method`/`contains` hop). A caller can bind to a class's method node rather + # than the class node itself (e.g. `Service.call` resolves to the `def + # self.call` node, #1634), so those callers are unreachable from the class + # otherwise. The member nodes are seeds only (not reported as hits), and + # `method`/`contains` stay out of the general relation-filtered walk, so this + # adds no forward noise anywhere else. + if hasattr(graph, "out_edges"): + member_edges = graph.out_edges(seed, data=True) + else: + member_edges = ( + (s, t, d) for s, t, d in graph.edges(data=True) if s == seed + ) + for _s, member, data in member_edges: + if str(data.get("relation", "")) not in ("method", "contains"): + continue + member = str(member) + if member not in seen: + seen.add(member) + queue.append((member, 0)) + while queue: current, current_depth = queue.popleft() if current_depth >= depth: diff --git a/graphify/analyze.py b/graphify/analyze.py index 4be1a401c..0d4bbe4c9 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -663,6 +663,11 @@ def _endpoint_source_file(node_id: str) -> str: if rel not in ("imports_from", "re_exports"): continue + # Deferred `import(...)` edges are real dependencies but do not form a + # hard file-level cycle, so they are excluded from cycle detection (#1241). + if data.get("deferred"): + continue + src_file_attr = data.get("source_file", "") if not isinstance(src_file_attr, str) or not src_file_attr: continue diff --git a/graphify/extract.py b/graphify/extract.py index ab671543c..f14f4638d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1932,8 +1932,13 @@ def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edge edges.append({ "source": caller_nid, "target": tgt_nid, + # A deferred `import(...)` is a real dependency, so keep it as an + # `imports_from` edge (visible in the graph) but mark it `deferred` + # so find_import_cycles does not treat it as a static import and + # report a phantom file cycle (#1241). "relation": "imports_from", "context": "import", + "deferred": True, "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", @@ -3445,6 +3450,10 @@ def _extract_generic( # `let vm = VM()`) live outside function bodies, so the call-walk never # reaches them. Collect (owner_nid, call_node) here and walk them too. initializer_nodes: list[tuple[str, object]] = [] + # Ruby include/extend/prepend mixins collected during the node walk (#1668), + # merged into raw_calls after the call-walk populates it (raw_calls does not + # exist yet while walk() runs). Resolved cross-file by the Ruby resolver. + _ruby_mixin_calls: list[dict] = [] # #1356: per-file map of local name -> declared type (properties + params), # threaded out as `swift_type_table` so member calls (`vm.update()`) can be # resolved to the receiver's real definition in _resolve_swift_member_calls. @@ -3814,6 +3823,36 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: seen_ids.add(base_nid) add_edge(class_nid, base_nid, "inherits", line) + # `include`/`extend`/`prepend ` in the class/module body -> + # a `mixes_in` edge to the module (#1668). The module usually lives + # in another file, so defer resolution to the cross-file Ruby + # resolver (reusing the #1634 candidate logic and the #1640 module + # nodes as targets). Only bare/namespaced constant arguments count; + # `extend self`, `include some_var`, etc. are skipped. + _rb_body = _find_body(node, config) + if _rb_body is not None: + for _stmt in _rb_body.children: + if _stmt.type != "call" or _stmt.child_by_field_name("receiver") is not None: + continue + _m = _stmt.child_by_field_name("method") + if _m is None or _read_text(_m, source) not in ("include", "extend", "prepend"): + continue + _args = _stmt.child_by_field_name("arguments") + if _args is None: + continue + for _arg in _args.children: + if _arg.type not in ("constant", "scope_resolution"): + continue + _mod = _ruby_const_last_name(_arg, source) + if _mod: + _ruby_mixin_calls.append({ + "caller_nid": class_nid, + "callee": _mod, + "is_mixin": True, + "source_file": str_path, + "source_location": f"L{_stmt.start_point[0] + 1}", + }) + # C#-specific: inheritance / interface implementation via base_list if config.ts_module == "tree_sitter_c_sharp": csharp_type_params = _csharp_type_parameters_in_scope(node, source) @@ -5571,6 +5610,10 @@ def _scan_js_module_dispatch(n) -> None: if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")): clean_edges.append(edge) + # Ruby mixins were collected during the node walk (before raw_calls existed); + # fold them in so the cross-file resolver sees them (#1668). + if _ruby_mixin_calls: + raw_calls.extend(_ruby_mixin_calls) result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} if callable_def_nids: # Mark function / method / class defs with a `_callable` attribute so the @@ -15839,7 +15882,7 @@ def _is_cpp_header(path: Path) -> bool: def _get_extractor(path: Path) -> Any | None: """Return the correct extractor function for a file, or None if unsupported.""" - if path.name.endswith(".blade.php"): + if path.name.lower().endswith(".blade.php"): return extract_blade # MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed # by filename before generic .json dispatch so they get MCP-aware nodes @@ -15855,14 +15898,17 @@ def _get_extractor(path: Path) -> Any | None: # (the suffix map sends `.h` to extract_c, which can't read @interface etc.). # ObjC sniffing has priority over the C++ sniff: an Objective-C++ header can # contain both `@interface` and inline C++ (`::`), and it must parse as ObjC. - if path.suffix == ".h": + suffix = path.suffix + if suffix not in _DISPATCH and suffix.lower() in _DISPATCH: + suffix = suffix.lower() + if suffix == ".h": if _is_objc_header(path): return extract_objc # A C++ class header routed to extract_c loses the class entirely (the C # grammar has no class_specifier). Reroute to extract_cpp (#1547). if _is_cpp_header(path): return extract_cpp - return _DISPATCH.get(path.suffix) + return _DISPATCH.get(suffix) def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict: @@ -15904,7 +15950,12 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, {"nodes": [], "edges": []} result = _safe_extract_with_xaml_root(extractor, path, cache_root) - if not bypass_cache and "error" not in result: + # Never cache a zero-node result for an extractable file. Every supported + # source produces at least a file node, so an empty node list is anomalous + # (e.g. a transient batch/parallel hiccup). Caching it makes the empty + # byte-stable across runs and silently blinds affected/explain to and + # through the file (#1666); skipping the write lets a rerun self-heal. + if not bypass_cache and "error" not in result and result.get("nodes"): save_cached(path, result, cache_root) return idx, result @@ -16028,7 +16079,8 @@ def _extract_sequential( continue bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES result = _safe_extract_with_xaml_root(extractor, path, effective_root) - if not bypass_cache and "error" not in result: + # See _extract_single_file: don't cache an anomalous zero-node result (#1666). + if not bypass_cache and "error" not in result and result.get("nodes"): save_cached(path, result, effective_root) per_file[idx] = result if total_files >= _PROGRESS_INTERVAL: @@ -16124,6 +16176,27 @@ def extract( if per_file[i] is None: per_file[i] = {"nodes": [], "edges": []} + # #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, + # so affected/explain are blind to and through it with no other signal. + _empty_sources: list[str] = [] + for i, _p in enumerate(paths): + _res = per_file[i] or {} + if _res.get("nodes") or _res.get("error"): + continue + if _get_extractor(_p) is not None: + _empty_sources.append(str(_p)) + if _empty_sources: + _shown = ", ".join(Path(x).name for x in _empty_sources[:5]) + _more = f" (+{len(_empty_sources) - 5} more)" if len(_empty_sources) > 5 else "" + print( + f" warning: {len(_empty_sources)} source file(s) produced zero nodes and " + f"are absent from the graph: {_shown}{_more}. A re-run will retry them " + f"(empties are no longer cached); if it persists, please report the " + f"file(s) (#1666).", + file=sys.stderr, flush=True, + ) + all_nodes: list[dict] = [] all_edges: list[dict] = [] all_raw_calls: list[dict] = [] @@ -16403,6 +16476,12 @@ def extract( # and collides with any top-level function named "log" in the corpus. if rc.get("is_member_call"): continue + # Skip Ruby include/extend/prepend mixin markers: they carry a module + # name as `callee` but are not calls — the Ruby resolver turns them into + # `mixes_in` edges. Letting the shared pass emit a `calls` edge here would + # both mislabel the relation and block the mixes_in emit as a dup (#1668). + if rc.get("is_mixin"): + continue # Exact-case match first (case is semantic). Fold only when the CALLING # file's language is case-insensitive, and only against the folded index of # case-insensitive-language definitions — so a Python `Path()` call can never @@ -16614,7 +16693,8 @@ def _ignored(p: Path) -> bool: ] for fname in filenames: p = dp / fname - if p.suffix in _EXTENSIONS and not _ignored(p) and _resolves_under_root(p, containment_root): + suffix = p.suffix + if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root): results.append(p) return sorted(results) # Walk with symlink following + cycle detection @@ -16634,7 +16714,8 @@ def _ignored(p: Path) -> bool: ] for fname in filenames: p = dp / fname - if p.suffix in _EXTENSIONS and not _ignored(p) and _resolves_under_root(p, containment_root): + suffix = p.suffix + if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root): results.append(p) return sorted(results) diff --git a/graphify/pg_introspect.py b/graphify/pg_introspect.py index 9182ffeae..24567fa18 100644 --- a/graphify/pg_introspect.py +++ b/graphify/pg_introspect.py @@ -1,5 +1,5 @@ from __future__ import annotations -from pathlib import Path +from pathlib import Path, PurePosixPath from graphify.extract import extract_sql @@ -135,7 +135,7 @@ def introspect_postgres(dsn: str | None = None) -> dict: info = psycopg.conninfo.conninfo_to_dict(dsn or "") host = info.get("host", "localhost") dbname = info.get("dbname", "db") - virtual_path = Path(f"postgresql://{host}/{dbname}") + virtual_path = PurePosixPath(f"postgresql://{host}/{dbname}") # Pass virtual path and in-memory DDL content to extract_sql result = extract_sql(virtual_path, content=ddl_string) diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index 5823ecf38..e344175e1 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -94,7 +94,8 @@ def _unique_class(name: str) -> str | None: nids = class_def_nids.get(_key(name), []) return nids[0] if len(nids) == 1 else None - def _emit(caller: str, target: str, rc: dict[str, Any]) -> None: + def _emit(caller: str, target: str, rc: dict[str, Any], + relation: str = "calls", context: str = "call") -> None: if not caller or not target or caller == target: return if (caller, target) in existing_pairs: @@ -103,8 +104,8 @@ def _emit(caller: str, target: str, rc: dict[str, Any]) -> None: all_edges.append({ "source": caller, "target": target, - "relation": "calls", - "context": "call", + "relation": relation, + "context": context, "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": rc.get("source_file", ""), @@ -112,6 +113,21 @@ def _emit(caller: str, target: str, rc: dict[str, Any]) -> None: "weight": 1.0, }) + # `include`/`extend`/`prepend ` mixins (#1668): resolve the module by + # its constant name to the single owning module/class node and emit a + # `mixes_in` edge, under the same single-definition god-node guard. An + # ambiguous or unresolved constant produces no edge. + for rc in _ruby_raw_calls(per_file): + if not rc.get("is_mixin"): + continue + caller = str(rc.get("caller_nid", "")) + module_name = rc.get("callee") + if not caller or not module_name: + continue + target = _unique_class(str(module_name)) + if target is not None: + _emit(caller, target, rc, relation="mixes_in", context="mixin") + for rc in _ruby_raw_calls(per_file): if not rc.get("is_member_call"): continue diff --git a/tests/test_affected_member_seed.py b/tests/test_affected_member_seed.py new file mode 100644 index 000000000..d1f6b3ae7 --- /dev/null +++ b/tests/test_affected_member_seed.py @@ -0,0 +1,61 @@ +"""#1669 — affected must reach callers that bind to the class's method +nodes (post-#1634 method-granularity resolution), by seeding the reverse walk +with the root's member nodes (one method/contains hop). method/contains stay out +of the general relation-filtered walk, so no forward noise is added elsewhere. +""" +from __future__ import annotations + +import networkx as nx + +from graphify.affected import affected_nodes + + +def _g(): + g = nx.DiGraph() + for nid, label in [ + ("proc", "Processor"), ("proc_call", ".call()"), + ("runner", "Runner"), ("runner_run", ".run()"), + ]: + g.add_node(nid, label=label) + g.add_edge("proc", "proc_call", relation="method") # class owns method + g.add_edge("runner", "runner_run", relation="method") + g.add_edge("runner_run", "proc_call", relation="calls") # caller binds to method node (#1634) + return g + + +def test_class_affected_reaches_method_bound_caller(): + g = _g() + hits = {h.node_id for h in affected_nodes(g, "proc", depth=2)} + assert "runner_run" in hits, "caller of Processor.call must be reachable from Processor" + + +def test_member_method_node_not_reported_as_hit(): + g = _g() + hits = {h.node_id for h in affected_nodes(g, "proc", depth=2)} + # the class's own method node is a seed, not an affected node + assert "proc_call" not in hits + + +def test_method_contains_still_excluded_from_general_walk(): + # A node two method-hops away (method of a DIFFERENT class discovered during + # the walk) must NOT be pulled in: only the root's own members are seeded. + g = nx.DiGraph() + for nid, label in [("a", "A"), ("a_m", ".m()"), ("b", "B"), ("b_m", ".n()")]: + g.add_node(nid, label=label) + g.add_edge("a", "a_m", relation="method") + g.add_edge("a_m", "b", relation="calls") # A.m calls class B + g.add_edge("b", "b_m", relation="method") # B's own method + hits = {h.node_id for h in affected_nodes(g, "a", depth=3)} + # We seeded A's members and walk reverse; B and B's method are downstream of A + # (A.m -> B), not reverse-callers of A, so they must not appear. + assert hits == set() or "b_m" not in hits + + +def test_class_level_caller_still_works(): + # A caller bound to the class node itself (not a method) is unaffected. + g = nx.DiGraph() + g.add_node("svc", label="Svc") + g.add_node("caller", label=".use()") + g.add_edge("caller", "svc", relation="references") + hits = {h.node_id for h in affected_nodes(g, "svc", depth=2)} + assert "caller" in hits diff --git a/tests/test_extract.py b/tests/test_extract.py index 38be9f679..ae88e364b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -408,7 +408,8 @@ def _legacy_collect_files(target, *, root=None): for ext in sorted(extensions): results.extend( p for p in target.rglob(f"*{ext}") - if not any(_is_noise_dir(part) for part in p.parts) + if p.suffix == ext + and not any(_is_noise_dir(part) for part in p.parts) and not (patterns and _is_ignored(p, ignore_root, patterns)) ) return sorted(results) @@ -1693,3 +1694,28 @@ def test_non_colliding_path_id_is_not_salted(tmp_path): result = extract([p], cache_root=tmp_path) file_id = next(n["id"] for n in result["nodes"] if n.get("source_location") == "L1") assert file_id == make_id(_file_stem(Path("src/auth/session.py"))) == "src_auth_session" + + +def test_case_insensitive_suffix_filtering(tmp_path): + py_file = tmp_path / "app.PY" + js_file = tmp_path / "script.JS" + ts_file = tmp_path / "lib.Ts" + + py_file.write_text("class MyPythonClass:\n pass\n") + js_file.write_text("function myJSFunction() {}\n") + ts_file.write_text("export class MyTSClass {}\n") + + collected = collect_files(tmp_path) + collected_names = {f.name for f in collected} + assert "app.PY" in collected_names + assert "script.JS" in collected_names + assert "lib.Ts" in collected_names + + result = extract(collected, cache_root=tmp_path) + nodes = result["nodes"] + labels = {n.get("label") for n in nodes if "label" in n} + + assert "MyPythonClass" in labels + assert "myJSFunction()" in labels + assert "MyTSClass" in labels + diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index f5f892c6f..b8314a971 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -404,6 +404,52 @@ def test_svelte_rune_import_resolves_svelte_ts_file(tmp_path: Path): assert _has_edge(result, "src/routes/page.ts", "src/lib/hooks/is-mobile.svelte.ts") +def test_ts_dynamic_import_does_not_create_phantom_cycle(tmp_path: Path): + # A deferred `import('./x')` is not a static import: it must be emitted as a + # `dynamic_import` edge (like the Svelte/Astro/Vue emitters), not + # `imports_from`. Otherwise two files that reference each other via one static + # import + one dynamic import are reported as a phantom circular dependency. + # Regression test for #1241. + import networkx as nx + + from graphify.analyze import find_import_cycles + + actions = _write( + tmp_path / "actions.ts", + 'export function doThing() {}\n' + 'export async function lazy() {\n' + ' const m = await import("./modal");\n' + ' return m.openModal();\n' + '}\n', + ) + modal = _write( + tmp_path / "modal.ts", + 'import { doThing } from "./actions";\n' + 'export function openModal() { doThing(); }\n', + ) + + result = _extract_for([actions, modal], tmp_path) + + # The deferred import() edge stays in the graph as an `imports_from` edge + # marked `deferred` (the dependency remains visible); the real static import + # (modal.ts -> actions.ts) is unaffected. + deferred = [edge for edge in result["edges"] if edge.get("deferred")] + assert deferred and all(edge["relation"] == "imports_from" for edge in deferred) + assert _has_edge(result, "modal.ts", "actions.ts", "imports_from") + + # End to end: the deferred import must not manufacture a file cycle. + graph = nx.DiGraph() + for node in result["nodes"]: + graph.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) + for edge in result["edges"]: + graph.add_edge( + edge["source"], + edge["target"], + **{k: v for k, v in edge.items() if k not in ("source", "target")}, + ) + assert find_import_cycles(graph) == [] + + def test_tsconfig_alias_import_resolves_existing_ts_file(tmp_path: Path): _write( tmp_path / "tsconfig.json", diff --git a/tests/test_pg_introspect.py b/tests/test_pg_introspect.py index 42ddb9893..a133cdad3 100644 --- a/tests/test_pg_introspect.py +++ b/tests/test_pg_introspect.py @@ -276,3 +276,17 @@ def test_pg_introspect_import_error(): with patch.dict("sys.modules", {"psycopg": None}): with pytest.raises(ImportError, match="psycopg is required"): introspect_postgres("postgresql://localhost/db") + + +def test_pg_introspect_uri_forward_slashes(): + """Assert that the virtual path in postgresql introspection output uses forward slashes on all platforms.""" + mock_psycopg = _make_mock_psycopg([], [], [], [], host="some-host", dbname="some-db") + with patch.dict("sys.modules", {"psycopg": mock_psycopg}): + res = introspect_postgres("postgresql://some-host/some-db") + + # We should have at least the file node + assert len(res["nodes"]) > 0 + for node in res["nodes"]: + assert "\\" not in node["source_file"] + assert "postgresql:/some-host/some-db" in node["source_file"] + diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index adc77577b..5cd1b1150 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -290,3 +290,61 @@ def test_ambiguous_constant_receiver_emits_no_edge(tmp_path: Path) -> None: "class Runner\n def run\n Processor.call\n end\nend\n") graph = extract([caller, tmp_path / "a.rb", tmp_path / "b.rb"], cache_root=tmp_path, parallel=False) assert _has_call_edge(graph, "run", "call") is None + + +# ── #1668 include/extend/prepend -> mixes_in ───────────────────────────────── + + +def _mixes_in(graph: dict) -> set[tuple[str, str]]: + labels = _labels(graph["nodes"]) + return { + (labels.get(e["source"], ""), labels.get(e["target"], "")) + for e in graph["edges"] if e.get("relation") == "mixes_in" + } + + +def test_include_emits_mixes_in_edge(tmp_path: Path) -> None: + _write(tmp_path, "concern.rb", "module SealedProtection\n def sealed?; true; end\nend\n") + _write(tmp_path, "model.rb", + "class Roster < ApplicationRecord\n include SealedProtection\nend\n") + g = extract([tmp_path / "model.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False) + assert ("Roster", "SealedProtection") in _mixes_in(g) + + +def test_extend_and_prepend_emit_mixes_in(tmp_path: Path) -> None: + _write(tmp_path, "helpers.rb", "module Helpers\n def h; end\nend\n") + _write(tmp_path, "audit.rb", "module Audit\n def a; end\nend\n") + _write(tmp_path, "svc.rb", + "class Svc\n extend Helpers\n prepend Audit\nend\n") + mix = _mixes_in(extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False)) + assert ("Svc", "Helpers") in mix + assert ("Svc", "Audit") in mix + + +def test_extend_self_and_nonconstant_args_emit_no_mixin(tmp_path: Path) -> None: + # `extend self` and `include some_var` are not constant module references. + _write(tmp_path, "m.rb", + "module M\n extend self\n def go; end\nend\n") + mix = _mixes_in(extract([tmp_path / "m.rb"], cache_root=tmp_path, parallel=False)) + assert not any(t == "self" for _s, t in mix) + assert not mix + + +def test_include_of_undefined_or_ambiguous_module_emits_no_edge(tmp_path: Path) -> None: + # Undefined module (no node) -> no edge, under the single-owner guard. + _write(tmp_path, "x.rb", "class X\n include NotDefinedAnywhere\nend\n") + mix = _mixes_in(extract([tmp_path / "x.rb"], cache_root=tmp_path, parallel=False)) + assert not any(t == "NotDefinedAnywhere" for _s, t in mix) + + +def test_mixin_is_not_emitted_as_calls_edge(tmp_path: Path) -> None: + # Regression: the shared cross-file call pass must not turn a mixin into a + # `calls` edge (which would mislabel it and block the mixes_in emit). + _write(tmp_path, "concern.rb", "module C\n def m; end\nend\n") + _write(tmp_path, "k.rb", "class K\n include C\nend\n") + g = extract([tmp_path / "k.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False) + labels = _labels(g["nodes"]) + calls = {(labels.get(e["source"], ""), labels.get(e["target"], "")) + for e in g["edges"] if e.get("relation") == "calls"} + assert ("K", "C") not in calls + assert ("K", "C") in _mixes_in(g) diff --git a/tests/test_zero_node_no_cache.py b/tests/test_zero_node_no_cache.py new file mode 100644 index 000000000..5e62547ba --- /dev/null +++ b/tests/test_zero_node_no_cache.py @@ -0,0 +1,54 @@ +"""#1666 — an extractable source file that yields zero nodes must not be cached, +and must be surfaced. + +Every supported file produces at least a file node, so a zero-node result is +anomalous (a transient batch/parallel hiccup). Caching it made the empty +byte-stable across runs and silently blinded affected/explain to the file. We +now skip the cache write for a zero-node result (so a rerun self-heals) and warn. +""" +from __future__ import annotations + +from pathlib import Path + +import graphify.extract as ex + + +def test_zero_node_result_not_cached_then_self_heals(tmp_path, capsys, monkeypatch): + f = tmp_path / "thing.rb" + f.write_text("class Foo\n def bar; end\nend\n") + + real = ex._safe_extract_with_xaml_root + + def _empty(extractor, path, root): + return {"nodes": [], "edges": []} + + # First run: force a zero-node extraction for this file. + monkeypatch.setattr(ex, "_safe_extract_with_xaml_root", _empty) + ex.extract([f], cache_root=tmp_path / "out", parallel=False) + + err = capsys.readouterr().err + assert "zero nodes" in err and "thing.rb" in err, err + + # Second run with the real extractor: because the empty was NOT cached, the + # file re-extracts and lands in the graph (self-heal). + monkeypatch.setattr(ex, "_safe_extract_with_xaml_root", real) + r2 = ex.extract([f], cache_root=tmp_path / "out", parallel=False) + assert any(str(n.get("source_file", "")).endswith("thing.rb") for n in r2["nodes"]) + + +def test_normal_file_still_cached(tmp_path): + # Guard against over-correction: a normal (non-empty) result must still cache. + f = tmp_path / "ok.rb" + f.write_text("class Bar\n def baz; end\nend\n") + r1 = ex.extract([f], cache_root=tmp_path / "out", parallel=False) + assert r1["nodes"] + from graphify.cache import load_cached + assert load_cached(f, tmp_path / "out") is not None, "non-empty result should be cached" + + +def test_no_warning_when_all_files_produce_nodes(tmp_path, capsys): + f = tmp_path / "fine.rb" + f.write_text("module M\n def self.go; end\nend\n") + ex.extract([f], cache_root=tmp_path / "out", parallel=False) + err = capsys.readouterr().err + assert "zero nodes" not in err