diff --git a/graphify/diagnostics.py b/graphify/diagnostics.py index fcb9a11cf..d070a2cc1 100644 --- a/graphify/diagnostics.py +++ b/graphify/diagnostics.py @@ -10,6 +10,7 @@ from typing import Any import networkx as nx +from graphify.ids import normalize_id _SUPPRESSION_DECL_RE = re.compile(r"^\s*(?Pseen_[A-Za-z0-9_]+)\s*[:=]") @@ -42,7 +43,7 @@ def _node_ids(extraction: dict[str, Any]) -> set[str]: } -def _canonical_edge(edge: Any) -> dict[str, str]: +def _canonical_edge(edge: Any) -> dict[str, Any]: if not isinstance(edge, dict): return { "source": "", @@ -52,6 +53,8 @@ def _canonical_edge(edge: Any) -> dict[str, str]: "source_file": "", "source_location": "", "context": "", + "external": False, + "unresolved_internal": False, "_invalid": "non_object_edge", } source = edge.get("source", edge.get("from")) @@ -64,10 +67,33 @@ def _canonical_edge(edge: Any) -> dict[str, str]: "source_file": _safe_text(edge.get("source_file")), "source_location": _safe_text(edge.get("source_location")), "context": _safe_text(edge.get("context")), + "external": edge.get("external") is True, + "unresolved_internal": edge.get("unresolved_internal") is True, "_invalid": "", } +def _malformed_endpoint(source: str, target: str, root: str | Path | None) -> bool: + """Detect machine-absolute endpoint ids that escaped canonical remapping.""" + endpoints = (source.replace("\\", "/"), target.replace("\\", "/")) + if any( + value.startswith("/") + or re.match(r"^[A-Za-z]:/", value) + for value in endpoints + ): + return True + if root is None: + return False + try: + prefix = normalize_id(str(Path(root).resolve())) + except OSError: + prefix = normalize_id(str(root)) + return bool(prefix) and any( + value == prefix or value.startswith(prefix + "_") + for value in endpoints + ) + + def _exact_signature(edge: Any) -> str: if not isinstance(edge, dict): return "" @@ -184,8 +210,29 @@ def diagnose_extraction( non_object_edges = 0 missing_endpoint_edges = 0 dangling_endpoint_edges = 0 + external_endpoint_edges = 0 + unresolved_internal_endpoint_edges = 0 + malformed_endpoint_edges = 0 + unclassified_endpoint_edges = 0 self_loop_edges = 0 valid_candidate_edges = 0 + endpoint_examples: dict[str, list[dict[str, str]]] = { + "external": [], + "unresolved_internal": [], + "malformed": [], + "unclassified": [], + } + + def _remember(category: str, edge: dict[str, Any]) -> None: + if max_examples <= 0 or len(endpoint_examples[category]) >= max_examples: + return + endpoint_examples[category].append({ + "source": edge["source"], + "target": edge["target"], + "relation": edge["relation"], + "source_file": edge["source_file"], + "source_location": edge["source_location"], + }) for edge in canonical_edges: if edge["_invalid"]: @@ -198,6 +245,18 @@ def diagnose_extraction( continue if source not in node_ids or target not in node_ids: dangling_endpoint_edges += 1 + if _malformed_endpoint(source, target, root): + malformed_endpoint_edges += 1 + _remember("malformed", edge) + elif edge["external"]: + external_endpoint_edges += 1 + _remember("external", edge) + elif edge["unresolved_internal"]: + unresolved_internal_endpoint_edges += 1 + _remember("unresolved_internal", edge) + else: + unclassified_endpoint_edges += 1 + _remember("unclassified", edge) continue if source == target: self_loop_edges += 1 @@ -252,6 +311,10 @@ def diagnose_extraction( "non_object_edges": non_object_edges, "missing_endpoint_edges": missing_endpoint_edges, "dangling_endpoint_edges": dangling_endpoint_edges, + "external_endpoint_edges": external_endpoint_edges, + "unresolved_internal_endpoint_edges": unresolved_internal_endpoint_edges, + "malformed_endpoint_edges": malformed_endpoint_edges, + "unclassified_endpoint_edges": unclassified_endpoint_edges, "self_loop_edges": self_loop_edges, "valid_candidate_edges": valid_candidate_edges, "exact_duplicate_edges": _count_extra(exact_counts), @@ -274,6 +337,7 @@ def diagnose_extraction( "post_build_error": build_error, "producer_suppression": scan_producer_suppression_sites(suppression_path), "examples": examples, + "endpoint_examples": endpoint_examples, } @@ -329,13 +393,14 @@ def diagnose_file( def format_diagnostic_json(summary: dict[str, Any]) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "summary": { key: value for key, value in summary.items() - if key not in {"examples", "producer_suppression"} + if key not in {"examples", "endpoint_examples", "producer_suppression"} }, "examples": summary.get("examples", []), + "endpoint_examples": summary.get("endpoint_examples", {}), "producer_suppression": summary.get("producer_suppression", {}), "notes": [ "Diagnostics are read-only.", @@ -358,6 +423,13 @@ def format_diagnostic_report(summary: dict[str, Any]) -> str: f"valid_candidate_edges: {summary['valid_candidate_edges']}", f"missing_endpoint_edges: {summary['missing_endpoint_edges']}", f"dangling_endpoint_edges: {summary['dangling_endpoint_edges']}", + f"external_endpoint_edges: {summary.get('external_endpoint_edges', 0)}", + ( + "unresolved_internal_endpoint_edges: " + f"{summary.get('unresolved_internal_endpoint_edges', 0)}" + ), + f"malformed_endpoint_edges: {summary.get('malformed_endpoint_edges', 0)}", + f"unclassified_endpoint_edges: {summary.get('unclassified_endpoint_edges', 0)}", f"self_loop_edges: {summary['self_loop_edges']}", f"exact_duplicate_edges: {summary['exact_duplicate_edges']}", f"directed_unique_endpoint_pairs: {summary['directed_unique_endpoint_pairs']}", diff --git a/graphify/extract.py b/graphify/extract.py index bbfa301cc..ea31fb64f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -182,75 +182,251 @@ def _file_node_id(rel_path: Path) -> str: def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None: - """Repoint Python absolute-import edges to the real file node under a nested - (e.g. ``src/``) package root (#2072). - - Absolute imports target an id derived from the dotted module path - (``_make_id('pkg.mod')`` -> ``pkg_mod``), but file-node ids are - scan-root-relative (``src_pkg_mod`` when the code lives under ``src/``), so - the edge dangles and is silently dropped — the graph loses most ``imports`` - edges purely because of where the scan started. Build an alias map from the - dotted-module id to the real file-node id by detecting each ``.py`` file's - package root (the contiguous run of ancestor dirs carrying ``__init__.py``) - and rewrite matching ``imports``/``imports_from`` edge targets. Guards: never - shadow an existing node id, and drop an alias claimed by more than one file - (ambiguous -> leave dangling, as before). Files whose package root IS the - scan root are skipped (ids already coincide).""" + """Resolve Python import edges against files in the importer's workspace. + + The old alias pass inferred a package root only from a contiguous chain of + ``__init__.py`` files. That loses imports in PEP 420 namespace packages and + mixed monorepos where each project has its own ``src/`` or application root. + Import handlers now retain the original module and relative level as + transient metadata. Resolve those specifiers to physical files, then map the + files directly to their canonical scan-root-relative node ids. + + A workspace is the nearest Python project manifest. For unconfigured + projects it is the first directory below the scan root. Multiple physical + matches are never guessed. Unresolved absolute imports whose top-level + module is absent from the workspace corpus are explicitly marked external; + unresolved relative/local imports remain visible as internal defects.""" try: root = Path(root).resolve() except OSError: root = Path(root) - node_ids = {n.get("id") for n in all_nodes if isinstance(n, dict)} - alias_to_files: dict[str, set[str]] = {} - for p in paths: - if p.suffix.lower() not in (".py", ".pyi"): - continue + + python_paths = [ + Path(p) for p in paths if Path(p).suffix.lower() in (".py", ".pyi") + ] + if not python_paths: + return + + manifests = ("pyproject.toml", "setup.py", "setup.cfg") + + def _workspace_for(path: Path) -> Path: + try: + current = path.resolve().parent + except OSError: + current = path.parent + for candidate in (current, *current.parents): + try: + candidate.relative_to(root) + except ValueError: + break + if any((candidate / name).is_file() for name in manifests): + return candidate + if candidate == root: + break + try: + rel = current.relative_to(root) + except ValueError: + return root + return root / rel.parts[0] if rel.parts else root + + def _candidate(path: Path) -> Path | None: + if path.is_dir(): + for init_name in ("__init__.py", "__init__.pyi"): + init_path = path / init_name + if init_path.is_file(): + return init_path.resolve() + if path.is_file(): + return path.resolve() + for suffix in (".py", ".pyi"): + module_path = path.with_suffix(suffix) + if module_path.is_file(): + return module_path.resolve() + return None + + def _module_candidates( + module_name: str, + current_path: Path, + workspace: Path, + level: int, + ) -> set[Path]: + if level > 0: + base = current_path.resolve().parent + for _ in range(level - 1): + base = base.parent + candidate = base / module_name.replace(".", "/") if module_name else base + hit = _candidate(candidate) + return {hit} if hit is not None else set() + + rel_module = module_name.replace(".", "/") + search_roots: list[Path] = [workspace] + for ancestor in current_path.resolve().parents: + try: + ancestor.relative_to(workspace) + except ValueError: + break + if ancestor == workspace: + continue + # An absolute import starts at a sys.path root, not inside a regular + # package. Namespace/source roots have no __init__ and are valid. + if not ( + (ancestor / "__init__.py").is_file() + or (ancestor / "__init__.pyi").is_file() + ): + search_roots.append(ancestor) + hits = { + hit + for base in search_roots + if (hit := _candidate(base / rel_module)) is not None + } + return hits + + file_node_by_path: dict[Path, str] = {} + workspace_by_path: dict[Path, Path] = {} + module_paths_by_workspace: dict[Path, dict[str, set[Path]]] = {} + global_module_paths: dict[str, set[Path]] = {} + public_names_by_path: dict[Path, set[str]] = {} + for path in python_paths: try: - rel = Path(p).resolve().relative_to(root) + resolved = path.resolve() + rel = resolved.relative_to(root) except (ValueError, OSError): continue - parts = rel.parts - if len(parts) < 2: - continue # top-level file: scan-root-relative id already matches - d = Path(p).resolve().parent - levels = 0 - # Bounded by the number of dirs between the file and the scan root, so a - # pathological `/__init__.py` chain can't loop forever. - while levels < len(parts) - 1 and (d / "__init__.py").is_file(): - levels += 1 - d = d.parent - if levels == 0: - continue # not inside a package (namespace pkg / loose module) - mod_parts = parts[-(levels + 1):] # package dirs + the file itself - if len(mod_parts) == len(parts): - continue # package root == scan root: file-node id already coincides - file_node = _file_node_id(rel) - alias = _make_id(str(Path(*mod_parts).with_suffix(""))) - alias_to_files.setdefault(alias, set()).add(file_node) - if p.name in ("__init__.py", "__init__.pyi") and len(mod_parts) > 1: - # `import pkg` / `from pkg import x` targets the package-dir id. - pkg_alias = _make_id(str(Path(*mod_parts[:-1]))) - alias_to_files.setdefault(pkg_alias, set()).add(file_node) - alias_map = { - a: next(iter(fs)) - for a, fs in alias_to_files.items() - if len(fs) == 1 and a not in node_ids - } - if not alias_map: - return - for e in all_edges: - # Only repoint edges emitted from a Python file: a non-Python import edge - # (e.g. C# `using Pkg.Mod;`, Java/Go dotted imports) can have a dangling - # target string that coincides with a Python alias, and repointing it - # would fabricate a cross-language import edge (#2072 review). - if ( - isinstance(e, dict) - and e.get("relation") in ("imports", "imports_from") - and str(e.get("source_file", "")).lower().endswith((".py", ".pyi")) + workspace = _workspace_for(resolved) + file_node_by_path[resolved] = _file_node_id(rel) + workspace_by_path[resolved] = workspace + try: + workspace_rel = resolved.relative_to(workspace) + except ValueError: + workspace_rel = rel + module_parts = list(workspace_rel.with_suffix("").parts) + if module_parts and module_parts[-1] == "__init__": + module_parts.pop() + aliases = module_paths_by_workspace.setdefault(workspace, {}) + for index in range(len(module_parts)): + alias = ".".join(module_parts[index:]) + aliases.setdefault(alias, set()).add(resolved) + global_module_paths.setdefault(alias, set()).add(resolved) + parsed = _parse_python_tree(resolved) + if parsed is not None: + parsed_source, tree_root = parsed + public_names: set[str] = set() + for child in tree_root.children: + if child.type in ("class_definition", "function_definition"): + name_node = child.child_by_field_name("name") + if name_node is not None: + public_names.add(_read_text(name_node, parsed_source)) + elif child.type == "import_from_statement": + public_names.update( + local_name + for _, local_name in _python_imported_names( + child, parsed_source + ) + ) + public_names_by_path[resolved] = public_names + + for edge in all_edges: + if not ( + isinstance(edge, dict) + and edge.get("relation") in ("imports", "imports_from") + and str(edge.get("source_file", "")).lower().endswith((".py", ".pyi")) + and "_import_module" in edge ): - tgt = e.get("target") - if tgt in alias_map: - e["target"] = alias_map[tgt] + continue + module_name = str(edge.pop("_import_module", "")) + try: + level = int(edge.pop("_import_level", 0)) + except (TypeError, ValueError): + level = 0 + imported_names = { + str(name) + for name in edge.pop("_imported_names", []) + if name and name != "*" + } + source_path = Path(str(edge.get("source_file", ""))) + if not source_path.is_absolute(): + source_path = root / source_path + try: + source_path = source_path.resolve() + except OSError: + pass + workspace = workspace_by_path.get(source_path, _workspace_for(source_path)) + candidates = _module_candidates(module_name, source_path, workspace, level) + if level == 0: + candidates.update( + module_paths_by_workspace.get(workspace, {}).get(module_name, set()) + ) + if not candidates: + # Explicit runtime path bootstraps are common in monorepos + # (one project adds a sibling project's src/ to sys.path). A + # unique corpus-wide module is safe to connect; duplicates stay + # unresolved rather than crossing projects arbitrarily. + candidates.update(global_module_paths.get(module_name, set())) + elif not candidates and imported_names: + candidates.update( + module_paths_by_workspace.get(workspace, {}).get( + module_name.split(".")[-1], set() + ) + ) + # Importing a module with the same basename as the current file does not + # prove a self-import. It is commonly a stdlib/third-party name collision. + candidates.discard(source_path) + + top_level = module_name.split(".", 1)[0] if module_name else "" + if level == 0 and top_level in getattr(sys, "stdlib_module_names", ()): + edge["external"] = True + edge.pop("unresolved_internal", None) + continue + + if len(candidates) > 1 and imported_names: + symbol_matches = [ + candidate + for candidate in candidates + if imported_names <= public_names_by_path.get(candidate, set()) + ] + if len(symbol_matches) == 1: + candidates = {symbol_matches[0]} + elif len(symbol_matches) > 1: + package_matches = [ + candidate + for candidate in symbol_matches + if candidate.name in ("__init__.py", "__init__.pyi") + ] + if len(package_matches) == 1: + candidates = {package_matches[0]} + + if len(candidates) == 1: + target_path = next(iter(candidates)) + target_id = file_node_by_path.get(target_path) + if target_id is not None: + edge["target"] = target_id + edge.pop("external", None) + edge.pop("unresolved_internal", None) + continue + + if level > 0 or candidates: + edge["unresolved_internal"] = True + edge.pop("external", None) + if level > 0: + base = source_path.parent + for _ in range(level - 1): + base = base.parent + unresolved_path = ( + base / module_name.replace(".", "/") + if module_name else base + ) + try: + unresolved_rel = unresolved_path.resolve().relative_to(root) + edge["target"] = _make_id( + "ref_local", _file_stem(unresolved_rel) + ) + except (ValueError, OSError): + pass + else: + # Both stdlib and third-party packages are intentionally outside the + # corpus graph. Marking them makes diagnostics distinguish expected + # exclusions from lost internal structure. + edge["external"] = True + edge.pop("unresolved_internal", None) SEMANTIC_RELATIONS = frozenset({ @@ -325,6 +501,10 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, + # Transient provenance used after all files are known to + # resolve this import inside the importer's workspace. + "_import_module": module_name, + "_import_level": 0, } if raw_alias: # `import pkg.mod as alias` binds the local name `alias`, not @@ -334,30 +514,43 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s edge["local_alias"] = raw_alias.strip() edges.append(edge) elif t == "import_from_statement": - module_node = node.child_by_field_name("module_name") - if module_node: - raw = _read_text(module_node, source) - if raw.startswith("."): - # Relative import - resolve to full path so IDs match file node IDs - dots = len(raw) - len(raw.lstrip(".")) - module_name = raw.lstrip(".") - base = Path(str_path).parent - for _ in range(dots - 1): - base = base.parent - rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" - tgt_nid = _make_id(str(base / rel)) - else: - tgt_nid = _make_id(raw) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports_from", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) + parsed_module = _python_import_from_module(node, source) + if parsed_module is not None: + dots, module_name = parsed_module + imported_names = [ + name for name, _ in _python_imported_names(node, source) + ] + modules = [module_name] + # `from . import services` has no module name before `import`. + # Treat imported names as submodule candidates; symbol-only names + # that have no file remain classified as unresolved internal. + if dots > 0 and not module_name: + modules = [name for name, _ in _python_imported_names(node, source)] + for imported_module in modules: + if dots > 0: + # Relative import - resolve to full path so IDs match file node IDs + module_name = imported_module + base = Path(str_path).parent + for _ in range(dots - 1): + base = base.parent + rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" + tgt_nid = _make_id(str(base / rel)) + else: + module_name = imported_module + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "_import_module": module_name, + "_import_level": dots, + "_imported_names": imported_names, + }) def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: @@ -408,7 +601,28 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p # back onto the importer's own variant, a phantom self-loop (#1814). if resolved_path is not None: edge["target_file"] = str(resolved_path) + if not resolved_path.is_file(): + edge["unresolved_internal"] = True + else: + aliases = _load_tsconfig_aliases(Path(str_path).parent) + local_alias = any( + _match_tsconfig_alias(raw, pattern) is not None + for pattern in aliases + ) + workspace_packages = _load_workspace_packages(Path(str_path).parent) + local_workspace = any( + raw == name or raw.startswith(name + "/") + for name in workspace_packages + ) + if not raw.startswith((".", "/")) and not local_alias and not local_workspace: + edge["external"] = True + else: + edge["unresolved_internal"] = True edges.append(edge) + if resolved_path is not None and not resolved_path.is_file(): + # Keep the file-level dangling edge for diagnostics, but do not + # synthesize symbol edges for a file that is not in the corpus. + resolved_path = None # Emit symbol-level edges for named imports/re-exports from local/aliased files. # e.g. `import { Foo, type Bar } from './bar'` → file → Foo, file → Bar (EXTRACTED) @@ -3967,6 +4181,7 @@ def add_existing_edge(edge: dict) -> None: _DISPATCH: dict[str, Any] = { ".py": extract_python, + ".pyi": extract_python, ".js": extract_js, ".jsx": extract_js, ".mjs": extract_js, @@ -4869,6 +5084,19 @@ def _learn(e: dict) -> None: if dec is not None: e["target"] = f"{dec[0]}_{dec[1]}" + # A missing local JS/TS target still carries target_file so diagnostics can + # distinguish it from a package import. Canonicalize that path even though + # no target node exists; otherwise the dangling endpoint embeds the absolute + # checkout prefix and is misclassified as malformed/machine-specific. + for edge in all_edges: + if not edge.get("unresolved_internal") or not edge.get("target_file"): + continue + try: + target_rel = Path(edge["target_file"]).resolve().relative_to(root) + except (ValueError, OSError): + continue + edge["target"] = _make_id("ref_local", _file_stem(target_rel)) + # Repoint Python absolute imports onto the real file nodes under a nested # (src/) package root before the resolver/import-evidence passes run, so the # graph is identical regardless of scan root (#2072). @@ -4895,9 +5123,12 @@ def _learn(e: dict) -> None: _rewire_unique_stub_nodes(all_nodes, all_edges) # Add cross-file class-level edges (Python only - uses Python parser internally) - py_paths = [p for p in paths if p.suffix == ".py"] + py_paths = [p for p in paths if p.suffix.lower() in (".py", ".pyi")] if py_paths: - py_results = [r for r, p in zip(per_file, paths) if p.suffix == ".py"] + py_results = [ + r for r, p in zip(per_file, paths) + if p.suffix.lower() in (".py", ".pyi") + ] try: cross_file_edges = _resolve_cross_file_imports(py_results, py_paths) all_edges.extend(cross_file_edges) @@ -5310,6 +5541,9 @@ def _portable_out_of_root_sf(p: Path) -> str: # so it cannot be popped at that earlier point without breaking the fix. for e in all_edges: e.pop("local_alias", None) + e.pop("_import_module", None) + e.pop("_import_level", None) + e.pop("_imported_names", None) # Tag AST provenance so the incremental watch rebuild can distinguish # AST-extracted nodes from semantic/LLM nodes. On a full re-extraction diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index e88e36372..0ccd7865e 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -1617,14 +1617,16 @@ def _probe_python_module_candidate(candidate: Path) -> Path | None: """Resolve one module-path candidate to a .py file (dir+__init__, exact, or with a .py suffix), or None.""" if candidate.is_dir(): - init_path = candidate / "__init__.py" - if init_path.is_file(): - return init_path + for init_name in ("__init__.py", "__init__.pyi"): + init_path = candidate / init_name + if init_path.is_file(): + return init_path if candidate.is_file(): return candidate - py_candidate = candidate.with_suffix(".py") - if py_candidate.is_file(): - return py_candidate + for suffix in (".py", ".pyi"): + module_candidate = candidate.with_suffix(suffix) + if module_candidate.is_file(): + return module_candidate return None @@ -1691,7 +1693,9 @@ def _collect_python_symbol_resolution_facts( root: Path, facts: _SymbolResolutionFacts, ) -> None: - py_paths = [path for path in paths if path.suffix == ".py"] + py_paths = [ + path for path in paths if path.suffix.lower() in (".py", ".pyi") + ] if not py_paths: return diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py index ae3aa61fc..6266d2a83 100644 --- a/graphify/manifest_ingest.py +++ b/graphify/manifest_ingest.py @@ -105,6 +105,10 @@ def extract_package_manifest(path: Path) -> dict[str, Any]: "source_file": str_path, "source_location": "L1", "weight": 1.0, + # Dependency package nodes are materialized only when their own + # manifest is part of the corpus. Otherwise this is an expected + # external endpoint, not lost internal structure. + "external": True, }) return {"nodes": nodes, "edges": edges} diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md index afb4ecc12..870c68952 100644 --- a/graphify/skill-agents.md +++ b/graphify/skill-agents.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md index afb4ecc12..870c68952 100644 --- a/graphify/skill-amp.md +++ b/graphify/skill-amp.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index d98865cc8..ff44d276c 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index 0c821a278..8ed58de66 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index d98865cc8..ff44d276c 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index c3815d556..6f24bbf2b 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md index dbb4658ca..22a4c6896 100644 --- a/graphify/skill-kilo.md +++ b/graphify/skill-kilo.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index d98865cc8..ff44d276c 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index cf5dae440..a148428b1 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -456,13 +456,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index d98865cc8..ff44d276c 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index b0cbeb122..472336828 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -462,13 +462,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 3e6bc6b7b..5841beacd 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -460,13 +460,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index 574384576..056bef11d 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -486,13 +486,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/graphify/skill.md b/graphify/skill.md index d98865cc8..ff44d276c 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tests/test_endpoint_classification.py b/tests/test_endpoint_classification.py new file mode 100644 index 000000000..45306420e --- /dev/null +++ b/tests/test_endpoint_classification.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from graphify.diagnostics import diagnose_extraction +from graphify.extract import extract + + +def test_typescript_external_and_missing_local_imports_are_separate(tmp_path: Path) -> None: + source = tmp_path / "src" / "app.ts" + source.parent.mkdir(parents=True) + source.write_text( + "import {Component} from '@angular/core';\n" + "import {Missing} from './missing';\n", + encoding="utf-8", + ) + + result = extract( + [source], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + imports = [ + edge for edge in result["edges"] + if edge.get("relation") == "imports_from" + ] + + assert len(imports) == 2 + assert sum(edge.get("external") is True for edge in imports) == 1 + assert sum(edge.get("unresolved_internal") is True for edge in imports) == 1 + + summary = diagnose_extraction(result, root=tmp_path) + assert summary["external_endpoint_edges"] == 1 + assert summary["unresolved_internal_endpoint_edges"] == 1 + assert summary["unclassified_endpoint_edges"] == 0 + + +def test_unresolved_typescript_alias_is_internal(tmp_path: Path) -> None: + (tmp_path / "tsconfig.json").write_text( + '{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}', + encoding="utf-8", + ) + source = tmp_path / "src" / "app.ts" + source.parent.mkdir() + source.write_text( + "import {Missing} from '@app/missing';\n", + encoding="utf-8", + ) + + result = extract( + [source], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + edge = next( + edge for edge in result["edges"] + if edge.get("relation") == "imports_from" + ) + + assert edge.get("unresolved_internal") is True + assert edge.get("external") is not True diff --git a/tests/test_multigraph_diagnostics.py b/tests/test_multigraph_diagnostics.py index 8c39b8e23..7ad4517b7 100644 --- a/tests/test_multigraph_diagnostics.py +++ b/tests/test_multigraph_diagnostics.py @@ -14,6 +14,7 @@ format_diagnostic_report, scan_producer_suppression_sites, ) +from graphify.ids import normalize_id def _diagnostic_fixture() -> dict: @@ -92,6 +93,10 @@ def test_diagnose_extraction_categorizes_same_endpoint_collapse() -> None: assert summary["valid_candidate_edges"] == 5 assert summary["missing_endpoint_edges"] == 1 assert summary["dangling_endpoint_edges"] == 1 + assert summary["unclassified_endpoint_edges"] == 1 + assert summary["external_endpoint_edges"] == 0 + assert summary["unresolved_internal_endpoint_edges"] == 0 + assert summary["malformed_endpoint_edges"] == 0 assert summary["self_loop_edges"] == 1 assert summary["exact_duplicate_edges"] == 1 assert summary["directed_unique_endpoint_pairs"] == 2 @@ -146,10 +151,50 @@ def test_diagnose_extraction_handles_malformed_shapes_without_crashing() -> None assert summary["non_object_edges"] == 2 assert summary["missing_endpoint_edges"] == 1 assert summary["dangling_endpoint_edges"] == 2 + assert summary["unclassified_endpoint_edges"] == 2 assert summary["valid_candidate_edges"] == 1 assert summary["post_build_error"].startswith("TypeError:") +def test_diagnose_extraction_classifies_endpoint_loss_categories(tmp_path: Path) -> None: + root_prefix = normalize_id(str(tmp_path)) + extraction = { + "nodes": [{"id": "app", "label": "app.py", "file_type": "code"}], + "edges": [ + { + "source": "app", + "target": "pathlib", + "relation": "imports", + "external": True, + }, + { + "source": "app", + "target": "models_base", + "relation": "imports_from", + "unresolved_internal": True, + }, + { + "source": f"{root_prefix}_pkg_app", + "target": "app", + "relation": "indirect_call", + }, + { + "source": "app", + "target": "mystery", + "relation": "references", + }, + ], + } + + summary = diagnose_extraction(extraction, root=tmp_path) + + assert summary["dangling_endpoint_edges"] == 4 + assert summary["external_endpoint_edges"] == 1 + assert summary["unresolved_internal_endpoint_edges"] == 1 + assert summary["malformed_endpoint_edges"] == 1 + assert summary["unclassified_endpoint_edges"] == 1 + + def test_diagnose_extraction_handles_non_list_nodes_and_edges() -> None: summary = diagnose_extraction( {"nodes": {"id": "a"}, "edges": {"source": "a", "target": "b"}}, @@ -239,7 +284,7 @@ def test_diagnostic_json_report_is_serializable(tmp_path: Path) -> None: summary = diagnose_file(graph_path, directed=True) payload = format_diagnostic_json(summary) - assert payload["schema_version"] == 1 + assert payload["schema_version"] == 2 assert payload["summary"]["raw_edge_count"] == 7 assert "producer_suppression" in payload json.dumps(payload) @@ -399,7 +444,7 @@ def test_diagnose_multigraph_cli_json_output(monkeypatch, tmp_path: Path, capsys mainmod.main() payload = json.loads(capsys.readouterr().out) - assert payload["schema_version"] == 1 + assert payload["schema_version"] == 2 assert payload["summary"]["directed_same_endpoint_collapsed_edges"] == 3 diff --git a/tests/test_src_layout_import_resolution.py b/tests/test_src_layout_import_resolution.py index beeb6b95b..fec414db3 100644 --- a/tests/test_src_layout_import_resolution.py +++ b/tests/test_src_layout_import_resolution.py @@ -13,6 +13,7 @@ from graphify.extract import extract from graphify.extractors.resolution import _resolve_python_module_path from graphify.build import build_from_json +from graphify.diagnostics import diagnose_extraction _FILES = { @@ -37,6 +38,31 @@ def _write(base: Path, prefix: str = "") -> list[Path]: return written +def _write_file(path: Path, body: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + + +def _node_id(result: dict, label: str, source_file: str) -> str: + matches = [ + node["id"] + for node in result["nodes"] + if node.get("label") == label and node.get("source_file") == source_file + ] + assert len(matches) == 1 + return matches[0] + + +def _has_edge(result: dict, source: str, target: str, relation: str) -> bool: + return any( + edge.get("source") == source + and edge.get("target") == target + and edge.get("relation") == relation + for edge in result["edges"] + ) + + def _import_edges(G): """(relation, source, target) for import edges, present-endpoints only.""" return { @@ -102,6 +128,9 @@ def test_import_edges_identical_from_root_or_src(tmp_path): def test_ambiguous_package_alias_is_not_repointed(tmp_path): """A dotted-module id claimed by two different files (two src roots with the same package) must stay dangling rather than pick an arbitrary file.""" + (tmp_path / "pyproject.toml").write_text( + "[project]\nname='ambiguous'\nversion='1'\n" + ) for sub in ("a", "b"): d = tmp_path / sub / "src" / "pkg" d.mkdir(parents=True) @@ -151,3 +180,210 @@ def test_non_python_import_edge_is_not_repointed(tmp_path): assert not any(v == "src_pkg_mod" and u == "app_cs" for _, u, v in _import_edges(G)), ( "non-Python import edge was repointed onto a Python file (#2072 review)" ) + + +def test_namespace_package_import_resolves_without_init_file(tmp_path): + project = tmp_path / "api" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname='api'\nversion='1'\n") + model = _write_file(project / "src/models/base.py", "class Base:\n pass\n") + service = _write_file( + project / "src/services/use.py", "from models.base import Base\n" + ) + + result = extract( + [model, service], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + model_id = _node_id(result, "base.py", "api/src/models/base.py") + service_id = _node_id(result, "use.py", "api/src/services/use.py") + + assert _has_edge(result, service_id, model_id, "imports_from") + summary = diagnose_extraction(result, root=tmp_path) + assert summary["unresolved_internal_endpoint_edges"] == 0 + + +def test_same_module_name_resolves_inside_each_monorepo_workspace(tmp_path): + paths = [] + expected = [] + for project_name in ("alpha", "beta"): + project = tmp_path / project_name + (project / "pyproject.toml").parent.mkdir(parents=True, exist_ok=True) + (project / "pyproject.toml").write_text( + f"[project]\nname='{project_name}'\nversion='1'\n" + ) + model = _write_file( + project / "src/models/base.py", + f"class {project_name.title()}Base:\n pass\n", + ) + service = _write_file( + project / "src/services/use.py", "import models.base\n" + ) + paths.extend((model, service)) + expected.append((project_name, model, service)) + + result = extract( + paths, + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + for project_name, _, _ in expected: + model_id = _node_id( + result, "base.py", f"{project_name}/src/models/base.py" + ) + service_id = _node_id( + result, "use.py", f"{project_name}/src/services/use.py" + ) + assert _has_edge(result, service_id, model_id, "imports") + + +def test_project_without_manifest_uses_first_scan_root_directory(tmp_path): + model = _write_file( + tmp_path / "legacy/src/models/base.py", "class Base:\n pass\n" + ) + service = _write_file( + tmp_path / "legacy/src/app.py", "from models.base import Base\n" + ) + + result = extract( + [model, service], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + + assert _has_edge( + result, + _node_id(result, "app.py", "legacy/src/app.py"), + _node_id(result, "base.py", "legacy/src/models/base.py"), + "imports_from", + ) + + +def test_python_stub_module_and_external_import_are_classified(tmp_path): + project = tmp_path / "typed" + (project / "pyproject.toml").parent.mkdir(parents=True, exist_ok=True) + (project / "pyproject.toml").write_text("[project]\nname='typed'\nversion='1'\n") + stub = _write_file( + project / "src/contracts/types.pyi", "class Payload: ...\n" + ) + consumer = _write_file( + project / "src/app.py", + "from contracts.types import Payload\n" + "import pathlib\n" + "import third_party_sdk\n", + ) + + result = extract( + [stub, consumer], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + assert _has_edge( + result, + _node_id(result, "app.py", "typed/src/app.py"), + _node_id(result, "types.pyi", "typed/src/contracts/types.pyi"), + "imports_from", + ) + external = [ + edge for edge in result["edges"] + if edge.get("target") in {"pathlib", "third_party_sdk"} + ] + assert len(external) == 2 + assert all(edge.get("external") is True for edge in external) + summary = diagnose_extraction(result, root=tmp_path) + assert summary["external_endpoint_edges"] == 2 + assert summary["unresolved_internal_endpoint_edges"] == 0 + assert summary["unclassified_endpoint_edges"] == 0 + + +def test_unresolved_relative_import_is_internal_not_external(tmp_path): + source = _write_file( + tmp_path / "pkg/__init__.py", "from .missing import value\n" + ) + + result = extract( + [source], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + edge = next(e for e in result["edges"] if e["relation"] == "imports_from") + assert edge.get("unresolved_internal") is True + assert edge.get("external") is not True + + +def test_imported_symbol_disambiguates_stale_relative_module_path(tmp_path): + (tmp_path / "pyproject.toml").write_text( + "[project]\nname='moved-models'\nversion='1'\n" + ) + wrong = _write_file( + tmp_path / "models/legacy/scenario.py", + "class OtherScenario:\n pass\n", + ) + intended = _write_file( + tmp_path / "models/scenario/scenario.py", + "class Scenario:\n pass\n", + ) + consumer = _write_file( + tmp_path / "models/node/node.py", + "from .scenario import Scenario\n", + ) + + result = extract( + [wrong, intended, consumer], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + + assert _has_edge( + result, + _node_id(result, "node.py", "models/node/node.py"), + _node_id(result, "scenario.py", "models/scenario/scenario.py"), + "imports_from", + ) + + +def test_imported_symbol_disambiguates_absolute_package_facade(tmp_path): + (tmp_path / "pyproject.toml").write_text( + "[project]\nname='facades'\nversion='1'\n" + ) + alpha = _write_file( + tmp_path / "alpha/models/__init__.py", + "from .entity import Alpha\n", + ) + alpha_entity = _write_file( + tmp_path / "alpha/models/entity.py", + "class Alpha:\n pass\n", + ) + beta = _write_file( + tmp_path / "beta/models/__init__.py", + "from .entity import Beta\n", + ) + beta_entity = _write_file( + tmp_path / "beta/models/entity.py", + "class Beta:\n pass\n", + ) + consumer = _write_file( + tmp_path / "consumer.py", + "from models import Beta\n", + ) + + result = extract( + [alpha, alpha_entity, beta, beta_entity, consumer], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + + assert _has_edge( + result, + _node_id(result, "consumer.py", "consumer.py"), + "beta_models_init", + "imports_from", + ) diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md index afb4ecc12..870c68952 100644 --- a/tools/skillgen/expected/graphify__skill-agents.md +++ b/tools/skillgen/expected/graphify__skill-agents.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md index afb4ecc12..870c68952 100644 --- a/tools/skillgen/expected/graphify__skill-amp.md +++ b/tools/skillgen/expected/graphify__skill-amp.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md index d98865cc8..ff44d276c 100644 --- a/tools/skillgen/expected/graphify__skill-claw.md +++ b/tools/skillgen/expected/graphify__skill-claw.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md index 0c821a278..8ed58de66 100644 --- a/tools/skillgen/expected/graphify__skill-codex.md +++ b/tools/skillgen/expected/graphify__skill-codex.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md index d98865cc8..ff44d276c 100644 --- a/tools/skillgen/expected/graphify__skill-copilot.md +++ b/tools/skillgen/expected/graphify__skill-copilot.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md index c3815d556..6f24bbf2b 100644 --- a/tools/skillgen/expected/graphify__skill-droid.md +++ b/tools/skillgen/expected/graphify__skill-droid.md @@ -461,13 +461,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md index dbb4658ca..22a4c6896 100644 --- a/tools/skillgen/expected/graphify__skill-kilo.md +++ b/tools/skillgen/expected/graphify__skill-kilo.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md index d98865cc8..ff44d276c 100644 --- a/tools/skillgen/expected/graphify__skill-kiro.md +++ b/tools/skillgen/expected/graphify__skill-kiro.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md index cf5dae440..a148428b1 100644 --- a/tools/skillgen/expected/graphify__skill-opencode.md +++ b/tools/skillgen/expected/graphify__skill-opencode.md @@ -456,13 +456,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md index d98865cc8..ff44d276c 100644 --- a/tools/skillgen/expected/graphify__skill-pi.md +++ b/tools/skillgen/expected/graphify__skill-pi.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md index b0cbeb122..472336828 100644 --- a/tools/skillgen/expected/graphify__skill-trae.md +++ b/tools/skillgen/expected/graphify__skill-trae.md @@ -462,13 +462,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md index 3e6bc6b7b..5841beacd 100644 --- a/tools/skillgen/expected/graphify__skill-vscode.md +++ b/tools/skillgen/expected/graphify__skill-vscode.md @@ -460,13 +460,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md index 574384576..056bef11d 100644 --- a/tools/skillgen/expected/graphify__skill-windows.md +++ b/tools/skillgen/expected/graphify__skill-windows.md @@ -486,13 +486,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md index d98865cc8..ff44d276c 100644 --- a/tools/skillgen/expected/graphify__skill.md +++ b/tools/skillgen/expected/graphify__skill.md @@ -464,13 +464,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ``` diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md index e28910728..e2d90b867 100644 --- a/tools/skillgen/fragments/core/core.md +++ b/tools/skillgen/fragments/core/core.md @@ -399,13 +399,15 @@ extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(en summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH') print(format_diagnostic_report(summary)) flags = [f'{summary[k]} {label}' for k, label in ( - ('dangling_endpoint_edges', 'dangling-endpoint edges'), + ('unresolved_internal_endpoint_edges', 'unresolved-internal endpoint edges'), + ('malformed_endpoint_edges', 'malformed endpoint edges'), + ('unclassified_endpoint_edges', 'unclassified endpoint edges'), ('missing_endpoint_edges', 'missing-endpoint edges'), ('self_loop_edges', 'self-loop edges'), ('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'), ('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'), ) if summary.get(k, 0)] -print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).') +print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (external imports excluded; no unresolved/missing/collapsed edges).') " ```