fix(extract): preserve existing semantic layer on --code-only --force (#2923)

A `--code-only --force` rebuild over an existing graph dropped the doc/paper/image
semantic tier, because force took the full-rebuild path and code-only never re-dispatched
those files. When an existing graph is present, keep incremental mode so build_merge
carries the semantic layer forward; files deleted from disk are still pruned.
This commit is contained in:
santhiprakash
2026-08-21 18:26:24 +01:00
committed by safishamsi
parent 9452ef216b
commit 5be36dac0d
2 changed files with 129 additions and 0 deletions
+14
View File
@@ -3129,6 +3129,20 @@ def dispatch_command(cmd: str) -> None:
# --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():
+115
View File
@@ -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)"
)