Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,64 @@ def _keep_edge(edge: dict) -> bool:
return preserved_edges


def _referenced_unscanned_identities(
existing: dict,
source_paths: "_StoredSourcePaths",
*,
out: Path,
project_root: Path,
live_sources: "set[str | None]",
rebuilt_sources: "set[str | None]",
) -> set[str]:
"""Missing source paths that were never scanned but an unchanged file still points at.

An extractor can mint a node for a file that is not in the checkout: the
target of an unresolved dynamic import (``import('./queue.js')``) or a
project a ``.sln`` / ``<ProjectReference>`` names. The node carries that
path as its ``source_file``, but it is the REFERRING file's output. The
corpus sweep read the missing path as a deleted source and evicted it, so
the unchanged referrer's preserved edges to it dangled and were dropped,
while a full rebuild of the same tree keeps both.

Deletion evidence (#1795) is the previous scan: a path listed in
manifest.json was a real file and is evicted as before. A path that was
never scanned cannot have been deleted; its nodes stay while a referrer
that is not re-extracted this run still points at them. A re-extracted
referrer re-emits them (or not) itself. Without a readable manifest the
old behaviour is kept.
"""
from graphify.build import _is_ast_tier

try:
scanned = json.loads((out / "manifest.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return set()
if not isinstance(scanned, dict):
return set()
scanned_before = {source_paths.absolute_identity(str(key), project_root) for key in scanned}

owner: dict[str, str] = {}
for node in existing.get("nodes", []):
source_file = node.get("source_file")
if not source_file or not node.get("id") or not _is_ast_tier(node):
continue
identity = source_paths.identity(source_file)
if identity and identity not in live_sources and identity not in scanned_before:
owner[node["id"]] = identity
if not owner:
return set()
referenced: set[str] = set()
for edge in existing.get("links", existing.get("edges", [])):
edge_source = source_paths.identity(edge.get("source_file"))
if edge_source not in live_sources or edge_source in rebuilt_sources:
continue
for endpoint in (edge.get("source"), edge.get("target")):
identity = owner.get(endpoint)
if identity:
referenced.add(identity)
return referenced


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _reconcile_existing_graph()

fans out to 9 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

existing_graph: Path,
result: dict,
Expand Down Expand Up @@ -912,6 +970,20 @@ def _reconcile_existing_graph(
newly_ignored_files: set[str] = set()
newly_ignored_nodes = 0
_alive_cache: dict[str, bool] = {}
_unscanned_refs: set[str] | None = None

def _referenced_unscanned(identity: str) -> bool:
nonlocal _unscanned_refs
if _unscanned_refs is None:
_unscanned_refs = _referenced_unscanned_identities(
existing,
source_paths,
out=out,
project_root=project_root,
live_sources=current_sources,
rebuilt_sources=rebuilt_source_identities,
)
return identity in _unscanned_refs
_ignored_cache: dict[str, bool] = {}

def _ignored_now(identity: str) -> bool:
Expand Down Expand Up @@ -955,6 +1027,8 @@ def _ignored_now(identity: str) -> bool:
if alive is None:
alive = Path(identity).exists()
_alive_cache[identity] = alive
if not alive and _referenced_unscanned(identity):
continue # never scanned: a referrer's placeholder, not a deletion
ignored = alive and _ignored_now(identity)
if ignored:
newly_ignored_files.add(identity)
Expand Down Expand Up @@ -984,6 +1058,8 @@ def _ignored_now(identity: str) -> bool:
excluded_alive_files.add(identity)
excluded_alive_nodes += 1
continue
elif _referenced_unscanned(identity):
continue # never scanned: a referrer's placeholder, not a deletion
normalized = source_paths.normalize(source_file)
if normalized:
deleted_paths.add(normalized)
Expand Down
90 changes: 90 additions & 0 deletions tests/test_incremental_unscanned_placeholders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""A changed-files rebuild must keep nodes minted for files that never existed.

An unresolved dynamic import (``import('./queue.js')``) or a project a ``.sln``
lists but the checkout lacks makes the REFERRING file emit a node whose
``source_file`` is the missing path. The corpus sweep of an incremental
rebuild read that missing path as a deleted source and evicted the node, so the
unchanged referrer's edge to it dangled and was dropped; a full rebuild of the
same tree keeps both. The previous scan (manifest.json) is the deletion
evidence (#1795): a path it never listed cannot have been deleted.
"""
from __future__ import annotations

import json
from pathlib import Path

from graphify.watch import _rebuild_code


def _graph(corpus: Path) -> dict:
return json.loads((corpus / "graphify-out" / "graph.json").read_text(encoding="utf-8"))


def _shape(graph: dict) -> tuple[set, set]:
drop = ("community", "community_name")
nodes = {json.dumps({k: v for k, v in n.items() if k not in drop}, sort_keys=True) for n in graph["nodes"]}
edges = {json.dumps({k: v for k, v in e.items() if k not in drop}, sort_keys=True)
for e in graph.get("links", graph.get("edges", []))}
return nodes, edges


def _corpus(tmp_path: Path) -> Path:
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "app.ts").write_text(
"export async function load() {\n return import('./queue.js');\n}\n", encoding="utf-8")
(corpus / "other.ts").write_text("export function other() {\n return 1;\n}\n", encoding="utf-8")
return corpus


def _rebuild(corpus: Path, changed: list[str] | None = None) -> None:
assert _rebuild_code(
corpus,
changed_paths=[Path(p) for p in changed] if changed is not None else None,
no_cluster=True,
acquire_lock=False,
force=True,
) is True


def _placeholder_ids(graph: dict) -> set[str]:
return {n["id"] for n in graph["nodes"] if n.get("source_file", "").endswith("queue.js")}


def test_placeholder_for_a_missing_import_target_survives_an_unrelated_change(tmp_path):
corpus = _corpus(tmp_path)
_rebuild(corpus)
placeholder = _placeholder_ids(_graph(corpus))
assert placeholder, "fixture no longer mints a placeholder for ./queue.js"

(corpus / "other.ts").write_text("export function other() {\n return 2;\n}\n", encoding="utf-8")
_rebuild(corpus, ["other.ts"])
incremental = _graph(corpus)

assert _placeholder_ids(incremental) == placeholder
assert any(e["target"] in placeholder for e in incremental["links"])

_rebuild(corpus)
assert _shape(incremental) == _shape(_graph(corpus)), "incremental graph differs from a full rebuild"


def test_a_scanned_file_that_was_deleted_is_still_evicted(tmp_path):
corpus = _corpus(tmp_path)
_rebuild(corpus)
assert any(n.get("source_file", "").endswith("other.ts") for n in _graph(corpus)["nodes"])

(corpus / "other.ts").unlink()
_rebuild(corpus, ["other.ts"])

assert not [n for n in _graph(corpus)["nodes"] if n.get("source_file", "").endswith("other.ts")]


def test_without_a_manifest_the_old_eviction_is_kept(tmp_path):
corpus = _corpus(tmp_path)
_rebuild(corpus)
(corpus / "graphify-out" / "manifest.json").unlink()

(corpus / "other.ts").write_text("export function other() {\n return 2;\n}\n", encoding="utf-8")
_rebuild(corpus, ["other.ts"])

assert not _placeholder_ids(_graph(corpus))
Loading