mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
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:
committed by
safishamsi
parent
9452ef216b
commit
5be36dac0d
@@ -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():
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user