Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 14 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3129,6 +3129,20 @@ def _parse_float(name: str, raw: str) -> float:
# --force: full scan, not the manifest-gated incremental diff — a warm
# unchanged tree would otherwise dispatch zero files (#1894).
incremental_mode = incremental_mode and not force
# #2923: --force --code-only must NOT drop the existing semantic layer.
# The AST pass is fully replaced (full re-scan, semantic cache reads
# skipped), but the semantic pass is itself skipped entirely, so
# doc/paper/image nodes from the existing graph carry forward via the
# incremental merge (build_merge / merge_raw_extraction keep them
# because no new semantic-tier sources are dispatched). Without this,
# a single --code-only --force silently erases every doc/paper/image
# node plus its connected hyperedges.
if force and code_only and existing_graph_path.exists():
incremental_mode = True
print(
"[graphify extract] --force --code-only: full AST re-scan, "
"existing semantic layer preserved (no semantic pass this run)"
)
if force:
print("[graphify extract] --force: full re-scan, semantic cache reads skipped")
elif incremental_mode and not manifest_path.exists():
Expand Down
115 changes: 115 additions & 0 deletions tests/test_extract_code_only_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,118 @@ def test_extract_names_skipped_sensitive_files(tmp_path):
out = r.stdout + r.stderr
assert "skipped as potentially sensitive" in out
assert "github_token.txt" in out, "the skipped filename must be surfaced (#2106)"


def test_code_only_force_preserves_existing_semantic_layer(tmp_path):
"""#2923 regression: --code-only --force must not drop the existing semantic
layer. The AST pass is fully replaced (full re-scan, semantic cache reads
skipped) but the semantic pass is itself skipped, so doc/paper/image nodes
from graph.json must be carried forward. Before the fix this combination
silently rewrote graph.json with only the AST tier, losing every semantic
node and every hyperedge connected to one.
"""
repo = _mixed_repo(tmp_path)
out = repo / "graphify-out"
out.mkdir()
graph = out / "graph.json"
# Seed a graph.json as if a prior full extract with an LLM backend had run:
# 2 AST nodes from app.py + 4 SEMANTIC nodes from README.md/NOTES.txt.
graph.write_text(json.dumps({
"nodes": [
{"id": "app_py", "label": "app.py", "type": "file",
"source_file": "app.py", "origin": "AST"},
{"id": "app_hello", "label": "hello()", "type": "function",
"source_file": "app.py", "origin": "AST"},
{"id": "readme_md", "label": "readme.md", "type": "file",
"source_file": "README.md", "origin": "SEMANTIC"},
{"id": "readme_design", "label": "Design", "type": "concept",
"source_file": "README.md", "origin": "SEMANTIC"},
{"id": "notes_txt", "label": "NOTES.txt", "type": "file",
"source_file": "NOTES.txt", "origin": "SEMANTIC"},
{"id": "notes_architecture", "label": "Architecture", "type": "concept",
"source_file": "NOTES.txt", "origin": "SEMANTIC"},
],
"edges": [
{"id": "e1", "source": "app_py", "target": "app_hello",
"relation": "contains", "source_file": "app.py"},
{"id": "e2", "source": "readme_md", "target": "readme_design",
"relation": "concept_about", "source_file": "README.md"},
{"id": "e3", "source": "notes_txt", "target": "notes_architecture",
"relation": "concept_about", "source_file": "NOTES.txt"},
],
"hyperedges": [],
"input_tokens": 0,
"output_tokens": 0,
}))

r = _run(repo, "--code-only", "--force", "--no-cluster")
assert r.returncode == 0, r.stderr

out_graph = json.loads(graph.read_text())
semantic_labels = {n["label"] for n in out_graph["nodes"]
if n.get("origin") == "SEMANTIC"}
semantic_source_files = {
Path(str(n["source_file"])).name.lower()
for n in out_graph["nodes"]
if n.get("origin") == "SEMANTIC"
}
# Every seeded semantic node must survive. The AST pass may add new nodes
# (or relabel existing ones — e.g. hello vs hello()) but it must not
# silently drop the semantic tier.
assert {"readme.md", "notes.txt"}.issubset(semantic_source_files), (
"code-only --force erased the existing semantic layer (#2923); "
f"semantic nodes remaining: {semantic_labels}"
)
# Hyperedges are also semantic tier; the seeded graph had none but the
# AST re-extract must not have invented any non-semantic work, and the
# surviving edges list must not have been wholesale replaced.
assert "edges" in out_graph, "graph.json must still have an edges key"
# And the user-visible console line must explain why a semantic-layer-
# preserving branch fired.
assert "existing semantic layer preserved" in r.stdout + r.stderr, (
"the --force --code-only print must announce the semantic-preserving branch"
)


def test_code_only_force_prunes_removed_semantic_files(tmp_path):
"""#2923 follow-up: --code-only --force preserves surviving semantic nodes
but must still prune semantic nodes for files that have been removed from
disk (the doc/paper/image tier cannot outlive the corpus it indexes).
"""
repo = _mixed_repo(tmp_path)
out = repo / "graphify-out"
out.mkdir()
graph = out / "graph.json"
graph.write_text(json.dumps({
"nodes": [
{"id": "app_py", "label": "app.py", "type": "file",
"source_file": "app.py", "origin": "AST"},
{"id": "app_hello", "label": "hello()", "type": "function",
"source_file": "app.py", "origin": "AST"},
{"id": "notes_txt", "label": "NOTES.txt", "type": "file",
"source_file": "NOTES.txt", "origin": "SEMANTIC"},
{"id": "notes_architecture", "label": "Architecture", "type": "concept",
"source_file": "NOTES.txt", "origin": "SEMANTIC"},
],
"edges": [],
"hyperedges": [],
"input_tokens": 0,
"output_tokens": 0,
}))

# Delete NOTES.txt between seed and re-run. The merge's graph_stale_sources
# path must drop its semantic nodes because the file no longer exists.
(repo / "NOTES.txt").unlink()

r = _run(repo, "--code-only", "--force", "--no-cluster")
assert r.returncode == 0, r.stderr
out_graph = json.loads(graph.read_text())
remaining_sources = {
Path(n["source_file"]).name
for n in out_graph["nodes"]
if n.get("origin") == "SEMANTIC"
}
assert "NOTES.txt" not in remaining_sources, (
"NOTES.txt was deleted from disk; its semantic nodes must be pruned "
"(#2923 follow-up)"
)